Compare commits
880 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16d151e683 | |||
| 83f0d12261 | |||
| 20082b8bff | |||
| bb6f67a4bb | |||
| 12443b5d78 | |||
| 1f246d8c03 | |||
| e2bda1cfce | |||
| 5d28f14978 | |||
| 25213e3d0d | |||
| 0322e920ed | |||
| fd02bbe4d6 | |||
| 218318286b | |||
| 9620b71d08 | |||
| c5a911de4c | |||
| a7b9ec0837 | |||
| e2331bef2c | |||
| 14707b68ca | |||
| 37b05f710f | |||
| e842729f5e | |||
| e233448ab5 | |||
| 5bf82fa7ac | |||
| 431f71fb64 | |||
| b3a8081df7 | |||
| 67a48e07a2 | |||
| b48c0c946b | |||
| d5b4a4f226 | |||
| a71cefb583 | |||
| a413c77b32 | |||
| 5d6c19002d | |||
| a6b8068b7c | |||
| 70a647813b | |||
| ec5aaffe26 | |||
| 2e07fc17f2 | |||
| 66fd2d10ac | |||
| 5218c9ff45 | |||
| f53e6488db | |||
| d33bd854e0 | |||
| abccb4387e | |||
| b9c4c86e1f | |||
| 25ece03102 | |||
| 08fbe4b43b | |||
| 104004535f | |||
| 8727137847 | |||
| 7c2023b4b3 | |||
| 1d085475d9 | |||
| 3ebb63bc80 | |||
| c0ce9cf187 | |||
| d7649eac1f | |||
| f80eb29a5a | |||
| ecd7a6679b | |||
| b6ed77a56c | |||
| 213ffcb41a | |||
| 79de8e7f93 | |||
| a732b0c75b | |||
| a38527b722 | |||
| 1d9ad95aaf | |||
| 9785bc9746 | |||
| 328e342b9c | |||
| 7ed39c15dd | |||
| 7cca57332b | |||
| cef6cb8826 | |||
| 1faf26547b | |||
| 54555b2b16 | |||
| 152a5f12c3 | |||
| b530c0e875 | |||
| c03be0e2f0 | |||
| ceb641daa5 | |||
| 10c9ab7a8f | |||
| 63dd6a1f3c | |||
| 460528bf87 | |||
| ccb90734bd | |||
| 2ed731ce15 | |||
| c0547a63ca | |||
| 54b9439e54 | |||
| 91267191f1 | |||
| f8854fe89a | |||
| 2ee9e6333b | |||
| f6395e4022 | |||
| 160b5f8416 | |||
| 34ce399d7c | |||
| 68b7b57bbd | |||
| f40e79a78b | |||
| 2039cf78d6 | |||
| 5926fbe2bd | |||
| 5f4d83ddc2 | |||
| 25644ede67 | |||
| 2a68d04d60 | |||
| 4486c44020 | |||
| 8f39a2037f | |||
| e9e0c60bb1 | |||
| 768f4a43bc | |||
| c3c0c4584f | |||
| b5b6dc827a | |||
| 6a4124c8b1 | |||
| b709799039 | |||
| c3d85a0b6d | |||
| a8033f386c | |||
| 15993ce647 | |||
| 484788757b | |||
| b36971ed49 | |||
| b6ed4103ef | |||
| 58fb9728ad | |||
| f7526d338c | |||
| aa72a205f2 | |||
| 2d97781fe5 | |||
| d5d191847f | |||
| 8cd6a05301 | |||
| f5435b1c81 | |||
| 0a1a164cb1 | |||
| 200ff42385 | |||
| ca6a041ac8 | |||
| bf74bc0a0b | |||
| b58f072348 | |||
| a449ce3bb8 | |||
| d7d7a2c49d | |||
| 051545444b | |||
| dfb3943643 | |||
| a00af07e58 | |||
| c79908b320 | |||
| 864ecf4c7c | |||
| e0a996c090 | |||
| 8dd1f2344c | |||
| 4e326452d5 | |||
| b0bd236b40 | |||
| 202eb610af | |||
| 6dfb479da5 | |||
| b3f8a3ca4b | |||
| daacc015b8 | |||
| 62b5ab6b5a | |||
| 2984bedcd0 | |||
| e08a0fa847 | |||
| 3705d5510d | |||
| 727772fe31 | |||
| a7b13a15e5 | |||
| 9d7ed43add | |||
| 24f18379e4 | |||
| f339c90fc3 | |||
| 4c32df1f22 | |||
| 47a18336af | |||
| 5c23a0b375 | |||
| 5e8cbad452 | |||
| 2cd305ab3e | |||
| e038418bd3 | |||
| 6a1323b218 | |||
| ae1aaaddb8 | |||
| e97ad5cd06 | |||
| c0fc1e9a50 | |||
| f245383c28 | |||
| 89a2f7aaef | |||
| 78a881fe56 | |||
| 680bc12303 | |||
| 05efb826b8 | |||
| fdd0da3c43 | |||
| c9344822a3 | |||
| 0d15c2db01 | |||
| 272c36ac65 | |||
| 8c1ab6cce7 | |||
| b5c1b2e912 | |||
| 43ae916e96 | |||
| c431c85abe | |||
| 5a3971a304 | |||
| 5331c8d44e | |||
| d125ffaae5 | |||
| 29b5e782de | |||
| 163115da88 | |||
| 25a1ce1a5f | |||
| a5a7eb6092 | |||
| 17cce96721 | |||
| 615185e373 | |||
| ab29e34f5a | |||
| 101da7367e | |||
| 75faf5c0fd | |||
| 71f44a0d77 | |||
| 8ce2f70370 | |||
| bd61a43b2f | |||
| 7ffa1e6b23 | |||
| fe29f5e2f3 | |||
| cd10f3f07a | |||
| 1d39627311 | |||
| 006ef3e8c8 | |||
| f799137483 | |||
| 4dadafb87f | |||
| e85c21f40e | |||
| e5c50242fa | |||
| 2eda69d429 | |||
| 6e027133cc | |||
| eb49003bd6 | |||
| 707b189ab2 | |||
| dfb0b85877 | |||
| e0b7ce46cb | |||
| 5912f366f8 | |||
| 8b3786a875 | |||
| 815cee81c5 | |||
| 3b08b452a0 | |||
| b03f97c91e | |||
| d62a09beba | |||
| 90ebdebe99 | |||
| 2e37b94bd3 | |||
| f10cbc04e2 | |||
| 85de66bcf2 | |||
| a98b5a2a04 | |||
| 51cf652fe2 | |||
| ae117cb383 | |||
| 7ec08b2dcb | |||
| e721d6c295 | |||
| 9d9bf92a4f | |||
| fadf3d935f | |||
| 06aae43d7f | |||
| 9b81449dbd | |||
| c369e29975 | |||
| 1a00f6fe9a | |||
| b2c38e24a2 | |||
| 19b3fe8495 | |||
| 7752b92588 | |||
| cddeb3e552 | |||
| 84eb32a4f1 | |||
| 7779fc0a21 | |||
| 1c8f7c1954 | |||
| 9fc92ab065 | |||
| ea8b646c96 | |||
| bc218fa138 | |||
| 2ef474960b | |||
| fe6cfa663a | |||
| 769b16de83 | |||
| 4e8ea6587b | |||
| 4eb23d6512 | |||
| b756704121 | |||
| 192b7fce1d | |||
| 1c35aac8ce | |||
| 99a071f486 | |||
| 62e71e4c75 | |||
| e44944c538 | |||
| 2dd9409f9c | |||
| 5d2292092f | |||
| 9fb3576bb2 | |||
| 04e3512f71 | |||
| 327a40ea75 | |||
| dc59b44371 | |||
| 979bc39522 | |||
| db1c404a02 | |||
| e05297b09f | |||
| b60cb8a81f | |||
| 835dcd6eec | |||
| 26cb66aa45 | |||
| d4dc59aeba | |||
| 5258ef400f | |||
| 50ef6efcfa | |||
| 4b1cb36343 | |||
| 4324460005 | |||
| 620688d92a | |||
| 89d5ea929b | |||
| 168004dc89 | |||
| 1ad88032f2 | |||
| 4000f9e894 | |||
| 8b825263d4 | |||
| 7247568d96 | |||
| 74d5175152 | |||
| 130d961e72 | |||
| 02dd9a77f0 | |||
| f6f40d81a1 | |||
| 79fb0442dd | |||
| 56a8b2c7cf | |||
| 71a06d5bbc | |||
| 55ac0a941b | |||
| 0aaa4347f8 | |||
| 4c11e2d5c1 | |||
| 3bc239ffee | |||
| e797516468 | |||
| aa67ccd098 | |||
| b1c5ca5a00 | |||
| 050d29a186 | |||
| 7d8ce8bb30 | |||
| 38f5d303f9 | |||
| 38c86b4cf1 | |||
| 1da6d181d5 | |||
| e0788be5af | |||
| 52709a3e25 | |||
| 25cf0ac996 | |||
| 4c3d7d5b9d | |||
| b4cbfc0de2 | |||
| 600393c12a | |||
| faeeeca39a | |||
| 537d33b66a | |||
| 3933db4936 | |||
| 86c6d2c253 | |||
| 75ffe3767f | |||
| 015b7547db | |||
| 2a6d861968 | |||
| 0f188e30b0 | |||
| 5faf8277f1 | |||
| b86b4f1405 | |||
| 98f903174a | |||
| d8df8a4624 | |||
| 0df19e300f | |||
| b26bf1c470 | |||
| 53cd3f6118 | |||
| c23f955b6c | |||
| 5c5b2656ec | |||
| c6278909ff | |||
| 64d599d6f7 | |||
| 90d638e9c6 | |||
| 1dabbf78b2 | |||
| 58b0b0c756 | |||
| 1426d9d62d | |||
| 85db6c7884 | |||
| 43d02c349f | |||
| c7af4573ca | |||
| 73a217eb1b | |||
| b1854222a0 | |||
| 7cb72b2bb2 | |||
| 00245dd44d | |||
| c1972ab26f | |||
| b86254b0b8 | |||
| 00157ee655 | |||
| 6aaece1c44 | |||
| 6445064836 | |||
| 9d0c9d93a0 | |||
| 01a06166ff | |||
| f41fc2bceb | |||
| 5ffa344f1c | |||
| 31c2b21350 | |||
| b2c6562c64 | |||
| 02a36d9cad | |||
| 5dcb1916b2 | |||
| c5623ec868 | |||
| 1c17c1987f | |||
| b6318bfae0 | |||
| 0e8489951d | |||
| 21d3a1c1bb | |||
| 9055021282 | |||
| 10fcfd21b1 | |||
| c142233ff9 | |||
| e660e47265 | |||
| 40c4c0aeec | |||
| 97c9a7b025 | |||
| 9145f2530b | |||
| a1b073e5d6 | |||
| 7425153760 | |||
| c0836a94a9 | |||
| 9dc24951a2 | |||
| f3eaad8d1c | |||
| effde5014e | |||
| 74c4c06a5c | |||
| f10b7683c9 | |||
| 5e26185c9f | |||
| 2d350f5340 | |||
| f0519bbefb | |||
| 7acfb3f027 | |||
| 24621e15e3 | |||
| 3474b2363f | |||
| 560dea3e3e | |||
| f6e5cf91ab | |||
| 57e8770fda | |||
| 7e8f7e41b5 | |||
| 84d890d7b0 | |||
| e57c90b97e | |||
| 236c43b807 | |||
| bb32c286d9 | |||
| f1d4975079 | |||
| 464b783477 | |||
| c4756030c7 | |||
| bf538fee32 | |||
| cc32c29930 | |||
| 27b8703949 | |||
| 0d9bbff534 | |||
| cad60e73a6 | |||
| 1d14d21684 | |||
| 0733ef7d32 | |||
| 143f872166 | |||
| 95abab3c18 | |||
| 2e5fb46ebc | |||
| 0d11092178 | |||
| 80589f325a | |||
| 0b6b3d0905 | |||
| bc97f772b8 | |||
| efe944a10c | |||
| 9d9aed2d4b | |||
| 9cd8b7d593 | |||
| abfd2361d4 | |||
| b10e616ec2 | |||
| 7dc2a90edc | |||
| cb53199748 | |||
| 7b2402c436 | |||
| 0715437888 | |||
| bea95113e7 | |||
| e73e969f58 | |||
| fd30cf7388 | |||
| fa9792b280 | |||
| e93530c7c7 | |||
| 35d4c37d61 | |||
| 4d61d3fc42 | |||
| a24388137e | |||
| 60c7666610 | |||
| e11f1c4b28 | |||
| e1b32f237c | |||
| fe76634d02 | |||
| b499540bed | |||
| 7b4a9c2060 | |||
| a84cc80a88 | |||
| 241cea9729 | |||
| 4b83ddc63e | |||
| 0b431fbb8d | |||
| 3736d921a1 | |||
| 380fb28d2e | |||
| 64c0ee4ccf | |||
| 5977bf69fb | |||
| 179ae6a24e | |||
| 16942ec4c7 | |||
| 6f5378ea40 | |||
| 02ee2039f4 | |||
| e7f89c1a19 | |||
| be4f9d92eb | |||
| ca63b97e79 | |||
| c1ed4a0232 | |||
| b826a0bf08 | |||
| fd0d87d719 | |||
| 094373f3c1 | |||
| 5dac623c9f | |||
| 57d1e772ec | |||
| 983efbfd9b | |||
| 1f11e293c1 | |||
| bfd278ae1c | |||
| dbe1b9d2af | |||
| d71bc4bf09 | |||
| faf2e1dfa4 | |||
| 93360aa1bb | |||
| 8df7780ef9 | |||
| c1fda7f44c | |||
| ce42201eee | |||
| f65131e5b0 | |||
| 8d9efa5dc0 | |||
| 1a54bfb732 | |||
| a15c1f05b2 | |||
| ccfd341fe9 | |||
| aab8e0aa4d | |||
| 9b669c8a3d | |||
| 50960fed24 | |||
| ff61bc8d2d | |||
| e5d9c229ed | |||
| b0e38c7e2c | |||
| c12dade5ea | |||
| cb35dabf31 | |||
| a66cf4b501 | |||
| c355d6a24a | |||
| 60c1e93037 | |||
| 708126aca3 | |||
| bf763a3ad7 | |||
| bb0c3b91a2 | |||
| a7c0cd0d19 | |||
| f315ef9bd0 | |||
| b262a5491c | |||
| d7b30336b6 | |||
| c834bac460 | |||
| 36da0a1204 | |||
| 6ece9271eb | |||
| a465058bb3 | |||
| bad7dbb55c | |||
| 501aaf076e | |||
| 49d27cc7e4 | |||
| e3903f77e9 | |||
| b6ee9b5d7e | |||
| e3301cefd7 | |||
| 74ac38329e | |||
| 50c39382e6 | |||
| 9a65708855 | |||
| 4ebcb2cc95 | |||
| 0c3c41fb4a | |||
| f44942f78e | |||
| d7f2211800 | |||
| 60610e2032 | |||
| f11402c452 | |||
| 277b046b41 | |||
| 7878fb2f92 | |||
| f90c4634c2 | |||
| b52fbc8af5 | |||
| cde1175a7b | |||
| 30786743e3 | |||
| c675bad95c | |||
| 288b92d2ce | |||
| f6e656117c | |||
| 896baa6a04 | |||
| b8f820a9af | |||
| eb8b046dbd | |||
| ff9e0f3190 | |||
| c823eda245 | |||
| 6340de0ddb | |||
| e4f10b9e77 | |||
| 5ca2623c13 | |||
| e560823271 | |||
| 3f83b13a29 | |||
| 507bf09466 | |||
| 688a07dbe9 | |||
| e7c4bc9fc3 | |||
| 04c74afad6 | |||
| e8a4521b7e | |||
| 768b9d203c | |||
| ba5e03342f | |||
| cb3ff0334d | |||
| b96bf9b9a5 | |||
| 4bf673dfc6 | |||
| 223fe8025c | |||
| cf47cf1512 | |||
| 1bac0c3e8c | |||
| 4634c353d8 | |||
| 9ac2dde4e1 | |||
| 07a6db43e6 | |||
| c40f1f0cc2 | |||
| c4ddc6d793 | |||
| 8f58974a1a | |||
| b17fbd6fc0 | |||
| 57d5d57c53 | |||
| 5436512b1f | |||
| 40e10ea72c | |||
| ea6aba4e92 | |||
| e4817dd0d3 | |||
| 751a2d822a | |||
| 38cc8422b6 | |||
| 35f2e47195 | |||
| ab3ad0fe70 | |||
| ccf26730a2 | |||
| c23868dfb1 | |||
| b24af17326 | |||
| 45aff11bff | |||
| 1ee700fa9d | |||
| 4f74dc9031 | |||
| b53fd0bd61 | |||
| 80e3514d56 | |||
| 3f1cac3ccc | |||
| 261e4e8f67 | |||
| bec7ec1901 | |||
| 69015293a4 | |||
| ca55e417ee | |||
| ed37415ee2 | |||
| 68dc99f7ab | |||
| 4d9cac8941 | |||
| fbd6fe00d6 | |||
| e491680dff | |||
| ffc8474918 | |||
| dd7f26e51e | |||
| 498460031e | |||
| d977e3569f | |||
| f122e7e4ed | |||
| 3af3db3df6 | |||
| f4246de8a7 | |||
| 43b647ed28 | |||
| 04958f63de | |||
| b15b2c0e7b | |||
| 61909b68cc | |||
| 71c91bed29 | |||
| a8aa57dcd6 | |||
| a7de8c81b4 | |||
| c2688fe81c | |||
| 295474506b | |||
| c4a989dd3d | |||
| 7f6e192149 | |||
| 28b40e9174 | |||
| f2d2da94a1 | |||
| 14d19ff090 | |||
| a547464d1e | |||
| 23b9aba560 | |||
| 347038c528 | |||
| 2fc26d910a | |||
| 4828730ea3 | |||
| 7c4f539a44 | |||
| 598ef91a30 | |||
| 4862f12619 | |||
| c96ac214bb | |||
| 8fb9cf6a0b | |||
| 4a177b6008 | |||
| 58b59a3304 | |||
| 83e84a24b1 | |||
| 322e6c1f1c | |||
| d1ca77d7db | |||
| d885c78b9a | |||
| 6c8a8a53e7 | |||
| 21ec9335fc | |||
| 4c40a463da | |||
| 169e97975d | |||
| e26c65d4f1 | |||
| d1dd86c825 | |||
| e8388a757b | |||
| 51c7d46390 | |||
| 84688acf32 | |||
| f16ecd220a | |||
| a0f89e46a8 | |||
| 6fce503814 | |||
| e873a2fbd2 | |||
| 53e15969c1 | |||
| c11d83a204 | |||
| 35d664d414 | |||
| 11a33e5425 | |||
| 97bd82967e | |||
| ca89339042 | |||
| 07054c3f68 | |||
| 72405f5c0e | |||
| d5232b8a68 | |||
| d44d032e93 | |||
| d7122a9c98 | |||
| eb0b9f29f2 | |||
| a8b7763a13 | |||
| 1e7fe9d216 | |||
| a0dbfe8c4b | |||
| ef2571d626 | |||
| d3cde7bd05 | |||
| 01191d632b | |||
| 5c934766f5 | |||
| decd0e2510 | |||
| cd46dad14b | |||
| 69c5075ced | |||
| 4da9b7080d | |||
| be112bc091 | |||
| 8389c86ce7 | |||
| fa55d32563 | |||
| 185cc9844c | |||
| 705fb0d342 | |||
| 2ce9ac0862 | |||
| a237cd856d | |||
| b1e685ffc2 | |||
| d19dde9843 | |||
| 74693bf747 | |||
| 530bc7f5ff | |||
| 1b660d1e6d | |||
| 34ce0d640c | |||
| ae2c08bfbd | |||
| f517a683e3 | |||
| b512439d41 | |||
| c62a2a7672 | |||
| bcd5c8f610 | |||
| a75aff65d6 | |||
| 9500f882d5 | |||
| 7fc5f2ef97 | |||
| 5d0808a1f9 | |||
| 511fe7410b | |||
| 7ed6819b03 | |||
| 4709e26bd5 | |||
| 6e1fa561f0 | |||
| d7668d3573 | |||
| 410295486a | |||
| 0d63b47560 | |||
| d3cb956061 | |||
| 2e00accfab | |||
| c54a60097c | |||
| 58a54bce06 | |||
| d00e6905e1 | |||
| c7388c9247 | |||
| 5a2ab412b8 | |||
| 584acb0856 | |||
| 122f41507a | |||
| 6a78564460 | |||
| 096cd5e09c | |||
| cc3fa4b11a | |||
| f050687487 | |||
| 4165ed0b9e | |||
| 3032590b8e | |||
| 113b504057 | |||
| 1a49f0b3ab | |||
| a79143644b | |||
| a2559d2d31 | |||
| 057933d737 | |||
| bb16df5770 | |||
| b9c8ac23cc | |||
| bad6f77a01 | |||
| bb28945eba | |||
| 5a6ed82c5f | |||
| 055b2c7139 | |||
| e049eebeb0 | |||
| 0f3c68b7ef | |||
| 607efff342 | |||
| a88c05f7c3 | |||
| 3aabbfde4c | |||
| b447825a42 | |||
| 2a1fb7bcb7 | |||
| fa7211c8b6 | |||
| bfa961a005 | |||
| 4d9855d91c | |||
| b09c069af8 | |||
| 8fdb241a72 | |||
| de781d0eec | |||
| 2959f52099 | |||
| 1e11c02006 | |||
| 12a58b0ca3 | |||
| 27f521d74e | |||
| bbc3472873 | |||
| 680c699dfa | |||
| b7ba6d51e2 | |||
| 1f2128999f | |||
| f03503b7f8 | |||
| ae1b86173e | |||
| e783c255b7 | |||
| 0f86d7321f | |||
| f085b0fc8e | |||
| b4beba284d | |||
| c1e70e9e21 | |||
| fca2c698fc | |||
| 5d5b23f907 | |||
| 2860e58830 | |||
| e8dec6c143 | |||
| 8b2b19de4b | |||
| af162578c0 | |||
| c6ccfa4417 | |||
| 132001a1be | |||
| 830c09dde7 | |||
| 57bcde2511 | |||
| c1db5b43e5 | |||
| 7b7f29720c | |||
| 13320db360 | |||
| dd9d63d74e | |||
| 739ed2be6d | |||
| 95d9ed685f | |||
| 1064d60873 | |||
| 014c3249b5 | |||
| 59920a5537 | |||
| 8e04de4d65 | |||
| 3ff8ebea7d | |||
| 29096d6700 | |||
| 1c59fc829f | |||
| ca5c0427b8 | |||
| f1dd77bdfd | |||
| a93f74c3ec | |||
| a5b9d2dbd6 | |||
| 2e6e27991a | |||
| b0ae70a88f | |||
| af2e8ba7bd | |||
| 15ca8dac1d | |||
| b6f6d17daa | |||
| 4d06316d22 | |||
| 9290d7e96f | |||
| 10f931ab68 | |||
| 75986be154 | |||
| 1b53c9377d | |||
| 7b31c000cd | |||
| d180dd5f6a | |||
| b7d4d67bec | |||
| 8a018ed3a8 | |||
| fe41ab9c6b | |||
| 7cf00b4b58 | |||
| 224b2399bc | |||
| bef04df3b3 | |||
| a956707669 | |||
| d812af8535 | |||
| 0edb45d851 | |||
| e25903f282 | |||
| 591f98957f | |||
| 01eb6a7a5a | |||
| 2e9fc52ba2 | |||
| 25c5b0f695 | |||
| 88b242fe65 | |||
| 28b7414347 | |||
| 71e58336aa | |||
| b3388cfef9 | |||
| ad1cbc8318 | |||
| 6d47022bb5 | |||
| 4e96c77d2b | |||
| b1071e0870 | |||
| e786c456af | |||
| 68fb07ee0d | |||
| 35bf0b803c | |||
| ef1a142c59 | |||
| 14ff363ce6 | |||
| 7872bca40d | |||
| 4b4272ce85 | |||
| 28b99ad374 | |||
| b7fb9a7703 | |||
| 3eb4913863 | |||
| fe46afd571 | |||
| e8ba65ef22 | |||
| 4854b2630e | |||
| e7d3465cdd | |||
| b04975af2e | |||
| 947384aa6f | |||
| 8db65824bb | |||
| ec1c625a1a | |||
| ded2171375 | |||
| fdc388a117 | |||
| 2320cfd8e5 | |||
| 1be88b8454 | |||
| e3ea8ee06b | |||
| d7c10ae1b8 | |||
| 03f209b2b0 | |||
| c0ddac5eed | |||
| 0e06088b3e | |||
| 75de6bbb3d | |||
| 0e0189d518 | |||
| c166bfd334 | |||
| 496cb3bc68 | |||
| d5176b1b48 | |||
| 2560c5f711 | |||
| 062fcae014 | |||
| 90e6a4b94a | |||
| c7bf7dcb45 | |||
| 069ab7405f | |||
| ed2e8aa510 | |||
| 08d7ce7a2b | |||
| 8dabb8cbbd | |||
| d12226eb7e | |||
| 6d9c5c34f2 | |||
| 251b1fb446 | |||
| f39df2ff46 | |||
| 945bcee3f5 | |||
| fef5c09a76 | |||
| 54a9851dee | |||
| b609659f65 | |||
| a352ecc771 | |||
| abbf3bc20f | |||
| 21704696da | |||
| ffa38694df | |||
| 090bdbeccd | |||
| 7f0d9fea54 | |||
| 8f6839dfe8 | |||
| 246d35fdb1 | |||
| 11b0037986 | |||
| ab34a23bb4 | |||
| 288b9f00e8 | |||
| 712842a235 | |||
| 82b6b3faf5 | |||
| 563d0f7566 | |||
| a9c605f7c9 | |||
| 21a1c1243b | |||
| 7c94f1792b | |||
| b0f87f4e37 | |||
| 4bb10cf89a | |||
| da22709623 | |||
| 7c8e0457ce | |||
| f18656738d | |||
| 517cf6205c | |||
| f45f0b492d | |||
| 2271d3d4c5 | |||
| b5293e4455 | |||
| c6614e2faa | |||
| b8ddc72fe8 | |||
| 3e63414b80 | |||
| 58338fc946 | |||
| acba7d1228 | |||
| 956d8ef0d8 | |||
| 7bd6db0c5d | |||
| a291af863c | |||
| 7f828783a7 | |||
| eb57a83bf5 | |||
| 9720679461 | |||
| cc10d69b47 | |||
| e5b8b2fc8e | |||
| b3880b3ec8 | |||
| eaafa7fb25 | |||
| be346af2ba | |||
| a5d53eaeba | |||
| af340eec7b | |||
| 0baac5d829 | |||
| 1631d48490 | |||
| f9874c3c37 | |||
| 2f8e49b8df | |||
| 8455b2c480 | |||
| fa25502803 | |||
| bfaa45252a | |||
| 9af3627b62 | |||
| 8edccedbbd | |||
| 8d020fd39a | |||
| 139314eaba | |||
| 9cbd5ccf05 | |||
| 314cab60aa | |||
| 025b53092e | |||
| 7a0ea2bdff | |||
| 0a79820dd6 | |||
| 7203ad93e4 | |||
| 504dda817a | |||
| 1069cff6cf | |||
| 2662150710 | |||
| ad7c187201 | |||
| d7125413e5 | |||
| 87d2d9e1f2 | |||
| 9d712b86fa | |||
| 69baf1e039 | |||
| 906707f0e0 | |||
| 3762ab1878 | |||
| 12d7fe7ebf | |||
| 5a6c278167 | |||
| bddfa15eb8 | |||
| 899de2ebe3 | |||
| eef578b939 | |||
| a7608171b1 | |||
| 4d3b050a81 |
@@ -0,0 +1,8 @@
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
raise NotImplementedError
|
||||
def __repr__
|
||||
if __name__ == .__main__.:
|
||||
omit =
|
||||
subliminal/cli.py
|
||||
+64
-8
@@ -1,8 +1,64 @@
|
||||
build
|
||||
dist
|
||||
subliminal.egg-info
|
||||
*.pyc
|
||||
.settings
|
||||
.project
|
||||
.pydevproject
|
||||
*~
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Pycharm
|
||||
.idea
|
||||
|
||||
# Subliminal
|
||||
tests/data/mkv/
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
sudo: false
|
||||
|
||||
language: python
|
||||
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.3"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
|
||||
env:
|
||||
- PARSER=native
|
||||
- PARSER=lxml
|
||||
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- unrar
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- python: "3.5"
|
||||
env:
|
||||
- PARSER=native
|
||||
- VCR_RECORD_MODE=all
|
||||
- PYTEST_ADDOPTS="-m integration"
|
||||
allow_failures:
|
||||
- python: "3.5"
|
||||
env:
|
||||
- PARSER=native
|
||||
- VCR_RECORD_MODE=all
|
||||
- PYTEST_ADDOPTS="-m integration"
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
- tests/data/mkv
|
||||
|
||||
before_cache:
|
||||
- rm -f $HOME/.cache/pip/log/debug.log
|
||||
|
||||
install:
|
||||
- pip install -e .[test]
|
||||
- if [ $PARSER = "lxml" ]; then pip install lxml; fi
|
||||
- pip install coveralls
|
||||
|
||||
script: python setup.py test --addopts "--cov subliminal --verbose $PYTEST_ADDOPTS"
|
||||
|
||||
after_success: coveralls
|
||||
@@ -0,0 +1,19 @@
|
||||
Contributing
|
||||
============
|
||||
|
||||
Issues
|
||||
------
|
||||
Issues are intended for bug report and feature requests. For any bug report please make sure to include the complete
|
||||
stack trace and DEBUG level logs as well as reproduce steps.
|
||||
|
||||
If you use the CLI, you can create a debug log file with `subliminal --debug [...] 2> debug.log`.
|
||||
|
||||
Pull Requests
|
||||
-------------
|
||||
You can contribute code and documentation with pull requests. Any code contribution must be unit tested and the pull
|
||||
request open against the *develop* branch.
|
||||
|
||||
Translations
|
||||
------------
|
||||
Contribution to translations can be made on [subliminal's transifex page](https://www.transifex.com/subliminal/subliminal/)
|
||||
Subliminal is configured to work with [transifex-client](http://docs.transifex.com/client/)
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
Changelog
|
||||
---------
|
||||
|
||||
2.0.4
|
||||
^^^^^
|
||||
**release date:** 2016-09-03
|
||||
|
||||
* Fix subscenter
|
||||
|
||||
|
||||
2.0.3
|
||||
^^^^^
|
||||
**release date:** 2016-06-10
|
||||
|
||||
* Fix clearing cache in CLI
|
||||
|
||||
|
||||
2.0.2
|
||||
^^^^^
|
||||
**release date:** 2016-06-06
|
||||
|
||||
* Fix for dogpile.cache>=0.6.0
|
||||
* Fix missing sphinx_rtd_theme dependency
|
||||
|
||||
|
||||
2.0.1
|
||||
^^^^^
|
||||
**release date:** 2016-06-06
|
||||
|
||||
* Fix beautifulsoup4 minimal requirement
|
||||
|
||||
|
||||
2.0.0
|
||||
^^^^^
|
||||
**release date:** 2016-06-04
|
||||
|
||||
* Add refiners to enrich videos with information from metadata, tvdb and omdb
|
||||
* Add asynchronous provider search for faster searches
|
||||
* Add registrable managers so subliminal can run without install
|
||||
* Add archive support
|
||||
* Add the ability to customize scoring logic
|
||||
* Add an age argument to scan_videos for faster scanning
|
||||
* Add legendas.tv provider
|
||||
* Add shooter.cn provider
|
||||
* Improve matching and scoring
|
||||
* Improve documentation
|
||||
* Split nautilus integration into its own project
|
||||
|
||||
|
||||
1.1.1
|
||||
^^^^^
|
||||
**release date:** 2016-01-03
|
||||
|
||||
* Fix scanning videos on bad MKV files
|
||||
|
||||
|
||||
1.1
|
||||
^^^
|
||||
**release date:** 2015-12-29
|
||||
|
||||
* Fix library usage example in README
|
||||
* Fix for series name with special characters in addic7ed provider
|
||||
* Fix id property in thesubdb provider
|
||||
* Improve matching on titles
|
||||
* Add support for nautilus context menu with translations
|
||||
* Add support for searching subtitles in a separate directory
|
||||
* Add subscenter provider
|
||||
* Add support for python 3.5
|
||||
|
||||
|
||||
1.0.1
|
||||
^^^^^
|
||||
**release date:** 2015-07-23
|
||||
|
||||
* Fix unicode issues in CLI (python 2 only)
|
||||
* Fix score scaling in CLI (python 2 only)
|
||||
* Improve error handling in CLI
|
||||
* Color collect report in CLI
|
||||
|
||||
|
||||
1.0
|
||||
^^^
|
||||
**release date:** 2015-07-22
|
||||
|
||||
* Many changes and fixes
|
||||
* New test suite
|
||||
* New documentation
|
||||
* New CLI
|
||||
* Added support for SubsCenter
|
||||
|
||||
|
||||
0.7.5
|
||||
^^^^^
|
||||
**release date:** 2015-03-04
|
||||
|
||||
* Update requirements
|
||||
* Remove BierDopje provider
|
||||
* Add pre-guessed video optional argument in scan_video
|
||||
* Improve hearing impaired support
|
||||
* Fix TVSubtitles and Podnapisi providers
|
||||
|
||||
|
||||
0.7.4
|
||||
^^^^^
|
||||
**release date:** 2014-01-27
|
||||
|
||||
* Fix requirements for guessit and babelfish
|
||||
|
||||
|
||||
0.7.3
|
||||
^^^^^
|
||||
**release date:** 2013-11-22
|
||||
|
||||
* Fix windows compatibility
|
||||
* Improve subtitle validation
|
||||
* Improve embedded subtitle languages detection
|
||||
* Improve unittests
|
||||
|
||||
|
||||
0.7.2
|
||||
^^^^^
|
||||
**release date:** 2013-11-10
|
||||
|
||||
* Fix TVSubtitles for ambiguous series
|
||||
* Add a CACHE_VERSION to force cache reloading on version change
|
||||
* Set CLI default cache expiration time to 30 days
|
||||
* Add podnapisi provider
|
||||
* Support script for languages e.g. Latn, Cyrl
|
||||
* Improve logging levels
|
||||
* Fix subtitle validation in some rare cases
|
||||
|
||||
|
||||
0.7.1
|
||||
^^^^^
|
||||
**release date:** 2013-11-06
|
||||
|
||||
* Improve CLI
|
||||
* Add login support for Addic7ed
|
||||
* Remove lxml dependency
|
||||
* Many fixes
|
||||
|
||||
|
||||
0.7.0
|
||||
^^^^^
|
||||
**release date:** 2013-10-29
|
||||
|
||||
**WARNING:** Complete rewrite of subliminal with backward incompatible changes
|
||||
|
||||
* Use enzyme to parse metadata of videos
|
||||
* Use babelfish to handle languages
|
||||
* Use dogpile.cache for caching
|
||||
* Use charade to detect subtitle encoding
|
||||
* Use pysrt for subtitle validation
|
||||
* Use entry points for subtitle providers
|
||||
* New subtitle score computation
|
||||
* Hearing impaired subtitles support
|
||||
* Drop async support
|
||||
* Drop a few providers
|
||||
* And much more...
|
||||
|
||||
|
||||
0.6.4
|
||||
^^^^^
|
||||
**release date:** 2013-05-19
|
||||
|
||||
* Fix requirements due to enzyme 0.3
|
||||
|
||||
|
||||
0.6.3
|
||||
^^^^^
|
||||
**release date:** 2013-01-17
|
||||
|
||||
* Fix requirements due to requests 1.0
|
||||
|
||||
|
||||
0.6.2
|
||||
^^^^^
|
||||
**release date:** 2012-09-15
|
||||
|
||||
* Fix BierDopje
|
||||
* Fix Addic7ed
|
||||
* Fix SubsWiki
|
||||
* Fix missing enzyme import
|
||||
* Add Catalan and Galician languages to Addic7ed
|
||||
* Add possible services in help message of the CLI
|
||||
* Allow existing filenames to be passed without the ./ prefix
|
||||
|
||||
|
||||
0.6.1
|
||||
^^^^^
|
||||
**release date:** 2012-06-24
|
||||
|
||||
* Fix subtitle release name in BierDopje
|
||||
* Fix subtitles being downloaded multiple times
|
||||
* Add Chinese support to TvSubtitles
|
||||
* Fix encoding issues
|
||||
* Fix single download subtitles without the force option
|
||||
* Add Spanish (Latin America) exception to Addic7ed
|
||||
* Fix group_by_video when a list entry has None as subtitles
|
||||
* Add support for Galician language in Subtitulos
|
||||
* Add an integrity check after subtitles download for Addic7ed
|
||||
* Add error handling for if not strict in Language
|
||||
* Fix TheSubDB hash method to return None if the file is too small
|
||||
* Fix guessit.Language in Video.scan
|
||||
* Fix language detection of subtitles
|
||||
|
||||
|
||||
0.6.0
|
||||
^^^^^
|
||||
**release date:** 2012-06-16
|
||||
|
||||
**WARNING:** Backward incompatible changes
|
||||
|
||||
* Fix --workers option in CLI
|
||||
* Use a dedicated module for languages
|
||||
* Use beautifulsoup4
|
||||
* Improve return types
|
||||
* Add scan_filter option
|
||||
* Add --age option in CLI
|
||||
* Add TvSubtitles service
|
||||
* Add Addic7ed service
|
||||
|
||||
|
||||
0.5.1
|
||||
^^^^^
|
||||
**release date:** 2012-03-25
|
||||
|
||||
* Improve error handling of enzyme parsing
|
||||
|
||||
|
||||
0.5
|
||||
^^^
|
||||
**release date:** 2012-03-25
|
||||
**WARNING:** Backward incompatible changes
|
||||
|
||||
* Use more unicode
|
||||
* New list_subtitles and download_subtitles methods
|
||||
* New Pool object for asynchronous work
|
||||
* Improve sort algorithm
|
||||
* Better error handling
|
||||
* Make sorting customizable
|
||||
* Remove class Subliminal
|
||||
* Remove permissions handling
|
||||
|
||||
|
||||
0.4
|
||||
^^^
|
||||
**release date:** 2011-11-11
|
||||
|
||||
* Many fixes
|
||||
* Better error handling
|
||||
|
||||
|
||||
0.3
|
||||
^^^
|
||||
**release date:** 2011-08-18
|
||||
|
||||
* Fix a bug when series is not guessed by guessit
|
||||
* Fix dependencies failure when installing package
|
||||
* Fix encoding issues with logging
|
||||
* Add a script to ease subtitles download
|
||||
* Add possibility to choose mode of created files
|
||||
* Add more checks before adjusting permissions
|
||||
|
||||
|
||||
0.2
|
||||
^^^
|
||||
**release date:** 2011-07-11
|
||||
|
||||
* Fix plugin configuration
|
||||
* Fix some encoding issues
|
||||
* Remove extra logging
|
||||
|
||||
|
||||
0.1
|
||||
^^^
|
||||
**release date:** *private release*
|
||||
|
||||
* Initial release
|
||||
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Antoine Bertin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS 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.
|
||||
@@ -0,0 +1 @@
|
||||
include LICENSE HISTORY.rst requirements.txt
|
||||
@@ -1,6 +0,0 @@
|
||||
subliminal
|
||||
==========
|
||||
Python module to search and download subtitles
|
||||
|
||||
* [Project page](https://github.com/Diaoul/subliminal)
|
||||
* [Initial project by Patrick Dessalle](http://code.google.com/p/periscope/)
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
Subliminal
|
||||
==========
|
||||
Subtitles, faster than your thoughts.
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/subliminal.svg
|
||||
:target: https://pypi.python.org/pypi/subliminal
|
||||
:alt: Latest Version
|
||||
|
||||
.. image:: https://travis-ci.org/Diaoul/subliminal.svg?branch=develop
|
||||
:target: https://travis-ci.org/Diaoul/subliminal
|
||||
:alt: Travis CI build status
|
||||
|
||||
.. image:: https://readthedocs.org/projects/subliminal/badge/?version=latest
|
||||
:target: https://subliminal.readthedocs.org/
|
||||
:alt: Documentation Status
|
||||
|
||||
.. image:: https://coveralls.io/repos/Diaoul/subliminal/badge.svg?branch=develop&service=github
|
||||
:target: https://coveralls.io/github/Diaoul/subliminal?branch=develop
|
||||
:alt: Code coverage
|
||||
|
||||
.. image:: https://img.shields.io/github/license/Diaoul/subliminal.svg
|
||||
:target: https://github.com/Diaoul/subliminal/blob/master/LICENSE
|
||||
:alt: License
|
||||
|
||||
.. image:: https://img.shields.io/badge/gitter-join%20chat-1dce73.svg
|
||||
:alt: Join the chat at https://gitter.im/Diaoul/subliminal
|
||||
:target: https://gitter.im/Diaoul/subliminal
|
||||
|
||||
|
||||
:Project page: https://github.com/Diaoul/subliminal
|
||||
:Documentation: https://subliminal.readthedocs.org/
|
||||
|
||||
|
||||
Usage
|
||||
-----
|
||||
CLI
|
||||
^^^
|
||||
Download English subtitles::
|
||||
|
||||
$ subliminal download -l en The.Big.Bang.Theory.S05E18.HDTV.x264-LOL.mp4
|
||||
Collecting videos [####################################] 100%
|
||||
1 video collected / 0 video ignored / 0 error
|
||||
Downloading subtitles [####################################] 100%
|
||||
Downloaded 1 subtitle
|
||||
|
||||
Library
|
||||
^^^^^^^
|
||||
Download best subtitles in French and English for videos less than two weeks old in a video folder:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from babelfish import Language
|
||||
from subliminal import download_best_subtitles, region, save_subtitles, scan_videos
|
||||
|
||||
# configure the cache
|
||||
region.configure('dogpile.cache.dbm', arguments={'filename': 'cachefile.dbm'})
|
||||
|
||||
# scan for videos newer than 2 weeks and their existing subtitles in a folder
|
||||
videos = scan_videos('/video/folder', age=timedelta(weeks=2))
|
||||
|
||||
# download best subtitles
|
||||
subtitles = download_best_subtitles(videos, {Language('eng'), Language('fra')})
|
||||
|
||||
# save them to disk, next to the video
|
||||
for v in videos:
|
||||
save_subtitles(v, subtitles[v])
|
||||
|
||||
|
||||
Installation
|
||||
------------
|
||||
Subliminal can be installed as a regular python module by running::
|
||||
|
||||
$ [sudo] pip install subliminal
|
||||
|
||||
For a better isolation with your system you should use a dedicated virtualenv or install for your user only using
|
||||
the ``--user`` flag.
|
||||
|
||||
Nautilus/Nemo integration
|
||||
-------------------------
|
||||
See the dedicated `project page <https://github.com/Diaoul/nautilus-subliminal>`_ for more information.
|
||||
@@ -0,0 +1 @@
|
||||
-e .[dev,test]
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
# Makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS = -n -W
|
||||
SPHINXBUILD = sphinx-build
|
||||
PAPER =
|
||||
BUILDDIR = _build
|
||||
|
||||
# User-friendly check for sphinx-build
|
||||
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
|
||||
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
|
||||
endif
|
||||
|
||||
# Internal variables.
|
||||
PAPEROPT_a4 = -D latex_paper_size=a4
|
||||
PAPEROPT_letter = -D latex_paper_size=letter
|
||||
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
# the i18n builder cannot share the environment and doctrees with the others
|
||||
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
|
||||
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext
|
||||
|
||||
help:
|
||||
@echo "Please use \`make <target>' where <target> is one of"
|
||||
@echo " html to make standalone HTML files"
|
||||
@echo " dirhtml to make HTML files named index.html in directories"
|
||||
@echo " singlehtml to make a single large HTML file"
|
||||
@echo " pickle to make pickle files"
|
||||
@echo " json to make JSON files"
|
||||
@echo " htmlhelp to make HTML files and a HTML help project"
|
||||
@echo " qthelp to make HTML files and a qthelp project"
|
||||
@echo " applehelp to make an Apple Help Book"
|
||||
@echo " devhelp to make HTML files and a Devhelp project"
|
||||
@echo " epub to make an epub"
|
||||
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
||||
@echo " latexpdf to make LaTeX files and run them through pdflatex"
|
||||
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
|
||||
@echo " text to make text files"
|
||||
@echo " man to make manual pages"
|
||||
@echo " texinfo to make Texinfo files"
|
||||
@echo " info to make Texinfo files and run them through makeinfo"
|
||||
@echo " gettext to make PO message catalogs"
|
||||
@echo " changes to make an overview of all changed/added/deprecated items"
|
||||
@echo " xml to make Docutils-native XML files"
|
||||
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
|
||||
@echo " linkcheck to check all external links for integrity"
|
||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||
@echo " coverage to run coverage check of the documentation (if enabled)"
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILDDIR)/*
|
||||
|
||||
html:
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
||||
dirhtml:
|
||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
|
||||
|
||||
singlehtml:
|
||||
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
|
||||
|
||||
pickle:
|
||||
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
|
||||
@echo
|
||||
@echo "Build finished; now you can process the pickle files."
|
||||
|
||||
json:
|
||||
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
|
||||
@echo
|
||||
@echo "Build finished; now you can process the JSON files."
|
||||
|
||||
htmlhelp:
|
||||
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run HTML Help Workshop with the" \
|
||||
".hhp project file in $(BUILDDIR)/htmlhelp."
|
||||
|
||||
qthelp:
|
||||
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
|
||||
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
|
||||
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/subliminal.qhcp"
|
||||
@echo "To view the help file:"
|
||||
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/subliminal.qhc"
|
||||
|
||||
applehelp:
|
||||
$(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
|
||||
@echo
|
||||
@echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
|
||||
@echo "N.B. You won't be able to view it unless you put it in" \
|
||||
"~/Library/Documentation/Help or install it in your application" \
|
||||
"bundle."
|
||||
|
||||
devhelp:
|
||||
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
|
||||
@echo
|
||||
@echo "Build finished."
|
||||
@echo "To view the help file:"
|
||||
@echo "# mkdir -p $$HOME/.local/share/devhelp/subliminal"
|
||||
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/subliminal"
|
||||
@echo "# devhelp"
|
||||
|
||||
epub:
|
||||
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
|
||||
@echo
|
||||
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
|
||||
|
||||
latex:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo
|
||||
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
|
||||
@echo "Run \`make' in that directory to run these through (pdf)latex" \
|
||||
"(use \`make latexpdf' here to do that automatically)."
|
||||
|
||||
latexpdf:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through pdflatex..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
latexpdfja:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through platex and dvipdfmx..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
text:
|
||||
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
|
||||
@echo
|
||||
@echo "Build finished. The text files are in $(BUILDDIR)/text."
|
||||
|
||||
man:
|
||||
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
|
||||
@echo
|
||||
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
|
||||
|
||||
texinfo:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo
|
||||
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
|
||||
@echo "Run \`make' in that directory to run these through makeinfo" \
|
||||
"(use \`make info' here to do that automatically)."
|
||||
|
||||
info:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo "Running Texinfo files through makeinfo..."
|
||||
make -C $(BUILDDIR)/texinfo info
|
||||
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
|
||||
|
||||
gettext:
|
||||
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
|
||||
@echo
|
||||
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
|
||||
|
||||
changes:
|
||||
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
|
||||
@echo
|
||||
@echo "The overview file is in $(BUILDDIR)/changes."
|
||||
|
||||
linkcheck:
|
||||
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
|
||||
@echo
|
||||
@echo "Link check complete; look for any errors in the above output " \
|
||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
||||
|
||||
doctest:
|
||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||
@echo "Testing of doctests in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/doctest/output.txt."
|
||||
|
||||
coverage:
|
||||
$(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
|
||||
@echo "Testing of coverage in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/coverage/python.txt."
|
||||
|
||||
xml:
|
||||
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
|
||||
@echo
|
||||
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
|
||||
|
||||
pseudoxml:
|
||||
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
|
||||
@echo
|
||||
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
|
||||
@@ -0,0 +1,21 @@
|
||||
Cache
|
||||
=====
|
||||
.. module:: subliminal.cache
|
||||
|
||||
.. autodata:: SHOW_EXPIRATION_TIME
|
||||
:annotation:
|
||||
|
||||
.. autodata:: EPISODE_EXPIRATION_TIME
|
||||
:annotation:
|
||||
|
||||
.. autodata:: REFINER_EXPIRATION_TIME
|
||||
:annotation:
|
||||
|
||||
.. data:: region
|
||||
:annotation:
|
||||
|
||||
The :class:`~dogpile.cache.region.CacheRegion`
|
||||
|
||||
|
||||
Refer to dogpile.cache's `region configuration documentation
|
||||
<http://dogpilecache.readthedocs.org/en/latest/usage.html#region-configuration>`_ to see how to configure the region
|
||||
@@ -0,0 +1,3 @@
|
||||
CLI
|
||||
===
|
||||
.. automodule:: subliminal.cli
|
||||
@@ -0,0 +1,7 @@
|
||||
Core
|
||||
====
|
||||
.. automodule:: subliminal.core
|
||||
:exclude-members: ARCHIVE_EXTENSIONS
|
||||
|
||||
.. autodata:: ARCHIVE_EXTENSIONS
|
||||
:annotation:
|
||||
@@ -0,0 +1,3 @@
|
||||
Exceptions
|
||||
==========
|
||||
.. automodule:: subliminal.exceptions
|
||||
@@ -0,0 +1,3 @@
|
||||
Extensions
|
||||
==========
|
||||
.. automodule:: subliminal.extensions
|
||||
@@ -0,0 +1,48 @@
|
||||
Providers
|
||||
=========
|
||||
.. automodule:: subliminal.providers
|
||||
|
||||
Addic7ed
|
||||
--------
|
||||
.. automodule:: subliminal.providers.addic7ed
|
||||
:private-members:
|
||||
|
||||
LegendasTv
|
||||
----------
|
||||
.. automodule:: subliminal.providers.legendastv
|
||||
:private-members:
|
||||
|
||||
NapiProjekt
|
||||
-----------
|
||||
.. automodule:: subliminal.providers.napiprojekt
|
||||
:private-members:
|
||||
|
||||
OpenSubtitles
|
||||
-------------
|
||||
.. automodule:: subliminal.providers.opensubtitles
|
||||
:private-members:
|
||||
|
||||
Podnapisi
|
||||
---------
|
||||
.. automodule:: subliminal.providers.podnapisi
|
||||
:private-members:
|
||||
|
||||
Shooter
|
||||
-------
|
||||
.. automodule:: subliminal.providers.shooter
|
||||
:private-members:
|
||||
|
||||
SubsCenter
|
||||
----------
|
||||
.. automodule:: subliminal.providers.subscenter
|
||||
:private-members:
|
||||
|
||||
TheSubDB
|
||||
--------
|
||||
.. automodule:: subliminal.providers.thesubdb
|
||||
:private-members:
|
||||
|
||||
TVsubtitles
|
||||
-----------
|
||||
.. automodule:: subliminal.providers.tvsubtitles
|
||||
:private-members:
|
||||
@@ -0,0 +1,20 @@
|
||||
.. _refiners:
|
||||
|
||||
Refiners
|
||||
========
|
||||
.. automodule:: subliminal.refiners
|
||||
|
||||
|
||||
Metadata
|
||||
--------
|
||||
.. autofunction:: subliminal.refiners.metadata.refine
|
||||
|
||||
|
||||
TVDB
|
||||
----
|
||||
.. autofunction:: subliminal.refiners.tvdb.refine
|
||||
|
||||
|
||||
OMDb
|
||||
----
|
||||
.. autofunction:: subliminal.refiners.omdb.refine
|
||||
@@ -0,0 +1,3 @@
|
||||
Score
|
||||
=====
|
||||
.. automodule:: subliminal.score
|
||||
@@ -0,0 +1,7 @@
|
||||
Subtitle
|
||||
========
|
||||
.. automodule:: subliminal.subtitle
|
||||
:exclude-members: SUBTITLE_EXTENSIONS
|
||||
|
||||
.. autodata:: SUBTITLE_EXTENSIONS
|
||||
:annotation:
|
||||
@@ -0,0 +1,3 @@
|
||||
Utils
|
||||
=====
|
||||
.. automodule:: subliminal.utils
|
||||
@@ -0,0 +1,7 @@
|
||||
Video
|
||||
=====
|
||||
.. automodule:: subliminal.video
|
||||
:exclude-members: VIDEO_EXTENSIONS
|
||||
|
||||
.. autodata:: VIDEO_EXTENSIONS
|
||||
:annotation:
|
||||
@@ -0,0 +1,658 @@
|
||||
interactions:
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: http://podnapisi.net/subtitles/search/old?sTS=5&sL=hu&sTE=18&sK=The+Big+Bang+Theory&sXML=1
|
||||
response:
|
||||
body: {string: "<html>\r\n<head><title>302 Found</title></head>\r\n<body bgcolor=\"\
|
||||
white\">\r\n<center><h1>302 Found</h1></center>\r\n<hr><center>nginx/1.8.0</center>\r\
|
||||
\n</body>\r\n</html>\r\n"}
|
||||
headers:
|
||||
Connection: [keep-alive]
|
||||
Content-Length: ['160']
|
||||
Content-Type: [text/html]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:06 GMT']
|
||||
Location: ['http://www.podnapisi.net/subtitles/search/old?sTS=5&sL=hu&sTE=18&sK=The+Big+Bang+Theory&sXML=1']
|
||||
Server: [nginx/1.8.0]
|
||||
status: {code: 302, message: Moved Temporarily}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: http://www.podnapisi.net/subtitles/search/old?sTS=5&sL=hu&sTE=18&sK=The+Big+Bang+Theory&sXML=1
|
||||
response:
|
||||
body: {string: "<html>\r\n<head><title>301 Moved Permanently</title></head>\r\n\
|
||||
<body bgcolor=\"white\">\r\n<center><h1>301 Moved Permanently</h1></center>\r\
|
||||
\n<hr><center>nginx/1.8.0</center>\r\n</body>\r\n</html>\r\n"}
|
||||
headers:
|
||||
Connection: [keep-alive]
|
||||
Content-Length: ['184']
|
||||
Content-Type: [text/html]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:07 GMT']
|
||||
Location: ['https://www.podnapisi.net/subtitles/search/old?sTS=5&sL=hu&sTE=18&sK=The+Big+Bang+Theory&sXML=1']
|
||||
Server: [nginx/1.8.0]
|
||||
status: {code: 301, message: Moved Permanently}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: https://www.podnapisi.net/subtitles/search/old?sTS=5&sL=hu&sTE=18&sK=The+Big+Bang+Theory&sXML=1
|
||||
response:
|
||||
body:
|
||||
string: !!binary |
|
||||
H4sIAAAAAAAAA81VWW/bRhB+z6/YKC9OIXJJHT6C8QZKpNYCHDmFhbZJUARrcU0uSi0JHpIN5Md3
|
||||
hjecZNu+FHmRZma/2Tn2myG8ftjH7KCyXCfmcuS73ogps0sCbcLLUVncO+cjlhfSBDJOjLocPap8
|
||||
9Fo8g+fLm7fbD+9XLFN5GRc5+/SMMXi+ul69W222nfXkxfu3y8V28SWVoTaywChf8vKu0EWsXv4k
|
||||
0Gfo1YPYya7MMmWKMdslJf01cV7WPkOvBsnaWN+C0B1PAX22bUbsRAdjViU3Zo9KZmO2Tw5ardFa
|
||||
ZjH+pHEiA5VVhkbeyL2i9GIlcxRiacJShpVPK9eQQhOwONwiMDEkrVKdJ0FtTNVOSwyxC/Ixu0+y
|
||||
vcSi71NUMmybCVGJZYhqkBwNZZF/o04dPC1y2KjUflzVbfOnjtjOm17ZINhF63HX33+DorbacM2b
|
||||
2CDtC62tjWtR/xSwxdkiEgus5w0/7JiGOXbQbc0pGwjZZjuueWhFpNYLau5aLyBW2wBGHT9X1P8a
|
||||
NCQ3Qb5G9DPejY0NpB7krriSeaSGKf0pGDTrR0C/owQ0m0f4wFsRqnVVWWjniM5xAry7gw8vaZeP
|
||||
AB0If3Yx92eIRRlwXsXHYvE7cJKgmk+xjRR7o0P2BvcMQyXJHoHXR0ADKiaedwa8EqGZSJF/8H4B
|
||||
3mqAUyiiokhfcX48Ht00CYzEVaRdowre5pPzqHSKSDl3OnTuMBopGM2hAM6tN1/557xOj+6DfjeK
|
||||
mefPT4EPLN0pjZD465DFqj+vbNioaoNSgS4W6FKBbl2gW0dzr5bb35zrm2uq+3uYs4mXVkD3YXI6
|
||||
c5Zr/B7drm821P46ALRjusZ+z4EP1O6oSumqNKHMtDQ9pk619RBR2R/RA2Ft/nTqTfDn9IKeBQ3Q
|
||||
bnyBsToZrc0MC/+c7K1G8HpuhVfhGwVoVLmAeiTFhi+ANzLgZ6IxoAD1zAnPRf9GhmqARIQuNG4C
|
||||
uqHCC9EiIh1GnwN1r42mb3SNE8AHuG6AxPSCUu51GI4NpthRSEBLpo7c3gUSg9hMlL7Z/NDkrtN7
|
||||
Sm4c9u8Se2ES87hPyrzH/Cdyu38c9NL5+dd3/ydb/elsfkbc/LHYSmTv6EdKT7jpHB+hVy3863bu
|
||||
37blw93pCgAA
|
||||
headers:
|
||||
Connection: [keep-alive]
|
||||
Content-Encoding: [gzip]
|
||||
Content-Type: [text/xml;charset=utf-8]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:07 GMT']
|
||||
Server: [nginx/1.8.0]
|
||||
Vary: [Accept-Encoding]
|
||||
status: {code: 200, message: OK}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: http://podnapisi.net/subtitles/ZtAW/download?container=zip
|
||||
response:
|
||||
body: {string: "<html>\r\n<head><title>302 Found</title></head>\r\n<body bgcolor=\"\
|
||||
white\">\r\n<center><h1>302 Found</h1></center>\r\n<hr><center>nginx/1.8.0</center>\r\
|
||||
\n</body>\r\n</html>\r\n"}
|
||||
headers:
|
||||
Connection: [keep-alive]
|
||||
Content-Length: ['160']
|
||||
Content-Type: [text/html]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:07 GMT']
|
||||
Location: ['http://www.podnapisi.net/subtitles/ZtAW/download?container=zip']
|
||||
Server: [nginx/1.8.0]
|
||||
status: {code: 302, message: Moved Temporarily}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: http://www.podnapisi.net/subtitles/ZtAW/download?container=zip
|
||||
response:
|
||||
body: {string: "<html>\r\n<head><title>301 Moved Permanently</title></head>\r\n\
|
||||
<body bgcolor=\"white\">\r\n<center><h1>301 Moved Permanently</h1></center>\r\
|
||||
\n<hr><center>nginx/1.8.0</center>\r\n</body>\r\n</html>\r\n"}
|
||||
headers:
|
||||
Connection: [keep-alive]
|
||||
Content-Length: ['184']
|
||||
Content-Type: [text/html]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:07 GMT']
|
||||
Location: ['https://www.podnapisi.net/subtitles/ZtAW/download?container=zip']
|
||||
Server: [nginx/1.8.0]
|
||||
status: {code: 301, message: Moved Permanently}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: https://www.podnapisi.net/subtitles/ZtAW/download?container=zip
|
||||
response:
|
||||
body:
|
||||
string: !!binary |
|
||||
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
|
||||
headers:
|
||||
Accept-Ranges: [bytes]
|
||||
Connection: [keep-alive]
|
||||
Content-Disposition: [attachment; filename="e638ea178f406cb584f48051501e2cb4db4fce1d.zip"]
|
||||
Content-Length: ['10541']
|
||||
Content-Type: [application/octet-stream]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:07 GMT']
|
||||
ETag: ['"4f497ffb-292d"']
|
||||
Last-Modified: ['Sun, 26 Feb 2012 00:42:35 GMT']
|
||||
Server: [nginx/1.8.0]
|
||||
status: {code: 200, message: OK}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: http://podnapisi.net/subtitles/search/old?sTS=5&sL=hu&sTE=18&sK=The+Big+Bang+Theory&sXML=1
|
||||
response:
|
||||
body: {string: "<html>\r\n<head><title>302 Found</title></head>\r\n<body bgcolor=\"\
|
||||
white\">\r\n<center><h1>302 Found</h1></center>\r\n<hr><center>nginx/1.8.0</center>\r\
|
||||
\n</body>\r\n</html>\r\n"}
|
||||
headers:
|
||||
Connection: [keep-alive]
|
||||
Content-Length: ['160']
|
||||
Content-Type: [text/html]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:07 GMT']
|
||||
Location: ['http://www.podnapisi.net/subtitles/search/old?sTS=5&sL=hu&sTE=18&sK=The+Big+Bang+Theory&sXML=1']
|
||||
Server: [nginx/1.8.0]
|
||||
status: {code: 302, message: Moved Temporarily}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: http://www.podnapisi.net/subtitles/search/old?sTS=5&sL=hu&sTE=18&sK=The+Big+Bang+Theory&sXML=1
|
||||
response:
|
||||
body: {string: "<html>\r\n<head><title>301 Moved Permanently</title></head>\r\n\
|
||||
<body bgcolor=\"white\">\r\n<center><h1>301 Moved Permanently</h1></center>\r\
|
||||
\n<hr><center>nginx/1.8.0</center>\r\n</body>\r\n</html>\r\n"}
|
||||
headers:
|
||||
Connection: [keep-alive]
|
||||
Content-Length: ['184']
|
||||
Content-Type: [text/html]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:08 GMT']
|
||||
Location: ['https://www.podnapisi.net/subtitles/search/old?sTS=5&sL=hu&sTE=18&sK=The+Big+Bang+Theory&sXML=1']
|
||||
Server: [nginx/1.8.0]
|
||||
status: {code: 301, message: Moved Permanently}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: https://www.podnapisi.net/subtitles/search/old?sTS=5&sL=hu&sTE=18&sK=The+Big+Bang+Theory&sXML=1
|
||||
response:
|
||||
body:
|
||||
string: !!binary |
|
||||
H4sIAAAAAAAAA81VWW/bRhB+z6/YKC9OIXJJHT6C8QZKpNYCHDmFhbZJUARrcU0uSi0JHpIN5Md3
|
||||
hjecZNu+FHmRZma/2Tn2myG8ftjH7KCyXCfmcuS73ogps0sCbcLLUVncO+cjlhfSBDJOjLocPap8
|
||||
9Fo8g+fLm7fbD+9XLFN5GRc5+/SMMXi+ul69W222nfXkxfu3y8V28SWVoTaywChf8vKu0EWsXv4k
|
||||
0Gfo1YPYya7MMmWKMdslJf01cV7WPkOvBsnaWN+C0B1PAX22bUbsRAdjViU3Zo9KZmO2Tw5ardFa
|
||||
ZjH+pHEiA5VVhkbeyL2i9GIlcxRiacJShpVPK9eQQhOwONwiMDEkrVKdJ0FtTNVOSwyxC/Ixu0+y
|
||||
vcSi71NUMmybCVGJZYhqkBwNZZF/o04dPC1y2KjUflzVbfOnjtjOm17ZINhF63HX33+DorbacM2b
|
||||
2CDtC62tjWtR/xSwxdkiEgus5w0/7JiGOXbQbc0pGwjZZjuueWhFpNYLau5aLyBW2wBGHT9X1P8a
|
||||
NCQ3Qb5G9DPejY0NpB7krriSeaSGKf0pGDTrR0C/owQ0m0f4wFsRqnVVWWjniM5xAry7gw8vaZeP
|
||||
AB0If3Yx92eIRRlwXsXHYvE7cJKgmk+xjRR7o0P2BvcMQyXJHoHXR0ADKiaedwa8EqGZSJF/8H4B
|
||||
3mqAUyiiokhfcX48Ht00CYzEVaRdowre5pPzqHSKSDl3OnTuMBopGM2hAM6tN1/557xOj+6DfjeK
|
||||
mefPT4EPLN0pjZD465DFqj+vbNioaoNSgS4W6FKBbl2gW0dzr5bb35zrm2uq+3uYs4mXVkD3YXI6
|
||||
c5Zr/B7drm821P46ALRjusZ+z4EP1O6oSumqNKHMtDQ9pk619RBR2R/RA2Ft/nTqTfDn9IKeBQ3Q
|
||||
bnyBsToZrc0MC/+c7K1G8HpuhVfhGwVoVLmAeiTFhi+ANzLgZ6IxoAD1zAnPRf9GhmqARIQuNG4C
|
||||
uqHCC9EiIh1GnwN1r42mb3SNE8AHuG6AxPSCUu51GI4NpthRSEBLpo7c3gUSg9hMlL7Z/NDkrtN7
|
||||
Sm4c9u8Se2ES87hPyrzH/Cdyu38c9NL5+dd3/ydb/elsfkbc/LHYSmTv6EdKT7jpHB+hVy3863bu
|
||||
37blw93pCgAA
|
||||
headers:
|
||||
Connection: [keep-alive]
|
||||
Content-Encoding: [gzip]
|
||||
Content-Type: [text/xml;charset=utf-8]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:08 GMT']
|
||||
Server: [nginx/1.8.0]
|
||||
Vary: [Accept-Encoding]
|
||||
status: {code: 200, message: OK}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: http://podnapisi.net/subtitles/ZtAW/download?container=zip
|
||||
response:
|
||||
body: {string: "<html>\r\n<head><title>302 Found</title></head>\r\n<body bgcolor=\"\
|
||||
white\">\r\n<center><h1>302 Found</h1></center>\r\n<hr><center>nginx/1.8.0</center>\r\
|
||||
\n</body>\r\n</html>\r\n"}
|
||||
headers:
|
||||
Connection: [keep-alive]
|
||||
Content-Length: ['160']
|
||||
Content-Type: [text/html]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:08 GMT']
|
||||
Location: ['http://www.podnapisi.net/subtitles/ZtAW/download?container=zip']
|
||||
Server: [nginx/1.8.0]
|
||||
status: {code: 302, message: Moved Temporarily}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: http://www.podnapisi.net/subtitles/ZtAW/download?container=zip
|
||||
response:
|
||||
body: {string: "<html>\r\n<head><title>301 Moved Permanently</title></head>\r\n\
|
||||
<body bgcolor=\"white\">\r\n<center><h1>301 Moved Permanently</h1></center>\r\
|
||||
\n<hr><center>nginx/1.8.0</center>\r\n</body>\r\n</html>\r\n"}
|
||||
headers:
|
||||
Connection: [keep-alive]
|
||||
Content-Length: ['184']
|
||||
Content-Type: [text/html]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:08 GMT']
|
||||
Location: ['https://www.podnapisi.net/subtitles/ZtAW/download?container=zip']
|
||||
Server: [nginx/1.8.0]
|
||||
status: {code: 301, message: Moved Permanently}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: https://www.podnapisi.net/subtitles/ZtAW/download?container=zip
|
||||
response:
|
||||
body:
|
||||
string: !!binary |
|
||||
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
|
||||
headers:
|
||||
Accept-Ranges: [bytes]
|
||||
Connection: [keep-alive]
|
||||
Content-Disposition: [attachment; filename="e638ea178f406cb584f48051501e2cb4db4fce1d.zip"]
|
||||
Content-Length: ['10541']
|
||||
Content-Type: [application/octet-stream]
|
||||
Date: ['Wed, 23 Mar 2016 09:34:08 GMT']
|
||||
ETag: ['"4f497ffb-292d"']
|
||||
Last-Modified: ['Sun, 26 Feb 2012 00:42:35 GMT']
|
||||
Server: [nginx/1.8.0]
|
||||
status: {code: 200, message: OK}
|
||||
version: 1
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# subliminal documentation build configuration file, created by
|
||||
# sphinx-quickstart on Sat Jul 11 00:40:28 2015.
|
||||
#
|
||||
# This file is execfile()d with the current directory set to its
|
||||
# containing dir.
|
||||
#
|
||||
# Note that not all possible configuration values are present in this
|
||||
# autogenerated file.
|
||||
#
|
||||
# All configuration values have a default; values that are commented out
|
||||
# serve to show the default.
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shlex
|
||||
import sphinx_rtd_theme
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
import subliminal
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
#needs_sphinx = '1.0'
|
||||
|
||||
# If true, Sphinx will warn about all references where the target cannot be found.
|
||||
nitpicky = True
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.viewcode',
|
||||
'sphinxcontrib.programoutput',
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# The suffix(es) of source filenames.
|
||||
# You can specify multiple suffix as a list of string:
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = '.rst'
|
||||
|
||||
# The encoding of source files.
|
||||
#source_encoding = 'utf-8-sig'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = subliminal.__title__
|
||||
copyright = ' '.join(subliminal.__copyright__.split()[1:])
|
||||
author = subliminal.__copyright__.split(', ')[1]
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = subliminal.__version__.split('-')[0]
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = subliminal.__version__
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
|
||||
# There are two options for replacing |today|: either, you set today to some
|
||||
# non-false value, then it is used:
|
||||
#today = ''
|
||||
# Else, today_fmt is used as the format for a strftime call.
|
||||
#today_fmt = '%B %d, %Y'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
exclude_patterns = ['_build']
|
||||
|
||||
# The reST default role (used for this markup: `text`) to use for all
|
||||
# documents.
|
||||
#default_role = None
|
||||
|
||||
# If true, '()' will be appended to :func: etc. cross-reference text.
|
||||
#add_function_parentheses = True
|
||||
|
||||
# If true, the current module name will be prepended to all description
|
||||
# unit titles (such as .. function::).
|
||||
#add_module_names = True
|
||||
|
||||
# If true, sectionauthor and moduleauthor directives will be shown in the
|
||||
# output. They are ignored by default.
|
||||
#show_authors = False
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# A list of ignored prefixes for module index sorting.
|
||||
#modindex_common_prefix = []
|
||||
|
||||
# If true, keep warnings as "system message" paragraphs in the built documents.
|
||||
#keep_warnings = False
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = False
|
||||
|
||||
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#html_theme_options = {}
|
||||
# html_theme_options = {
|
||||
# 'github_user': 'Diaoul',
|
||||
# 'github_repo': project,
|
||||
# 'travis_button': True,
|
||||
# 'gratipay_user': 'Diaoul'
|
||||
# }
|
||||
|
||||
# Add any paths that contain custom themes here, relative to this directory.
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
# The name for this set of Sphinx documents. If None, it defaults to
|
||||
# "<project> v<release> documentation".
|
||||
#html_title = None
|
||||
|
||||
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||
#html_short_title = None
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
#html_logo = None
|
||||
|
||||
# The name of an image file (within the static path) to use as favicon of the
|
||||
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
# pixels large.
|
||||
#html_favicon = None
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# Add any extra paths that contain custom files (such as robots.txt or
|
||||
# .htaccess) here, relative to this directory. These files are copied
|
||||
# directly to the root of the documentation.
|
||||
#html_extra_path = []
|
||||
|
||||
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
||||
# using the given strftime format.
|
||||
#html_last_updated_fmt = '%b %d, %Y'
|
||||
|
||||
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||
# typographically correct entities.
|
||||
#html_use_smartypants = True
|
||||
|
||||
# Custom sidebar templates, maps document names to template names.
|
||||
#html_sidebars = {}
|
||||
|
||||
# Additional templates that should be rendered to pages, maps page names to
|
||||
# template names.
|
||||
#html_additional_pages = {}
|
||||
|
||||
# If false, no module index is generated.
|
||||
#html_domain_indices = True
|
||||
|
||||
# If false, no index is generated.
|
||||
#html_use_index = True
|
||||
|
||||
# If true, the index is split into individual pages for each letter.
|
||||
#html_split_index = False
|
||||
|
||||
# If true, links to the reST sources are added to the pages.
|
||||
#html_show_sourcelink = True
|
||||
|
||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||
#html_show_sphinx = True
|
||||
|
||||
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||
#html_show_copyright = True
|
||||
|
||||
# If true, an OpenSearch description file will be output, and all pages will
|
||||
# contain a <link> tag referring to it. The value of this option must be the
|
||||
# base URL from which the finished HTML is served.
|
||||
#html_use_opensearch = ''
|
||||
|
||||
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
||||
#html_file_suffix = None
|
||||
|
||||
# Language to be used for generating the HTML full-text search index.
|
||||
# Sphinx supports the following languages:
|
||||
# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja'
|
||||
# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr'
|
||||
#html_search_language = 'en'
|
||||
|
||||
# A dictionary with options for the search language support, empty by default.
|
||||
# Now only 'ja' uses this config value
|
||||
#html_search_options = {'type': 'default'}
|
||||
|
||||
# The name of a javascript file (relative to the configuration directory) that
|
||||
# implements a search results scorer. If empty, the default will be used.
|
||||
#html_search_scorer = 'scorer.js'
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = project + 'doc'
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#'preamble': '',
|
||||
|
||||
# Latex figure (float) alignment
|
||||
#'figure_align': 'htbp',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, project + '.tex', project + ' Documentation',
|
||||
author, 'manual'),
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
# the title page.
|
||||
#latex_logo = None
|
||||
|
||||
# For "manual" documents, if this is true, then toplevel headings are parts,
|
||||
# not chapters.
|
||||
#latex_use_parts = False
|
||||
|
||||
# If true, show page references after internal links.
|
||||
#latex_show_pagerefs = False
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#latex_show_urls = False
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#latex_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#latex_domain_indices = True
|
||||
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
(master_doc, project, project + ' Documentation',
|
||||
[author], 1)
|
||||
]
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#man_show_urls = False
|
||||
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(master_doc, project, project + ' Documentation',
|
||||
author, project, 'Subtitles, faster than your thoughts',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#texinfo_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#texinfo_domain_indices = True
|
||||
|
||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||
#texinfo_show_urls = 'footnote'
|
||||
|
||||
# If true, do not generate a @detailmenu in the "Top" node's menu.
|
||||
#texinfo_no_detailmenu = False
|
||||
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {
|
||||
'python': ('http://docs.python.org/3.5', None),
|
||||
'guessit': ('http://guessit.readthedocs.org/en/latest', None),
|
||||
'babelfish': ('http://babelfish.readthedocs.org/en/latest', None),
|
||||
'dogpilecache': ('http://dogpilecache.readthedocs.org/en/latest', None),
|
||||
'dogpilecore': ('http://dogpilecore.readthedocs.org/en/latest', None),
|
||||
'stevedore': ('http://docs.openstack.org/developer/stevedore', None),
|
||||
'click': ('http://click.pocoo.org/4', None),
|
||||
'rarfile': ('http://rarfile.readthedocs.org/en/latest/', None)
|
||||
}
|
||||
|
||||
|
||||
# -- Options for autodoc -------------------------------------------------------
|
||||
autodoc_member_order = 'bysource'
|
||||
|
||||
autodoc_default_flags = ['members']
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
try:
|
||||
from unittest.mock import Mock
|
||||
except ImportError:
|
||||
from mock import Mock
|
||||
from vcr import VCR
|
||||
|
||||
from subliminal.cache import region
|
||||
|
||||
vcr = VCR(path_transformer=lambda path: path + '.yaml',
|
||||
record_mode=os.environ.get('VCR_RECORD_MODE', 'once'),
|
||||
match_on=['method', 'scheme', 'host', 'port', 'path', 'query', 'body'],
|
||||
cassette_library_dir=os.path.realpath(os.path.join('docs', 'cassettes')))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope='session')
|
||||
def configure_region():
|
||||
region.configure('dogpile.cache.null')
|
||||
region.configure = Mock()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def chdir(tmpdir, monkeypatch):
|
||||
monkeypatch.chdir(str(tmpdir))
|
||||
|
||||
|
||||
@pytest.yield_fixture(autouse=True)
|
||||
def use_cassette(request):
|
||||
with vcr.use_cassette('test_' + request.fspath.purebasename):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def skip_python_2():
|
||||
if sys.version_info < (3, 0):
|
||||
return pytest.skip('Requires python 3')
|
||||
@@ -0,0 +1,52 @@
|
||||
.. subliminal documentation master file, created by
|
||||
sphinx-quickstart on Sat Jul 11 00:40:28 2015.
|
||||
You can adapt this file completely to your liking, but it should at least
|
||||
contain the root `toctree` directive.
|
||||
|
||||
Welcome to subliminal!
|
||||
======================
|
||||
Subliminal is a python 2.7+ library to search and download subtitles.
|
||||
It comes with an easy to use yet powerful :abbr:`CLI (command-line interface)` suitable for direct use or cron jobs.
|
||||
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
user/usage
|
||||
user/how_it_works
|
||||
user/cli
|
||||
user/provider_guide
|
||||
|
||||
|
||||
API Documentation
|
||||
-----------------
|
||||
If you are looking for information on a specific function, class or method, this part of the documentation is for you.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
api/core
|
||||
api/video
|
||||
api/subtitle
|
||||
api/providers
|
||||
api/refiners
|
||||
api/extensions
|
||||
api/score
|
||||
api/utils
|
||||
api/cache
|
||||
api/cli
|
||||
api/exceptions
|
||||
|
||||
|
||||
License
|
||||
-------
|
||||
MIT
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
@ECHO OFF
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set BUILDDIR=_build
|
||||
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
|
||||
set I18NSPHINXOPTS=%SPHINXOPTS% .
|
||||
if NOT "%PAPER%" == "" (
|
||||
set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
|
||||
set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
|
||||
)
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
if "%1" == "help" (
|
||||
:help
|
||||
echo.Please use `make ^<target^>` where ^<target^> is one of
|
||||
echo. html to make standalone HTML files
|
||||
echo. dirhtml to make HTML files named index.html in directories
|
||||
echo. singlehtml to make a single large HTML file
|
||||
echo. pickle to make pickle files
|
||||
echo. json to make JSON files
|
||||
echo. htmlhelp to make HTML files and a HTML help project
|
||||
echo. qthelp to make HTML files and a qthelp project
|
||||
echo. devhelp to make HTML files and a Devhelp project
|
||||
echo. epub to make an epub
|
||||
echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
|
||||
echo. text to make text files
|
||||
echo. man to make manual pages
|
||||
echo. texinfo to make Texinfo files
|
||||
echo. gettext to make PO message catalogs
|
||||
echo. changes to make an overview over all changed/added/deprecated items
|
||||
echo. xml to make Docutils-native XML files
|
||||
echo. pseudoxml to make pseudoxml-XML files for display purposes
|
||||
echo. linkcheck to check all external links for integrity
|
||||
echo. doctest to run all doctests embedded in the documentation if enabled
|
||||
echo. coverage to run coverage check of the documentation if enabled
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "clean" (
|
||||
for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
|
||||
del /q /s %BUILDDIR%\*
|
||||
goto end
|
||||
)
|
||||
|
||||
|
||||
REM Check if sphinx-build is available and fallback to Python version if any
|
||||
%SPHINXBUILD% 2> nul
|
||||
if errorlevel 9009 goto sphinx_python
|
||||
goto sphinx_ok
|
||||
|
||||
:sphinx_python
|
||||
|
||||
set SPHINXBUILD=python -m sphinx.__init__
|
||||
%SPHINXBUILD% 2> nul
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.http://sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:sphinx_ok
|
||||
|
||||
|
||||
if "%1" == "html" (
|
||||
%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/html.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "dirhtml" (
|
||||
%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "singlehtml" (
|
||||
%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "pickle" (
|
||||
%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can process the pickle files.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "json" (
|
||||
%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can process the JSON files.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "htmlhelp" (
|
||||
%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can run HTML Help Workshop with the ^
|
||||
.hhp project file in %BUILDDIR%/htmlhelp.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "qthelp" (
|
||||
%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can run "qcollectiongenerator" with the ^
|
||||
.qhcp project file in %BUILDDIR%/qthelp, like this:
|
||||
echo.^> qcollectiongenerator %BUILDDIR%\qthelp\subliminal.qhcp
|
||||
echo.To view the help file:
|
||||
echo.^> assistant -collectionFile %BUILDDIR%\qthelp\subliminal.ghc
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "devhelp" (
|
||||
%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "epub" (
|
||||
%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The epub file is in %BUILDDIR%/epub.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "latex" (
|
||||
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "latexpdf" (
|
||||
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
|
||||
cd %BUILDDIR%/latex
|
||||
make all-pdf
|
||||
cd %~dp0
|
||||
echo.
|
||||
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "latexpdfja" (
|
||||
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
|
||||
cd %BUILDDIR%/latex
|
||||
make all-pdf-ja
|
||||
cd %~dp0
|
||||
echo.
|
||||
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "text" (
|
||||
%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The text files are in %BUILDDIR%/text.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "man" (
|
||||
%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The manual pages are in %BUILDDIR%/man.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "texinfo" (
|
||||
%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "gettext" (
|
||||
%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "changes" (
|
||||
%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.The overview file is in %BUILDDIR%/changes.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "linkcheck" (
|
||||
%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Link check complete; look for any errors in the above output ^
|
||||
or in %BUILDDIR%/linkcheck/output.txt.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "doctest" (
|
||||
%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Testing of doctests in the sources finished, look at the ^
|
||||
results in %BUILDDIR%/doctest/output.txt.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "coverage" (
|
||||
%SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Testing of coverage in the sources finished, look at the ^
|
||||
results in %BUILDDIR%/coverage/python.txt.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "xml" (
|
||||
%SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The XML files are in %BUILDDIR%/xml.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "pseudoxml" (
|
||||
%SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
|
||||
goto end
|
||||
)
|
||||
|
||||
:end
|
||||
@@ -0,0 +1,18 @@
|
||||
.. _cli:
|
||||
|
||||
CLI
|
||||
===
|
||||
|
||||
subliminal
|
||||
----------
|
||||
.. program-output:: subliminal --help
|
||||
|
||||
|
||||
subliminal download
|
||||
-------------------
|
||||
.. program-output:: subliminal download --help
|
||||
|
||||
|
||||
subliminal cache
|
||||
----------------
|
||||
.. program-output:: subliminal cache --help
|
||||
@@ -0,0 +1,52 @@
|
||||
How it works
|
||||
============
|
||||
|
||||
Providers
|
||||
---------
|
||||
Subliminal uses multiple providers to give users a vast choice and have a better chance to find the best matching
|
||||
subtitles. Current supported providers are:
|
||||
|
||||
* Addic7ed
|
||||
* LegendasTV
|
||||
* NapiProjekt
|
||||
* OpenSubtitles
|
||||
* Podnapisi
|
||||
* Shooter
|
||||
* SubsCenter
|
||||
* TheSubDB
|
||||
* TvSubtitles
|
||||
|
||||
Providers all inherit the same :class:`~subliminal.providers.Provider` base class and thus share the same API.
|
||||
They are registered on the ``subliminal.providers`` entry point and are exposed through the
|
||||
:data:`~subliminal.extensions.provider_manager` for easy access.
|
||||
|
||||
To work with multiple providers seamlessly, the :class:`~subliminal.core.ProviderPool` exposes the same API but
|
||||
distributes it to its providers and :class:`~subliminal.core.AsyncProviderPool` does it asynchronously.
|
||||
|
||||
.. _scoring:
|
||||
|
||||
Scoring
|
||||
-------
|
||||
Rating subtitles and comparing them is probably the most difficult part and this is where subliminal excels with its
|
||||
powerful scoring algorithm.
|
||||
|
||||
Using `guessit <http://guessit.readthedocs.org>`_ and `enzyme <http://enzyme.readthedocs.org>`_, subliminal extracts
|
||||
properties of the video and match them with the properties of the subtitles found with the providers.
|
||||
|
||||
Equations in :mod:`subliminal.score` give a score to each property (called a match). The more matches the video and
|
||||
the subtitle have, the higher the score computed with :func:`~subliminal.score.compute_score` gets.
|
||||
|
||||
|
||||
Libraries
|
||||
---------
|
||||
Various libraries are used by subliminal and are key to its success:
|
||||
|
||||
* `guessit <http://guessit.readthedocs.org>`_ to guess information from filenames
|
||||
* `enzyme <http://enzyme.readthedocs.org>`_ to detect embedded subtitles in videos and read other video metadata
|
||||
* `babelfish <http://babelfish.readthedocs.org>`_ to work with languages
|
||||
* `requests <http://docs.python-requests.org>`_ to make human readable HTTP requests
|
||||
* `BeautifulSoup <http://www.crummy.com/software/BeautifulSoup>`_ to parse HTML and XML
|
||||
* `dogpile.cache <http://dogpilecache.readthedocs.org>`_ to cache intermediate search results
|
||||
* `stevedore <http://docs.openstack.org/developer/stevedore/>`_ to manage the provider entry point
|
||||
* `chardet <http://chardet.readthedocs.org>`_ to detect subtitles' encoding
|
||||
* `pysrt <https://github.com/byroot/pysrt>`_ to validate downloaded subtitles
|
||||
@@ -0,0 +1,96 @@
|
||||
Provider Guide
|
||||
==============
|
||||
This guide is going to explain how to add a :class:`~subliminal.providers.Provider` to subliminal. You are encouraged
|
||||
to take a look at the existing providers, it can be a nice base to start your own provider.
|
||||
|
||||
|
||||
Requirements
|
||||
------------
|
||||
When starting a provider you should be able to answer to the following questions:
|
||||
|
||||
* What languages does my provider support?
|
||||
* What are the language codes for the supported languages?
|
||||
* Does my provider deliver subtitles for episodes? for movies?
|
||||
* Does my provider require a video hash?
|
||||
|
||||
Each response of these questions will help you set the correct attributes for your
|
||||
:class:`~subliminal.providers.Provider`.
|
||||
|
||||
|
||||
Video Validation
|
||||
----------------
|
||||
Not all providers deliver subtitles for :class:`~subliminal.video.Episode`. Some may require a hash.
|
||||
The :meth:`~subliminal.providers.Provider.check` method does validation against a :class:`~subliminal.video.Video`
|
||||
object and will return `False` if the given :class:`~subliminal.video.Video` isn't suitable. If you're not happy
|
||||
with the default implementation, you can override it.
|
||||
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
API keys must not be configurable by the user and must remain linked to subliminal. Hence they must be written
|
||||
in the provider module.
|
||||
|
||||
Per-user authentication is allowed and must be configured at instantiation as keyword arguments. Configuration
|
||||
will be done by the user through the `provider_configs` argument of the :func:`~subliminal.core.list_subtitles` and
|
||||
:func:`~subliminal.core.download_best_subtitles` functions. No network operation must be done during instantiation,
|
||||
only configuration. Any error in the configuration must raise a
|
||||
:class:`~subliminal.exceptions.ConfigurationError`.
|
||||
|
||||
|
||||
Beyond this point, if an error occurs, a generic :class:`~subliminal.exceptions.ProviderError` exception
|
||||
must be raised. You can also use more explicit exception classes :class:`~subliminal.exceptions.AuthenticationError`
|
||||
and :class:`~subliminal.exceptions.DownloadLimitExceeded`.
|
||||
|
||||
|
||||
Initialization / Termination
|
||||
----------------------------
|
||||
Actual authentication operations must take place in the :meth:`~subliminal.providers.Provider.initialize` method.
|
||||
If you need anything to be executed when the provider isn't used anymore like logout,
|
||||
use :meth:`~subliminal.providers.Provider.terminate`.
|
||||
|
||||
|
||||
Caching policy
|
||||
--------------
|
||||
To save bandwidth and improve querying time, intermediate data should be cached when possible. Typical use case is
|
||||
when a query to retrieve show ids is required prior to the query to actually search for subtitles. In that case
|
||||
the function that gets the show id from the show name must be cached.
|
||||
Expiration time should be :data:`~subliminal.cache.SHOW_EXPIRATION_TIME` for shows and
|
||||
:data:`~subliminal.cache.EPISODE_EXPIRATION_TIME` for episodes.
|
||||
|
||||
|
||||
Language
|
||||
--------
|
||||
To be able to handle various language codes, subliminal makes use of `babelfish <http://babelfish.readthedocs.org>`_
|
||||
Language and converters. You must set the attribute :attr:`~subliminal.providers.Provider.languages` with a set of
|
||||
supported :class:`~babelfish.language.Language`.
|
||||
|
||||
If you cannot find a suitable converter for your provider, you can `make one of your own
|
||||
<http://babelfish.readthedocs.org/en/latest/#custom-converters>`_.
|
||||
|
||||
|
||||
Querying
|
||||
--------
|
||||
The :meth:`~subliminal.providers.Provider.query` method parameters must include all aspects of provider's querying with
|
||||
primary types.
|
||||
|
||||
|
||||
Subtitle
|
||||
--------
|
||||
A custom :class:`~subliminal.subtitle.Subtitle` subclass must be created to represent a subtitle from the provider.
|
||||
It must have relevant attributes that can be used to compute the matches of the subtitle against a
|
||||
:class:`~subliminal.video.Video` object.
|
||||
|
||||
|
||||
Score computation
|
||||
-----------------
|
||||
To be able to compare subtitles coming from different providers between them, the
|
||||
:meth:`~subliminal.subtitle.Subtitle.get_matches` method must be implemented.
|
||||
|
||||
|
||||
Unittesting
|
||||
-----------
|
||||
All possible uses of :meth:`~subliminal.providers.Provider.query`,
|
||||
:meth:`~subliminal.providers.Provider.list_subtitles` and :meth:`~subliminal.providers.Provider.download_subtitle`
|
||||
methods must have integration tests. Use `vcrpy <https://github.com/kevin1024/vcrpy>`_ for recording and playback
|
||||
of network activity.
|
||||
Other functions must be unittested. If necessary, you can use :mod:`unittest.mock` to mock some functions.
|
||||
@@ -0,0 +1,148 @@
|
||||
Usage
|
||||
=====
|
||||
CLI
|
||||
---
|
||||
Download English subtitles::
|
||||
|
||||
$ subliminal download -l en The.Big.Bang.Theory.S05E18.HDTV.x264-LOL.mp4
|
||||
Collecting videos [####################################] 100%
|
||||
1 video collected / 0 video ignored
|
||||
Downloading subtitles [####################################] 100%
|
||||
Downloaded 1 subtitle
|
||||
|
||||
.. warning::
|
||||
|
||||
For cron usage, make sure to specify a maximum age (with ``--age``) so subtitles are searched for recent videos
|
||||
only. Otherwise you will get banned from the providers for abuse due to too many requests. If subliminal didn't
|
||||
find subtitles for an old video, it's unlikely it will find subtitles for that video ever anyway.
|
||||
|
||||
See :ref:`cli` for more details on the available commands and options.
|
||||
|
||||
|
||||
Nautilus/Nemo integration
|
||||
-------------------------
|
||||
See the dedicated `project page <https://github.com/Diaoul/nautilus-subliminal>`_ for more information.
|
||||
|
||||
|
||||
High level API
|
||||
--------------
|
||||
You can call subliminal in many different ways depending on how much control you want over the process. For most use
|
||||
cases, you can stick to the standard API.
|
||||
|
||||
Common
|
||||
^^^^^^
|
||||
Let's start by importing subliminal:
|
||||
|
||||
>>> import os
|
||||
>>> from babelfish import *
|
||||
>>> from subliminal import *
|
||||
|
||||
Before going further, there are a few things to know about subliminal.
|
||||
|
||||
Video
|
||||
^^^^^
|
||||
The :class:`~subliminal.video.Movie` and :class:`~subliminal.video.Episode` classes represent a video,
|
||||
existing or not. You can create a video by name (or path) with :meth:`Video.fromname <subliminal.video.Video.fromname>`,
|
||||
use :func:`~subliminal.core.scan_video` on an existing file path to get even more information about the video or
|
||||
use :func:`~subliminal.core.scan_videos` on an existing directory path to scan a whole directory for videos.
|
||||
|
||||
>>> video = Video.fromname('The.Big.Bang.Theory.S05E18.HDTV.x264-LOL.mp4')
|
||||
>>> video
|
||||
<Episode ['The Big Bang Theory', 5x18]>
|
||||
|
||||
Here video information was guessed based on the name of the video, you can access some video attributes:
|
||||
|
||||
>>> video.video_codec
|
||||
'h264'
|
||||
>>> video.release_group
|
||||
'LOL'
|
||||
|
||||
Configuration
|
||||
^^^^^^^^^^^^^
|
||||
Before proceeding to listing and downloading subtitles, you need to configure the cache. Subliminal uses a cache to
|
||||
reduce repeated queries to providers and improve overall performance with no impact on search quality. For the sake
|
||||
of this example, we're going to use a memory backend.
|
||||
|
||||
>>> my_region = region.configure('dogpile.cache.memory')
|
||||
|
||||
.. warning::
|
||||
|
||||
Choose a cache that fits your application and prefer persistent over volatile backends. The ``file`` backend is
|
||||
usually a good choice.
|
||||
See `dogpile.cache's documentation <http://dogpilecache.readthedocs.org>`_ for more details on backends.
|
||||
|
||||
Now that we're done with the basics, let's have some *real* fun.
|
||||
|
||||
Listing
|
||||
^^^^^^^
|
||||
To list subtitles, subliminal provides a :func:`~subliminal.core.list_subtitles` function that will return all found
|
||||
subtitles:
|
||||
|
||||
>>> subtitles = list_subtitles([video], {Language('hun')}, providers=['podnapisi'])
|
||||
>>> subtitles[video]
|
||||
[<PodnapisiSubtitle 'ZtAW' [hu]>, <PodnapisiSubtitle 'ONAW' [hu]>]
|
||||
|
||||
.. note::
|
||||
|
||||
As you noticed, all parameters are iterables but only contain one item which means you can deal with a lot of
|
||||
videos, languages and providers at the same time. For the sake of this example, we filter providers to use only one,
|
||||
pass ``providers=None`` (default) to search on all providers.
|
||||
|
||||
Scoring
|
||||
^^^^^^^
|
||||
It's usual you have multiple candidates for subtitles. To help you chose which one to download, subliminal can compare
|
||||
them to the video and tell you exactly what matches with :meth:`~subliminal.subtitle.Subtitle.get_matches`:
|
||||
|
||||
>>> for s in subtitles[video]:
|
||||
... sorted(s.get_matches(video))
|
||||
['episode', 'format', 'release_group', 'season', 'series', 'video_codec', 'year']
|
||||
['episode', 'format', 'season', 'series', 'year']
|
||||
|
||||
And then compute a score with those matches with :func:`~subliminal.score.compute_score`:
|
||||
|
||||
>>> for s in subtitles[video]:
|
||||
... {s: compute_score(s, video)}
|
||||
{<PodnapisiSubtitle 'ZtAW' [hu]>: 354}
|
||||
{<PodnapisiSubtitle 'ONAW' [hu]>: 337}
|
||||
|
||||
Now you should have a better idea about which one you should choose.
|
||||
|
||||
Downloading
|
||||
^^^^^^^^^^^
|
||||
We can settle on the first subtitle and download its content using :func:`~subliminal.core.download_subtitles`:
|
||||
|
||||
>>> subtitle = subtitles[video][0]
|
||||
>>> subtitle.content is None
|
||||
True
|
||||
>>> download_subtitles([subtitle])
|
||||
>>> subtitle.content.split(b'\n')[2]
|
||||
b'Elszaladok a boltba'
|
||||
|
||||
If you want a string instead of bytes, you can access decoded content with the
|
||||
:attr:`~subliminal.subtitle.Subtitle.text` property:
|
||||
|
||||
>>> subtitle.text.split('\n')[3]
|
||||
'néhány apróságért.'
|
||||
|
||||
Downloading best subtitles
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Downloading best subtitles is what you want to do in almost all cases, as a shortcut for listing, scoring and
|
||||
downloading you can use :func:`~subliminal.core.download_best_subtitles`:
|
||||
|
||||
>>> best_subtitles = download_best_subtitles([video], {Language('hun')}, providers=['podnapisi'])
|
||||
>>> best_subtitles[video]
|
||||
[<PodnapisiSubtitle 'ZtAW' [hu]>]
|
||||
>>> best_subtitle = best_subtitles[video][0]
|
||||
>>> best_subtitle.content.split(b'\n')[2]
|
||||
b'Elszaladok a boltba'
|
||||
|
||||
We end up with the same subtitle but with one line of code. Neat.
|
||||
|
||||
Save
|
||||
^^^^
|
||||
We got ourselves a nice subtitle, now we can save it on the file system using :func:`~subliminal.core.save_subtitles`:
|
||||
|
||||
>>> save_subtitles(video, [best_subtitle])
|
||||
[<PodnapisiSubtitle 'ZtAW' [hu]>]
|
||||
>>> os.listdir()
|
||||
['The.Big.Bang.Theory.S05E18.HDTV.x264-LOL.hu.srt']
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
[pytest]
|
||||
norecursedirs = build dist env .tox .eggs
|
||||
addopts = --pep8 --flakes --doctest-glob='*.rst'
|
||||
pep8maxlinelength = 120
|
||||
pep8ignore =
|
||||
docs/conf.py ALL
|
||||
subliminal/__init__.py E402
|
||||
flakes-ignore =
|
||||
docs/conf.py ALL
|
||||
subliminal/__init__.py UnusedImport
|
||||
doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL
|
||||
@@ -0,0 +1 @@
|
||||
-e .
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2008-2011 Patrick Dessalle <patrick@dessalle.be>
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import argparse
|
||||
import subliminal
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import os.path
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
'''Download subtitles'''
|
||||
# parse command line options
|
||||
parser = argparse.ArgumentParser(description='Subtitles, faster than your thoughts')
|
||||
parser.add_argument('-l', '--language', action='append', dest='languages', help='wanted language (ISO 639-1)', metavar='LG')
|
||||
parser.add_argument('-p', '--plugin', action='append', dest='plugins', help='plugin to use', metavar='NAME')
|
||||
parser.add_argument('-m', '--multi', action='store_true', help='download multiple subtitle languages')
|
||||
parser.add_argument('-f', '--force', action='store_true', help='replace existing subtitle file')
|
||||
parser.add_argument('-w', '--workers', action='store', help='use N threads (default: %(default)s)', metavar='N', default=4)
|
||||
group_verbosity = parser.add_mutually_exclusive_group()
|
||||
group_verbosity.add_argument('-q', '--quiet', action='store_true', help='disable output')
|
||||
group_verbosity.add_argument('-v', '--verbose', action='store_true', help='verbose output')
|
||||
group_cache = parser.add_mutually_exclusive_group()
|
||||
group_cache.add_argument('--cache-dir', action='store', dest='cache_dir', help='cache directory to use', metavar='DIR', default=os.path.expanduser('~/.config/subliminal'))
|
||||
group_cache.add_argument('--no-cache-dir', action='store_false', dest='cache_dir', help='do not use cache directory (some plugins may not work)')
|
||||
parser.add_argument('--version', action='version', version=subliminal.__version__)
|
||||
parser.add_argument('paths', nargs='+', help='path to video files or folders', metavar='PATH')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set log verbosity
|
||||
if args.verbose:
|
||||
logging.basicConfig(level=logging.DEBUG, format='%(levelname)-8s %(asctime)s %(name)-24s %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
||||
elif not args.quiet:
|
||||
logging.basicConfig(level=logging.WARN, format='%(levelname)s: %(name)s %(message)s')
|
||||
|
||||
# Initialize the instance
|
||||
subli = subliminal.Subliminal(cache_dir=args.cache_dir, workers=args.workers, multi=args.multi, force=args.force, max_depth=3, files_mode=-1)
|
||||
if args.plugins:
|
||||
subli.plugins = args.plugins
|
||||
if args.languages:
|
||||
subli.languages = args.languages
|
||||
try:
|
||||
subtitles = subli.downloadSubtitles([unicode(x) for x in args.paths])
|
||||
finally:
|
||||
subli.stopWorkers()
|
||||
if len(subtitles) == 0:
|
||||
if not args.quiet:
|
||||
sys.stderr.write('No subtitles found\n')
|
||||
exit(1)
|
||||
if not args.quiet:
|
||||
print '*' * 50
|
||||
print 'Downloaded %s subtitles' % len(subtitles)
|
||||
for subtitle in subtitles:
|
||||
print subtitle
|
||||
print '*' * 50
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
[aliases]
|
||||
test=pytest
|
||||
|
||||
[build_sphinx]
|
||||
source-dir = docs/
|
||||
build-dir = docs/_build
|
||||
all_files = 1
|
||||
|
||||
[upload_sphinx]
|
||||
upload-dir = docs/_build/html
|
||||
@@ -1,45 +1,101 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from setuptools import setup
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
here = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
def read(*parts):
|
||||
# intentionally *not* adding an encoding option to open, See:
|
||||
# https://github.com/pypa/virtualenv/issues/201#issuecomment-3145690
|
||||
return io.open(os.path.join(here, *parts), 'r').read()
|
||||
|
||||
|
||||
def find_version(*file_paths):
|
||||
version_file = read(*file_paths)
|
||||
version_match = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', version_file, re.M)
|
||||
if version_match:
|
||||
return version_match.group(1)
|
||||
raise RuntimeError('Unable to find version string.')
|
||||
|
||||
|
||||
# requirements
|
||||
setup_requirements = ['pytest-runner'] if {'pytest', 'test', 'ptr'}.intersection(sys.argv) else []
|
||||
|
||||
install_requirements = ['guessit>=2.0.1', 'babelfish>=0.5.2', 'enzyme>=0.4.1', 'beautifulsoup4>=4.4.0',
|
||||
'requests>=2.0', 'click>=4.0', 'dogpile.cache>=0.6.0', 'stevedore>=1.0.0',
|
||||
'chardet>=2.3.0', 'pysrt>=1.0.1', 'six>=1.9.0', 'appdirs>=1.3', 'rarfile>=2.7',
|
||||
'pytz>=2012c']
|
||||
if sys.version_info < (3, 2):
|
||||
install_requirements.append('futures>=3.0')
|
||||
|
||||
test_requirements = ['sympy', 'vcrpy>=1.6.1', 'pytest', 'pytest-pep8', 'pytest-flakes', 'pytest-cov']
|
||||
if sys.version_info < (3, 3):
|
||||
test_requirements.append('mock')
|
||||
|
||||
dev_requirements = ['tox', 'sphinx', 'sphinx_rtd_theme', 'sphinxcontrib-programoutput', 'wheel']
|
||||
|
||||
|
||||
execfile('subliminal/version.py')
|
||||
setup(name='subliminal',
|
||||
version=__version__,
|
||||
license='LGPLv3',
|
||||
description='Subliminal - Subtitles, faster than your thoughts',
|
||||
classifiers=['Development Status :: 4 - Beta',
|
||||
'License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)',
|
||||
'Intended Audience :: Developers',
|
||||
'Operating System :: OS Independent',
|
||||
'Programming Language :: Python :: 2.6',
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
'Topic :: Multimedia :: Video'],
|
||||
keywords='subliminal video movie subtitle python library',
|
||||
author='Antoine Bertin',
|
||||
author_email='diaoulael@gmail.com',
|
||||
url='https://github.com/Diaoul/subliminal',
|
||||
packages=['subliminal', 'subliminal/plugins'],
|
||||
scripts=['scripts/subliminal'],
|
||||
py_modules=['subliminal'],
|
||||
install_requires=['BeautifulSoup>=3.2.0', 'guessit>=0.2'])
|
||||
version=find_version('subliminal', '__init__.py'),
|
||||
license='MIT',
|
||||
description='Subtitles, faster than your thoughts',
|
||||
long_description=read('README.rst') + '\n\n' + read('HISTORY.rst'),
|
||||
keywords='subtitle subtitles video movie episode tv show series',
|
||||
url='https://github.com/Diaoul/subliminal',
|
||||
author='Antoine Bertin',
|
||||
author_email='diaoulael@gmail.com',
|
||||
packages=find_packages(),
|
||||
classifiers=[
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Operating System :: OS Independent',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 2',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.3',
|
||||
'Programming Language :: Python :: 3.4',
|
||||
'Programming Language :: Python :: 3.5',
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
'Topic :: Multimedia :: Video'
|
||||
],
|
||||
entry_points={
|
||||
'subliminal.providers': [
|
||||
'addic7ed = subliminal.providers.addic7ed:Addic7edProvider',
|
||||
'legendastv = subliminal.providers.legendastv:LegendasTVProvider',
|
||||
'opensubtitles = subliminal.providers.opensubtitles:OpenSubtitlesProvider',
|
||||
'podnapisi = subliminal.providers.podnapisi:PodnapisiProvider',
|
||||
'shooter = subliminal.providers.shooter:ShooterProvider',
|
||||
'subscenter = subliminal.providers.subscenter:SubsCenterProvider',
|
||||
'thesubdb = subliminal.providers.thesubdb:TheSubDBProvider',
|
||||
'tvsubtitles = subliminal.providers.tvsubtitles:TVsubtitlesProvider'
|
||||
],
|
||||
'subliminal.refiners': [
|
||||
'metadata = subliminal.refiners.metadata:refine',
|
||||
'omdb = subliminal.refiners.omdb:refine',
|
||||
'tvdb = subliminal.refiners.tvdb:refine'
|
||||
],
|
||||
'babelfish.language_converters': [
|
||||
'addic7ed = subliminal.converters.addic7ed:Addic7edConverter',
|
||||
'shooter = subliminal.converters.shooter:ShooterConverter',
|
||||
'thesubdb = subliminal.converters.thesubdb:TheSubDBConverter',
|
||||
'tvsubtitles = subliminal.converters.tvsubtitles:TVsubtitlesConverter'
|
||||
],
|
||||
'console_scripts': [
|
||||
'subliminal = subliminal.cli:subliminal'
|
||||
]
|
||||
},
|
||||
setup_requires=setup_requirements,
|
||||
install_requires=install_requirements,
|
||||
tests_require=test_requirements,
|
||||
extras_require={
|
||||
'test': test_requirements,
|
||||
'dev': dev_requirements
|
||||
})
|
||||
|
||||
+19
-23
@@ -1,25 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
__title__ = 'subliminal'
|
||||
__version__ = '2.0.4'
|
||||
__short_version__ = '.'.join(__version__.split('.')[:2])
|
||||
__author__ = 'Antoine Bertin'
|
||||
__license__ = 'MIT'
|
||||
__copyright__ = 'Copyright 2016, Antoine Bertin'
|
||||
|
||||
__all__ = ['FORMATS', 'LANGUAGES', 'PLUGINS', 'API_PLUGINS', 'Subliminal', 'Subtitle']
|
||||
from classes import *
|
||||
from subliminal import *
|
||||
from version import __version__
|
||||
import logging
|
||||
|
||||
from .core import (AsyncProviderPool, ProviderPool, check_video, download_best_subtitles, download_subtitles,
|
||||
list_subtitles, refine, save_subtitles, scan_video, scan_videos)
|
||||
from .cache import region
|
||||
from .exceptions import Error, ProviderError
|
||||
from .extensions import provider_manager, refiner_manager
|
||||
from .providers import Provider
|
||||
from .score import compute_score, get_scores
|
||||
from .subtitle import SUBTITLE_EXTENSIONS, Subtitle
|
||||
from .video import VIDEO_EXTENSIONS, Episode, Movie, Video
|
||||
|
||||
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
|
||||
from dogpile.cache import make_region
|
||||
|
||||
#: Expiration time for show caching
|
||||
SHOW_EXPIRATION_TIME = datetime.timedelta(weeks=3).total_seconds()
|
||||
|
||||
#: Expiration time for episode caching
|
||||
EPISODE_EXPIRATION_TIME = datetime.timedelta(days=3).total_seconds()
|
||||
|
||||
#: Expiration time for scraper searches
|
||||
REFINER_EXPIRATION_TIME = datetime.timedelta(weeks=1).total_seconds()
|
||||
|
||||
|
||||
region = make_region()
|
||||
@@ -1,127 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
|
||||
class Error(Exception):
|
||||
"""Base class for exceptions in subliminal"""
|
||||
pass
|
||||
|
||||
|
||||
class BadStateError(Error):
|
||||
"""Exception raised when an invalid action is asked
|
||||
|
||||
Attributes:
|
||||
current -- current state of Subliminal instance
|
||||
expected -- expected state of Subliminal instance
|
||||
"""
|
||||
def __init__(self, current, expected):
|
||||
self.current = current
|
||||
self.expected = expected
|
||||
|
||||
def __str__(self):
|
||||
return 'Expected state %d but current state is %d' % (self.expected, self.current)
|
||||
|
||||
|
||||
class LanguageError(Error):
|
||||
"""Exception raised when invalid language is submitted
|
||||
|
||||
Attributes:
|
||||
language -- language that cause the error
|
||||
"""
|
||||
def __init__(self, language):
|
||||
self.language = language
|
||||
|
||||
def __str__(self):
|
||||
return self.language
|
||||
|
||||
|
||||
class PluginError(Error):
|
||||
""""Exception raised when invalid plugin is submitted
|
||||
|
||||
Attributes:
|
||||
plugin -- plugin that cause the error
|
||||
"""
|
||||
def __init__(self, plugin):
|
||||
self.plugin = plugin
|
||||
|
||||
def __str__(self):
|
||||
return self.plugin
|
||||
|
||||
|
||||
class WrongTaskError(Error):
|
||||
""""Exception raised when invalid task is submitted"""
|
||||
pass
|
||||
|
||||
|
||||
class DownloadFailedError(Error):
|
||||
""""Exception raised when a download task has failed in plugin"""
|
||||
pass
|
||||
|
||||
|
||||
class Subtitle(object):
|
||||
"""Subtitle class
|
||||
|
||||
Attributes:
|
||||
video_path -- path to the video file
|
||||
path -- path to the subtitle file
|
||||
plugin -- plugin used
|
||||
language -- language of the subtitle
|
||||
link -- download link
|
||||
release -- release group identified by guessit
|
||||
teams -- identified by subliminal
|
||||
"""
|
||||
def __init__(self, video_path=None, path=None, plugin=None, language=None, link=None, release=None, teams=None):
|
||||
self.video_path = video_path
|
||||
self.path = path
|
||||
self.plugin = plugin
|
||||
self.language = language
|
||||
self.link = link
|
||||
self.release = release
|
||||
self.teams = teams
|
||||
|
||||
def __repr__(self):
|
||||
return repr({'video_path': self.video_path, 'path': self.path, 'plugin': self.plugin,
|
||||
'language': self.language, 'link': self.link, 'release': self.release, 'teams': self.teams})
|
||||
|
||||
|
||||
class Task(object):
|
||||
"""Base class for tasks to use in subliminal"""
|
||||
pass
|
||||
|
||||
|
||||
class ListTask(Task):
|
||||
"""List task to list subtitles"""
|
||||
def __init__(self, filepath, languages, plugin, config):
|
||||
self.filepath = filepath
|
||||
self.plugin = plugin
|
||||
self.languages = languages
|
||||
self.config = config
|
||||
|
||||
|
||||
class DownloadTask(Task):
|
||||
"""Download task to download subtitles"""
|
||||
def __init__(self, subtitles):
|
||||
self.subtitles = subtitles
|
||||
|
||||
|
||||
class StopTask(Task):
|
||||
"""Stop task to stop workers"""
|
||||
pass
|
||||
@@ -0,0 +1,461 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Subliminal uses `click <http://click.pocoo.org>`_ to provide a powerful :abbr:`CLI (command-line interface)`.
|
||||
|
||||
"""
|
||||
from __future__ import division
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from appdirs import AppDirs
|
||||
from babelfish import Error as BabelfishError, Language
|
||||
import click
|
||||
from dogpile.cache.backends.file import AbstractFileLock
|
||||
from dogpile.util.readwrite_lock import ReadWriteMutex
|
||||
from six.moves import configparser
|
||||
|
||||
from subliminal import (AsyncProviderPool, Episode, Movie, Video, __version__, check_video, compute_score, get_scores,
|
||||
provider_manager, refine, refiner_manager, region, save_subtitles, scan_video, scan_videos)
|
||||
from subliminal.core import ARCHIVE_EXTENSIONS, search_external_subtitles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MutexLock(AbstractFileLock):
|
||||
""":class:`MutexLock` is a thread-based rw lock based on :class:`dogpile.core.ReadWriteMutex`."""
|
||||
def __init__(self, filename):
|
||||
self.mutex = ReadWriteMutex()
|
||||
|
||||
def acquire_read_lock(self, wait):
|
||||
ret = self.mutex.acquire_read_lock(wait)
|
||||
return wait or ret
|
||||
|
||||
def acquire_write_lock(self, wait):
|
||||
ret = self.mutex.acquire_write_lock(wait)
|
||||
return wait or ret
|
||||
|
||||
def release_read_lock(self):
|
||||
return self.mutex.release_read_lock()
|
||||
|
||||
def release_write_lock(self):
|
||||
return self.mutex.release_write_lock()
|
||||
|
||||
|
||||
class Config(object):
|
||||
"""A :class:`~configparser.ConfigParser` wrapper to store configuration.
|
||||
|
||||
Interaction with the configuration is done with the properties.
|
||||
|
||||
:param str path: path to the configuration file.
|
||||
|
||||
"""
|
||||
def __init__(self, path):
|
||||
#: Path to the configuration file
|
||||
self.path = path
|
||||
|
||||
#: The underlying configuration object
|
||||
self.config = configparser.SafeConfigParser()
|
||||
self.config.add_section('general')
|
||||
self.config.set('general', 'languages', json.dumps(['en']))
|
||||
self.config.set('general', 'providers', json.dumps(sorted([p.name for p in provider_manager])))
|
||||
self.config.set('general', 'refiners', json.dumps(sorted([r.name for r in refiner_manager])))
|
||||
self.config.set('general', 'single', str(0))
|
||||
self.config.set('general', 'embedded_subtitles', str(1))
|
||||
self.config.set('general', 'age', str(int(timedelta(weeks=2).total_seconds())))
|
||||
self.config.set('general', 'hearing_impaired', str(1))
|
||||
self.config.set('general', 'min_score', str(0))
|
||||
|
||||
def read(self):
|
||||
"""Read the configuration from :attr:`path`"""
|
||||
self.config.read(self.path)
|
||||
|
||||
def write(self):
|
||||
"""Write the configuration to :attr:`path`"""
|
||||
with open(self.path, 'w') as f:
|
||||
self.config.write(f)
|
||||
|
||||
@property
|
||||
def languages(self):
|
||||
return {Language.fromietf(l) for l in json.loads(self.config.get('general', 'languages'))}
|
||||
|
||||
@languages.setter
|
||||
def languages(self, value):
|
||||
self.config.set('general', 'languages', json.dumps(sorted([str(l) for l in value])))
|
||||
|
||||
@property
|
||||
def providers(self):
|
||||
return json.loads(self.config.get('general', 'providers'))
|
||||
|
||||
@providers.setter
|
||||
def providers(self, value):
|
||||
self.config.set('general', 'providers', json.dumps(sorted([p.lower() for p in value])))
|
||||
|
||||
@property
|
||||
def refiners(self):
|
||||
return json.loads(self.config.get('general', 'refiners'))
|
||||
|
||||
@refiners.setter
|
||||
def refiners(self, value):
|
||||
self.config.set('general', 'refiners', json.dumps([r.lower() for r in value]))
|
||||
|
||||
@property
|
||||
def single(self):
|
||||
return self.config.getboolean('general', 'single')
|
||||
|
||||
@single.setter
|
||||
def single(self, value):
|
||||
self.config.set('general', 'single', str(int(value)))
|
||||
|
||||
@property
|
||||
def embedded_subtitles(self):
|
||||
return self.config.getboolean('general', 'embedded_subtitles')
|
||||
|
||||
@embedded_subtitles.setter
|
||||
def embedded_subtitles(self, value):
|
||||
self.config.set('general', 'embedded_subtitles', str(int(value)))
|
||||
|
||||
@property
|
||||
def age(self):
|
||||
return timedelta(seconds=self.config.getint('general', 'age'))
|
||||
|
||||
@age.setter
|
||||
def age(self, value):
|
||||
self.config.set('general', 'age', str(int(value.total_seconds())))
|
||||
|
||||
@property
|
||||
def hearing_impaired(self):
|
||||
return self.config.getboolean('general', 'hearing_impaired')
|
||||
|
||||
@hearing_impaired.setter
|
||||
def hearing_impaired(self, value):
|
||||
self.config.set('general', 'hearing_impaired', str(int(value)))
|
||||
|
||||
@property
|
||||
def min_score(self):
|
||||
return self.config.getfloat('general', 'min_score')
|
||||
|
||||
@min_score.setter
|
||||
def min_score(self, value):
|
||||
self.config.set('general', 'min_score', str(value))
|
||||
|
||||
@property
|
||||
def provider_configs(self):
|
||||
rv = {}
|
||||
for provider in provider_manager:
|
||||
if self.config.has_section(provider.name):
|
||||
rv[provider.name] = {k: v for k, v in self.config.items(provider.name)}
|
||||
return rv
|
||||
|
||||
@provider_configs.setter
|
||||
def provider_configs(self, value):
|
||||
# loop over provider configurations
|
||||
for provider, config in value.items():
|
||||
# create the corresponding section if necessary
|
||||
if not self.config.has_section(provider):
|
||||
self.config.add_section(provider)
|
||||
|
||||
# add config options
|
||||
for k, v in config.items():
|
||||
self.config.set(provider, k, v)
|
||||
|
||||
|
||||
class LanguageParamType(click.ParamType):
|
||||
""":class:`~click.ParamType` for languages that returns a :class:`~babelfish.language.Language`"""
|
||||
name = 'language'
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
try:
|
||||
return Language.fromietf(value)
|
||||
except BabelfishError:
|
||||
self.fail('%s is not a valid language' % value)
|
||||
|
||||
LANGUAGE = LanguageParamType()
|
||||
|
||||
|
||||
class AgeParamType(click.ParamType):
|
||||
""":class:`~click.ParamType` for age strings that returns a :class:`~datetime.timedelta`
|
||||
|
||||
An age string is in the form `number + identifier` with possible identifiers:
|
||||
|
||||
* ``w`` for weeks
|
||||
* ``d`` for days
|
||||
* ``h`` for hours
|
||||
|
||||
The form can be specified multiple times but only with that idenfier ordering. For example:
|
||||
|
||||
* ``1w2d4h`` for 1 week, 2 days and 4 hours
|
||||
* ``2w`` for 2 weeks
|
||||
* ``3w6h`` for 3 weeks and 6 hours
|
||||
|
||||
"""
|
||||
name = 'age'
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
match = re.match(r'^(?:(?P<weeks>\d+?)w)?(?:(?P<days>\d+?)d)?(?:(?P<hours>\d+?)h)?$', value)
|
||||
if not match:
|
||||
self.fail('%s is not a valid age' % value)
|
||||
|
||||
return timedelta(**{k: int(v) for k, v in match.groupdict(0).items()})
|
||||
|
||||
AGE = AgeParamType()
|
||||
|
||||
PROVIDER = click.Choice(sorted(provider_manager.names()))
|
||||
|
||||
REFINER = click.Choice(sorted(refiner_manager.names()))
|
||||
|
||||
dirs = AppDirs('subliminal')
|
||||
cache_file = 'subliminal.dbm'
|
||||
config_file = 'config.ini'
|
||||
|
||||
|
||||
@click.group(context_settings={'max_content_width': 100}, epilog='Suggestions and bug reports are greatly appreciated: '
|
||||
'https://github.com/Diaoul/subliminal/')
|
||||
@click.option('--addic7ed', type=click.STRING, nargs=2, metavar='USERNAME PASSWORD', help='Addic7ed configuration.')
|
||||
@click.option('--legendastv', type=click.STRING, nargs=2, metavar='USERNAME PASSWORD', help='LegendasTV configuration.')
|
||||
@click.option('--opensubtitles', type=click.STRING, nargs=2, metavar='USERNAME PASSWORD',
|
||||
help='OpenSubtitles configuration.')
|
||||
@click.option('--subscenter', type=click.STRING, nargs=2, metavar='USERNAME PASSWORD', help='SubsCenter configuration.')
|
||||
@click.option('--cache-dir', type=click.Path(writable=True, file_okay=False), default=dirs.user_cache_dir,
|
||||
show_default=True, expose_value=True, help='Path to the cache directory.')
|
||||
@click.option('--debug', is_flag=True, help='Print useful information for debugging subliminal and for reporting bugs.')
|
||||
@click.version_option(__version__)
|
||||
@click.pass_context
|
||||
def subliminal(ctx, addic7ed, legendastv, opensubtitles, subscenter, cache_dir, debug):
|
||||
"""Subtitles, faster than your thoughts."""
|
||||
# create cache directory
|
||||
try:
|
||||
os.makedirs(cache_dir)
|
||||
except OSError:
|
||||
if not os.path.isdir(cache_dir):
|
||||
raise
|
||||
|
||||
# configure cache
|
||||
region.configure('dogpile.cache.dbm', expiration_time=timedelta(days=30),
|
||||
arguments={'filename': os.path.join(cache_dir, cache_file), 'lock_factory': MutexLock})
|
||||
|
||||
# configure logging
|
||||
if debug:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
|
||||
logging.getLogger('subliminal').addHandler(handler)
|
||||
logging.getLogger('subliminal').setLevel(logging.DEBUG)
|
||||
|
||||
# provider configs
|
||||
ctx.obj = {'provider_configs': {}}
|
||||
if addic7ed:
|
||||
ctx.obj['provider_configs']['addic7ed'] = {'username': addic7ed[0], 'password': addic7ed[1]}
|
||||
if legendastv:
|
||||
ctx.obj['provider_configs']['legendastv'] = {'username': legendastv[0], 'password': legendastv[1]}
|
||||
if opensubtitles:
|
||||
ctx.obj['provider_configs']['opensubtitles'] = {'username': opensubtitles[0], 'password': opensubtitles[1]}
|
||||
if subscenter:
|
||||
ctx.obj['provider_configs']['subscenter'] = {'username': subscenter[0], 'password': subscenter[1]}
|
||||
|
||||
|
||||
@subliminal.command()
|
||||
@click.option('--clear-subliminal', is_flag=True, help='Clear subliminal\'s cache. Use this ONLY if your cache is '
|
||||
'corrupted or if you experience issues.')
|
||||
@click.pass_context
|
||||
def cache(ctx, clear_subliminal):
|
||||
"""Cache management."""
|
||||
if clear_subliminal:
|
||||
for file in glob.glob(os.path.join(ctx.parent.params['cache_dir'], cache_file) + '*'):
|
||||
os.remove(file)
|
||||
click.echo('Subliminal\'s cache cleared.')
|
||||
else:
|
||||
click.echo('Nothing done.')
|
||||
|
||||
|
||||
@subliminal.command()
|
||||
@click.option('-l', '--language', type=LANGUAGE, required=True, multiple=True, help='Language as IETF code, '
|
||||
'e.g. en, pt-BR (can be used multiple times).')
|
||||
@click.option('-p', '--provider', type=PROVIDER, multiple=True, help='Provider to use (can be used multiple times).')
|
||||
@click.option('-r', '--refiner', type=REFINER, multiple=True, help='Refiner to use (can be used multiple times).')
|
||||
@click.option('-a', '--age', type=AGE, help='Filter videos newer than AGE, e.g. 12h, 1w2d.')
|
||||
@click.option('-d', '--directory', type=click.STRING, metavar='DIR', help='Directory where to save subtitles, '
|
||||
'default is next to the video file.')
|
||||
@click.option('-e', '--encoding', type=click.STRING, metavar='ENC', help='Subtitle file encoding, default is to '
|
||||
'preserve original encoding.')
|
||||
@click.option('-s', '--single', is_flag=True, default=False, help='Save subtitle without language code in the file '
|
||||
'name, i.e. use .srt extension. Do not use this unless your media player requires it.')
|
||||
@click.option('-f', '--force', is_flag=True, default=False, help='Force download even if a subtitle already exist.')
|
||||
@click.option('-hi', '--hearing-impaired', is_flag=True, default=False, help='Prefer hearing impaired subtitles.')
|
||||
@click.option('-m', '--min-score', type=click.IntRange(0, 100), default=0, help='Minimum score for a subtitle '
|
||||
'to be downloaded (0 to 100).')
|
||||
@click.option('-w', '--max-workers', type=click.IntRange(1, 50), default=None, help='Maximum number of threads to use.')
|
||||
@click.option('-z/-Z', '--archives/--no-archives', default=True, show_default=True, help='Scan archives for videos '
|
||||
'(supported extensions: %s).' % ', '.join(ARCHIVE_EXTENSIONS))
|
||||
@click.option('-v', '--verbose', count=True, help='Increase verbosity.')
|
||||
@click.argument('path', type=click.Path(), required=True, nargs=-1)
|
||||
@click.pass_obj
|
||||
def download(obj, provider, refiner, language, age, directory, encoding, single, force, hearing_impaired, min_score,
|
||||
max_workers, archives, verbose, path):
|
||||
"""Download best subtitles.
|
||||
|
||||
PATH can be an directory containing videos, a video file path or a video file name. It can be used multiple times.
|
||||
|
||||
If an existing subtitle is detected (external or embedded) in the correct language, the download is skipped for
|
||||
the associated video.
|
||||
|
||||
"""
|
||||
# process parameters
|
||||
language = set(language)
|
||||
|
||||
# scan videos
|
||||
videos = []
|
||||
ignored_videos = []
|
||||
errored_paths = []
|
||||
with click.progressbar(path, label='Collecting videos', item_show_func=lambda p: p or '') as bar:
|
||||
for p in bar:
|
||||
logger.debug('Collecting path %s', p)
|
||||
|
||||
# non-existing
|
||||
if not os.path.exists(p):
|
||||
try:
|
||||
video = Video.fromname(p)
|
||||
except:
|
||||
logger.exception('Unexpected error while collecting non-existing path %s', p)
|
||||
errored_paths.append(p)
|
||||
continue
|
||||
if not force:
|
||||
video.subtitle_languages |= set(search_external_subtitles(video.name, directory=directory).values())
|
||||
refine(video, episode_refiners=refiner, movie_refiners=refiner, embedded_subtitles=not force)
|
||||
videos.append(video)
|
||||
continue
|
||||
|
||||
# directories
|
||||
if os.path.isdir(p):
|
||||
try:
|
||||
scanned_videos = scan_videos(p, age=age, archives=archives)
|
||||
except:
|
||||
logger.exception('Unexpected error while collecting directory path %s', p)
|
||||
errored_paths.append(p)
|
||||
continue
|
||||
for video in scanned_videos:
|
||||
if check_video(video, languages=language, age=age, undefined=single):
|
||||
if not force:
|
||||
video.subtitle_languages |= set(search_external_subtitles(video.name,
|
||||
directory=directory).values())
|
||||
refine(video, episode_refiners=refiner, movie_refiners=refiner, embedded_subtitles=not force)
|
||||
videos.append(video)
|
||||
else:
|
||||
ignored_videos.append(video)
|
||||
continue
|
||||
|
||||
# other inputs
|
||||
try:
|
||||
video = scan_video(p)
|
||||
except:
|
||||
logger.exception('Unexpected error while collecting path %s', p)
|
||||
errored_paths.append(p)
|
||||
continue
|
||||
if check_video(video, languages=language, age=age, undefined=single):
|
||||
if not force:
|
||||
video.subtitle_languages |= set(search_external_subtitles(video.name, directory=directory).values())
|
||||
refine(video, episode_refiners=refiner, movie_refiners=refiner, embedded_subtitles=not force)
|
||||
videos.append(video)
|
||||
else:
|
||||
ignored_videos.append(video)
|
||||
|
||||
# output errored paths
|
||||
if verbose > 0:
|
||||
for p in errored_paths:
|
||||
click.secho('%s errored' % p, fg='red')
|
||||
|
||||
# output ignored videos
|
||||
if verbose > 1:
|
||||
for video in ignored_videos:
|
||||
click.secho('%s ignored - subtitles: %s / age: %d day%s' % (
|
||||
os.path.split(video.name)[1],
|
||||
', '.join(str(s) for s in video.subtitle_languages) or 'none',
|
||||
video.age.days,
|
||||
's' if video.age.days > 1 else ''
|
||||
), fg='yellow')
|
||||
|
||||
# report collected videos
|
||||
click.echo('%s video%s collected / %s video%s ignored / %s error%s' % (
|
||||
click.style(str(len(videos)), bold=True, fg='green' if videos else None),
|
||||
's' if len(videos) > 1 else '',
|
||||
click.style(str(len(ignored_videos)), bold=True, fg='yellow' if ignored_videos else None),
|
||||
's' if len(ignored_videos) > 1 else '',
|
||||
click.style(str(len(errored_paths)), bold=True, fg='red' if errored_paths else None),
|
||||
's' if len(errored_paths) > 1 else '',
|
||||
))
|
||||
|
||||
# exit if no video collected
|
||||
if not videos:
|
||||
return
|
||||
|
||||
# download best subtitles
|
||||
downloaded_subtitles = defaultdict(list)
|
||||
with AsyncProviderPool(max_workers=max_workers, providers=provider, provider_configs=obj['provider_configs']) as p:
|
||||
with click.progressbar(videos, label='Downloading subtitles',
|
||||
item_show_func=lambda v: os.path.split(v.name)[1] if v is not None else '') as bar:
|
||||
for v in bar:
|
||||
scores = get_scores(v)
|
||||
subtitles = p.download_best_subtitles(p.list_subtitles(v, language - v.subtitle_languages),
|
||||
v, language, min_score=scores['hash'] * min_score / 100,
|
||||
hearing_impaired=hearing_impaired, only_one=single)
|
||||
downloaded_subtitles[v] = subtitles
|
||||
|
||||
if p.discarded_providers:
|
||||
click.secho('Some providers have been discarded due to unexpected errors: %s' %
|
||||
', '.join(p.discarded_providers), fg='yellow')
|
||||
|
||||
# save subtitles
|
||||
total_subtitles = 0
|
||||
for v, subtitles in downloaded_subtitles.items():
|
||||
saved_subtitles = save_subtitles(v, subtitles, single=single, directory=directory, encoding=encoding)
|
||||
total_subtitles += len(saved_subtitles)
|
||||
|
||||
if verbose > 0:
|
||||
click.echo('%s subtitle%s downloaded for %s' % (click.style(str(len(saved_subtitles)), bold=True),
|
||||
's' if len(saved_subtitles) > 1 else '',
|
||||
os.path.split(v.name)[1]))
|
||||
|
||||
if verbose > 1:
|
||||
for s in saved_subtitles:
|
||||
matches = s.get_matches(v)
|
||||
score = compute_score(s, v)
|
||||
|
||||
# score color
|
||||
score_color = None
|
||||
scores = get_scores(v)
|
||||
if isinstance(v, Movie):
|
||||
if score < scores['title']:
|
||||
score_color = 'red'
|
||||
elif score < scores['title'] + scores['year'] + scores['release_group']:
|
||||
score_color = 'yellow'
|
||||
else:
|
||||
score_color = 'green'
|
||||
elif isinstance(v, Episode):
|
||||
if score < scores['series'] + scores['season'] + scores['episode']:
|
||||
score_color = 'red'
|
||||
elif score < scores['series'] + scores['season'] + scores['episode'] + scores['release_group']:
|
||||
score_color = 'yellow'
|
||||
else:
|
||||
score_color = 'green'
|
||||
|
||||
# scale score from 0 to 100 taking out preferences
|
||||
scaled_score = score
|
||||
if s.hearing_impaired == hearing_impaired:
|
||||
scaled_score -= scores['hearing_impaired']
|
||||
scaled_score *= 100 / scores['hash']
|
||||
|
||||
# echo some nice colored output
|
||||
click.echo(' - [{score}] {language} subtitle from {provider_name} (match on {matches})'.format(
|
||||
score=click.style('{:5.1f}'.format(scaled_score), fg=score_color, bold=score >= scores['hash']),
|
||||
language=s.language.name if s.language.country is None else '%s (%s)' % (s.language.name,
|
||||
s.language.country.name),
|
||||
provider_name=s.provider_name,
|
||||
matches=', '.join(sorted(matches, key=scores.get, reverse=True))
|
||||
))
|
||||
|
||||
if verbose == 0:
|
||||
click.echo('Downloaded %s subtitle%s' % (click.style(str(total_subtitles), bold=True),
|
||||
's' if total_subtitles > 1 else ''))
|
||||
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from babelfish import LanguageReverseConverter, language_converters
|
||||
|
||||
|
||||
class Addic7edConverter(LanguageReverseConverter):
|
||||
def __init__(self):
|
||||
self.name_converter = language_converters['name']
|
||||
self.from_addic7ed = {u'Català': ('cat',), 'Chinese (Simplified)': ('zho',), 'Chinese (Traditional)': ('zho',),
|
||||
'Euskera': ('eus',), 'Galego': ('glg',), 'Greek': ('ell',), 'Malay': ('msa',),
|
||||
'Portuguese (Brazilian)': ('por', 'BR'), 'Serbian (Cyrillic)': ('srp', None, 'Cyrl'),
|
||||
'Serbian (Latin)': ('srp',), 'Spanish (Latin America)': ('spa',),
|
||||
'Spanish (Spain)': ('spa',)}
|
||||
self.to_addic7ed = {('cat',): 'Català', ('zho',): 'Chinese (Simplified)', ('eus',): 'Euskera',
|
||||
('glg',): 'Galego', ('ell',): 'Greek', ('msa',): 'Malay',
|
||||
('por', 'BR'): 'Portuguese (Brazilian)', ('srp', None, 'Cyrl'): 'Serbian (Cyrillic)'}
|
||||
self.codes = self.name_converter.codes | set(self.from_addic7ed.keys())
|
||||
|
||||
def convert(self, alpha3, country=None, script=None):
|
||||
if (alpha3, country, script) in self.to_addic7ed:
|
||||
return self.to_addic7ed[(alpha3, country, script)]
|
||||
if (alpha3, country) in self.to_addic7ed:
|
||||
return self.to_addic7ed[(alpha3, country)]
|
||||
if (alpha3,) in self.to_addic7ed:
|
||||
return self.to_addic7ed[(alpha3,)]
|
||||
|
||||
return self.name_converter.convert(alpha3, country, script)
|
||||
|
||||
def reverse(self, addic7ed):
|
||||
if addic7ed in self.from_addic7ed:
|
||||
return self.from_addic7ed[addic7ed]
|
||||
|
||||
return self.name_converter.reverse(addic7ed)
|
||||
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from babelfish import LanguageReverseConverter
|
||||
|
||||
from ..exceptions import ConfigurationError
|
||||
|
||||
|
||||
class LegendasTVConverter(LanguageReverseConverter):
|
||||
def __init__(self):
|
||||
self.from_legendastv = {1: ('por', 'BR'), 2: ('eng',), 3: ('spa',), 4: ('fra',), 5: ('deu',), 6: ('jpn',),
|
||||
7: ('dan',), 8: ('nor',), 9: ('swe',), 10: ('por',), 11: ('ara',), 12: ('ces',),
|
||||
13: ('zho',), 14: ('kor',), 15: ('bul',), 16: ('ita',), 17: ('pol',)}
|
||||
self.to_legendastv = {v: k for k, v in self.from_legendastv.items()}
|
||||
self.codes = set(self.from_legendastv.keys())
|
||||
|
||||
def convert(self, alpha3, country=None, script=None):
|
||||
if (alpha3, country) in self.to_legendastv:
|
||||
return self.to_legendastv[(alpha3, country)]
|
||||
if (alpha3,) in self.to_legendastv:
|
||||
return self.to_legendastv[(alpha3,)]
|
||||
|
||||
raise ConfigurationError('Unsupported language code for legendastv: %s, %s, %s' % (alpha3, country, script))
|
||||
|
||||
def reverse(self, legendastv):
|
||||
if legendastv in self.from_legendastv:
|
||||
return self.from_legendastv[legendastv]
|
||||
|
||||
raise ConfigurationError('Unsupported language number for legendastv: %s' % legendastv)
|
||||
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from babelfish import LanguageReverseConverter
|
||||
|
||||
from ..exceptions import ConfigurationError
|
||||
|
||||
|
||||
class ShooterConverter(LanguageReverseConverter):
|
||||
def __init__(self):
|
||||
self.from_shooter = {'chn': ('zho',), 'eng': ('eng',)}
|
||||
self.to_shooter = {v: k for k, v in self.from_shooter.items()}
|
||||
self.codes = set(self.from_shooter.keys())
|
||||
|
||||
def convert(self, alpha3, country=None, script=None):
|
||||
if (alpha3,) in self.to_shooter:
|
||||
return self.to_shooter[(alpha3,)]
|
||||
|
||||
raise ConfigurationError('Unsupported language for shooter: %s, %s, %s' % (alpha3, country, script))
|
||||
|
||||
def reverse(self, shooter):
|
||||
if shooter in self.from_shooter:
|
||||
return self.from_shooter[shooter]
|
||||
|
||||
raise ConfigurationError('Unsupported language code for shooter: %s' % shooter)
|
||||
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from babelfish import LanguageReverseConverter
|
||||
|
||||
from ..exceptions import ConfigurationError
|
||||
|
||||
|
||||
class TheSubDBConverter(LanguageReverseConverter):
|
||||
def __init__(self):
|
||||
self.from_thesubdb = {'en': ('eng',), 'es': ('spa',), 'fr': ('fra',), 'it': ('ita',), 'nl': ('nld',),
|
||||
'pl': ('pol',), 'pt': ('por', 'BR'), 'ro': ('ron',), 'sv': ('swe',), 'tr': ('tur',)}
|
||||
self.to_thesubdb = {v: k for k, v in self.from_thesubdb.items()}
|
||||
self.codes = set(self.from_thesubdb.keys())
|
||||
|
||||
def convert(self, alpha3, country=None, script=None):
|
||||
if (alpha3, country) in self.to_thesubdb:
|
||||
return self.to_thesubdb[(alpha3, country)]
|
||||
if (alpha3,) in self.to_thesubdb:
|
||||
return self.to_thesubdb[(alpha3,)]
|
||||
|
||||
raise ConfigurationError('Unsupported language for thesubdb: %s, %s, %s' % (alpha3, country, script))
|
||||
|
||||
def reverse(self, thesubdb):
|
||||
if thesubdb in self.from_thesubdb:
|
||||
return self.from_thesubdb[thesubdb]
|
||||
|
||||
raise ConfigurationError('Unsupported language code for thesubdb: %s' % thesubdb)
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from babelfish import LanguageReverseConverter, language_converters
|
||||
|
||||
|
||||
class TVsubtitlesConverter(LanguageReverseConverter):
|
||||
def __init__(self):
|
||||
self.alpha2_converter = language_converters['alpha2']
|
||||
self.from_tvsubtitles = {'br': ('por', 'BR'), 'ua': ('ukr',), 'gr': ('ell',), 'cn': ('zho',), 'jp': ('jpn',),
|
||||
'cz': ('ces',)}
|
||||
self.to_tvsubtitles = {v: k for k, v in self.from_tvsubtitles.items()}
|
||||
self.codes = self.alpha2_converter.codes | set(self.from_tvsubtitles.keys())
|
||||
|
||||
def convert(self, alpha3, country=None, script=None):
|
||||
if (alpha3, country) in self.to_tvsubtitles:
|
||||
return self.to_tvsubtitles[(alpha3, country)]
|
||||
if (alpha3,) in self.to_tvsubtitles:
|
||||
return self.to_tvsubtitles[(alpha3,)]
|
||||
|
||||
return self.alpha2_converter.convert(alpha3, country, script)
|
||||
|
||||
def reverse(self, tvsubtitles):
|
||||
if tvsubtitles in self.from_tvsubtitles:
|
||||
return self.from_tvsubtitles[tvsubtitles]
|
||||
|
||||
return self.alpha2_converter.reverse(tvsubtitles)
|
||||
@@ -0,0 +1,705 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
import io
|
||||
import itertools
|
||||
import logging
|
||||
import operator
|
||||
import os.path
|
||||
import socket
|
||||
|
||||
from babelfish import Language, LanguageReverseError
|
||||
from guessit import guessit
|
||||
from rarfile import NotRarFile, RarCannotExec, RarFile
|
||||
import requests
|
||||
|
||||
from .extensions import provider_manager, refiner_manager
|
||||
from .score import compute_score as default_compute_score
|
||||
from .subtitle import SUBTITLE_EXTENSIONS, get_subtitle_path
|
||||
from .utils import hash_napiprojekt, hash_opensubtitles, hash_shooter, hash_thesubdb
|
||||
from .video import VIDEO_EXTENSIONS, Episode, Movie, Video
|
||||
|
||||
#: Supported archive extensions
|
||||
ARCHIVE_EXTENSIONS = ('.rar',)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProviderPool(object):
|
||||
"""A pool of providers with the same API as a single :class:`~subliminal.providers.Provider`.
|
||||
|
||||
It has a few extra features:
|
||||
|
||||
* Lazy loads providers when needed and supports the `with` statement to :meth:`terminate`
|
||||
the providers on exit.
|
||||
* Automatically discard providers on failure.
|
||||
|
||||
:param list providers: name of providers to use, if not all.
|
||||
:param dict provider_configs: provider configuration as keyword arguments per provider name to pass when
|
||||
instanciating the :class:`~subliminal.providers.Provider`.
|
||||
|
||||
"""
|
||||
def __init__(self, providers=None, provider_configs=None):
|
||||
#: Name of providers to use
|
||||
self.providers = providers or provider_manager.names()
|
||||
|
||||
#: Provider configuration
|
||||
self.provider_configs = provider_configs or {}
|
||||
|
||||
#: Initialized providers
|
||||
self.initialized_providers = {}
|
||||
|
||||
#: Discarded providers
|
||||
self.discarded_providers = set()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.terminate()
|
||||
|
||||
def __getitem__(self, name):
|
||||
if name not in self.providers:
|
||||
raise KeyError
|
||||
if name not in self.initialized_providers:
|
||||
logger.info('Initializing provider %s', name)
|
||||
provider = provider_manager[name].plugin(**self.provider_configs.get(name, {}))
|
||||
provider.initialize()
|
||||
self.initialized_providers[name] = provider
|
||||
|
||||
return self.initialized_providers[name]
|
||||
|
||||
def __delitem__(self, name):
|
||||
if name not in self.initialized_providers:
|
||||
raise KeyError(name)
|
||||
|
||||
try:
|
||||
logger.info('Terminating provider %s', name)
|
||||
self.initialized_providers[name].terminate()
|
||||
except (requests.Timeout, socket.timeout):
|
||||
logger.error('Provider %r timed out, improperly terminated', name)
|
||||
except:
|
||||
logger.exception('Provider %r terminated unexpectedly', name)
|
||||
|
||||
del self.initialized_providers[name]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.initialized_providers)
|
||||
|
||||
def list_subtitles_provider(self, provider, video, languages):
|
||||
"""List subtitles with a single provider.
|
||||
|
||||
The video and languages are checked against the provider.
|
||||
|
||||
:param str provider: name of the provider.
|
||||
:param video: video to list subtitles for.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:param languages: languages to search for.
|
||||
:type languages: set of :class:`~babelfish.language.Language`
|
||||
:return: found subtitles.
|
||||
:rtype: list of :class:`~subliminal.subtitle.Subtitle` or None
|
||||
|
||||
"""
|
||||
# check video validity
|
||||
if not provider_manager[provider].plugin.check(video):
|
||||
logger.info('Skipping provider %r: not a valid video', provider)
|
||||
return []
|
||||
|
||||
# check supported languages
|
||||
provider_languages = provider_manager[provider].plugin.languages & languages
|
||||
if not provider_languages:
|
||||
logger.info('Skipping provider %r: no language to search for', provider)
|
||||
return []
|
||||
|
||||
# list subtitles
|
||||
logger.info('Listing subtitles with provider %r and languages %r', provider, provider_languages)
|
||||
try:
|
||||
return self[provider].list_subtitles(video, provider_languages)
|
||||
except (requests.Timeout, socket.timeout):
|
||||
logger.error('Provider %r timed out', provider)
|
||||
except:
|
||||
logger.exception('Unexpected error in provider %r', provider)
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
"""List subtitles.
|
||||
|
||||
:param video: video to list subtitles for.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:param languages: languages to search for.
|
||||
:type languages: set of :class:`~babelfish.language.Language`
|
||||
:return: found subtitles.
|
||||
:rtype: list of :class:`~subliminal.subtitle.Subtitle`
|
||||
|
||||
"""
|
||||
subtitles = []
|
||||
|
||||
for name in self.providers:
|
||||
# check discarded providers
|
||||
if name in self.discarded_providers:
|
||||
logger.debug('Skipping discarded provider %r', name)
|
||||
continue
|
||||
|
||||
# list subtitles
|
||||
provider_subtitles = self.list_subtitles_provider(name, video, languages)
|
||||
if provider_subtitles is None:
|
||||
logger.info('Discarding provider %s', name)
|
||||
self.discarded_providers.add(name)
|
||||
continue
|
||||
|
||||
# add the subtitles
|
||||
subtitles.extend(provider_subtitles)
|
||||
|
||||
return subtitles
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
"""Download `subtitle`'s :attr:`~subliminal.subtitle.Subtitle.content`.
|
||||
|
||||
:param subtitle: subtitle to download.
|
||||
:type subtitle: :class:`~subliminal.subtitle.Subtitle`
|
||||
:return: `True` if the subtitle has been successfully downloaded, `False` otherwise.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
# check discarded providers
|
||||
if subtitle.provider_name in self.discarded_providers:
|
||||
logger.warning('Provider %r is discarded', subtitle.provider_name)
|
||||
return False
|
||||
|
||||
logger.info('Downloading subtitle %r', subtitle)
|
||||
try:
|
||||
self[subtitle.provider_name].download_subtitle(subtitle)
|
||||
except (requests.Timeout, socket.timeout):
|
||||
logger.error('Provider %r timed out, discarding it', subtitle.provider_name)
|
||||
self.discarded_providers.add(subtitle.provider_name)
|
||||
return False
|
||||
except:
|
||||
logger.exception('Unexpected error in provider %r, discarding it', subtitle.provider_name)
|
||||
self.discarded_providers.add(subtitle.provider_name)
|
||||
return False
|
||||
|
||||
# check subtitle validity
|
||||
if not subtitle.is_valid():
|
||||
logger.error('Invalid subtitle')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def download_best_subtitles(self, subtitles, video, languages, min_score=0, hearing_impaired=False, only_one=False,
|
||||
compute_score=None):
|
||||
"""Download the best matching subtitles.
|
||||
|
||||
:param subtitles: the subtitles to use.
|
||||
:type subtitles: list of :class:`~subliminal.subtitle.Subtitle`
|
||||
:param video: video to download subtitles for.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:param languages: languages to download.
|
||||
:type languages: set of :class:`~babelfish.language.Language`
|
||||
:param int min_score: minimum score for a subtitle to be downloaded.
|
||||
:param bool hearing_impaired: hearing impaired preference.
|
||||
:param bool only_one: download only one subtitle, not one per language.
|
||||
:param compute_score: function that takes `subtitle` and `video` as positional arguments,
|
||||
`hearing_impaired` as keyword argument and returns the score.
|
||||
:return: downloaded subtitles.
|
||||
:rtype: list of :class:`~subliminal.subtitle.Subtitle`
|
||||
|
||||
"""
|
||||
compute_score = compute_score or default_compute_score
|
||||
|
||||
# sort subtitles by score
|
||||
scored_subtitles = sorted([(s, compute_score(s, video, hearing_impaired=hearing_impaired))
|
||||
for s in subtitles], key=operator.itemgetter(1), reverse=True)
|
||||
|
||||
# download best subtitles, falling back on the next on error
|
||||
downloaded_subtitles = []
|
||||
for subtitle, score in scored_subtitles:
|
||||
# check score
|
||||
if score < min_score:
|
||||
logger.info('Score %d is below min_score (%d)', score, min_score)
|
||||
break
|
||||
|
||||
# check downloaded languages
|
||||
if subtitle.language in set(s.language for s in downloaded_subtitles):
|
||||
logger.debug('Skipping subtitle: %r already downloaded', subtitle.language)
|
||||
continue
|
||||
|
||||
# download
|
||||
if self.download_subtitle(subtitle):
|
||||
downloaded_subtitles.append(subtitle)
|
||||
|
||||
# stop when all languages are downloaded
|
||||
if set(s.language for s in downloaded_subtitles) == languages:
|
||||
logger.debug('All languages downloaded')
|
||||
break
|
||||
|
||||
# stop if only one subtitle is requested
|
||||
if only_one:
|
||||
logger.debug('Only one subtitle downloaded')
|
||||
break
|
||||
|
||||
return downloaded_subtitles
|
||||
|
||||
def terminate(self):
|
||||
"""Terminate all the :attr:`initialized_providers`."""
|
||||
logger.debug('Terminating initialized providers')
|
||||
for name in list(self.initialized_providers):
|
||||
del self[name]
|
||||
|
||||
|
||||
class AsyncProviderPool(ProviderPool):
|
||||
"""Subclass of :class:`ProviderPool` with asynchronous support for :meth:`~ProviderPool.list_subtitles`.
|
||||
|
||||
:param int max_workers: maximum number of threads to use. If `None`, :attr:`max_workers` will be set
|
||||
to the number of :attr:`~ProviderPool.providers`.
|
||||
|
||||
"""
|
||||
def __init__(self, max_workers=None, *args, **kwargs):
|
||||
super(AsyncProviderPool, self).__init__(*args, **kwargs)
|
||||
|
||||
#: Maximum number of threads to use
|
||||
self.max_workers = max_workers or len(self.providers)
|
||||
|
||||
def list_subtitles_provider(self, provider, video, languages):
|
||||
return provider, super(AsyncProviderPool, self).list_subtitles_provider(provider, video, languages)
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
subtitles = []
|
||||
|
||||
with ThreadPoolExecutor(self.max_workers) as executor:
|
||||
for provider, provider_subtitles in executor.map(self.list_subtitles_provider, self.providers,
|
||||
itertools.repeat(video, len(self.providers)),
|
||||
itertools.repeat(languages, len(self.providers))):
|
||||
# discard provider that failed
|
||||
if provider_subtitles is None:
|
||||
logger.info('Discarding provider %s', provider)
|
||||
self.discarded_providers.add(provider)
|
||||
continue
|
||||
|
||||
# add subtitles
|
||||
subtitles.extend(provider_subtitles)
|
||||
|
||||
return subtitles
|
||||
|
||||
|
||||
def check_video(video, languages=None, age=None, undefined=False):
|
||||
"""Perform some checks on the `video`.
|
||||
|
||||
All the checks are optional. Return `False` if any of this check fails:
|
||||
|
||||
* `languages` already exist in `video`'s :attr:`~subliminal.video.Video.subtitle_languages`.
|
||||
* `video` is older than `age`.
|
||||
* `video` has an `undefined` language in :attr:`~subliminal.video.Video.subtitle_languages`.
|
||||
|
||||
:param video: video to check.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:param languages: desired languages.
|
||||
:type languages: set of :class:`~babelfish.language.Language`
|
||||
:param datetime.timedelta age: maximum age of the video.
|
||||
:param bool undefined: fail on existing undefined language.
|
||||
:return: `True` if the video passes the checks, `False` otherwise.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
# language test
|
||||
if languages and not (languages - video.subtitle_languages):
|
||||
logger.debug('All languages %r exist', languages)
|
||||
return False
|
||||
|
||||
# age test
|
||||
if age and video.age > age:
|
||||
logger.debug('Video is older than %r', age)
|
||||
return False
|
||||
|
||||
# undefined test
|
||||
if undefined and Language('und') in video.subtitle_languages:
|
||||
logger.debug('Undefined language found')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def search_external_subtitles(path, directory=None):
|
||||
"""Search for external subtitles from a video `path` and their associated language.
|
||||
|
||||
Unless `directory` is provided, search will be made in the same directory as the video file.
|
||||
|
||||
:param str path: path to the video.
|
||||
:param str directory: directory to search for subtitles.
|
||||
:return: found subtitles with their languages.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
# split path
|
||||
dirpath, filename = os.path.split(path)
|
||||
dirpath = dirpath or '.'
|
||||
fileroot, fileext = os.path.splitext(filename)
|
||||
|
||||
# search for subtitles
|
||||
subtitles = {}
|
||||
for p in os.listdir(directory or dirpath):
|
||||
# keep only valid subtitle filenames
|
||||
if not p.startswith(fileroot) or not p.endswith(SUBTITLE_EXTENSIONS):
|
||||
continue
|
||||
|
||||
# extract the potential language code
|
||||
language = Language('und')
|
||||
language_code = p[len(fileroot):-len(os.path.splitext(p)[1])].replace(fileext, '').replace('_', '-')[1:]
|
||||
if language_code:
|
||||
try:
|
||||
language = Language.fromietf(language_code)
|
||||
except (ValueError, LanguageReverseError):
|
||||
logger.error('Cannot parse language code %r', language_code)
|
||||
|
||||
subtitles[p] = language
|
||||
|
||||
logger.debug('Found subtitles %r', subtitles)
|
||||
|
||||
return subtitles
|
||||
|
||||
|
||||
def scan_video(path):
|
||||
"""Scan a video from a `path`.
|
||||
|
||||
:param str path: existing path to the video.
|
||||
:return: the scanned video.
|
||||
:rtype: :class:`~subliminal.video.Video`
|
||||
|
||||
"""
|
||||
# check for non-existing path
|
||||
if not os.path.exists(path):
|
||||
raise ValueError('Path does not exist')
|
||||
|
||||
# check video extension
|
||||
if not path.endswith(VIDEO_EXTENSIONS):
|
||||
raise ValueError('%r is not a valid video extension' % os.path.splitext(path)[1])
|
||||
|
||||
dirpath, filename = os.path.split(path)
|
||||
logger.info('Scanning video %r in %r', filename, dirpath)
|
||||
|
||||
# guess
|
||||
video = Video.fromguess(path, guessit(path))
|
||||
|
||||
# size and hashes
|
||||
video.size = os.path.getsize(path)
|
||||
if video.size > 10485760:
|
||||
logger.debug('Size is %d', video.size)
|
||||
video.hashes['opensubtitles'] = hash_opensubtitles(path)
|
||||
video.hashes['shooter'] = hash_shooter(path)
|
||||
video.hashes['thesubdb'] = hash_thesubdb(path)
|
||||
video.hashes['napiprojekt'] = hash_napiprojekt(path)
|
||||
logger.debug('Computed hashes %r', video.hashes)
|
||||
else:
|
||||
logger.warning('Size is lower than 10MB: hashes not computed')
|
||||
|
||||
return video
|
||||
|
||||
|
||||
def scan_archive(path):
|
||||
"""Scan an archive from a `path`.
|
||||
|
||||
:param str path: existing path to the archive.
|
||||
:return: the scanned video.
|
||||
:rtype: :class:`~subliminal.video.Video`
|
||||
|
||||
"""
|
||||
# check for non-existing path
|
||||
if not os.path.exists(path):
|
||||
raise ValueError('Path does not exist')
|
||||
|
||||
# check video extension
|
||||
if not path.endswith(ARCHIVE_EXTENSIONS):
|
||||
raise ValueError('%r is not a valid archive extension' % os.path.splitext(path)[1])
|
||||
|
||||
dirpath, filename = os.path.split(path)
|
||||
logger.info('Scanning archive %r in %r', filename, dirpath)
|
||||
|
||||
# rar extension
|
||||
if filename.endswith('.rar'):
|
||||
rar = RarFile(path)
|
||||
|
||||
# filter on video extensions
|
||||
rar_filenames = [f for f in rar.namelist() if f.endswith(VIDEO_EXTENSIONS)]
|
||||
|
||||
# no video found
|
||||
if not rar_filenames:
|
||||
raise ValueError('No video in archive')
|
||||
|
||||
# more than one video found
|
||||
if len(rar_filenames) > 1:
|
||||
raise ValueError('More than one video in archive')
|
||||
|
||||
# guess
|
||||
rar_filename = rar_filenames[0]
|
||||
rar_filepath = os.path.join(dirpath, rar_filename)
|
||||
video = Video.fromguess(rar_filepath, guessit(rar_filepath))
|
||||
|
||||
# size
|
||||
video.size = rar.getinfo(rar_filename).file_size
|
||||
else:
|
||||
raise ValueError('Unsupported extension %r' % os.path.splitext(path)[1])
|
||||
|
||||
return video
|
||||
|
||||
|
||||
def scan_videos(path, age=None, archives=True):
|
||||
"""Scan `path` for videos and their subtitles.
|
||||
|
||||
See :func:`refine` to find additional information for the video.
|
||||
|
||||
:param str path: existing directory path to scan.
|
||||
:param datetime.timedelta age: maximum age of the video or archive.
|
||||
:param bool archives: scan videos in archives.
|
||||
:return: the scanned videos.
|
||||
:rtype: list of :class:`~subliminal.video.Video`
|
||||
|
||||
"""
|
||||
# check for non-existing path
|
||||
if not os.path.exists(path):
|
||||
raise ValueError('Path does not exist')
|
||||
|
||||
# check for non-directory path
|
||||
if not os.path.isdir(path):
|
||||
raise ValueError('Path is not a directory')
|
||||
|
||||
# walk the path
|
||||
videos = []
|
||||
for dirpath, dirnames, filenames in os.walk(path):
|
||||
logger.debug('Walking directory %r', dirpath)
|
||||
|
||||
# remove badly encoded and hidden dirnames
|
||||
for dirname in list(dirnames):
|
||||
if dirname.startswith('.'):
|
||||
logger.debug('Skipping hidden dirname %r in %r', dirname, dirpath)
|
||||
dirnames.remove(dirname)
|
||||
|
||||
# scan for videos
|
||||
for filename in filenames:
|
||||
# filter on videos and archives
|
||||
if not (filename.endswith(VIDEO_EXTENSIONS) or archives and filename.endswith(ARCHIVE_EXTENSIONS)):
|
||||
continue
|
||||
|
||||
# skip hidden files
|
||||
if filename.startswith('.'):
|
||||
logger.debug('Skipping hidden filename %r in %r', filename, dirpath)
|
||||
continue
|
||||
|
||||
# reconstruct the file path
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
|
||||
# skip links
|
||||
if os.path.islink(filepath):
|
||||
logger.debug('Skipping link %r in %r', filename, dirpath)
|
||||
continue
|
||||
|
||||
# skip old files
|
||||
if age and datetime.utcnow() - datetime.utcfromtimestamp(os.path.getmtime(filepath)) > age:
|
||||
logger.debug('Skipping old file %r in %r', filename, dirpath)
|
||||
continue
|
||||
|
||||
# scan
|
||||
if filename.endswith(VIDEO_EXTENSIONS): # video
|
||||
try:
|
||||
video = scan_video(filepath)
|
||||
except ValueError: # pragma: no cover
|
||||
logger.exception('Error scanning video')
|
||||
continue
|
||||
elif archives and filename.endswith(ARCHIVE_EXTENSIONS): # archive
|
||||
try:
|
||||
video = scan_archive(filepath)
|
||||
except (NotRarFile, RarCannotExec, ValueError): # pragma: no cover
|
||||
logger.exception('Error scanning archive')
|
||||
continue
|
||||
else: # pragma: no cover
|
||||
raise ValueError('Unsupported file %r' % filename)
|
||||
|
||||
videos.append(video)
|
||||
|
||||
return videos
|
||||
|
||||
|
||||
def refine(video, episode_refiners=None, movie_refiners=None, **kwargs):
|
||||
"""Refine a video using :ref:`refiners`.
|
||||
|
||||
.. note::
|
||||
|
||||
Exceptions raised in refiners are silently passed and logged.
|
||||
|
||||
:param video: the video to refine.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:param tuple episode_refiners: refiners to use for episodes.
|
||||
:param tuple movie_refiners: refiners to use for movies.
|
||||
:param \*\*kwargs: additional parameters for the :func:`~subliminal.refiners.refine` functions.
|
||||
|
||||
"""
|
||||
refiners = ()
|
||||
if isinstance(video, Episode):
|
||||
refiners = episode_refiners or ('metadata', 'tvdb', 'omdb')
|
||||
elif isinstance(video, Movie):
|
||||
refiners = movie_refiners or ('metadata', 'omdb')
|
||||
for refiner in refiners:
|
||||
logger.info('Refining video with %s', refiner)
|
||||
try:
|
||||
refiner_manager[refiner].plugin(video, **kwargs)
|
||||
except:
|
||||
logger.exception('Failed to refine video')
|
||||
|
||||
|
||||
def list_subtitles(videos, languages, pool_class=ProviderPool, **kwargs):
|
||||
"""List subtitles.
|
||||
|
||||
The `videos` must pass the `languages` check of :func:`check_video`.
|
||||
|
||||
:param videos: videos to list subtitles for.
|
||||
:type videos: set of :class:`~subliminal.video.Video`
|
||||
:param languages: languages to search for.
|
||||
:type languages: set of :class:`~babelfish.language.Language`
|
||||
:param pool_class: class to use as provider pool.
|
||||
:type pool_class: :class:`ProviderPool`, :class:`AsyncProviderPool` or similar
|
||||
:param \*\*kwargs: additional parameters for the provided `pool_class` constructor.
|
||||
:return: found subtitles per video.
|
||||
:rtype: dict of :class:`~subliminal.video.Video` to list of :class:`~subliminal.subtitle.Subtitle`
|
||||
|
||||
"""
|
||||
listed_subtitles = defaultdict(list)
|
||||
|
||||
# check videos
|
||||
checked_videos = []
|
||||
for video in videos:
|
||||
if not check_video(video, languages=languages):
|
||||
logger.info('Skipping video %r', video)
|
||||
continue
|
||||
checked_videos.append(video)
|
||||
|
||||
# return immediately if no video passed the checks
|
||||
if not checked_videos:
|
||||
return listed_subtitles
|
||||
|
||||
# list subtitles
|
||||
with pool_class(**kwargs) as pool:
|
||||
for video in checked_videos:
|
||||
logger.info('Listing subtitles for %r', video)
|
||||
subtitles = pool.list_subtitles(video, languages - video.subtitle_languages)
|
||||
listed_subtitles[video].extend(subtitles)
|
||||
logger.info('Found %d subtitle(s)', len(subtitles))
|
||||
|
||||
return listed_subtitles
|
||||
|
||||
|
||||
def download_subtitles(subtitles, pool_class=ProviderPool, **kwargs):
|
||||
"""Download :attr:`~subliminal.subtitle.Subtitle.content` of `subtitles`.
|
||||
|
||||
:param subtitles: subtitles to download.
|
||||
:type subtitles: list of :class:`~subliminal.subtitle.Subtitle`
|
||||
:param pool_class: class to use as provider pool.
|
||||
:type pool_class: :class:`ProviderPool`, :class:`AsyncProviderPool` or similar
|
||||
:param \*\*kwargs: additional parameters for the provided `pool_class` constructor.
|
||||
|
||||
"""
|
||||
with pool_class(**kwargs) as pool:
|
||||
for subtitle in subtitles:
|
||||
logger.info('Downloading subtitle %r', subtitle)
|
||||
pool.download_subtitle(subtitle)
|
||||
|
||||
|
||||
def download_best_subtitles(videos, languages, min_score=0, hearing_impaired=False, only_one=False, compute_score=None,
|
||||
pool_class=ProviderPool, **kwargs):
|
||||
"""List and download the best matching subtitles.
|
||||
|
||||
The `videos` must pass the `languages` and `undefined` (`only_one`) checks of :func:`check_video`.
|
||||
|
||||
:param videos: videos to download subtitles for.
|
||||
:type videos: set of :class:`~subliminal.video.Video`
|
||||
:param languages: languages to download.
|
||||
:type languages: set of :class:`~babelfish.language.Language`
|
||||
:param int min_score: minimum score for a subtitle to be downloaded.
|
||||
:param bool hearing_impaired: hearing impaired preference.
|
||||
:param bool only_one: download only one subtitle, not one per language.
|
||||
:param compute_score: function that takes `subtitle` and `video` as positional arguments,
|
||||
`hearing_impaired` as keyword argument and returns the score.
|
||||
:param pool_class: class to use as provider pool.
|
||||
:type pool_class: :class:`ProviderPool`, :class:`AsyncProviderPool` or similar
|
||||
:param \*\*kwargs: additional parameters for the provided `pool_class` constructor.
|
||||
:return: downloaded subtitles per video.
|
||||
:rtype: dict of :class:`~subliminal.video.Video` to list of :class:`~subliminal.subtitle.Subtitle`
|
||||
|
||||
"""
|
||||
downloaded_subtitles = defaultdict(list)
|
||||
|
||||
# check videos
|
||||
checked_videos = []
|
||||
for video in videos:
|
||||
if not check_video(video, languages=languages, undefined=only_one):
|
||||
logger.info('Skipping video %r', video)
|
||||
continue
|
||||
checked_videos.append(video)
|
||||
|
||||
# return immediately if no video passed the checks
|
||||
if not checked_videos:
|
||||
return downloaded_subtitles
|
||||
|
||||
# download best subtitles
|
||||
with pool_class(**kwargs) as pool:
|
||||
for video in checked_videos:
|
||||
logger.info('Downloading best subtitles for %r', video)
|
||||
subtitles = pool.download_best_subtitles(pool.list_subtitles(video, languages - video.subtitle_languages),
|
||||
video, languages, min_score=min_score,
|
||||
hearing_impaired=hearing_impaired, only_one=only_one,
|
||||
compute_score=compute_score)
|
||||
logger.info('Downloaded %d subtitle(s)', len(subtitles))
|
||||
downloaded_subtitles[video].extend(subtitles)
|
||||
|
||||
return downloaded_subtitles
|
||||
|
||||
|
||||
def save_subtitles(video, subtitles, single=False, directory=None, encoding=None):
|
||||
"""Save subtitles on filesystem.
|
||||
|
||||
Subtitles are saved in the order of the list. If a subtitle with a language has already been saved, other subtitles
|
||||
with the same language are silently ignored.
|
||||
|
||||
The extension used is `.lang.srt` by default or `.srt` is `single` is `True`, with `lang` being the IETF code for
|
||||
the :attr:`~subliminal.subtitle.Subtitle.language` of the subtitle.
|
||||
|
||||
:param video: video of the subtitles.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:param subtitles: subtitles to save.
|
||||
:type subtitles: list of :class:`~subliminal.subtitle.Subtitle`
|
||||
:param bool single: save a single subtitle, default is to save one subtitle per language.
|
||||
:param str directory: path to directory where to save the subtitles, default is next to the video.
|
||||
:param str encoding: encoding in which to save the subtitles, default is to keep original encoding.
|
||||
:return: the saved subtitles
|
||||
:rtype: list of :class:`~subliminal.subtitle.Subtitle`
|
||||
|
||||
"""
|
||||
saved_subtitles = []
|
||||
for subtitle in subtitles:
|
||||
# check content
|
||||
if subtitle.content is None:
|
||||
logger.error('Skipping subtitle %r: no content', subtitle)
|
||||
continue
|
||||
|
||||
# check language
|
||||
if subtitle.language in set(s.language for s in saved_subtitles):
|
||||
logger.debug('Skipping subtitle %r: language already saved', subtitle)
|
||||
continue
|
||||
|
||||
# create subtitle path
|
||||
subtitle_path = get_subtitle_path(video.name, None if single else subtitle.language)
|
||||
if directory is not None:
|
||||
subtitle_path = os.path.join(directory, os.path.split(subtitle_path)[1])
|
||||
|
||||
# save content as is or in the specified encoding
|
||||
logger.info('Saving %r to %r', subtitle, subtitle_path)
|
||||
if encoding is None:
|
||||
with io.open(subtitle_path, 'wb') as f:
|
||||
f.write(subtitle.content)
|
||||
else:
|
||||
with io.open(subtitle_path, 'w', encoding=encoding) as f:
|
||||
f.write(subtitle.text)
|
||||
saved_subtitles.append(subtitle)
|
||||
|
||||
# check single
|
||||
if single:
|
||||
break
|
||||
|
||||
return saved_subtitles
|
||||
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
class Error(Exception):
|
||||
"""Base class for exceptions in subliminal."""
|
||||
pass
|
||||
|
||||
|
||||
class ProviderError(Error):
|
||||
"""Exception raised by providers."""
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(ProviderError):
|
||||
"""Exception raised by providers when badly configured."""
|
||||
pass
|
||||
|
||||
|
||||
class AuthenticationError(ProviderError):
|
||||
"""Exception raised by providers when authentication failed."""
|
||||
pass
|
||||
|
||||
|
||||
class TooManyRequests(ProviderError):
|
||||
"""Exception raised by providers when too many requests are made."""
|
||||
pass
|
||||
|
||||
|
||||
class DownloadLimitExceeded(ProviderError):
|
||||
"""Exception raised by providers when download limit is exceeded."""
|
||||
pass
|
||||
@@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from pkg_resources import EntryPoint
|
||||
|
||||
from stevedore import ExtensionManager
|
||||
|
||||
|
||||
class RegistrableExtensionManager(ExtensionManager):
|
||||
""":class:~stevedore.extensions.ExtensionManager` with support for registration.
|
||||
|
||||
It allows loading of internal extensions without setup and registering/unregistering additional extensions.
|
||||
|
||||
Loading is done in this order:
|
||||
|
||||
* Entry point extensions
|
||||
* Internal extensions
|
||||
* Registered extensions
|
||||
|
||||
:param str namespace: namespace argument for :class:~stevedore.extensions.ExtensionManager`.
|
||||
:param list internal_extensions: internal extensions to use with entry point syntax.
|
||||
:param \*\*kwargs: additional parameters for the :class:~stevedore.extensions.ExtensionManager` constructor.
|
||||
|
||||
"""
|
||||
def __init__(self, namespace, internal_extensions, **kwargs):
|
||||
#: Registered extensions with entry point syntax
|
||||
self.registered_extensions = []
|
||||
|
||||
#: Internal extensions with entry point syntax
|
||||
self.internal_extensions = internal_extensions
|
||||
|
||||
super(RegistrableExtensionManager, self).__init__(namespace, **kwargs)
|
||||
|
||||
def _find_entry_points(self, namespace):
|
||||
# copy of default extensions
|
||||
eps = list(super(RegistrableExtensionManager, self)._find_entry_points(namespace))
|
||||
|
||||
# internal extensions
|
||||
for iep in self.internal_extensions:
|
||||
ep = EntryPoint.parse(iep)
|
||||
if ep.name not in [e.name for e in eps]:
|
||||
eps.append(ep)
|
||||
|
||||
# registered extensions
|
||||
for rep in self.registered_extensions:
|
||||
ep = EntryPoint.parse(rep)
|
||||
if ep.name not in [e.name for e in eps]:
|
||||
eps.append(ep)
|
||||
|
||||
return eps
|
||||
|
||||
def register(self, entry_point):
|
||||
"""Register an extension
|
||||
|
||||
:param str entry_point: extension to register (entry point syntax).
|
||||
:raise: ValueError if already registered.
|
||||
|
||||
"""
|
||||
if entry_point in self.registered_extensions:
|
||||
raise ValueError('Extension already registered')
|
||||
|
||||
ep = EntryPoint.parse(entry_point)
|
||||
if ep.name in self.names():
|
||||
raise ValueError('An extension with the same name already exist')
|
||||
|
||||
ext = self._load_one_plugin(ep, False, (), {}, False)
|
||||
self.extensions.append(ext)
|
||||
if self._extensions_by_name is not None:
|
||||
self._extensions_by_name[ext.name] = ext
|
||||
self.registered_extensions.insert(0, entry_point)
|
||||
|
||||
def unregister(self, entry_point):
|
||||
"""Unregister a provider
|
||||
|
||||
:param str entry_point: provider to unregister (entry point syntax).
|
||||
|
||||
"""
|
||||
if entry_point not in self.registered_extensions:
|
||||
raise ValueError('Extension not registered')
|
||||
|
||||
ep = EntryPoint.parse(entry_point)
|
||||
self.registered_extensions.remove(entry_point)
|
||||
if self._extensions_by_name is not None:
|
||||
del self._extensions_by_name[ep.name]
|
||||
for i, ext in enumerate(self.extensions):
|
||||
if ext.name == ep.name:
|
||||
del self.extensions[i]
|
||||
break
|
||||
|
||||
|
||||
#: Provider manager
|
||||
provider_manager = RegistrableExtensionManager('subliminal.providers', [
|
||||
'addic7ed = subliminal.providers.addic7ed:Addic7edProvider',
|
||||
'legendastv = subliminal.providers.legendastv:LegendasTVProvider',
|
||||
'opensubtitles = subliminal.providers.opensubtitles:OpenSubtitlesProvider',
|
||||
'podnapisi = subliminal.providers.podnapisi:PodnapisiProvider',
|
||||
'shooter = subliminal.providers.shooter:ShooterProvider',
|
||||
'subscenter = subliminal.providers.subscenter:SubsCenterProvider',
|
||||
'thesubdb = subliminal.providers.thesubdb:TheSubDBProvider',
|
||||
'tvsubtitles = subliminal.providers.tvsubtitles:TVsubtitlesProvider'
|
||||
])
|
||||
|
||||
#: Refiner manager
|
||||
refiner_manager = RegistrableExtensionManager('subliminal.refiners', [
|
||||
'metadata = subliminal.refiners.metadata:refine',
|
||||
'omdb = subliminal.refiners.omdb:refine',
|
||||
'tvdb = subliminal.refiners.tvdb:refine'
|
||||
])
|
||||
@@ -1,134 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2008-2011 Patrick Dessalle <patrick@dessalle.be>
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from BeautifulSoup import BeautifulSoup
|
||||
from subliminal.classes import Subtitle
|
||||
import guessit
|
||||
import PluginBase
|
||||
import urllib2
|
||||
import re
|
||||
|
||||
|
||||
class Addic7ed(PluginBase.PluginBase):
|
||||
site_url = 'http://www.addic7ed.com'
|
||||
site_name = 'Addic7ed'
|
||||
server_url = 'http://www.addic7ed.com'
|
||||
api_based = False
|
||||
_plugin_languages = {u'English': 'en',
|
||||
u'English (US)': 'en',
|
||||
u'English (UK)': 'en',
|
||||
u'Italian': 'it',
|
||||
u'Portuguese': 'pt',
|
||||
u'Portuguese (Brazilian)': 'pt-br',
|
||||
u'Romanian': 'ro',
|
||||
u'Español (Latinoamérica)': 'es',
|
||||
u'Español (España)': 'es',
|
||||
u'Spanish (Latin America)': 'es',
|
||||
u'Español': 'es',
|
||||
u'Spanish': 'es',
|
||||
u'Spanish (Spain)': 'es',
|
||||
u'French': 'fr',
|
||||
u'Greek': 'el',
|
||||
u'Arabic': 'ar',
|
||||
u'German': 'de',
|
||||
u'Croatian': 'hr',
|
||||
u'Indonesian': 'id',
|
||||
u'Hebrew': 'he',
|
||||
u'Russian': 'ru',
|
||||
u'Turkish': 'tr',
|
||||
u'Swedish': 'se',
|
||||
u'Czech': 'cs',
|
||||
u'Dutch': 'nl',
|
||||
u'Hungarian': 'hu',
|
||||
u'Norwegian': 'no',
|
||||
u'Polish': 'pl',
|
||||
u'Persian': 'fa'}
|
||||
|
||||
def __init__(self, config_dict=None):
|
||||
super(Addic7ed, self).__init__(self._plugin_languages, config_dict, isRevert=True)
|
||||
#http://www.addic7ed.com/serie/Smallville/9/11/Absolute_Justice
|
||||
self.release_pattern = re.compile(' \nVersion (.+), ([0-9]+).([0-9])+ MBs')
|
||||
|
||||
def list(self, filepath, languages):
|
||||
if not self.checkLanguages(languages):
|
||||
return []
|
||||
guess = guessit.guess_file_info(filepath, 'autodetect')
|
||||
if guess['type'] != 'episode':
|
||||
self.logger.debug(u'Not an episode')
|
||||
return []
|
||||
# add multiple things to the release group set
|
||||
release_group = set()
|
||||
if 'releaseGroup' in guess:
|
||||
release_group.add(guess['releaseGroup'].lower())
|
||||
else:
|
||||
if 'title' in guess:
|
||||
release_group.add(guess['title'].lower())
|
||||
if 'screenSize' in guess:
|
||||
release_group.add(guess['screenSize'].lower())
|
||||
if 'series' not in guess or len(release_group) == 0:
|
||||
self.logger.debug(u'Not enough information to proceed')
|
||||
return []
|
||||
self.release_group = release_group # used to sort results
|
||||
return self.query(guess['series'], guess['season'], guess['episodeNumber'], release_group, filepath, languages)
|
||||
|
||||
def query(self, name, season, episode, release_group, filepath, languages=None):
|
||||
searchname = name.lower().replace(' ', '_')
|
||||
if isinstance(searchname, unicode):
|
||||
searchname = searchname.encode('utf-8')
|
||||
searchurl = '%s/serie/%s/%s/%s/%s' % (self.server_url, urllib2.quote(searchname), season, episode, urllib2.quote(searchname))
|
||||
self.logger.debug(u'Searching in %s' % searchurl)
|
||||
try:
|
||||
req = urllib2.Request(searchurl, headers={'User-Agent': self.user_agent})
|
||||
page = urllib2.urlopen(req, timeout=self.timeout)
|
||||
except urllib2.HTTPError as inst:
|
||||
self.logger.info(u'Error: %s - %s' % (searchurl, inst))
|
||||
return []
|
||||
except urllib2.URLError as inst:
|
||||
self.logger.info(u'TimeOut: %s' % inst)
|
||||
return []
|
||||
soup = BeautifulSoup(page.read())
|
||||
sublinks = []
|
||||
for html_sub in soup('td', {'class': 'NewsTitle', 'colspan': '3'}):
|
||||
if not self.release_pattern.match(str(html_sub.contents[1])): # On not needed soup td result
|
||||
continue
|
||||
sub_teams = self.listTeams([self.release_pattern.match(str(html_sub.contents[1])).groups()[0].lower()], ['.', '_', ' ', '/', '-'])
|
||||
if not release_group.intersection(sub_teams): # On wrong team
|
||||
continue
|
||||
html_language = html_sub.findNext('td', {'class': 'language'})
|
||||
sub_language = self.getRevertLanguage(html_language.contents[0].strip().replace(' ', ''))
|
||||
if languages and not sub_language in languages: # On wrong language
|
||||
continue
|
||||
html_status = html_language.findNextSibling('td')
|
||||
sub_status = html_status.find('b').string.strip()
|
||||
if not sub_status == 'Completed': # On not completed subtitles
|
||||
continue
|
||||
sub_link = self.server_url + html_status.findNextSibling('td', {'colspan': '3'}).find('a')['href']
|
||||
self.logger.debug(u'Found a match with teams: %s' % sub_teams)
|
||||
result = Subtitle(filepath, self.getSubtitlePath(filepath, sub_language), self.__class__.__name__, sub_language, sub_link, teams=sub_teams)
|
||||
sublinks.append(result)
|
||||
sublinks.sort(self._cmpReleaseGroup)
|
||||
return sublinks
|
||||
|
||||
def download(self, subtitle):
|
||||
self.downloadFile(subtitle.link, subtitle.path)
|
||||
return subtitle
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2008-2011 Patrick Dessalle <patrick@dessalle.be>
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from xml.dom import minidom
|
||||
import guessit
|
||||
import PluginBase
|
||||
import os
|
||||
try:
|
||||
import cPickle as pickle
|
||||
except ImportError:
|
||||
import pickle
|
||||
import urllib2
|
||||
from subliminal.classes import Subtitle
|
||||
|
||||
|
||||
class BierDopje(PluginBase.PluginBase):
|
||||
site_url = 'http://bierdopje.com'
|
||||
site_name = 'BierDopje'
|
||||
server_url = 'http://api.bierdopje.com/A2B638AC5D804C2E/'
|
||||
api_based = True
|
||||
exceptions = {'the office': 10358,
|
||||
'the office us': 10358,
|
||||
'greys anatomy': 3733,
|
||||
'sanctuary us': 7904,
|
||||
'human target 2010': 12986,
|
||||
'csi miami': 2187,
|
||||
'castle 2009': 12708,
|
||||
'chase 2010': 14228,
|
||||
'the defenders 2010': 14225,
|
||||
'hawaii five-0 2010': 14211}
|
||||
_plugin_languages = {'en': 'en', 'nl': 'nl'}
|
||||
|
||||
def __init__(self, config_dict=None):
|
||||
super(BierDopje, self).__init__(self._plugin_languages, config_dict)
|
||||
#http://api.bierdopje.com/23459DC262C0A742/GetShowByName/30+Rock
|
||||
#http://api.bierdopje.com/23459DC262C0A742/GetAllSubsFor/94/5/1/en (30 rock, season 5, episode 1)
|
||||
if config_dict and config_dict['cache_dir']:
|
||||
self.showid_cache = os.path.join(config_dict['cache_dir'], 'bierdopje_showid.cache')
|
||||
with self.lock:
|
||||
if not os.path.exists(self.showid_cache):
|
||||
if not os.path.exists(os.path.dirname(self.showid_cache)):
|
||||
raise Exception('Cache directory does not exist')
|
||||
f = open(self.showid_cache, 'w')
|
||||
pickle.dump({}, f)
|
||||
f.close()
|
||||
f = open(self.showid_cache, 'r')
|
||||
self.showids = pickle.load(f)
|
||||
self.logger.debug(u'Reading showids from cache: %s' % self.showids)
|
||||
f.close()
|
||||
|
||||
def list(self, filepath, languages):
|
||||
if not self.config_dict['cache_dir']:
|
||||
raise Exception('Cache directory is required for this plugin')
|
||||
possible_languages = self.possible_languages(languages)
|
||||
if not possible_languages:
|
||||
return []
|
||||
guess = guessit.guess_file_info(filepath, 'autodetect')
|
||||
if guess['type'] != 'episode':
|
||||
self.logger.debug(u'Not an episode')
|
||||
return []
|
||||
# add multiple things to the release group set
|
||||
release_group = set()
|
||||
if 'releaseGroup' in guess:
|
||||
release_group.add(guess['releaseGroup'].lower())
|
||||
else:
|
||||
if 'title' in guess:
|
||||
release_group.add(guess['title'].lower())
|
||||
if 'screenSize' in guess:
|
||||
release_group.add(guess['screenSize'].lower())
|
||||
if 'series' not in guess or len(release_group) == 0:
|
||||
self.logger.debug(u'Not enough information to proceed')
|
||||
return []
|
||||
self.release_group = release_group # used to sort results
|
||||
return self.query(guess['series'], guess['season'], guess['episodeNumber'], release_group, filepath, possible_languages)
|
||||
|
||||
def download(self, subtitle):
|
||||
self.downloadFile(subtitle.link, subtitle.path)
|
||||
return subtitle
|
||||
|
||||
def query(self, name, season, episode, release_group, filepath, languages):
|
||||
sublinks = []
|
||||
# get the show id
|
||||
show_name = name.lower()
|
||||
if show_name in self.exceptions: # get it from exceptions
|
||||
show_id = self.exceptions[show_name]
|
||||
elif show_name in self.showids: # get it from cache
|
||||
show_id = self.showids[show_name]
|
||||
else: # retrieve it
|
||||
show_name_encoded = show_name
|
||||
if isinstance(show_name_encoded, unicode):
|
||||
show_name_encoded = show_name_encoded.encode('utf-8')
|
||||
show_id_url = '%sGetShowByName/%s' % (self.server_url, urllib2.quote(show_name_encoded))
|
||||
self.logger.debug(u'Retrieving show id from web at %s' % show_id_url)
|
||||
page = urllib2.urlopen(show_id_url)
|
||||
dom = minidom.parse(page)
|
||||
if not dom or len(dom.getElementsByTagName('showid')) == 0: # no proper result
|
||||
page.close()
|
||||
return []
|
||||
show_id = dom.getElementsByTagName('showid')[0].firstChild.data
|
||||
self.showids[show_name] = show_id
|
||||
with self.lock:
|
||||
f = open(self.showid_cache, 'w')
|
||||
self.logger.debug(u'Writing showid %s to cache file' % show_id)
|
||||
pickle.dump(self.showids, f)
|
||||
f.close()
|
||||
page.close()
|
||||
|
||||
# get the subs for the show id we have
|
||||
for language in languages:
|
||||
subs_url = '%sGetAllSubsFor/%s/%s/%s/%s' % (self.server_url, show_id, season, episode, language)
|
||||
self.logger.debug(u'Getting subtitles at %s' % subs_url)
|
||||
page = urllib2.urlopen(subs_url)
|
||||
dom = minidom.parse(page)
|
||||
page.close()
|
||||
for sub in dom.getElementsByTagName('result'):
|
||||
sub_release = sub.getElementsByTagName('filename')[0].firstChild.data
|
||||
if sub_release.endswith('.srt'):
|
||||
sub_release = sub_release[:-4]
|
||||
sub_release = sub_release + '.avi' # put a random extension for guessit not to fail guessing that file
|
||||
# guess information from subtitle
|
||||
sub_guess = guessit.guess_file_info(sub_release, 'episode')
|
||||
sub_release_group = set()
|
||||
if 'releaseGroup' in sub_guess:
|
||||
sub_release_group.add(sub_guess['releaseGroup'].lower())
|
||||
else:
|
||||
if 'title' in sub_guess:
|
||||
sub_release_group.add(sub_guess['title'].lower())
|
||||
if 'screenSize' in sub_guess:
|
||||
sub_release_group.add(sub_guess['screenSize'].lower())
|
||||
sub_link = sub.getElementsByTagName('downloadlink')[0].firstChild.data
|
||||
result = Subtitle(filepath, self.getSubtitlePath(filepath, language), self.__class__.__name__, language, sub_link, sub_release, sub_release_group)
|
||||
sublinks.append(result)
|
||||
sublinks.sort(self._cmpReleaseGroup)
|
||||
return sublinks
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2008-2011 Patrick Dessalle <patrick@dessalle.be>
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import PluginBase
|
||||
import gzip
|
||||
import os
|
||||
import socket
|
||||
import xmlrpclib
|
||||
import guessit
|
||||
import unicodedata
|
||||
from subliminal.classes import Subtitle, DownloadFailedError
|
||||
|
||||
|
||||
class OpenSubtitles(PluginBase.PluginBase):
|
||||
site_url = 'http://www.opensubtitles.org'
|
||||
site_name = 'OpenSubtitles'
|
||||
server_url = 'http://api.opensubtitles.org/xml-rpc'
|
||||
user_agent = 'Subliminal v0.4'
|
||||
api_based = True
|
||||
_plugin_languages = {'aa': 'aar', 'ab': 'abk', 'af': 'afr', 'ak': 'aka', 'sq': 'alb', 'am': 'amh', 'ar': 'ara', 'an': 'arg', 'hy': 'arm',
|
||||
'as': 'asm', 'av': 'ava', 'ae': 'ave', 'ay': 'aym', 'az': 'aze', 'ba': 'bak', 'bm': 'bam', 'eu': 'baq', 'be': 'bel', 'bn': 'ben',
|
||||
'bh': 'bih', 'bi': 'bis', 'bs': 'bos', 'br': 'bre', 'bg': 'bul', 'my': 'bur', 'ca': 'cat', 'ch': 'cha', 'ce': 'che', 'zh': 'chi',
|
||||
'cu': 'chu', 'cv': 'chv', 'kw': 'cor', 'co': 'cos', 'cr': 'cre', 'cs': 'cze', 'da': 'dan', 'dv': 'div', 'nl': 'dut', 'dz': 'dzo',
|
||||
'en': 'eng', 'eo': 'epo', 'et': 'est', 'ee': 'ewe', 'fo': 'fao', 'fj': 'fij', 'fi': 'fin', 'fr': 'fre', 'fy': 'fry', 'ff': 'ful',
|
||||
'ka': 'geo', 'de': 'ger', 'gd': 'gla', 'ga': 'gle', 'gl': 'glg', 'gv': 'glv', 'el': 'ell', 'gn': 'grn', 'gu': 'guj', 'ht': 'hat',
|
||||
'ha': 'hau', 'he': 'heb', 'hz': 'her', 'hi': 'hin', 'ho': 'hmo', 'hr': 'hrv', 'hu': 'hun', 'ig': 'ibo', 'is': 'ice', 'io': 'ido',
|
||||
'ii': 'iii', 'iu': 'iku', 'ie': 'ile', 'ia': 'ina', 'id': 'ind', 'ik': 'ipk', 'it': 'ita', 'jv': 'jav', 'ja': 'jpn', 'kl': 'kal',
|
||||
'kn': 'kan', 'ks': 'kas', 'kr': 'kau', 'kk': 'kaz', 'km': 'khm', 'ki': 'kik', 'rw': 'kin', 'ky': 'kir', 'kv': 'kom', 'kg': 'kon',
|
||||
'ko': 'kor', 'kj': 'kua', 'ku': 'kur', 'lo': 'lao', 'la': 'lat', 'lv': 'lav', 'li': 'lim', 'ln': 'lin', 'lt': 'lit', 'lb': 'ltz',
|
||||
'lu': 'lub', 'lg': 'lug', 'mk': 'mac', 'mh': 'mah', 'ml': 'mal', 'mi': 'mao', 'mr': 'mar', 'ms': 'may', 'mg': 'mlg', 'mt': 'mlt',
|
||||
'mo': 'mol', 'mn': 'mon', 'na': 'nau', 'nv': 'nav', 'nr': 'nbl', 'nd': 'nde', 'ng': 'ndo', 'ne': 'nep', 'nn': 'nno', 'nb': 'nob',
|
||||
'no': 'nor', 'ny': 'nya', 'oc': 'oci', 'oj': 'oji', 'or': 'ori', 'om': 'orm', 'os': 'oss', 'pa': 'pan', 'fa': 'per', 'pi': 'pli',
|
||||
'pl': 'pol', 'pt': 'por', 'ps': 'pus', 'qu': 'que', 'rm': 'roh', 'rn': 'run', 'ru': 'rus', 'sg': 'sag', 'sa': 'san', 'sr': 'scc',
|
||||
'si': 'sin', 'sk': 'slo', 'sl': 'slv', 'se': 'sme', 'sm': 'smo', 'sn': 'sna', 'sd': 'snd', 'so': 'som', 'st': 'sot', 'es': 'spa',
|
||||
'sc': 'srd', 'ss': 'ssw', 'su': 'sun', 'sw': 'swa', 'sv': 'swe', 'ty': 'tah', 'ta': 'tam', 'tt': 'tat', 'te': 'tel', 'tg': 'tgk',
|
||||
'tl': 'tgl', 'th': 'tha', 'bo': 'tib', 'ti': 'tir', 'to': 'ton', 'tn': 'tsn', 'ts': 'tso', 'tk': 'tuk', 'tr': 'tur', 'tw': 'twi',
|
||||
'ug': 'uig', 'uk': 'ukr', 'ur': 'urd', 'uz': 'uzb', 've': 'ven', 'vi': 'vie', 'vo': 'vol', 'cy': 'wel', 'wa': 'wln', 'wo': 'wol',
|
||||
'xh': 'xho', 'yi': 'yid', 'yo': 'yor', 'za': 'zha', 'zu': 'zul', 'ro': 'rum', 'pb': 'pob', 'un': 'unk', 'ay': 'ass'}
|
||||
|
||||
def __init__(self, config_dict=None):
|
||||
super(OpenSubtitles, self).__init__(self._plugin_languages, config_dict)
|
||||
|
||||
def list(self, filepath, languages):
|
||||
possible_languages = self.possible_languages(languages)
|
||||
if not possible_languages:
|
||||
return []
|
||||
if os.path.isfile(filepath):
|
||||
filehash = self.hashFile(filepath)
|
||||
size = os.path.getsize(filepath)
|
||||
return self.query(moviehash=filehash, languages=possible_languages, bytesize=size, filepath=filepath)
|
||||
else:
|
||||
return self.query(languages=possible_languages, filepath=filepath)
|
||||
|
||||
def download(self, subtitle):
|
||||
try:
|
||||
self.downloadFile(subtitle.link, subtitle.path + '.gz')
|
||||
with open(subtitle.path, 'wb') as dump:
|
||||
gz = gzip.open(subtitle.path + '.gz')
|
||||
dump.write(gz.read())
|
||||
gz.close()
|
||||
self.adjustPermissions(subtitle.path)
|
||||
os.remove(subtitle.path + '.gz')
|
||||
except Exception as e:
|
||||
if os.path.exists(subtitle.path):
|
||||
os.remove(subtitle.path)
|
||||
raise DownloadFailedError(str(e))
|
||||
return subtitle
|
||||
|
||||
def query(self, filepath, imdbID=None, moviehash=None, bytesize=None, languages=None):
|
||||
"""Makes a query on OpenSubtitles and returns info about found subtitles.
|
||||
Note: if using moviehash, bytesize is required. """
|
||||
# prepare the search
|
||||
search = {}
|
||||
if moviehash:
|
||||
search['moviehash'] = moviehash
|
||||
if imdbID:
|
||||
search['imdbid'] = imdbID
|
||||
if bytesize:
|
||||
search['moviebytesize'] = str(bytesize)
|
||||
if languages:
|
||||
search['sublanguageid'] = ','.join([self.getLanguage(l) for l in languages])
|
||||
if not imdbID and not moviehash and not bytesize:
|
||||
self.logger.debug(u'No search term, using the filename')
|
||||
guess = guessit.guess_file_info(filepath, 'autodetect')
|
||||
if guess['type'] == 'episode' and 'series' in guess:
|
||||
search['query'] = guess['series'].lower()
|
||||
elif guess['type'] == 'movie':
|
||||
search['query'] = guess['title'].lower()
|
||||
else: # we don't know what we have
|
||||
return[]
|
||||
# login
|
||||
self.server = xmlrpclib.Server(self.server_url)
|
||||
socket.setdefaulttimeout(self.timeout)
|
||||
try:
|
||||
log_result = self.server.LogIn('', '', 'eng', self.user_agent)
|
||||
if not log_result['status'] or log_result['status'] != '200 OK' or not log_result['token']:
|
||||
raise Exception('OpenSubtitles login failed')
|
||||
token = log_result['token']
|
||||
except Exception:
|
||||
self.logger.error(u'Cannot login')
|
||||
token = None
|
||||
socket.setdefaulttimeout(None)
|
||||
return []
|
||||
# search
|
||||
sublinks = self.get_results(token, search, filepath)
|
||||
# logout
|
||||
try:
|
||||
self.server.LogOut(token)
|
||||
except:
|
||||
self.logger.error(u'Cannot logout')
|
||||
socket.setdefaulttimeout(None)
|
||||
return sublinks
|
||||
|
||||
def get_results(self, token, search, filepath):
|
||||
self.logger.debug(u'Query uses token %s and search parameters %s' % (token, search))
|
||||
try:
|
||||
results = self.server.SearchSubtitles(token, [search])
|
||||
except Exception:
|
||||
self.logger.debug(u'Cannot query the server')
|
||||
return []
|
||||
if not results['data']: # no subtitle found
|
||||
return []
|
||||
sublinks = []
|
||||
self.filename = self.getFileName(filepath)
|
||||
for r in sorted(results['data'], self._cmpSubFileName):
|
||||
result = Subtitle(filepath, self.getSubtitlePath(filepath, self.getRevertLanguage(r['SubLanguageID'])), self.__class__.__name__, self.getRevertLanguage(r['SubLanguageID']), r['SubDownloadLink'], r['SubFileName'])
|
||||
if 'query' in search: # query mode search, filter results
|
||||
query_encoded = search['query']
|
||||
if isinstance(query_encoded, unicode):
|
||||
query_encoded = unicodedata.normalize('NFKD', query_encoded).encode('ascii', 'ignore')
|
||||
if not r['MovieReleaseName'].replace('.', ' ').lower().startswith(query_encoded):
|
||||
self.logger.debug(u'Skipping %s it does not start with %s' % (r['MovieReleaseName'].replace('.', ' ').lower(), query_encoded))
|
||||
continue
|
||||
sublinks.append(result)
|
||||
return sublinks
|
||||
|
||||
def _cmpSubFileName(self, x, y):
|
||||
"""Sort based on the SubFileName name tag """
|
||||
#TODO add also support for subtitles release
|
||||
xmatch = x['SubFileName'] and (x['SubFileName'].find(self.filename) > -1 or self.filename.find(x['SubFileName']) > -1)
|
||||
ymatch = y['SubFileName'] and (y['SubFileName'].find(self.filename) > -1 or self.filename.find(y['SubFileName']) > -1)
|
||||
if xmatch and ymatch:
|
||||
if x['SubFileName'] == self.filename or x['SubFileName'].startswith(self.filename):
|
||||
return - 1
|
||||
return 0
|
||||
if not xmatch and not ymatch:
|
||||
return 0
|
||||
if xmatch and not ymatch:
|
||||
return - 1
|
||||
if not xmatch and ymatch:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import abc
|
||||
import logging
|
||||
import os
|
||||
import urllib2
|
||||
import struct
|
||||
import threading
|
||||
import socket
|
||||
from subliminal.classes import DownloadFailedError
|
||||
|
||||
|
||||
class PluginBase(object):
|
||||
__metaclass__ = abc.ABCMeta
|
||||
api_based = True
|
||||
timeout = 3
|
||||
user_agent = 'Mozilla/5.0 (X11; U; Linux x86_64; en-US; rv:1.9.1.3)'
|
||||
lock = threading.Lock()
|
||||
|
||||
@abc.abstractmethod
|
||||
def __init__(self, pluginLanguages, config_dict=None, isRevert=False):
|
||||
self.config_dict = config_dict
|
||||
if not pluginLanguages:
|
||||
self.pluginLanguages = None
|
||||
self.revertPluginLanguages = None
|
||||
elif not isRevert:
|
||||
self.pluginLanguages = pluginLanguages
|
||||
self.revertPluginLanguages = dict((v, k) for k, v in self.pluginLanguages.iteritems())
|
||||
else:
|
||||
self.revertPluginLanguages = pluginLanguages
|
||||
self.pluginLanguages = dict((v, k) for k, v in self.revertPluginLanguages.iteritems())
|
||||
self.logger = logging.getLogger('subliminal.%s' % self.__class__.__name__)
|
||||
|
||||
@staticmethod
|
||||
def getFileName(filepath):
|
||||
filename = filepath
|
||||
if os.path.isfile(filename):
|
||||
filename = os.path.basename(filename)
|
||||
if filename.endswith(('.avi', '.wmv', '.mov', '.mp4', '.mpeg', '.mpg', '.mkv')):
|
||||
filename = filename.rsplit('.', 1)[0]
|
||||
return filename
|
||||
|
||||
def possible_languages(self, languages):
|
||||
possible_languages = languages & set(self.pluginLanguages.keys())
|
||||
if not possible_languages:
|
||||
self.logger.debug(u'Languages %r are not in supported languages' % languages)
|
||||
return possible_languages
|
||||
|
||||
def hashFile(self, filename):
|
||||
"""Hash a file like OpenSubtitles"""
|
||||
longlongformat = 'q' # long long
|
||||
bytesize = struct.calcsize(longlongformat)
|
||||
f = open(filename, 'rb')
|
||||
filesize = os.path.getsize(filename)
|
||||
hash = filesize
|
||||
if filesize < 65536 * 2:
|
||||
self.logger.error(u'File %s is too small (SizeError < 2**16)' % filename)
|
||||
return []
|
||||
for _ in range(65536 / bytesize):
|
||||
buffer = f.read(bytesize)
|
||||
(l_value,) = struct.unpack(longlongformat, buffer)
|
||||
hash += l_value
|
||||
hash = hash & 0xFFFFFFFFFFFFFFFF # to remain as 64bit number
|
||||
f.seek(max(0, filesize - 65536), 0)
|
||||
for _ in range(65536 / bytesize):
|
||||
buffer = f.read(bytesize)
|
||||
(l_value,) = struct.unpack(longlongformat, buffer)
|
||||
hash += l_value
|
||||
hash = hash & 0xFFFFFFFFFFFFFFFF
|
||||
f.close()
|
||||
returnedhash = '%016x' % hash
|
||||
return returnedhash
|
||||
|
||||
def downloadFile(self, url, filepath, data=None):
|
||||
"""Download a subtitle file"""
|
||||
self.logger.info(u'Downloading %s' % url)
|
||||
socket.setdefaulttimeout(self.timeout)
|
||||
try:
|
||||
req = urllib2.Request(url, headers={'Referer': url, 'User-Agent': self.user_agent})
|
||||
with open(filepath, 'wb') as dump:
|
||||
f = urllib2.urlopen(req, data=data)
|
||||
dump.write(f.read())
|
||||
self.adjustPermissions(filepath)
|
||||
f.close()
|
||||
except Exception as e:
|
||||
self.logger.error(u'Download %s failed: %s' % (url, e))
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
raise DownloadFailedError(str(e))
|
||||
finally:
|
||||
socket.setdefaulttimeout(self.timeout)
|
||||
self.logger.debug(u'Download finished for file %s. Size: %s' % (filepath, os.path.getsize(filepath)))
|
||||
|
||||
def adjustPermissions(self, filepath):
|
||||
if self.config_dict and 'files_mode' in self.config_dict and self.config_dict['files_mode'] != -1:
|
||||
os.chmod(filepath, self.config_dict['files_mode'])
|
||||
|
||||
@abc.abstractmethod
|
||||
def list(self, filepath, languages):
|
||||
"""List subtitles"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def download(self, subtitle):
|
||||
"""Download a subtitle"""
|
||||
|
||||
def getRevertLanguage(self, language):
|
||||
"""ISO-639-1 language code from plugin language code"""
|
||||
try:
|
||||
return self.revertPluginLanguages[language]
|
||||
except KeyError:
|
||||
self.logger.warn(u'Ooops, you found a missing language in the configuration file of %s: %s. Send a bug report to have it added.' % (self.__class__.__name__, language))
|
||||
|
||||
def getLanguage(self, language):
|
||||
"""Plugin language code from ISO-639-1 language code"""
|
||||
try:
|
||||
return self.pluginLanguages[language]
|
||||
except KeyError:
|
||||
self.logger.warn(u'Ooops, you found a missing language in the configuration file of %s: %s. Send a bug report to have it added.' % (self.__class__.__name__, language))
|
||||
|
||||
def getSubtitlePath(self, video_path, language):
|
||||
if not os.path.exists(video_path):
|
||||
video_path = os.path.split(video_path)[1]
|
||||
path = video_path.rsplit('.', 1)[0]
|
||||
if self.config_dict and self.config_dict['multi']:
|
||||
return path + '.%s.srt' % language
|
||||
return path + '.srt'
|
||||
|
||||
def listTeams(self, sub_teams, separators):
|
||||
"""List teams of a given string using separators"""
|
||||
for sep in separators:
|
||||
sub_teams = self.splitTeam(sub_teams, sep)
|
||||
return set(sub_teams)
|
||||
|
||||
def splitTeam(self, sub_teams, sep):
|
||||
"""Split teams of a given string using separators"""
|
||||
teams = []
|
||||
for t in sub_teams:
|
||||
teams += t.split(sep)
|
||||
return teams
|
||||
|
||||
def _cmpReleaseGroup(self, x, y):
|
||||
"""Sort based on teams matching"""
|
||||
return -cmp(len(x.teams.intersection(self.release_group)), len(y.teams.intersection(self.release_group)))
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2008-2011 Patrick Dessalle <patrick@dessalle.be>
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from hashlib import md5, sha256
|
||||
import PluginBase
|
||||
import xmlrpclib
|
||||
import socket
|
||||
import os
|
||||
|
||||
|
||||
class Podnapisi(PluginBase.PluginBase):
|
||||
site_url = "http://www.podnapisi.net"
|
||||
site_name = "Podnapisi"
|
||||
server_url = 'http://ssp.podnapisi.net:8000'
|
||||
api_based = True
|
||||
_plugin_languages = {"sl": "1",
|
||||
"en": "2",
|
||||
"no": "3",
|
||||
"ko": "4",
|
||||
"de": "5",
|
||||
"is": "6",
|
||||
"cs": "7",
|
||||
"fr": "8",
|
||||
"it": "9",
|
||||
"bs": "10",
|
||||
"ja": "11",
|
||||
"ar": "12",
|
||||
"ro": "13",
|
||||
"es-ar": "14",
|
||||
"hu": "15",
|
||||
"el": "16",
|
||||
"zh": "17",
|
||||
"lt": "19",
|
||||
"et": "20",
|
||||
"lv": "21",
|
||||
"he": "22",
|
||||
"nl": "23",
|
||||
"da": "24",
|
||||
"se": "25",
|
||||
"pl": "26",
|
||||
"ru": "27",
|
||||
"es": "28",
|
||||
"sq": "29",
|
||||
"tr": "30",
|
||||
"fi": "31",
|
||||
"pt": "32",
|
||||
"bg": "33",
|
||||
"mk": "35",
|
||||
"sk": "37",
|
||||
"hr": "38",
|
||||
"zh": "40",
|
||||
"hi": "42",
|
||||
"th": "44",
|
||||
"uk": "46",
|
||||
"sr": "47",
|
||||
"pt-br": "48",
|
||||
"ga": "49",
|
||||
"be": "50",
|
||||
"vi": "51",
|
||||
"fa": "52",
|
||||
"ca": "53",
|
||||
"id": "54"}
|
||||
|
||||
def __init__(self, config_dict=None):
|
||||
super(Podnapisi, self).__init__(self._plugin_languages, config_dict)
|
||||
# Podnapisi uses two reference for latin serbian and cyrillic serbian (36 and 47)
|
||||
# add the 36 manually as cyrillic seems to be more used
|
||||
self.revertPluginLanguages["36"] = "sr"
|
||||
|
||||
def list(self, filenames, languages):
|
||||
"""Main method to call when you want to list subtitles"""
|
||||
filepath = filenames[0]
|
||||
if not os.path.isfile(filepath):
|
||||
return []
|
||||
return self.query(self.hashFile(filepath), languages)
|
||||
|
||||
def download(self, subtitle):
|
||||
return []
|
||||
|
||||
def query(self, moviehash, languages=None):
|
||||
"""Makes a query on podnapisi and returns info (link, lang) about found subtitles"""
|
||||
# login
|
||||
self.server = xmlrpclib.Server(self.server_url)
|
||||
socket.setdefaulttimeout(self.timeout)
|
||||
try:
|
||||
log_result = self.server.initiate(self.user_agent)
|
||||
self.logger.debug(u"Result: %s" % log_result)
|
||||
token = log_result["session"]
|
||||
nonce = log_result["nonce"]
|
||||
except Exception:
|
||||
self.logger.error(u"Cannot login" % log_result)
|
||||
socket.setdefaulttimeout(None)
|
||||
return []
|
||||
username = 'getmesubs'
|
||||
password = '99D31$$'
|
||||
hash = md5()
|
||||
hash.update(password)
|
||||
password = hash.hexdigest()
|
||||
hash = sha256()
|
||||
hash.update(password)
|
||||
hash.update(nonce)
|
||||
password = hash.hexdigest()
|
||||
self.server.authenticate(token, username, password)
|
||||
self.logger.debug(u'Authenticated')
|
||||
#if languages:
|
||||
# self.logger.debug([self.getLanguage(l) for l in languages])
|
||||
# self.server.setFilters(token, [self.getLanguage(l) for l in languages])
|
||||
# self.logger.debug('Filers set for languages %s' % languages)
|
||||
self.logger.debug(u"Starting search with token %s and hashs %s" % (token, [moviehash]))
|
||||
results = self.server.search(token, [moviehash])
|
||||
return results
|
||||
subs = []
|
||||
for sub in results['results']:
|
||||
subs.append(sub)
|
||||
self.server.terminate(token)
|
||||
return subs
|
||||
@@ -1,168 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2008-2011 Patrick Dessalle <patrick@dessalle.be>
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from BeautifulSoup import BeautifulSoup
|
||||
import PluginBase
|
||||
import zipfile
|
||||
import os
|
||||
import urllib2
|
||||
|
||||
|
||||
class SubScene(PluginBase.PluginBase):
|
||||
site_url = 'http://subscene.com'
|
||||
site_name = 'SubScene'
|
||||
server_url = 'http://subscene.com/s.aspx?subtitle='
|
||||
api_based = False
|
||||
_plugin_languages = {"en": "English",
|
||||
"se": "Swedish",
|
||||
"da": "Danish",
|
||||
"fi": "Finnish",
|
||||
"no": "Norwegian",
|
||||
"fr": "French",
|
||||
"es": "Spanish",
|
||||
"is": "Icelandic",
|
||||
"cs": "Czech",
|
||||
"bg": "Bulgarian",
|
||||
"de": "German",
|
||||
"ar": "Arabic",
|
||||
"el": "Greek",
|
||||
"fa": "Farsi/Persian",
|
||||
"nl": "Dutch",
|
||||
"he": "Hebrew",
|
||||
"id": "Indonesian",
|
||||
"ja": "Japanese",
|
||||
"vi": "Vietnamese",
|
||||
"pt": "Portuguese",
|
||||
"ro": "Romanian",
|
||||
"tr": "Turkish",
|
||||
"sr": "Serbian",
|
||||
"pt-br": "Brazillian Portuguese",
|
||||
"ru": "Russian",
|
||||
"hr": "Croatian",
|
||||
"sl": "Slovenian",
|
||||
"zh": "Chinese BG code",
|
||||
"it": "Italian",
|
||||
"pl": "Polish",
|
||||
"ko": "Korean",
|
||||
"hu": "Hungarian",
|
||||
"ku": "Kurdish",
|
||||
"et": "Estonian"}
|
||||
|
||||
def __init__(self, config_dict=None):
|
||||
super(SubScene, self).__init__(self._plugin_languages, config_dict)
|
||||
#http://subscene.com/s.aspx?subtitle=Dexter.S04E01.HDTV.XviD-NoTV
|
||||
|
||||
def list(self, filenames, languages):
|
||||
"""Main method to call when you want to list subtitles"""
|
||||
filepath = filenames[0]
|
||||
fname = self.getFileName(filepath)
|
||||
subs = self.query(fname, filepath, languages)
|
||||
if not subs and fname.rfind(".[") > 0:
|
||||
# Try to remove the [VTV] or [EZTV] at the end of the file
|
||||
teamless_filename = fname[0:fname.rfind(".[")]
|
||||
subs = self.query(teamless_filename, filepath, languages)
|
||||
return subs
|
||||
else:
|
||||
return subs
|
||||
|
||||
def download(self, subtitle):
|
||||
"""Main method to call when you want to download a subtitle"""
|
||||
subpage = subtitle["page"]
|
||||
page = urllib2.urlopen(subpage)
|
||||
soup = BeautifulSoup(page)
|
||||
dlhref = soup.find("div", {"class": "download"}).find("a")["href"]
|
||||
subtitle["link"] = self.site_url + dlhref.split('"')[7]
|
||||
format = "zip"
|
||||
archivefilename = subtitle["filename"].rsplit(".", 1)[0] + '.' + format
|
||||
self.downloadFile(subtitle["link"], archivefilename)
|
||||
subtitlefilename = None
|
||||
if zipfile.is_zipfile(archivefilename):
|
||||
self.logger.debug(u"Unzipping file " + archivefilename)
|
||||
zf = zipfile.ZipFile(archivefilename, "r")
|
||||
for el in zf.infolist():
|
||||
extension = el.orig_filename.rsplit(".", 1)[1]
|
||||
if extension in ("srt", "sub", "txt"):
|
||||
subtitlefilename = srtbasefilename + "." + extension
|
||||
outfile = open(subtitlefilename, "wb")
|
||||
outfile.write(zf.read(el.orig_filename))
|
||||
outfile.flush()
|
||||
self.adjustPermissions(subtitlefilename)
|
||||
outfile.close()
|
||||
else:
|
||||
self.logger.info(u"File %s does not seem to be valid " % el.orig_filename)
|
||||
# Deleting the zip file
|
||||
zf.close()
|
||||
os.remove(archivefilename)
|
||||
return subtitlefilename
|
||||
elif archivefilename.endswith('.rar'):
|
||||
self.logger.warn(u'Rar is not really supported yet. Trying to call unrar')
|
||||
import subprocess
|
||||
try:
|
||||
args = ['unrar', 'lb', archivefilename]
|
||||
output = subprocess.Popen(args, stdout=subprocess.PIPE).communicate()[0]
|
||||
for el in output.splitlines():
|
||||
extension = el.rsplit(".", 1)[1]
|
||||
if extension in ("srt", "sub"):
|
||||
args = ['unrar', 'e', archivefilename, el, os.path.dirname(archivefilename)]
|
||||
subprocess.Popen(args)
|
||||
tmpsubtitlefilename = os.path.join(os.path.dirname(archivefilename), el)
|
||||
subtitlefilename = os.path.join(os.path.dirname(archivefilename), srtbasefilename + "." + extension)
|
||||
if os.path.exists(tmpsubtitlefilename):
|
||||
# rename it to match the file
|
||||
os.rename(tmpsubtitlefilename, subtitlefilename)
|
||||
# exit
|
||||
return subtitlefilename
|
||||
except OSError as e:
|
||||
self.logger.error(u"Execution failed: %s" % e)
|
||||
return None
|
||||
else:
|
||||
self.logger.info(u"Unexpected file type (not zip) for %s" % archivefilename)
|
||||
return None
|
||||
|
||||
def downloadFile(self, url, filename):
|
||||
"""Downloads the given url to the given filename"""
|
||||
#FIXME: Not working
|
||||
|
||||
def query(self, token, filepath, langs=None):
|
||||
"""Make a query on SubScene and returns info about found subtitles"""
|
||||
sublinks = []
|
||||
searchurl = "%s%s" % (self.server_url, urllib2.quote(token))
|
||||
self.logger.debug(u"Query: %s" % searchurl)
|
||||
page = urllib2.urlopen(searchurl)
|
||||
soup = BeautifulSoup(page.read())
|
||||
for subs in soup("a", {"class": "a1"}):
|
||||
lang_span = subs.find("span")
|
||||
lang = self.getRevertLanguage(lang_span.contents[0].strip())
|
||||
release_span = lang_span.findNext("span")
|
||||
release = release_span.contents[0].strip().split(" (")[0]
|
||||
sub_page = subs["href"]
|
||||
#http://subscene.com//s-dlpath-260016/78348/rar.zipx
|
||||
if release.lower().startswith(token.lower()) and (not langs or lang in langs):
|
||||
result = {}
|
||||
result["release"] = release
|
||||
result["lang"] = lang
|
||||
result["link"] = None
|
||||
result["page"] = self.site_url + sub_page
|
||||
result["filename"] = filepath
|
||||
result["plugin"] = self.__class__.__name__
|
||||
sublinks.append(result)
|
||||
return sublinks
|
||||
@@ -1,116 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2008-2011 Patrick Dessalle <patrick@dessalle.be>
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from BeautifulSoup import BeautifulSoup
|
||||
import PluginBase
|
||||
import urllib2
|
||||
import re
|
||||
import guessit
|
||||
from subliminal.classes import Subtitle
|
||||
|
||||
|
||||
class SubsWiki(PluginBase.PluginBase):
|
||||
site_url = 'http://www.subswiki.com'
|
||||
site_name = 'SubsWiki'
|
||||
server_url = 'http://www.subswiki.com'
|
||||
api_based = False
|
||||
_plugin_languages = {u'English (US)': 'en',
|
||||
u'English (UK)': 'en',
|
||||
u'English': 'en',
|
||||
u'French': 'fr',
|
||||
u'Brazilian': 'pt-br',
|
||||
u'Portuguese': 'pt',
|
||||
u'Español (Latinoamérica)': 'es',
|
||||
u'Español (España)': 'es',
|
||||
u'Español': 'es',
|
||||
u'Italian': 'it',
|
||||
u'Català': 'ca'}
|
||||
|
||||
def __init__(self, config_dict=None):
|
||||
super(SubsWiki, self).__init__(self._plugin_languages, config_dict, True)
|
||||
self.release_pattern = re.compile('\nVersion (.+), ([0-9]+).([0-9])+ MBs')
|
||||
|
||||
def list(self, filepath, languages):
|
||||
possible_languages = self.possible_languages(languages)
|
||||
if not possible_languages:
|
||||
return []
|
||||
guess = guessit.guess_file_info(filepath, 'autodetect')
|
||||
if guess['type'] != 'episode':
|
||||
self.logger.debug(u'Not an episode')
|
||||
return []
|
||||
# add multiple things to the release group set
|
||||
release_group = set()
|
||||
if 'releaseGroup' in guess:
|
||||
release_group.add(guess['releaseGroup'].lower())
|
||||
else:
|
||||
if 'title' in guess:
|
||||
release_group.add(guess['title'].lower())
|
||||
if 'screenSize' in guess:
|
||||
release_group.add(guess['screenSize'].lower())
|
||||
if 'series' not in guess or len(release_group) == 0:
|
||||
self.logger.debug(u'Not enough information to proceed')
|
||||
return []
|
||||
self.release_group = release_group # used to sort results
|
||||
return self.query(guess['series'], guess['season'], guess['episodeNumber'], release_group, filepath, possible_languages)
|
||||
|
||||
def query(self, name, season, episode, release_group, filepath, languages):
|
||||
sublinks = []
|
||||
searchname = name.lower().replace(' ', '_')
|
||||
if isinstance(searchname, unicode):
|
||||
searchname = searchname.encode('utf-8')
|
||||
searchurl = '%s/serie/%s/%s/%s/' % (self.server_url, urllib2.quote(searchname), season, episode)
|
||||
self.logger.debug(u'Searching in %s' % searchurl)
|
||||
try:
|
||||
req = urllib2.Request(searchurl, headers={'User-Agent': self.user_agent})
|
||||
page = urllib2.urlopen(req, timeout=self.timeout)
|
||||
except urllib2.HTTPError as inst:
|
||||
self.logger.info(u'Error: %s - %s' % (searchurl, inst))
|
||||
return []
|
||||
except urllib2.URLError as inst:
|
||||
self.logger.info(u'TimeOut: %s' % inst)
|
||||
return []
|
||||
soup = BeautifulSoup(page.read())
|
||||
for subs in soup('td', {'class': 'NewsTitle'}):
|
||||
sub_teams = self.listTeams([self.release_pattern.search('%s' % subs.contents[1]).group(1).lower()], ['.', '_', ' ', '/', '-'])
|
||||
if not release_group.intersection(sub_teams): # On wrong team
|
||||
continue
|
||||
self.logger.debug(u'Team from website: %s' % sub_teams)
|
||||
self.logger.debug(u'Team from file: %s' % release_group)
|
||||
for html_language in subs.parent.parent.findAll('td', {'class': 'language'}):
|
||||
sub_language = self.getRevertLanguage(html_language.string.strip())
|
||||
self.logger.debug(u'Subtitle reverted language: %s' % sub_language)
|
||||
if not sub_language in languages: # On wrong language
|
||||
continue
|
||||
html_status = html_language.findNextSibling('td')
|
||||
sub_status = html_status.find('strong').string.strip()
|
||||
if not sub_status == 'Completed': # On not completed subtitles
|
||||
continue
|
||||
sub_link = html_status.findNext('td').find('a')['href']
|
||||
result = Subtitle(filepath, self.getSubtitlePath(filepath, sub_language), self.__class__.__name__, sub_language, self.server_url + sub_link, teams=sub_teams)
|
||||
sublinks.append(result)
|
||||
sublinks.sort(self._cmpReleaseGroup)
|
||||
return sublinks
|
||||
|
||||
def download(self, subtitle):
|
||||
self.downloadFile(subtitle.link, subtitle.path)
|
||||
return subtitle
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2008-2011 Patrick Dessalle <patrick@dessalle.be>
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from BeautifulSoup import BeautifulSoup
|
||||
import guessit
|
||||
import urllib2
|
||||
import unicodedata
|
||||
import re
|
||||
import PluginBase
|
||||
from subliminal.classes import Subtitle
|
||||
|
||||
|
||||
class Subtitulos(PluginBase.PluginBase):
|
||||
site_url = 'http://www.subtitulos.es'
|
||||
site_name = 'Subtitulos'
|
||||
server_url = 'http://www.subtitulos.es'
|
||||
api_based = False
|
||||
_plugin_languages = {u'English (US)': 'en', u'English (UK)': 'en', u'English': 'en', u'French': 'fr', u'Brazilian': 'pt-br',
|
||||
u'Portuguese': 'pt', u'Español (Latinoamérica)': 'es', u'Español (España)': 'es', u'Español': 'es', u'Italian': 'it',
|
||||
u'Català': 'ca'}
|
||||
|
||||
def __init__(self, config_dict=None):
|
||||
super(Subtitulos, self).__init__(self._plugin_languages, config_dict, True)
|
||||
self.release_pattern = re.compile('Versión (.+) ([0-9]+).([0-9])+ megabytes')
|
||||
|
||||
def list(self, filepath, languages):
|
||||
possible_languages = self.possible_languages(languages)
|
||||
if not possible_languages:
|
||||
return []
|
||||
guess = guessit.guess_file_info(filepath, 'autodetect')
|
||||
if guess['type'] != 'episode':
|
||||
self.logger.debug(u'Not an episode')
|
||||
return []
|
||||
# add multiple things to the release group set
|
||||
release_group = set()
|
||||
if 'releaseGroup' in guess:
|
||||
release_group.add(guess['releaseGroup'].lower())
|
||||
else:
|
||||
if 'title' in guess:
|
||||
release_group.add(guess['title'].lower())
|
||||
if 'screenSize' in guess:
|
||||
release_group.add(guess['screenSize'].lower())
|
||||
if 'series' not in guess or len(release_group) == 0:
|
||||
self.logger.debug(u'Not enough information to proceed')
|
||||
return []
|
||||
self.release_group = release_group # used to sort results
|
||||
return self.query(guess['series'], guess['season'], guess['episodeNumber'], release_group, filepath, possible_languages)
|
||||
|
||||
def query(self, name, season, episode, release_group, filepath, languages):
|
||||
sublinks = []
|
||||
searchname = name.lower().replace(' ', '-')
|
||||
if isinstance(searchname, unicode):
|
||||
searchname = unicodedata.normalize('NFKD', searchname).encode('ascii','ignore')
|
||||
searchurl = '%s/%s/%sx%.2d' % (self.server_url, urllib2.quote(searchname), season, episode)
|
||||
self.logger.debug(u'Searching in %s' % searchurl)
|
||||
try:
|
||||
req = urllib2.Request(searchurl, headers={'User-Agent': self.user_agent})
|
||||
page = urllib2.urlopen(req, timeout=self.timeout)
|
||||
except urllib2.HTTPError as inst:
|
||||
self.logger.info(u'Error: %s - %s' % (searchurl, inst))
|
||||
return []
|
||||
except urllib2.URLError as inst:
|
||||
self.logger.info(u'TimeOut: %s' % inst)
|
||||
return []
|
||||
soup = BeautifulSoup(page.read())
|
||||
for subs in soup('div', {'id': 'version'}):
|
||||
version = subs.find('p', {'class': 'title-sub'})
|
||||
sub_teams = self.listTeams([self.release_pattern.search('%s' % version.contents[1]).group(1).lower()], ['.', '_', ' ', '/', '-'])
|
||||
self.logger.debug(u'Team from website: %s' % sub_teams)
|
||||
self.logger.debug(u'Team from file: %s' % release_group)
|
||||
if not release_group.intersection(sub_teams): # On wrong team
|
||||
continue
|
||||
for html_language in subs.findAllNext('ul', {'class': 'sslist'}):
|
||||
sub_language = self.getRevertLanguage(html_language.findNext('li', {'class': 'li-idioma'}).find('strong').contents[0].string.strip())
|
||||
if not sub_language in languages: # On wrong language
|
||||
continue
|
||||
html_status = html_language.findNext('li', {'class': 'li-estado green'})
|
||||
sub_status = html_status.contents[0].string.strip()
|
||||
if not sub_status == 'Completado': # On not completed subtitles
|
||||
continue
|
||||
sub_link = html_status.findNext('span', {'class': 'descargar green'}).find('a')['href']
|
||||
result = Subtitle(filepath, self.getSubtitlePath(filepath, sub_language), self.__class__.__name__, sub_language, sub_link, teams=sub_teams)
|
||||
sublinks.append(result)
|
||||
sublinks.sort(self._cmpReleaseGroup)
|
||||
return sublinks
|
||||
|
||||
def download(self, subtitle):
|
||||
self.downloadFile(subtitle.link, subtitle.path)
|
||||
return subtitle
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2008-2011 Patrick Dessalle <patrick@dessalle.be>
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import PluginBase
|
||||
import hashlib
|
||||
import os
|
||||
import urllib2
|
||||
from subliminal.classes import Subtitle
|
||||
|
||||
|
||||
class TheSubDB(PluginBase.PluginBase):
|
||||
site_url = 'http://thesubdb.com'
|
||||
site_name = 'SubDB'
|
||||
server_url = 'http://api.thesubdb.com' # for testing purpose, use http://sandbox.thesubdb.com instead
|
||||
api_based = True
|
||||
user_agent = 'SubDB/1.0 (Subliminal/0.4; https://github.com/Diaoul/subliminal)' # defined by the API
|
||||
_plugin_languages = {'af': 'af', 'cs': 'cs', 'da': 'da', 'de': 'de', 'en': 'en', 'es': 'es', 'fi': 'fi', 'fr': 'fr', 'hu': 'hu', 'id': 'id',
|
||||
'it': 'it', 'la': 'la', 'nl': 'nl', 'no': 'no', 'oc': 'oc', 'pl': 'pl', 'pt': 'pt', 'ro': 'ro', 'ru': 'ru', 'sl': 'sl', 'sr': 'sr',
|
||||
'sv': 'sv', 'tr': 'tr'} # list available with the API at http://sandbox.thesubdb.com/?action=languages
|
||||
|
||||
|
||||
def __init__(self, config_dict=None):
|
||||
super(TheSubDB, self).__init__(self._plugin_languages, config_dict)
|
||||
|
||||
def list(self, filepath, languages):
|
||||
possible_languages = self.possible_languages(languages)
|
||||
if not possible_languages:
|
||||
return []
|
||||
if not os.path.isfile(filepath):
|
||||
return []
|
||||
return self.query(filepath, self.hashFile(filepath), possible_languages)
|
||||
|
||||
def query(self, filepath, moviehash, languages):
|
||||
searchurl = '%s/?action=%s&hash=%s' % (self.server_url, 'search', moviehash)
|
||||
self.logger.debug(u'Query URL: %s' % searchurl)
|
||||
try:
|
||||
req = urllib2.Request(searchurl, headers={'User-Agent': self.user_agent})
|
||||
page = urllib2.urlopen(req, timeout=self.timeout)
|
||||
except urllib2.HTTPError as inst:
|
||||
if inst.code == 404: # no result found
|
||||
return []
|
||||
self.logger.error(u'Error: %s - %s' % (searchurl, inst))
|
||||
return []
|
||||
except urllib2.URLError as inst:
|
||||
self.logger.error(u'TimeOut: %s' % inst)
|
||||
return []
|
||||
available_languages = page.readlines()[0].split(',')
|
||||
self.logger.debug(u'Available languages: %s' % available_languages)
|
||||
subs = []
|
||||
for l in available_languages:
|
||||
if l in languages:
|
||||
result = Subtitle(filepath, self.getSubtitlePath(filepath, l), self.__class__.__name__, l, '%s/?action=download&hash=%s&language=%s' % (self.server_url, moviehash, l))
|
||||
subs.append(result)
|
||||
return subs
|
||||
|
||||
def hashFile(self, filepath):
|
||||
"""TheSubDB specific hash function"""
|
||||
readsize = 64 * 1024
|
||||
with open(filepath, 'rb') as f:
|
||||
data = f.read(readsize)
|
||||
f.seek(-readsize, os.SEEK_END)
|
||||
data += f.read(readsize)
|
||||
return hashlib.md5(data).hexdigest()
|
||||
|
||||
def download(self, subtitle):
|
||||
self.downloadFile(subtitle.link, subtitle.path)
|
||||
return subtitle
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2008-2011 Patrick Dessalle <patrick@dessalle.be>
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
#from Addic7ed import Addic7ed
|
||||
from BierDopje import BierDopje
|
||||
from OpenSubtitles import OpenSubtitles
|
||||
#from Podnapisi import Podnapisi
|
||||
#from SubScene import SubScene
|
||||
from SubsWiki import SubsWiki
|
||||
from Subtitulos import Subtitulos
|
||||
from TheSubDB import TheSubDB
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
|
||||
from bs4 import BeautifulSoup, FeatureNotFound
|
||||
from six.moves.xmlrpc_client import SafeTransport
|
||||
|
||||
from ..video import Episode, Movie
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TimeoutSafeTransport(SafeTransport):
|
||||
"""Timeout support for ``xmlrpc.client.SafeTransport``."""
|
||||
def __init__(self, timeout, *args, **kwargs):
|
||||
SafeTransport.__init__(self, *args, **kwargs)
|
||||
self.timeout = timeout
|
||||
|
||||
def make_connection(self, host):
|
||||
c = SafeTransport.make_connection(self, host)
|
||||
c.timeout = self.timeout
|
||||
|
||||
return c
|
||||
|
||||
|
||||
class ParserBeautifulSoup(BeautifulSoup):
|
||||
"""A ``bs4.BeautifulSoup`` that picks the first parser available in `parsers`.
|
||||
|
||||
:param markup: markup for the ``bs4.BeautifulSoup``.
|
||||
:param list parsers: parser names, in order of preference.
|
||||
|
||||
"""
|
||||
def __init__(self, markup, parsers, **kwargs):
|
||||
# reject features
|
||||
if set(parsers).intersection({'fast', 'permissive', 'strict', 'xml', 'html', 'html5'}):
|
||||
raise ValueError('Features not allowed, only parser names')
|
||||
|
||||
# reject some kwargs
|
||||
if 'features' in kwargs:
|
||||
raise ValueError('Cannot use features kwarg')
|
||||
if 'builder' in kwargs:
|
||||
raise ValueError('Cannot use builder kwarg')
|
||||
|
||||
# pick the first parser available
|
||||
for parser in parsers:
|
||||
try:
|
||||
super(ParserBeautifulSoup, self).__init__(markup, parser, **kwargs)
|
||||
return
|
||||
except FeatureNotFound:
|
||||
pass
|
||||
|
||||
raise FeatureNotFound
|
||||
|
||||
|
||||
class Provider(object):
|
||||
"""Base class for providers.
|
||||
|
||||
If any configuration is possible for the provider, like credentials, it must take place during instantiation.
|
||||
|
||||
:raise: :class:`~subliminal.exceptions.ConfigurationError` if there is a configuration error
|
||||
|
||||
"""
|
||||
#: Supported set of :class:`~babelfish.language.Language`
|
||||
languages = set()
|
||||
|
||||
#: Supported video types
|
||||
video_types = (Episode, Movie)
|
||||
|
||||
#: Required hash, if any
|
||||
required_hash = None
|
||||
|
||||
def __enter__(self):
|
||||
self.initialize()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.terminate()
|
||||
|
||||
def initialize(self):
|
||||
"""Initialize the provider.
|
||||
|
||||
Must be called when starting to work with the provider. This is the place for network initialization
|
||||
or login operations.
|
||||
|
||||
.. note::
|
||||
This is called automatically when entering the `with` statement
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def terminate(self):
|
||||
"""Terminate the provider.
|
||||
|
||||
Must be called when done with the provider. This is the place for network shutdown or logout operations.
|
||||
|
||||
.. note::
|
||||
This is called automatically when exiting the `with` statement
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def check(cls, video):
|
||||
"""Check if the `video` can be processed.
|
||||
|
||||
The `video` is considered invalid if not an instance of :attr:`video_types` or if the :attr:`required_hash` is
|
||||
not present in :attr:`~subliminal.video.Video.hashes` attribute of the `video`.
|
||||
|
||||
:param video: the video to check.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:return: `True` if the `video` is valid, `False` otherwise.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
if not isinstance(video, cls.video_types):
|
||||
return False
|
||||
if cls.required_hash is not None and cls.required_hash not in video.hashes:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
"""Query the provider for subtitles.
|
||||
|
||||
Arguments should match as much as possible the actual parameters for querying the provider
|
||||
|
||||
:return: found subtitles.
|
||||
:rtype: list of :class:`~subliminal.subtitle.Subtitle`
|
||||
:raise: :class:`~subliminal.exceptions.ProviderError`
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
"""List subtitles for the `video` with the given `languages`.
|
||||
|
||||
This will call the :meth:`query` method internally. The parameters passed to the :meth:`query` method may
|
||||
vary depending on the amount of information available in the `video`.
|
||||
|
||||
:param video: video to list subtitles for.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:param languages: languages to search for.
|
||||
:type languages: set of :class:`~babelfish.language.Language`
|
||||
:return: found subtitles.
|
||||
:rtype: list of :class:`~subliminal.subtitle.Subtitle`
|
||||
:raise: :class:`~subliminal.exceptions.ProviderError`
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
"""Download `subtitle`'s :attr:`~subliminal.subtitle.Subtitle.content`.
|
||||
|
||||
:param subtitle: subtitle to download.
|
||||
:type subtitle: :class:`~subliminal.subtitle.Subtitle`
|
||||
:raise: :class:`~subliminal.exceptions.ProviderError`
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s [%r]>' % (self.__class__.__name__, self.video_types)
|
||||
@@ -0,0 +1,285 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import re
|
||||
|
||||
from babelfish import Language, language_converters
|
||||
from guessit import guessit
|
||||
from requests import Session
|
||||
|
||||
from . import ParserBeautifulSoup, Provider
|
||||
from .. import __short_version__
|
||||
from ..cache import SHOW_EXPIRATION_TIME, region
|
||||
from ..exceptions import AuthenticationError, ConfigurationError, DownloadLimitExceeded, TooManyRequests
|
||||
from ..subtitle import Subtitle, fix_line_ending, guess_matches
|
||||
from ..utils import sanitize, sanitize_release_group
|
||||
from ..video import Episode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
language_converters.register('addic7ed = subliminal.converters.addic7ed:Addic7edConverter')
|
||||
|
||||
#: Series header parsing regex
|
||||
series_year_re = re.compile(r'^(?P<series>[ \w\'.:-]+)(?: \((?P<year>\d{4})\))?$')
|
||||
|
||||
|
||||
class Addic7edSubtitle(Subtitle):
|
||||
"""Addic7ed Subtitle."""
|
||||
provider_name = 'addic7ed'
|
||||
|
||||
def __init__(self, language, hearing_impaired, page_link, series, season, episode, title, year, version,
|
||||
download_link):
|
||||
super(Addic7edSubtitle, self).__init__(language, hearing_impaired, page_link)
|
||||
self.series = series
|
||||
self.season = season
|
||||
self.episode = episode
|
||||
self.title = title
|
||||
self.year = year
|
||||
self.version = version
|
||||
self.download_link = download_link
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self.download_link
|
||||
|
||||
def get_matches(self, video):
|
||||
matches = set()
|
||||
|
||||
# series
|
||||
if video.series and sanitize(self.series) == sanitize(video.series):
|
||||
matches.add('series')
|
||||
# season
|
||||
if video.season and self.season == video.season:
|
||||
matches.add('season')
|
||||
# episode
|
||||
if video.episode and self.episode == video.episode:
|
||||
matches.add('episode')
|
||||
# title
|
||||
if video.title and sanitize(self.title) == sanitize(video.title):
|
||||
matches.add('title')
|
||||
# year
|
||||
if video.original_series and self.year is None or video.year and video.year == self.year:
|
||||
matches.add('year')
|
||||
# release_group
|
||||
if (video.release_group and self.version and
|
||||
sanitize_release_group(video.release_group) in sanitize_release_group(self.version)):
|
||||
matches.add('release_group')
|
||||
# resolution
|
||||
if video.resolution and self.version and video.resolution in self.version.lower():
|
||||
matches.add('resolution')
|
||||
# format
|
||||
if video.format and self.version and video.format.lower() in self.version.lower():
|
||||
matches.add('format')
|
||||
# other properties
|
||||
matches |= guess_matches(video, guessit(self.version), partial=True)
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
class Addic7edProvider(Provider):
|
||||
"""Addic7ed Provider."""
|
||||
languages = {Language('por', 'BR')} | {Language(l) for l in [
|
||||
'ara', 'aze', 'ben', 'bos', 'bul', 'cat', 'ces', 'dan', 'deu', 'ell', 'eng', 'eus', 'fas', 'fin', 'fra', 'glg',
|
||||
'heb', 'hrv', 'hun', 'hye', 'ind', 'ita', 'jpn', 'kor', 'mkd', 'msa', 'nld', 'nor', 'pol', 'por', 'ron', 'rus',
|
||||
'slk', 'slv', 'spa', 'sqi', 'srp', 'swe', 'tha', 'tur', 'ukr', 'vie', 'zho'
|
||||
]}
|
||||
video_types = (Episode,)
|
||||
server_url = 'http://www.addic7ed.com/'
|
||||
|
||||
def __init__(self, username=None, password=None):
|
||||
if username is not None and password is None or username is None and password is not None:
|
||||
raise ConfigurationError('Username and password must be specified')
|
||||
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.logged_in = False
|
||||
|
||||
def initialize(self):
|
||||
self.session = Session()
|
||||
self.session.headers['User-Agent'] = 'Subliminal/%s' % __short_version__
|
||||
|
||||
# login
|
||||
if self.username is not None and self.password is not None:
|
||||
logger.info('Logging in')
|
||||
data = {'username': self.username, 'password': self.password, 'Submit': 'Log in'}
|
||||
r = self.session.post(self.server_url + 'dologin.php', data, allow_redirects=False, timeout=10)
|
||||
|
||||
if r.status_code != 302:
|
||||
raise AuthenticationError(self.username)
|
||||
|
||||
logger.debug('Logged in')
|
||||
self.logged_in = True
|
||||
|
||||
def terminate(self):
|
||||
# logout
|
||||
if self.logged_in:
|
||||
logger.info('Logging out')
|
||||
r = self.session.get(self.server_url + 'logout.php', timeout=10)
|
||||
r.raise_for_status()
|
||||
logger.debug('Logged out')
|
||||
self.logged_in = False
|
||||
|
||||
self.session.close()
|
||||
|
||||
@region.cache_on_arguments(expiration_time=SHOW_EXPIRATION_TIME)
|
||||
def _get_show_ids(self):
|
||||
"""Get the ``dict`` of show ids per series by querying the `shows.php` page.
|
||||
|
||||
:return: show id per series, lower case and without quotes.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
# get the show page
|
||||
logger.info('Getting show ids')
|
||||
r = self.session.get(self.server_url + 'shows.php', timeout=10)
|
||||
r.raise_for_status()
|
||||
soup = ParserBeautifulSoup(r.content, ['lxml', 'html.parser'])
|
||||
|
||||
# populate the show ids
|
||||
show_ids = {}
|
||||
for show in soup.select('td.version > h3 > a[href^="/show/"]'):
|
||||
show_ids[sanitize(show.text)] = int(show['href'][6:])
|
||||
logger.debug('Found %d show ids', len(show_ids))
|
||||
|
||||
return show_ids
|
||||
|
||||
@region.cache_on_arguments(expiration_time=SHOW_EXPIRATION_TIME)
|
||||
def _search_show_id(self, series, year=None):
|
||||
"""Search the show id from the `series` and `year`.
|
||||
|
||||
:param str series: series of the episode.
|
||||
:param year: year of the series, if any.
|
||||
:type year: int
|
||||
:return: the show id, if found.
|
||||
:rtype: int
|
||||
|
||||
"""
|
||||
# addic7ed doesn't support search with quotes
|
||||
series = series.replace('\'', ' ')
|
||||
|
||||
# build the params
|
||||
series_year = '%s %d' % (series, year) if year is not None else series
|
||||
params = {'search': series_year, 'Submit': 'Search'}
|
||||
|
||||
# make the search
|
||||
logger.info('Searching show ids with %r', params)
|
||||
r = self.session.get(self.server_url + 'search.php', params=params, timeout=10)
|
||||
r.raise_for_status()
|
||||
if r.status_code == 304:
|
||||
raise TooManyRequests()
|
||||
soup = ParserBeautifulSoup(r.content, ['lxml', 'html.parser'])
|
||||
|
||||
# get the suggestion
|
||||
suggestion = soup.select('span.titulo > a[href^="/show/"]')
|
||||
if not suggestion:
|
||||
logger.warning('Show id not found: no suggestion')
|
||||
return None
|
||||
if not sanitize(suggestion[0].i.text.replace('\'', ' ')) == sanitize(series_year):
|
||||
logger.warning('Show id not found: suggestion does not match')
|
||||
return None
|
||||
show_id = int(suggestion[0]['href'][6:])
|
||||
logger.debug('Found show id %d', show_id)
|
||||
|
||||
return show_id
|
||||
|
||||
def get_show_id(self, series, year=None, country_code=None):
|
||||
"""Get the best matching show id for `series`, `year` and `country_code`.
|
||||
|
||||
First search in the result of :meth:`_get_show_ids` and fallback on a search with :meth:`_search_show_id`.
|
||||
|
||||
:param str series: series of the episode.
|
||||
:param year: year of the series, if any.
|
||||
:type year: int
|
||||
:param country_code: country code of the series, if any.
|
||||
:type country_code: str
|
||||
:return: the show id, if found.
|
||||
:rtype: int
|
||||
|
||||
"""
|
||||
series_sanitized = sanitize(series).lower()
|
||||
show_ids = self._get_show_ids()
|
||||
show_id = None
|
||||
|
||||
# attempt with country
|
||||
if not show_id and country_code:
|
||||
logger.debug('Getting show id with country')
|
||||
show_id = show_ids.get('%s %s' % (series_sanitized, country_code.lower()))
|
||||
|
||||
# attempt with year
|
||||
if not show_id and year:
|
||||
logger.debug('Getting show id with year')
|
||||
show_id = show_ids.get('%s %d' % (series_sanitized, year))
|
||||
|
||||
# attempt clean
|
||||
if not show_id:
|
||||
logger.debug('Getting show id')
|
||||
show_id = show_ids.get(series_sanitized)
|
||||
|
||||
# search as last resort
|
||||
if not show_id:
|
||||
logger.warning('Series not found in show ids')
|
||||
show_id = self._search_show_id(series)
|
||||
|
||||
return show_id
|
||||
|
||||
def query(self, series, season, year=None, country=None):
|
||||
# get the show id
|
||||
show_id = self.get_show_id(series, year, country)
|
||||
if show_id is None:
|
||||
logger.error('No show id found for %r (%r)', series, {'year': year, 'country': country})
|
||||
return []
|
||||
|
||||
# get the page of the season of the show
|
||||
logger.info('Getting the page of show id %d, season %d', show_id, season)
|
||||
r = self.session.get(self.server_url + 'show/%d' % show_id, params={'season': season}, timeout=10)
|
||||
r.raise_for_status()
|
||||
if r.status_code == 304:
|
||||
raise TooManyRequests()
|
||||
soup = ParserBeautifulSoup(r.content, ['lxml', 'html.parser'])
|
||||
|
||||
# loop over subtitle rows
|
||||
match = series_year_re.match(soup.select('#header font')[0].text.strip()[:-10])
|
||||
series = match.group('series')
|
||||
year = int(match.group('year')) if match.group('year') else None
|
||||
subtitles = []
|
||||
for row in soup.select('tr.epeven'):
|
||||
cells = row('td')
|
||||
|
||||
# ignore incomplete subtitles
|
||||
status = cells[5].text
|
||||
if status != 'Completed':
|
||||
logger.debug('Ignoring subtitle with status %s', status)
|
||||
continue
|
||||
|
||||
# read the item
|
||||
language = Language.fromaddic7ed(cells[3].text)
|
||||
hearing_impaired = bool(cells[6].text)
|
||||
page_link = self.server_url + cells[2].a['href'][1:]
|
||||
season = int(cells[0].text)
|
||||
episode = int(cells[1].text)
|
||||
title = cells[2].text
|
||||
version = cells[4].text
|
||||
download_link = cells[9].a['href'][1:]
|
||||
|
||||
subtitle = Addic7edSubtitle(language, hearing_impaired, page_link, series, season, episode, title, year,
|
||||
version, download_link)
|
||||
logger.debug('Found subtitle %r', subtitle)
|
||||
subtitles.append(subtitle)
|
||||
|
||||
return subtitles
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
return [s for s in self.query(video.series, video.season, video.year)
|
||||
if s.language in languages and s.episode == video.episode]
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
# download the subtitle
|
||||
logger.info('Downloading subtitle %r', subtitle)
|
||||
r = self.session.get(self.server_url + subtitle.download_link, headers={'Referer': subtitle.page_link},
|
||||
timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
# detect download limit exceeded
|
||||
if r.headers['Content-Type'] == 'text/html':
|
||||
raise DownloadLimitExceeded
|
||||
|
||||
subtitle.content = fix_line_ending(r.content)
|
||||
@@ -0,0 +1,448 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from babelfish import Language, language_converters
|
||||
from datetime import datetime, timedelta
|
||||
from dogpile.cache.api import NO_VALUE
|
||||
from guessit import guessit
|
||||
import pytz
|
||||
import rarfile
|
||||
from rarfile import RarFile, is_rarfile
|
||||
from requests import Session
|
||||
from zipfile import ZipFile, is_zipfile
|
||||
|
||||
from . import ParserBeautifulSoup, Provider
|
||||
from .. import __short_version__
|
||||
from ..cache import SHOW_EXPIRATION_TIME, region
|
||||
from ..exceptions import AuthenticationError, ConfigurationError, ProviderError
|
||||
from ..subtitle import SUBTITLE_EXTENSIONS, Subtitle, fix_line_ending, guess_matches, sanitize
|
||||
from ..video import Episode, Movie
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
language_converters.register('legendastv = subliminal.converters.legendastv:LegendasTVConverter')
|
||||
|
||||
# Configure :mod:`rarfile` to use the same path separator as :mod:`zipfile`
|
||||
rarfile.PATH_SEP = '/'
|
||||
|
||||
#: Conversion map for types
|
||||
type_map = {'M': 'movie', 'S': 'episode', 'C': 'episode'}
|
||||
|
||||
#: BR title season parsing regex
|
||||
season_re = re.compile(r' - (?P<season>\d+)(\xaa|a|st|nd|rd|th) (temporada|season)', re.IGNORECASE)
|
||||
|
||||
#: Downloads parsing regex
|
||||
downloads_re = re.compile(r'(?P<downloads>\d+) downloads')
|
||||
|
||||
#: Rating parsing regex
|
||||
rating_re = re.compile(r'nota (?P<rating>\d+)')
|
||||
|
||||
#: Timestamp parsing regex
|
||||
timestamp_re = re.compile(r'(?P<day>\d+)/(?P<month>\d+)/(?P<year>\d+) - (?P<hour>\d+):(?P<minute>\d+)')
|
||||
|
||||
#: Cache key for releases
|
||||
releases_key = __name__ + ':releases|{archive_id}'
|
||||
|
||||
|
||||
class LegendasTVArchive(object):
|
||||
"""LegendasTV Archive.
|
||||
|
||||
:param str id: identifier.
|
||||
:param str name: name.
|
||||
:param bool pack: contains subtitles for multiple episodes.
|
||||
:param bool pack: featured.
|
||||
:param str link: link.
|
||||
:param int downloads: download count.
|
||||
:param int rating: rating (0-10).
|
||||
:param timestamp: timestamp.
|
||||
:type timestamp: datetime.datetime
|
||||
|
||||
"""
|
||||
def __init__(self, id, name, pack, featured, link, downloads=0, rating=0, timestamp=None):
|
||||
#: Identifier
|
||||
self.id = id
|
||||
|
||||
#: Name
|
||||
self.name = name
|
||||
|
||||
#: Pack
|
||||
self.pack = pack
|
||||
|
||||
#: Featured
|
||||
self.featured = featured
|
||||
|
||||
#: Link
|
||||
self.link = link
|
||||
|
||||
#: Download count
|
||||
self.downloads = downloads
|
||||
|
||||
#: Rating (0-10)
|
||||
self.rating = rating
|
||||
|
||||
#: Timestamp
|
||||
self.timestamp = timestamp
|
||||
|
||||
#: Compressed content as :class:`rarfile.RarFile` or :class:`zipfile.ZipFile`
|
||||
self.content = None
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s [%s] %r>' % (self.__class__.__name__, self.id, self.name)
|
||||
|
||||
|
||||
class LegendasTVSubtitle(Subtitle):
|
||||
"""LegendasTV Subtitle."""
|
||||
provider_name = 'legendastv'
|
||||
|
||||
def __init__(self, language, type, title, year, imdb_id, season, archive, name):
|
||||
super(LegendasTVSubtitle, self).__init__(language, archive.link)
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.year = year
|
||||
self.imdb_id = imdb_id
|
||||
self.season = season
|
||||
self.archive = archive
|
||||
self.name = name
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return '%s-%s' % (self.archive.id, self.name.lower())
|
||||
|
||||
def get_matches(self, video, hearing_impaired=False):
|
||||
matches = set()
|
||||
|
||||
# episode
|
||||
if isinstance(video, Episode) and self.type == 'episode':
|
||||
# series
|
||||
if video.series and sanitize(self.title) == sanitize(video.series):
|
||||
matches.add('series')
|
||||
|
||||
# year (year is based on season air date hence the adjustment)
|
||||
if video.original_series and self.year is None or video.year and video.year == self.year - self.season + 1:
|
||||
matches.add('year')
|
||||
|
||||
# imdb_id
|
||||
if video.series_imdb_id and self.imdb_id == video.series_imdb_id:
|
||||
matches.add('series_imdb_id')
|
||||
|
||||
# movie
|
||||
elif isinstance(video, Movie) and self.type == 'movie':
|
||||
# title
|
||||
if video.title and sanitize(self.title) == sanitize(video.title):
|
||||
matches.add('title')
|
||||
|
||||
# year
|
||||
if video.year and self.year == video.year:
|
||||
matches.add('year')
|
||||
|
||||
# imdb_id
|
||||
if video.imdb_id and self.imdb_id == video.imdb_id:
|
||||
matches.add('imdb_id')
|
||||
|
||||
# archive name
|
||||
matches |= guess_matches(video, guessit(self.archive.name, {'type': self.type}))
|
||||
|
||||
# name
|
||||
matches |= guess_matches(video, guessit(self.name, {'type': self.type}))
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
class LegendasTVProvider(Provider):
|
||||
"""LegendasTV Provider.
|
||||
|
||||
:param str username: username.
|
||||
:param str password: password.
|
||||
|
||||
"""
|
||||
languages = {Language.fromlegendastv(l) for l in language_converters['legendastv'].codes}
|
||||
server_url = 'http://legendas.tv/'
|
||||
|
||||
def __init__(self, username=None, password=None):
|
||||
if username and not password or not username and password:
|
||||
raise ConfigurationError('Username and password must be specified')
|
||||
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.logged_in = False
|
||||
|
||||
def initialize(self):
|
||||
self.session = Session()
|
||||
self.session.headers['User-Agent'] = 'Subliminal/%s' % __short_version__
|
||||
|
||||
# login
|
||||
if self.username is not None and self.password is not None:
|
||||
logger.info('Logging in')
|
||||
data = {'_method': 'POST', 'data[User][username]': self.username, 'data[User][password]': self.password}
|
||||
r = self.session.post(self.server_url + 'login', data, allow_redirects=False, timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
soup = ParserBeautifulSoup(r.content, ['html.parser'])
|
||||
if soup.find('div', {'class': 'alert-error'}, string=re.compile(u'Usuário ou senha inválidos')):
|
||||
raise AuthenticationError(self.username)
|
||||
|
||||
logger.debug('Logged in')
|
||||
self.logged_in = True
|
||||
|
||||
def terminate(self):
|
||||
# logout
|
||||
if self.logged_in:
|
||||
logger.info('Logging out')
|
||||
r = self.session.get(self.server_url + 'users/logout', allow_redirects=False, timeout=10)
|
||||
r.raise_for_status()
|
||||
logger.debug('Logged out')
|
||||
self.logged_in = False
|
||||
|
||||
self.session.close()
|
||||
|
||||
@region.cache_on_arguments(expiration_time=SHOW_EXPIRATION_TIME)
|
||||
def search_titles(self, title):
|
||||
"""Search for titles matching the `title`.
|
||||
|
||||
:param str title: the title to search for.
|
||||
:return: found titles.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
# make the query
|
||||
logger.info('Searching title %r', title)
|
||||
r = self.session.get(self.server_url + 'legenda/sugestao/{}'.format(title), timeout=10)
|
||||
r.raise_for_status()
|
||||
results = json.loads(r.text)
|
||||
|
||||
# loop over results
|
||||
titles = {}
|
||||
for result in results:
|
||||
source = result['_source']
|
||||
|
||||
# extract id
|
||||
title_id = int(source['id_filme'])
|
||||
|
||||
# extract type and title
|
||||
title = {'type': type_map[source['tipo']], 'title': source['dsc_nome']}
|
||||
|
||||
# extract year
|
||||
if source['dsc_data_lancamento'] and source['dsc_data_lancamento'].isdigit():
|
||||
title['year'] = int(source['dsc_data_lancamento'])
|
||||
|
||||
# extract imdb_id
|
||||
if source['id_imdb'] != '0':
|
||||
if not source['id_imdb'].startswith('tt'):
|
||||
title['imdb_id'] = 'tt' + source['id_imdb'].zfill(7)
|
||||
else:
|
||||
title['imdb_id'] = source['id_imdb']
|
||||
|
||||
# extract season
|
||||
if title['type'] == 'episode':
|
||||
if source['temporada'] and source['temporada'].isdigit():
|
||||
title['season'] = int(source['temporada'])
|
||||
else:
|
||||
match = season_re.search(source['dsc_nome_br'])
|
||||
if match:
|
||||
title['season'] = int(match.group('season'))
|
||||
else:
|
||||
logger.warning('No season detected for title %d', title_id)
|
||||
|
||||
# add title
|
||||
titles[title_id] = title
|
||||
|
||||
logger.debug('Found %d titles', len(titles))
|
||||
|
||||
return titles
|
||||
|
||||
@region.cache_on_arguments(expiration_time=timedelta(minutes=15).total_seconds())
|
||||
def get_archives(self, title_id, language_code):
|
||||
"""Get the archive list from a given `title_id` and `language_code`.
|
||||
|
||||
:param int title_id: title id.
|
||||
:param int language_code: language code.
|
||||
:return: the archives.
|
||||
:rtype: list of :class:`LegendasTVArchive`
|
||||
|
||||
"""
|
||||
logger.info('Getting archives for title %d and language %d', title_id, language_code)
|
||||
archives = []
|
||||
page = 1
|
||||
while True:
|
||||
# get the archive page
|
||||
url = self.server_url + 'util/carrega_legendas_busca_filme/{title}/{language}/-/{page}'.format(
|
||||
title=title_id, language=language_code, page=page)
|
||||
r = self.session.get(url)
|
||||
r.raise_for_status()
|
||||
|
||||
# parse the results
|
||||
soup = ParserBeautifulSoup(r.content, ['lxml', 'html.parser'])
|
||||
for archive_soup in soup.select('div.list_element > article > div'):
|
||||
# create archive
|
||||
archive = LegendasTVArchive(archive_soup.a['href'].split('/')[2], archive_soup.a.text,
|
||||
'pack' in archive_soup['class'], 'destaque' in archive_soup['class'],
|
||||
self.server_url + archive_soup.a['href'][1:])
|
||||
|
||||
# extract text containing downloads, rating and timestamp
|
||||
data_text = archive_soup.find('p', class_='data').text
|
||||
|
||||
# match downloads
|
||||
archive.downloads = int(downloads_re.search(data_text).group('downloads'))
|
||||
|
||||
# match rating
|
||||
match = rating_re.search(data_text)
|
||||
if match:
|
||||
archive.rating = int(match.group('rating'))
|
||||
|
||||
# match timestamp and validate it
|
||||
time_data = {k: int(v) for k, v in timestamp_re.search(data_text).groupdict().items()}
|
||||
archive.timestamp = pytz.timezone('America/Sao_Paulo').localize(datetime(**time_data))
|
||||
if archive.timestamp > datetime.utcnow().replace(tzinfo=pytz.utc):
|
||||
raise ProviderError('Archive timestamp is in the future')
|
||||
|
||||
# add archive
|
||||
archives.append(archive)
|
||||
|
||||
# stop on last page
|
||||
if soup.find('a', attrs={'class': 'load_more'}, string='carregar mais') is None:
|
||||
break
|
||||
|
||||
# increment page count
|
||||
page += 1
|
||||
|
||||
logger.debug('Found %d archives', len(archives))
|
||||
|
||||
return archives
|
||||
|
||||
def download_archive(self, archive):
|
||||
"""Download an archive's :attr:`~LegendasTVArchive.content`.
|
||||
|
||||
:param archive: the archive to download :attr:`~LegendasTVArchive.content` of.
|
||||
:type archive: :class:`LegendasTVArchive`
|
||||
|
||||
"""
|
||||
logger.info('Downloading archive %s', archive.id)
|
||||
r = self.session.get(self.server_url + 'downloadarquivo/{}'.format(archive.id))
|
||||
r.raise_for_status()
|
||||
|
||||
# open the archive
|
||||
archive_stream = io.BytesIO(r.content)
|
||||
if is_rarfile(archive_stream):
|
||||
logger.debug('Identified rar archive')
|
||||
archive.content = RarFile(archive_stream)
|
||||
elif is_zipfile(archive_stream):
|
||||
logger.debug('Identified zip archive')
|
||||
archive.content = ZipFile(archive_stream)
|
||||
else:
|
||||
raise ValueError('Not a valid archive')
|
||||
|
||||
def query(self, language, title, season=None, episode=None, year=None):
|
||||
# search for titles
|
||||
titles = self.search_titles(sanitize(title))
|
||||
|
||||
# search for titles with the quote or dot character
|
||||
ignore_characters = {'\'', '.'}
|
||||
if any(c in title for c in ignore_characters):
|
||||
titles.update(self.search_titles(sanitize(title, ignore_characters=ignore_characters)))
|
||||
|
||||
subtitles = []
|
||||
# iterate over titles
|
||||
for title_id, t in titles.items():
|
||||
# discard mismatches on title
|
||||
if sanitize(t['title']) != sanitize(title):
|
||||
continue
|
||||
|
||||
# episode
|
||||
if season and episode:
|
||||
# discard mismatches on type
|
||||
if t['type'] != 'episode':
|
||||
continue
|
||||
|
||||
# discard mismatches on season
|
||||
if 'season' not in t or t['season'] != season:
|
||||
continue
|
||||
# movie
|
||||
else:
|
||||
# discard mismatches on type
|
||||
if t['type'] != 'movie':
|
||||
continue
|
||||
|
||||
# discard mismatches on year
|
||||
if year is not None and 'year' in t and t['year'] != year:
|
||||
continue
|
||||
|
||||
# iterate over title's archives
|
||||
for a in self.get_archives(title_id, language.legendastv):
|
||||
# clean name of path separators and pack flags
|
||||
clean_name = a.name.replace('/', '-')
|
||||
if a.pack and clean_name.startswith('(p)'):
|
||||
clean_name = clean_name[3:]
|
||||
|
||||
# guess from name
|
||||
guess = guessit(clean_name, {'type': t['type']})
|
||||
|
||||
# episode
|
||||
if season and episode:
|
||||
# discard mismatches on episode in non-pack archives
|
||||
if not a.pack and 'episode' in guess and guess['episode'] != episode:
|
||||
continue
|
||||
|
||||
# compute an expiration time based on the archive timestamp
|
||||
expiration_time = (datetime.utcnow().replace(tzinfo=pytz.utc) - a.timestamp).total_seconds()
|
||||
|
||||
# attempt to get the releases from the cache
|
||||
releases = region.get(releases_key.format(archive_id=a.id), expiration_time=expiration_time)
|
||||
|
||||
# the releases are not in cache or cache is expired
|
||||
if releases == NO_VALUE:
|
||||
logger.info('Releases not found in cache')
|
||||
|
||||
# download archive
|
||||
self.download_archive(a)
|
||||
|
||||
# extract the releases
|
||||
releases = []
|
||||
for name in a.content.namelist():
|
||||
# discard the legendastv file
|
||||
if name.startswith('Legendas.tv'):
|
||||
continue
|
||||
|
||||
# discard hidden files
|
||||
if os.path.split(name)[-1].startswith('.'):
|
||||
continue
|
||||
|
||||
# discard non-subtitle files
|
||||
if not name.lower().endswith(SUBTITLE_EXTENSIONS):
|
||||
continue
|
||||
|
||||
releases.append(name)
|
||||
|
||||
# cache the releases
|
||||
region.set(releases_key.format(archive_id=a.id), releases)
|
||||
|
||||
# iterate over releases
|
||||
for r in releases:
|
||||
subtitle = LegendasTVSubtitle(language, t['type'], t['title'], t.get('year'), t.get('imdb_id'),
|
||||
t.get('season'), a, r)
|
||||
logger.debug('Found subtitle %r', subtitle)
|
||||
subtitles.append(subtitle)
|
||||
|
||||
return subtitles
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
season = episode = None
|
||||
if isinstance(video, Episode):
|
||||
title = video.series
|
||||
season = video.season
|
||||
episode = video.episode
|
||||
else:
|
||||
title = video.title
|
||||
|
||||
return [s for l in languages for s in self.query(l, title, season=season, episode=episode, year=video.year)]
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
# download archive in case we previously hit the releases cache and didn't download it
|
||||
if subtitle.archive.content is None:
|
||||
self.download_archive(subtitle.archive)
|
||||
|
||||
# extract subtitle's content
|
||||
subtitle.content = fix_line_ending(subtitle.archive.content.read(subtitle.name))
|
||||
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
|
||||
from babelfish import Language
|
||||
from requests import Session
|
||||
|
||||
from . import Provider
|
||||
from .. import __short_version__
|
||||
from ..subtitle import Subtitle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_subhash(hash):
|
||||
"""Get a second hash based on napiprojekt's hash.
|
||||
|
||||
:param str hash: napiprojekt's hash.
|
||||
:return: the subhash.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
idx = [0xe, 0x3, 0x6, 0x8, 0x2]
|
||||
mul = [2, 2, 5, 4, 3]
|
||||
add = [0, 0xd, 0x10, 0xb, 0x5]
|
||||
|
||||
b = []
|
||||
for i in range(len(idx)):
|
||||
a = add[i]
|
||||
m = mul[i]
|
||||
i = idx[i]
|
||||
t = a + int(hash[i], 16)
|
||||
v = int(hash[t:t + 2], 16)
|
||||
b.append(('%x' % (v * m))[-1])
|
||||
|
||||
return ''.join(b)
|
||||
|
||||
|
||||
class NapiProjektSubtitle(Subtitle):
|
||||
"""NapiProjekt Subtitle."""
|
||||
provider_name = 'napiprojekt'
|
||||
|
||||
def __init__(self, language, hash):
|
||||
super(NapiProjektSubtitle, self).__init__(language)
|
||||
self.hash = hash
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self.hash
|
||||
|
||||
def get_matches(self, video):
|
||||
matches = set()
|
||||
|
||||
# hash
|
||||
if 'napiprojekt' in video.hashes and video.hashes['napiprojekt'] == self.hash:
|
||||
matches.add('hash')
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
class NapiProjektProvider(Provider):
|
||||
"""NapiProjekt Provider."""
|
||||
languages = {Language.fromalpha2(l) for l in ['pl']}
|
||||
required_hash = 'napiprojekt'
|
||||
server_url = 'http://napiprojekt.pl/unit_napisy/dl.php'
|
||||
|
||||
def initialize(self):
|
||||
self.session = Session()
|
||||
self.session.headers['User-Agent'] = 'Subliminal/%s' % __short_version__
|
||||
|
||||
def terminate(self):
|
||||
self.session.close()
|
||||
|
||||
def query(self, language, hash):
|
||||
params = {
|
||||
'v': 'dreambox',
|
||||
'kolejka': 'false',
|
||||
'nick': '',
|
||||
'pass': '',
|
||||
'napios': 'Linux',
|
||||
'l': language.alpha2.upper(),
|
||||
'f': hash,
|
||||
't': get_subhash(hash)}
|
||||
logger.info('Searching subtitle %r', params)
|
||||
response = self.session.get(self.server_url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
# handle subtitles not found and errors
|
||||
if response.content[:4] == b'NPc0':
|
||||
logger.debug('No subtitles found')
|
||||
return None
|
||||
|
||||
subtitle = NapiProjektSubtitle(language, hash)
|
||||
subtitle.content = response.content
|
||||
logger.debug('Found subtitle %r', subtitle)
|
||||
|
||||
return subtitle
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
return [s for s in [self.query(l, video.hashes['napiprojekt']) for l in languages] if s is not None]
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
# there is no download step, content is already filled from listing subtitles
|
||||
pass
|
||||
@@ -0,0 +1,294 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import zlib
|
||||
|
||||
from babelfish import Language, language_converters
|
||||
from guessit import guessit
|
||||
from six.moves.xmlrpc_client import ServerProxy
|
||||
|
||||
from . import Provider, TimeoutSafeTransport
|
||||
from .. import __short_version__
|
||||
from ..exceptions import AuthenticationError, ConfigurationError, DownloadLimitExceeded, ProviderError
|
||||
from ..subtitle import Subtitle, fix_line_ending, guess_matches
|
||||
from ..utils import sanitize
|
||||
from ..video import Episode, Movie
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenSubtitlesSubtitle(Subtitle):
|
||||
"""OpenSubtitles Subtitle."""
|
||||
provider_name = 'opensubtitles'
|
||||
series_re = re.compile(r'^"(?P<series_name>.*)" (?P<series_title>.*)$')
|
||||
|
||||
def __init__(self, language, hearing_impaired, page_link, subtitle_id, matched_by, movie_kind, hash, movie_name,
|
||||
movie_release_name, movie_year, movie_imdb_id, series_season, series_episode, filename, encoding):
|
||||
super(OpenSubtitlesSubtitle, self).__init__(language, hearing_impaired, page_link, encoding)
|
||||
self.subtitle_id = subtitle_id
|
||||
self.matched_by = matched_by
|
||||
self.movie_kind = movie_kind
|
||||
self.hash = hash
|
||||
self.movie_name = movie_name
|
||||
self.movie_release_name = movie_release_name
|
||||
self.movie_year = movie_year
|
||||
self.movie_imdb_id = movie_imdb_id
|
||||
self.series_season = series_season
|
||||
self.series_episode = series_episode
|
||||
self.filename = filename
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return str(self.subtitle_id)
|
||||
|
||||
@property
|
||||
def series_name(self):
|
||||
return self.series_re.match(self.movie_name).group('series_name')
|
||||
|
||||
@property
|
||||
def series_title(self):
|
||||
return self.series_re.match(self.movie_name).group('series_title')
|
||||
|
||||
def get_matches(self, video):
|
||||
matches = set()
|
||||
|
||||
# episode
|
||||
if isinstance(video, Episode) and self.movie_kind == 'episode':
|
||||
# tag match, assume series, year, season and episode matches
|
||||
if self.matched_by == 'tag':
|
||||
matches |= {'series', 'year', 'season', 'episode'}
|
||||
# series
|
||||
if video.series and sanitize(self.series_name) == sanitize(video.series):
|
||||
matches.add('series')
|
||||
# year
|
||||
if video.original_series and self.movie_year is None or video.year and video.year == self.movie_year:
|
||||
matches.add('year')
|
||||
# season
|
||||
if video.season and self.series_season == video.season:
|
||||
matches.add('season')
|
||||
# episode
|
||||
if video.episode and self.series_episode == video.episode:
|
||||
matches.add('episode')
|
||||
# title
|
||||
if video.title and sanitize(self.series_title) == sanitize(video.title):
|
||||
matches.add('title')
|
||||
# guess
|
||||
matches |= guess_matches(video, guessit(self.movie_release_name, {'type': 'episode'}))
|
||||
matches |= guess_matches(video, guessit(self.filename, {'type': 'episode'}))
|
||||
# hash
|
||||
if 'opensubtitles' in video.hashes and self.hash == video.hashes['opensubtitles']:
|
||||
if 'series' in matches and 'season' in matches and 'episode' in matches:
|
||||
matches.add('hash')
|
||||
else:
|
||||
logger.debug('Match on hash discarded')
|
||||
# movie
|
||||
elif isinstance(video, Movie) and self.movie_kind == 'movie':
|
||||
# tag match, assume title and year matches
|
||||
if self.matched_by == 'tag':
|
||||
matches |= {'title', 'year'}
|
||||
# title
|
||||
if video.title and sanitize(self.movie_name) == sanitize(video.title):
|
||||
matches.add('title')
|
||||
# year
|
||||
if video.year and self.movie_year == video.year:
|
||||
matches.add('year')
|
||||
# guess
|
||||
matches |= guess_matches(video, guessit(self.movie_release_name, {'type': 'movie'}))
|
||||
matches |= guess_matches(video, guessit(self.filename, {'type': 'movie'}))
|
||||
# hash
|
||||
if 'opensubtitles' in video.hashes and self.hash == video.hashes['opensubtitles']:
|
||||
if 'title' in matches:
|
||||
matches.add('hash')
|
||||
else:
|
||||
logger.debug('Match on hash discarded')
|
||||
else:
|
||||
logger.info('%r is not a valid movie_kind', self.movie_kind)
|
||||
return matches
|
||||
|
||||
# imdb_id
|
||||
if video.imdb_id and self.movie_imdb_id == video.imdb_id:
|
||||
matches.add('imdb_id')
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
class OpenSubtitlesProvider(Provider):
|
||||
"""OpenSubtitles Provider.
|
||||
|
||||
:param str username: username.
|
||||
:param str password: password.
|
||||
|
||||
"""
|
||||
languages = {Language.fromopensubtitles(l) for l in language_converters['opensubtitles'].codes}
|
||||
|
||||
def __init__(self, username=None, password=None):
|
||||
self.server = ServerProxy('https://api.opensubtitles.org/xml-rpc', TimeoutSafeTransport(10))
|
||||
if username and not password or not username and password:
|
||||
raise ConfigurationError('Username and password must be specified')
|
||||
# None values not allowed for logging in, so replace it by ''
|
||||
self.username = username or ''
|
||||
self.password = password or ''
|
||||
self.token = None
|
||||
|
||||
def initialize(self):
|
||||
logger.info('Logging in')
|
||||
response = checked(self.server.LogIn(self.username, self.password, 'eng',
|
||||
'subliminal v%s' % __short_version__))
|
||||
self.token = response['token']
|
||||
logger.debug('Logged in with token %r', self.token)
|
||||
|
||||
def terminate(self):
|
||||
logger.info('Logging out')
|
||||
checked(self.server.LogOut(self.token))
|
||||
self.server.close()
|
||||
self.token = None
|
||||
logger.debug('Logged out')
|
||||
|
||||
def no_operation(self):
|
||||
logger.debug('No operation')
|
||||
checked(self.server.NoOperation(self.token))
|
||||
|
||||
def query(self, languages, hash=None, size=None, imdb_id=None, query=None, season=None, episode=None, tag=None):
|
||||
# fill the search criteria
|
||||
criteria = []
|
||||
if hash and size:
|
||||
criteria.append({'moviehash': hash, 'moviebytesize': str(size)})
|
||||
if imdb_id:
|
||||
criteria.append({'imdbid': imdb_id[2:]})
|
||||
if tag:
|
||||
criteria.append({'tag': tag})
|
||||
if query and season and episode:
|
||||
criteria.append({'query': query.replace('\'', ''), 'season': season, 'episode': episode})
|
||||
elif query:
|
||||
criteria.append({'query': query.replace('\'', '')})
|
||||
if not criteria:
|
||||
raise ValueError('Not enough information')
|
||||
|
||||
# add the language
|
||||
for criterion in criteria:
|
||||
criterion['sublanguageid'] = ','.join(sorted(l.opensubtitles for l in languages))
|
||||
|
||||
# query the server
|
||||
logger.info('Searching subtitles %r', criteria)
|
||||
response = checked(self.server.SearchSubtitles(self.token, criteria))
|
||||
subtitles = []
|
||||
|
||||
# exit if no data
|
||||
if not response['data']:
|
||||
logger.debug('No subtitles found')
|
||||
return subtitles
|
||||
|
||||
# loop over subtitle items
|
||||
for subtitle_item in response['data']:
|
||||
# read the item
|
||||
language = Language.fromopensubtitles(subtitle_item['SubLanguageID'])
|
||||
hearing_impaired = bool(int(subtitle_item['SubHearingImpaired']))
|
||||
page_link = subtitle_item['SubtitlesLink']
|
||||
subtitle_id = int(subtitle_item['IDSubtitleFile'])
|
||||
matched_by = subtitle_item['MatchedBy']
|
||||
movie_kind = subtitle_item['MovieKind']
|
||||
hash = subtitle_item['MovieHash']
|
||||
movie_name = subtitle_item['MovieName']
|
||||
movie_release_name = subtitle_item['MovieReleaseName']
|
||||
movie_year = int(subtitle_item['MovieYear']) if subtitle_item['MovieYear'] else None
|
||||
movie_imdb_id = 'tt' + subtitle_item['IDMovieImdb']
|
||||
series_season = int(subtitle_item['SeriesSeason']) if subtitle_item['SeriesSeason'] else None
|
||||
series_episode = int(subtitle_item['SeriesEpisode']) if subtitle_item['SeriesEpisode'] else None
|
||||
filename = subtitle_item['SubFileName']
|
||||
encoding = subtitle_item.get('SubEncoding') or None
|
||||
|
||||
subtitle = OpenSubtitlesSubtitle(language, hearing_impaired, page_link, subtitle_id, matched_by, movie_kind,
|
||||
hash, movie_name, movie_release_name, movie_year, movie_imdb_id,
|
||||
series_season, series_episode, filename, encoding)
|
||||
logger.debug('Found subtitle %r by %s', subtitle, matched_by)
|
||||
subtitles.append(subtitle)
|
||||
|
||||
return subtitles
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
season = episode = None
|
||||
if isinstance(video, Episode):
|
||||
query = video.series
|
||||
season = video.season
|
||||
episode = video.episode
|
||||
else:
|
||||
query = video.title
|
||||
|
||||
return self.query(languages, hash=video.hashes.get('opensubtitles'), size=video.size, imdb_id=video.imdb_id,
|
||||
query=query, season=season, episode=episode, tag=os.path.basename(video.name))
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
logger.info('Downloading subtitle %r', subtitle)
|
||||
response = checked(self.server.DownloadSubtitles(self.token, [str(subtitle.subtitle_id)]))
|
||||
subtitle.content = fix_line_ending(zlib.decompress(base64.b64decode(response['data'][0]['data']), 47))
|
||||
|
||||
|
||||
class OpenSubtitlesError(ProviderError):
|
||||
"""Base class for non-generic :class:`OpenSubtitlesProvider` exceptions."""
|
||||
pass
|
||||
|
||||
|
||||
class Unauthorized(OpenSubtitlesError, AuthenticationError):
|
||||
"""Exception raised when status is '401 Unauthorized'."""
|
||||
pass
|
||||
|
||||
|
||||
class NoSession(OpenSubtitlesError, AuthenticationError):
|
||||
"""Exception raised when status is '406 No session'."""
|
||||
pass
|
||||
|
||||
|
||||
class DownloadLimitReached(OpenSubtitlesError, DownloadLimitExceeded):
|
||||
"""Exception raised when status is '407 Download limit reached'."""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidImdbid(OpenSubtitlesError):
|
||||
"""Exception raised when status is '413 Invalid ImdbID'."""
|
||||
pass
|
||||
|
||||
|
||||
class UnknownUserAgent(OpenSubtitlesError, AuthenticationError):
|
||||
"""Exception raised when status is '414 Unknown User Agent'."""
|
||||
pass
|
||||
|
||||
|
||||
class DisabledUserAgent(OpenSubtitlesError, AuthenticationError):
|
||||
"""Exception raised when status is '415 Disabled user agent'."""
|
||||
pass
|
||||
|
||||
|
||||
class ServiceUnavailable(OpenSubtitlesError):
|
||||
"""Exception raised when status is '503 Service Unavailable'."""
|
||||
pass
|
||||
|
||||
|
||||
def checked(response):
|
||||
"""Check a response status before returning it.
|
||||
|
||||
:param response: a response from a XMLRPC call to OpenSubtitles.
|
||||
:return: the response.
|
||||
:raise: :class:`OpenSubtitlesError`
|
||||
|
||||
"""
|
||||
status_code = int(response['status'][:3])
|
||||
if status_code == 401:
|
||||
raise Unauthorized
|
||||
if status_code == 406:
|
||||
raise NoSession
|
||||
if status_code == 407:
|
||||
raise DownloadLimitReached
|
||||
if status_code == 413:
|
||||
raise InvalidImdbid
|
||||
if status_code == 414:
|
||||
raise UnknownUserAgent
|
||||
if status_code == 415:
|
||||
raise DisabledUserAgent
|
||||
if status_code == 503:
|
||||
raise ServiceUnavailable
|
||||
if status_code != 200:
|
||||
raise OpenSubtitlesError(response['status'])
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,179 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
|
||||
from babelfish import Language, language_converters
|
||||
from guessit import guessit
|
||||
try:
|
||||
from lxml import etree
|
||||
except ImportError:
|
||||
try:
|
||||
import xml.etree.cElementTree as etree
|
||||
except ImportError:
|
||||
import xml.etree.ElementTree as etree
|
||||
from requests import Session
|
||||
from zipfile import ZipFile
|
||||
|
||||
from . import Provider
|
||||
from .. import __short_version__
|
||||
from ..exceptions import ProviderError
|
||||
from ..subtitle import Subtitle, fix_line_ending, guess_matches
|
||||
from ..utils import sanitize
|
||||
from ..video import Episode, Movie
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PodnapisiSubtitle(Subtitle):
|
||||
"""Podnapisi Subtitle."""
|
||||
provider_name = 'podnapisi'
|
||||
|
||||
def __init__(self, language, hearing_impaired, page_link, pid, releases, title, season=None, episode=None,
|
||||
year=None):
|
||||
super(PodnapisiSubtitle, self).__init__(language, hearing_impaired, page_link)
|
||||
self.pid = pid
|
||||
self.releases = releases
|
||||
self.title = title
|
||||
self.season = season
|
||||
self.episode = episode
|
||||
self.year = year
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self.pid
|
||||
|
||||
def get_matches(self, video):
|
||||
matches = set()
|
||||
|
||||
# episode
|
||||
if isinstance(video, Episode):
|
||||
# series
|
||||
if video.series and sanitize(self.title) == sanitize(video.series):
|
||||
matches.add('series')
|
||||
# year
|
||||
if video.original_series and self.year is None or video.year and video.year == self.year:
|
||||
matches.add('year')
|
||||
# season
|
||||
if video.season and self.season == video.season:
|
||||
matches.add('season')
|
||||
# episode
|
||||
if video.episode and self.episode == video.episode:
|
||||
matches.add('episode')
|
||||
# guess
|
||||
for release in self.releases:
|
||||
matches |= guess_matches(video, guessit(release, {'type': 'episode'}))
|
||||
# movie
|
||||
elif isinstance(video, Movie):
|
||||
# title
|
||||
if video.title and sanitize(self.title) == sanitize(video.title):
|
||||
matches.add('title')
|
||||
# year
|
||||
if video.year and self.year == video.year:
|
||||
matches.add('year')
|
||||
# guess
|
||||
for release in self.releases:
|
||||
matches |= guess_matches(video, guessit(release, {'type': 'movie'}))
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
class PodnapisiProvider(Provider):
|
||||
"""Podnapisi Provider."""
|
||||
languages = ({Language('por', 'BR'), Language('srp', script='Latn')} |
|
||||
{Language.fromalpha2(l) for l in language_converters['alpha2'].codes})
|
||||
server_url = 'http://podnapisi.net/subtitles/'
|
||||
|
||||
def initialize(self):
|
||||
self.session = Session()
|
||||
self.session.headers['User-Agent'] = 'Subliminal/%s' % __short_version__
|
||||
|
||||
def terminate(self):
|
||||
self.session.close()
|
||||
|
||||
def query(self, language, keyword, season=None, episode=None, year=None):
|
||||
# set parameters, see http://www.podnapisi.net/forum/viewtopic.php?f=62&t=26164#p212652
|
||||
params = {'sXML': 1, 'sL': str(language), 'sK': keyword}
|
||||
is_episode = False
|
||||
if season and episode:
|
||||
is_episode = True
|
||||
params['sTS'] = season
|
||||
params['sTE'] = episode
|
||||
if year:
|
||||
params['sY'] = year
|
||||
|
||||
# loop over paginated results
|
||||
logger.info('Searching subtitles %r', params)
|
||||
subtitles = []
|
||||
pids = set()
|
||||
while True:
|
||||
# query the server
|
||||
xml = etree.fromstring(self.session.get(self.server_url + 'search/old', params=params, timeout=10).content)
|
||||
|
||||
# exit if no results
|
||||
if not int(xml.find('pagination/results').text):
|
||||
logger.debug('No subtitles found')
|
||||
break
|
||||
|
||||
# loop over subtitles
|
||||
for subtitle_xml in xml.findall('subtitle'):
|
||||
# read xml elements
|
||||
language = Language.fromietf(subtitle_xml.find('language').text)
|
||||
hearing_impaired = 'n' in (subtitle_xml.find('flags').text or '')
|
||||
page_link = subtitle_xml.find('url').text
|
||||
pid = subtitle_xml.find('pid').text
|
||||
releases = []
|
||||
if subtitle_xml.find('release').text:
|
||||
for release in subtitle_xml.find('release').text.split():
|
||||
release = re.sub(r'\.+$', '', release) # remove trailing dots
|
||||
release = ''.join(filter(lambda x: ord(x) < 128, release)) # remove non-ascii characters
|
||||
releases.append(release)
|
||||
title = subtitle_xml.find('title').text
|
||||
season = int(subtitle_xml.find('tvSeason').text)
|
||||
episode = int(subtitle_xml.find('tvEpisode').text)
|
||||
year = int(subtitle_xml.find('year').text)
|
||||
|
||||
if is_episode:
|
||||
subtitle = PodnapisiSubtitle(language, hearing_impaired, page_link, pid, releases, title,
|
||||
season=season, episode=episode, year=year)
|
||||
else:
|
||||
subtitle = PodnapisiSubtitle(language, hearing_impaired, page_link, pid, releases, title,
|
||||
year=year)
|
||||
|
||||
# ignore duplicates, see http://www.podnapisi.net/forum/viewtopic.php?f=62&t=26164&start=10#p213321
|
||||
if pid in pids:
|
||||
continue
|
||||
|
||||
logger.debug('Found subtitle %r', subtitle)
|
||||
subtitles.append(subtitle)
|
||||
pids.add(pid)
|
||||
|
||||
# stop on last page
|
||||
if int(xml.find('pagination/current').text) >= int(xml.find('pagination/count').text):
|
||||
break
|
||||
|
||||
# increment current page
|
||||
params['page'] = int(xml.find('pagination/current').text) + 1
|
||||
logger.debug('Getting page %d', params['page'])
|
||||
|
||||
return subtitles
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
if isinstance(video, Episode):
|
||||
return [s for l in languages for s in self.query(l, video.series, season=video.season,
|
||||
episode=video.episode, year=video.year)]
|
||||
elif isinstance(video, Movie):
|
||||
return [s for l in languages for s in self.query(l, video.title, year=video.year)]
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
# download as a zip
|
||||
logger.info('Downloading subtitle %r', subtitle)
|
||||
r = self.session.get(self.server_url + subtitle.pid + '/download', params={'container': 'zip'}, timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
# open the zip
|
||||
with ZipFile(io.BytesIO(r.content)) as zf:
|
||||
if len(zf.namelist()) > 1:
|
||||
raise ProviderError('More than one file to unzip')
|
||||
|
||||
subtitle.content = fix_line_ending(zf.read(zf.namelist()[0]))
|
||||
@@ -0,0 +1,79 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from babelfish import Language, language_converters
|
||||
from requests import Session
|
||||
|
||||
from . import Provider
|
||||
from .. import __short_version__
|
||||
from ..subtitle import Subtitle, fix_line_ending
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
language_converters.register('shooter = subliminal.converters.shooter:ShooterConverter')
|
||||
|
||||
|
||||
class ShooterSubtitle(Subtitle):
|
||||
"""Shooter Subtitle."""
|
||||
provider_name = 'shooter'
|
||||
|
||||
def __init__(self, language, hash, download_link):
|
||||
super(ShooterSubtitle, self).__init__(language)
|
||||
self.hash = hash
|
||||
self.download_link = download_link
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self.download_link
|
||||
|
||||
def get_matches(self, video):
|
||||
matches = set()
|
||||
|
||||
# hash
|
||||
if 'shooter' in video.hashes and video.hashes['shooter'] == self.hash:
|
||||
matches.add('hash')
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
class ShooterProvider(Provider):
|
||||
"""Shooter Provider."""
|
||||
languages = {Language(l) for l in ['eng', 'zho']}
|
||||
server_url = 'https://www.shooter.cn/api/subapi.php'
|
||||
|
||||
def initialize(self):
|
||||
self.session = Session()
|
||||
self.session.headers['User-Agent'] = 'Subliminal/%s' % __short_version__
|
||||
|
||||
def terminate(self):
|
||||
self.session.close()
|
||||
|
||||
def query(self, language, filename, hash=None):
|
||||
# query the server
|
||||
params = {'filehash': hash, 'pathinfo': os.path.realpath(filename), 'format': 'json', 'lang': language.shooter}
|
||||
logger.debug('Searching subtitles %r', params)
|
||||
r = self.session.post(self.server_url, params=params, timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
# handle subtitles not found
|
||||
if r.content == b'\xff':
|
||||
logger.debug('No subtitles found')
|
||||
return []
|
||||
|
||||
# parse the subtitles
|
||||
results = json.loads(r.text)
|
||||
subtitles = [ShooterSubtitle(language, hash, t['Link']) for s in results for t in s['Files']]
|
||||
|
||||
return subtitles
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
return [s for l in languages for s in self.query(l, video.name, video.hashes.get('shooter'))]
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
logger.info('Downloading subtitle %r', subtitle)
|
||||
r = self.session.get(subtitle.download_link, timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
subtitle.content = fix_line_ending(r.content)
|
||||
@@ -0,0 +1,235 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import bisect
|
||||
from collections import defaultdict
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
|
||||
from babelfish import Language
|
||||
from guessit import guessit
|
||||
from requests import Session
|
||||
|
||||
from . import ParserBeautifulSoup, Provider
|
||||
from .. import __short_version__
|
||||
from ..cache import SHOW_EXPIRATION_TIME, region
|
||||
from ..exceptions import AuthenticationError, ConfigurationError, ProviderError
|
||||
from ..subtitle import Subtitle, fix_line_ending, guess_matches
|
||||
from ..utils import sanitize
|
||||
from ..video import Episode, Movie
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubsCenterSubtitle(Subtitle):
|
||||
"""SubsCenter Subtitle."""
|
||||
provider_name = 'subscenter'
|
||||
|
||||
def __init__(self, language, hearing_impaired, page_link, series, season, episode, title, subtitle_id, subtitle_key,
|
||||
downloaded, releases):
|
||||
super(SubsCenterSubtitle, self).__init__(language, hearing_impaired, page_link)
|
||||
self.series = series
|
||||
self.season = season
|
||||
self.episode = episode
|
||||
self.title = title
|
||||
self.subtitle_id = subtitle_id
|
||||
self.subtitle_key = subtitle_key
|
||||
self.downloaded = downloaded
|
||||
self.releases = releases
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return str(self.subtitle_id)
|
||||
|
||||
def get_matches(self, video):
|
||||
matches = set()
|
||||
|
||||
# episode
|
||||
if isinstance(video, Episode):
|
||||
# series
|
||||
if video.series and sanitize(self.series) == sanitize(video.series):
|
||||
matches.add('series')
|
||||
# season
|
||||
if video.season and self.season == video.season:
|
||||
matches.add('season')
|
||||
# episode
|
||||
if video.episode and self.episode == video.episode:
|
||||
matches.add('episode')
|
||||
# guess
|
||||
for release in self.releases:
|
||||
matches |= guess_matches(video, guessit(release, {'type': 'episode'}))
|
||||
# movie
|
||||
elif isinstance(video, Movie):
|
||||
# guess
|
||||
for release in self.releases:
|
||||
matches |= guess_matches(video, guessit(release, {'type': 'movie'}))
|
||||
|
||||
# title
|
||||
if video.title and sanitize(self.title) == sanitize(video.title):
|
||||
matches.add('title')
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
class SubsCenterProvider(Provider):
|
||||
"""SubsCenter Provider."""
|
||||
languages = {Language.fromalpha2(l) for l in ['he']}
|
||||
server_url = 'http://www.subscenter.co/he/'
|
||||
|
||||
def __init__(self, username=None, password=None):
|
||||
if username is not None and password is None or username is None and password is not None:
|
||||
raise ConfigurationError('Username and password must be specified')
|
||||
|
||||
self.session = None
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.logged_in = False
|
||||
|
||||
def initialize(self):
|
||||
self.session = Session()
|
||||
self.session.headers['User-Agent'] = 'Subliminal/{}'.format(__short_version__)
|
||||
|
||||
# login
|
||||
if self.username is not None and self.password is not None:
|
||||
logger.debug('Logging in')
|
||||
url = self.server_url + 'subscenter/accounts/login/'
|
||||
|
||||
# retrieve CSRF token
|
||||
self.session.get(url)
|
||||
csrf_token = self.session.cookies['csrftoken']
|
||||
|
||||
# actual login
|
||||
data = {'username': self.username, 'password': self.password, 'csrfmiddlewaretoken': csrf_token}
|
||||
r = self.session.post(url, data, allow_redirects=False, timeout=10)
|
||||
|
||||
if r.status_code != 302:
|
||||
raise AuthenticationError(self.username)
|
||||
|
||||
logger.info('Logged in')
|
||||
self.logged_in = True
|
||||
|
||||
def terminate(self):
|
||||
# logout
|
||||
if self.logged_in:
|
||||
logger.info('Logging out')
|
||||
r = self.session.get(self.server_url + 'subscenter/accounts/logout/', timeout=10)
|
||||
r.raise_for_status()
|
||||
logger.info('Logged out')
|
||||
self.logged_in = False
|
||||
|
||||
self.session.close()
|
||||
|
||||
@region.cache_on_arguments(expiration_time=SHOW_EXPIRATION_TIME)
|
||||
def _search_url_titles(self, title):
|
||||
"""Search the URL titles by kind for the given `title`.
|
||||
|
||||
:param str title: title to search for.
|
||||
:return: the URL titles by kind.
|
||||
:rtype: collections.defaultdict
|
||||
|
||||
"""
|
||||
# make the search
|
||||
logger.info('Searching title name for %r', title)
|
||||
r = self.session.get(self.server_url + 'subtitle/search/', params={'q': title}, timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
# check for redirections
|
||||
if r.history and all([h.status_code == 302 for h in r.history]):
|
||||
logger.debug('Redirected to the subtitles page')
|
||||
links = [r.url]
|
||||
else:
|
||||
# get the suggestions (if needed)
|
||||
soup = ParserBeautifulSoup(r.content, ['lxml', 'html.parser'])
|
||||
links = [link.attrs['href'] for link in soup.select('#processes div.generalWindowTop a')]
|
||||
logger.debug('Found %d suggestions', len(links))
|
||||
|
||||
url_titles = defaultdict(list)
|
||||
for link in links:
|
||||
parts = link.split('/')
|
||||
url_titles[parts[-3]].append(parts[-2])
|
||||
|
||||
return url_titles
|
||||
|
||||
def query(self, title, season=None, episode=None):
|
||||
# search for the url title
|
||||
url_titles = self._search_url_titles(title)
|
||||
|
||||
# episode
|
||||
if season and episode:
|
||||
if 'series' not in url_titles:
|
||||
logger.error('No URL title found for series %r', title)
|
||||
return []
|
||||
url_title = url_titles['series'][0]
|
||||
logger.debug('Using series title %r', url_title)
|
||||
url = self.server_url + 'cst/data/series/sb/{}/{}/{}/'.format(url_title, season, episode)
|
||||
page_link = self.server_url + 'subtitle/series/{}/{}/{}/'.format(url_title, season, episode)
|
||||
else:
|
||||
if 'movie' not in url_titles:
|
||||
logger.error('No URL title found for movie %r', title)
|
||||
return []
|
||||
url_title = url_titles['movie'][0]
|
||||
logger.debug('Using movie title %r', url_title)
|
||||
url = self.server_url + 'cst/data/movie/sb/{}/'.format(url_title)
|
||||
page_link = self.server_url + 'subtitle/movie/{}/'.format(url_title)
|
||||
|
||||
# get the list of subtitles
|
||||
logger.debug('Getting the list of subtitles')
|
||||
r = self.session.get(url)
|
||||
r.raise_for_status()
|
||||
results = json.loads(r.text)
|
||||
|
||||
# loop over results
|
||||
subtitles = {}
|
||||
for language_code, language_data in results.items():
|
||||
for quality_data in language_data.values():
|
||||
for quality, subtitles_data in quality_data.items():
|
||||
for subtitle_item in subtitles_data.values():
|
||||
# read the item
|
||||
language = Language.fromalpha2(language_code)
|
||||
hearing_impaired = bool(subtitle_item['hearing_impaired'])
|
||||
subtitle_id = subtitle_item['id']
|
||||
subtitle_key = subtitle_item['key']
|
||||
downloaded = subtitle_item['downloaded']
|
||||
release = subtitle_item['subtitle_version']
|
||||
|
||||
# add the release and increment downloaded count if we already have the subtitle
|
||||
if subtitle_id in subtitles:
|
||||
logger.debug('Found additional release %r for subtitle %d', release, subtitle_id)
|
||||
bisect.insort_left(subtitles[subtitle_id].releases, release) # deterministic order
|
||||
subtitles[subtitle_id].downloaded += downloaded
|
||||
continue
|
||||
|
||||
# otherwise create it
|
||||
subtitle = SubsCenterSubtitle(language, hearing_impaired, page_link, title, season, episode,
|
||||
title, subtitle_id, subtitle_key, downloaded, [release])
|
||||
logger.debug('Found subtitle %r', subtitle)
|
||||
subtitles[subtitle_id] = subtitle
|
||||
|
||||
return subtitles.values()
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
season = episode = None
|
||||
title = video.title
|
||||
|
||||
if isinstance(video, Episode):
|
||||
title = video.series
|
||||
season = video.season
|
||||
episode = video.episode
|
||||
|
||||
return [s for s in self.query(title, season, episode) if s.language in languages]
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
# download
|
||||
url = self.server_url + 'subtitle/download/{}/{}/'.format(subtitle.language.alpha2, subtitle.subtitle_id)
|
||||
params = {'v': subtitle.releases[0], 'key': subtitle.subtitle_key}
|
||||
r = self.session.get(url, params=params, headers={'Referer': subtitle.page_link}, timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
# open the zip
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
# remove some filenames from the namelist
|
||||
namelist = [n for n in zf.namelist() if not n.endswith('.txt')]
|
||||
if len(namelist) > 1:
|
||||
raise ProviderError('More than one file to unzip')
|
||||
|
||||
subtitle.content = fix_line_ending(zf.read(namelist[0]))
|
||||
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
|
||||
from babelfish import Language, language_converters
|
||||
from requests import Session
|
||||
|
||||
from . import Provider
|
||||
from .. import __short_version__
|
||||
from ..subtitle import Subtitle, fix_line_ending
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
language_converters.register('thesubdb = subliminal.converters.thesubdb:TheSubDBConverter')
|
||||
|
||||
|
||||
class TheSubDBSubtitle(Subtitle):
|
||||
"""TheSubDB Subtitle."""
|
||||
provider_name = 'thesubdb'
|
||||
|
||||
def __init__(self, language, hash):
|
||||
super(TheSubDBSubtitle, self).__init__(language)
|
||||
self.hash = hash
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self.hash + '-' + str(self.language)
|
||||
|
||||
def get_matches(self, video):
|
||||
matches = set()
|
||||
|
||||
# hash
|
||||
if 'thesubdb' in video.hashes and video.hashes['thesubdb'] == self.hash:
|
||||
matches.add('hash')
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
class TheSubDBProvider(Provider):
|
||||
"""TheSubDB Provider."""
|
||||
languages = {Language.fromthesubdb(l) for l in language_converters['thesubdb'].codes}
|
||||
required_hash = 'thesubdb'
|
||||
server_url = 'http://api.thesubdb.com/'
|
||||
|
||||
def initialize(self):
|
||||
self.session = Session()
|
||||
self.session.headers['User-Agent'] = ('SubDB/1.0 (subliminal/%s; https://github.com/Diaoul/subliminal)' %
|
||||
__short_version__)
|
||||
|
||||
def terminate(self):
|
||||
self.session.close()
|
||||
|
||||
def query(self, hash):
|
||||
# make the query
|
||||
params = {'action': 'search', 'hash': hash}
|
||||
logger.info('Searching subtitles %r', params)
|
||||
r = self.session.get(self.server_url, params=params, timeout=10)
|
||||
|
||||
# handle subtitles not found and errors
|
||||
if r.status_code == 404:
|
||||
logger.debug('No subtitles found')
|
||||
return []
|
||||
r.raise_for_status()
|
||||
|
||||
# loop over languages
|
||||
subtitles = []
|
||||
for language_code in r.text.split(','):
|
||||
language = Language.fromthesubdb(language_code)
|
||||
|
||||
subtitle = TheSubDBSubtitle(language, hash)
|
||||
logger.debug('Found subtitle %r', subtitle)
|
||||
subtitles.append(subtitle)
|
||||
|
||||
return subtitles
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
return [s for s in self.query(video.hashes['thesubdb']) if s.language in languages]
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
logger.info('Downloading subtitle %r', subtitle)
|
||||
params = {'action': 'download', 'hash': subtitle.hash, 'language': subtitle.language.alpha2}
|
||||
r = self.session.get(self.server_url, params=params, timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
subtitle.content = fix_line_ending(r.content)
|
||||
@@ -0,0 +1,208 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from zipfile import ZipFile
|
||||
|
||||
from babelfish import Language, language_converters
|
||||
from guessit import guessit
|
||||
from requests import Session
|
||||
|
||||
from . import ParserBeautifulSoup, Provider
|
||||
from .. import __short_version__
|
||||
from ..cache import EPISODE_EXPIRATION_TIME, SHOW_EXPIRATION_TIME, region
|
||||
from ..exceptions import ProviderError
|
||||
from ..subtitle import Subtitle, fix_line_ending, guess_matches
|
||||
from ..utils import sanitize, sanitize_release_group
|
||||
from ..video import Episode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
language_converters.register('tvsubtitles = subliminal.converters.tvsubtitles:TVsubtitlesConverter')
|
||||
|
||||
link_re = re.compile(r'^(?P<series>.+?)(?: \(?\d{4}\)?| \((?:US|UK)\))? \((?P<first_year>\d{4})-\d{4}\)$')
|
||||
episode_id_re = re.compile(r'^episode-\d+\.html$')
|
||||
|
||||
|
||||
class TVsubtitlesSubtitle(Subtitle):
|
||||
"""TVsubtitles Subtitle."""
|
||||
provider_name = 'tvsubtitles'
|
||||
|
||||
def __init__(self, language, page_link, subtitle_id, series, season, episode, year, rip, release):
|
||||
super(TVsubtitlesSubtitle, self).__init__(language, page_link=page_link)
|
||||
self.subtitle_id = subtitle_id
|
||||
self.series = series
|
||||
self.season = season
|
||||
self.episode = episode
|
||||
self.year = year
|
||||
self.rip = rip
|
||||
self.release = release
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return str(self.subtitle_id)
|
||||
|
||||
def get_matches(self, video):
|
||||
matches = set()
|
||||
|
||||
# series
|
||||
if video.series and sanitize(self.series) == sanitize(video.series):
|
||||
matches.add('series')
|
||||
# season
|
||||
if video.season and self.season == video.season:
|
||||
matches.add('season')
|
||||
# episode
|
||||
if video.episode and self.episode == video.episode:
|
||||
matches.add('episode')
|
||||
# year
|
||||
if video.original_series and self.year is None or video.year and video.year == self.year:
|
||||
matches.add('year')
|
||||
# release_group
|
||||
if (video.release_group and self.release and
|
||||
sanitize_release_group(video.release_group) in sanitize_release_group(self.release)):
|
||||
matches.add('release_group')
|
||||
# other properties
|
||||
if self.release:
|
||||
matches |= guess_matches(video, guessit(self.release, {'type': 'episode'}), partial=True)
|
||||
if self.rip:
|
||||
matches |= guess_matches(video, guessit(self.rip), partial=True)
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
class TVsubtitlesProvider(Provider):
|
||||
"""TVsubtitles Provider."""
|
||||
languages = {Language('por', 'BR')} | {Language(l) for l in [
|
||||
'ara', 'bul', 'ces', 'dan', 'deu', 'ell', 'eng', 'fin', 'fra', 'hun', 'ita', 'jpn', 'kor', 'nld', 'pol', 'por',
|
||||
'ron', 'rus', 'spa', 'swe', 'tur', 'ukr', 'zho'
|
||||
]}
|
||||
video_types = (Episode,)
|
||||
server_url = 'http://www.tvsubtitles.net/'
|
||||
|
||||
def initialize(self):
|
||||
self.session = Session()
|
||||
self.session.headers['User-Agent'] = 'Subliminal/%s' % __short_version__
|
||||
|
||||
def terminate(self):
|
||||
self.session.close()
|
||||
|
||||
@region.cache_on_arguments(expiration_time=SHOW_EXPIRATION_TIME)
|
||||
def search_show_id(self, series, year=None):
|
||||
"""Search the show id from the `series` and `year`.
|
||||
|
||||
:param str series: series of the episode.
|
||||
:param year: year of the series, if any.
|
||||
:type year: int
|
||||
:return: the show id, if any.
|
||||
:rtype: int
|
||||
|
||||
"""
|
||||
# make the search
|
||||
logger.info('Searching show id for %r', series)
|
||||
r = self.session.post(self.server_url + 'search.php', data={'q': series}, timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
# get the series out of the suggestions
|
||||
soup = ParserBeautifulSoup(r.content, ['lxml', 'html.parser'])
|
||||
show_id = None
|
||||
for suggestion in soup.select('div.left li div a[href^="/tvshow-"]'):
|
||||
match = link_re.match(suggestion.text)
|
||||
if not match:
|
||||
logger.error('Failed to match %s', suggestion.text)
|
||||
continue
|
||||
|
||||
if match.group('series').lower() == series.lower():
|
||||
if year is not None and int(match.group('first_year')) != year:
|
||||
logger.debug('Year does not match')
|
||||
continue
|
||||
show_id = int(suggestion['href'][8:-5])
|
||||
logger.debug('Found show id %d', show_id)
|
||||
break
|
||||
|
||||
return show_id
|
||||
|
||||
@region.cache_on_arguments(expiration_time=EPISODE_EXPIRATION_TIME)
|
||||
def get_episode_ids(self, show_id, season):
|
||||
"""Get episode ids from the show id and the season.
|
||||
|
||||
:param int show_id: show id.
|
||||
:param int season: season of the episode.
|
||||
:return: episode ids per episode number.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
# get the page of the season of the show
|
||||
logger.info('Getting the page of show id %d, season %d', show_id, season)
|
||||
r = self.session.get(self.server_url + 'tvshow-%d-%d.html' % (show_id, season), timeout=10)
|
||||
soup = ParserBeautifulSoup(r.content, ['lxml', 'html.parser'])
|
||||
|
||||
# loop over episode rows
|
||||
episode_ids = {}
|
||||
for row in soup.select('table#table5 tr'):
|
||||
# skip rows that do not have a link to the episode page
|
||||
if not row('a', href=episode_id_re):
|
||||
continue
|
||||
|
||||
# extract data from the cells
|
||||
cells = row('td')
|
||||
episode = int(cells[0].text.split('x')[1])
|
||||
episode_id = int(cells[1].a['href'][8:-5])
|
||||
episode_ids[episode] = episode_id
|
||||
|
||||
if episode_ids:
|
||||
logger.debug('Found episode ids %r', episode_ids)
|
||||
else:
|
||||
logger.warning('No episode ids found')
|
||||
|
||||
return episode_ids
|
||||
|
||||
def query(self, series, season, episode, year=None):
|
||||
# search the show id
|
||||
show_id = self.search_show_id(series, year)
|
||||
if show_id is None:
|
||||
logger.error('No show id found for %r (%r)', series, {'year': year})
|
||||
return []
|
||||
|
||||
# get the episode ids
|
||||
episode_ids = self.get_episode_ids(show_id, season)
|
||||
if episode not in episode_ids:
|
||||
logger.error('Episode %d not found', episode)
|
||||
return []
|
||||
|
||||
# get the episode page
|
||||
logger.info('Getting the page for episode %d', episode_ids[episode])
|
||||
r = self.session.get(self.server_url + 'episode-%d.html' % episode_ids[episode], timeout=10)
|
||||
soup = ParserBeautifulSoup(r.content, ['lxml', 'html.parser'])
|
||||
|
||||
# loop over subtitles rows
|
||||
subtitles = []
|
||||
for row in soup.select('.subtitlen'):
|
||||
# read the item
|
||||
language = Language.fromtvsubtitles(row.h5.img['src'][13:-4])
|
||||
subtitle_id = int(row.parent['href'][10:-5])
|
||||
page_link = self.server_url + 'subtitle-%d.html' % subtitle_id
|
||||
rip = row.find('p', title='rip').text.strip() or None
|
||||
release = row.find('p', title='release').text.strip() or None
|
||||
|
||||
subtitle = TVsubtitlesSubtitle(language, page_link, subtitle_id, series, season, episode, year, rip,
|
||||
release)
|
||||
logger.debug('Found subtitle %s', subtitle)
|
||||
subtitles.append(subtitle)
|
||||
|
||||
return subtitles
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
return [s for s in self.query(video.series, video.season, video.episode, video.year) if s.language in languages]
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
# download as a zip
|
||||
logger.info('Downloading subtitle %r', subtitle)
|
||||
r = self.session.get(self.server_url + 'download-%d.html' % subtitle.subtitle_id, timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
# open the zip
|
||||
with ZipFile(io.BytesIO(r.content)) as zf:
|
||||
if len(zf.namelist()) > 1:
|
||||
raise ProviderError('More than one file to unzip')
|
||||
|
||||
subtitle.content = fix_line_ending(zf.read(zf.namelist()[0]))
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Refiners enrich a :class:`~subliminal.video.Video` object by adding information to it.
|
||||
|
||||
A refiner is a simple function:
|
||||
|
||||
.. py:function:: refine(video, **kwargs)
|
||||
|
||||
:param video: the video to refine.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:param \*\*kwargs: additional parameters for refiners.
|
||||
|
||||
"""
|
||||
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import os
|
||||
|
||||
from babelfish import Error as BabelfishError, Language
|
||||
from enzyme import MKV
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def refine(video, embedded_subtitles=True, **kwargs):
|
||||
"""Refine a video by searching its metadata.
|
||||
|
||||
Several :class:`~subliminal.video.Video` attributes can be found:
|
||||
|
||||
* :attr:`~subliminal.video.Video.resolution`
|
||||
* :attr:`~subliminal.video.Video.video_codec`
|
||||
* :attr:`~subliminal.video.Video.audio_codec`
|
||||
* :attr:`~subliminal.video.Video.subtitle_languages`
|
||||
|
||||
:param bool embedded_subtitles: search for embedded subtitles.
|
||||
|
||||
"""
|
||||
# skip non existing videos
|
||||
if not video.exists:
|
||||
return
|
||||
|
||||
# check extensions
|
||||
extension = os.path.splitext(video.name)[1]
|
||||
if extension == '.mkv':
|
||||
with open(video.name, 'rb') as f:
|
||||
mkv = MKV(f)
|
||||
|
||||
# main video track
|
||||
if mkv.video_tracks:
|
||||
video_track = mkv.video_tracks[0]
|
||||
|
||||
# resolution
|
||||
if video_track.height in (480, 720, 1080):
|
||||
if video_track.interlaced:
|
||||
video.resolution = '%di' % video_track.height
|
||||
else:
|
||||
video.resolution = '%dp' % video_track.height
|
||||
logger.debug('Found resolution %s', video.resolution)
|
||||
|
||||
# video codec
|
||||
if video_track.codec_id == 'V_MPEG4/ISO/AVC':
|
||||
video.video_codec = 'h264'
|
||||
logger.debug('Found video_codec %s', video.video_codec)
|
||||
elif video_track.codec_id == 'V_MPEG4/ISO/SP':
|
||||
video.video_codec = 'DivX'
|
||||
logger.debug('Found video_codec %s', video.video_codec)
|
||||
elif video_track.codec_id == 'V_MPEG4/ISO/ASP':
|
||||
video.video_codec = 'XviD'
|
||||
logger.debug('Found video_codec %s', video.video_codec)
|
||||
else:
|
||||
logger.warning('MKV has no video track')
|
||||
|
||||
# main audio track
|
||||
if mkv.audio_tracks:
|
||||
audio_track = mkv.audio_tracks[0]
|
||||
# audio codec
|
||||
if audio_track.codec_id == 'A_AC3':
|
||||
video.audio_codec = 'AC3'
|
||||
logger.debug('Found audio_codec %s', video.audio_codec)
|
||||
elif audio_track.codec_id == 'A_DTS':
|
||||
video.audio_codec = 'DTS'
|
||||
logger.debug('Found audio_codec %s', video.audio_codec)
|
||||
elif audio_track.codec_id == 'A_AAC':
|
||||
video.audio_codec = 'AAC'
|
||||
logger.debug('Found audio_codec %s', video.audio_codec)
|
||||
else:
|
||||
logger.warning('MKV has no audio track')
|
||||
|
||||
# subtitle tracks
|
||||
if mkv.subtitle_tracks:
|
||||
if embedded_subtitles:
|
||||
embedded_subtitle_languages = set()
|
||||
for st in mkv.subtitle_tracks:
|
||||
if st.language:
|
||||
try:
|
||||
embedded_subtitle_languages.add(Language.fromalpha3b(st.language))
|
||||
except BabelfishError:
|
||||
logger.error('Embedded subtitle track language %r is not a valid language', st.language)
|
||||
embedded_subtitle_languages.add(Language('und'))
|
||||
elif st.name:
|
||||
try:
|
||||
embedded_subtitle_languages.add(Language.fromname(st.name))
|
||||
except BabelfishError:
|
||||
logger.debug('Embedded subtitle track name %r is not a valid language', st.name)
|
||||
embedded_subtitle_languages.add(Language('und'))
|
||||
else:
|
||||
embedded_subtitle_languages.add(Language('und'))
|
||||
logger.debug('Found embedded subtitle %r', embedded_subtitle_languages)
|
||||
video.subtitle_languages |= embedded_subtitle_languages
|
||||
else:
|
||||
logger.debug('MKV has no subtitle track')
|
||||
else:
|
||||
logger.debug('Unsupported video extension %s', extension)
|
||||
@@ -0,0 +1,187 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import operator
|
||||
|
||||
import requests
|
||||
|
||||
from .. import __short_version__
|
||||
from ..cache import REFINER_EXPIRATION_TIME, region
|
||||
from ..video import Episode, Movie
|
||||
from ..utils import sanitize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OMDBClient(object):
|
||||
base_url = 'http://www.omdbapi.com'
|
||||
|
||||
def __init__(self, version=1, session=None, headers=None, timeout=10):
|
||||
#: Session for the requests
|
||||
self.session = session or requests.Session()
|
||||
self.session.timeout = timeout
|
||||
self.session.headers.update(headers or {})
|
||||
self.session.params['r'] = 'json'
|
||||
self.session.params['v'] = version
|
||||
|
||||
def get(self, id=None, title=None, type=None, year=None, plot='short', tomatoes=False):
|
||||
# build the params
|
||||
params = {}
|
||||
if id:
|
||||
params['i'] = id
|
||||
if title:
|
||||
params['t'] = title
|
||||
if not params:
|
||||
raise ValueError('At least id or title is required')
|
||||
params['type'] = type
|
||||
params['y'] = year
|
||||
params['plot'] = plot
|
||||
params['tomatoes'] = tomatoes
|
||||
|
||||
# perform the request
|
||||
r = self.session.get(self.base_url, params=params)
|
||||
r.raise_for_status()
|
||||
|
||||
# get the response as json
|
||||
j = r.json()
|
||||
|
||||
# check response status
|
||||
if j['Response'] == 'False':
|
||||
return None
|
||||
|
||||
return j
|
||||
|
||||
def search(self, title, type=None, year=None, page=1):
|
||||
# build the params
|
||||
params = {'s': title, 'type': type, 'y': year, 'page': page}
|
||||
|
||||
# perform the request
|
||||
r = self.session.get(self.base_url, params=params)
|
||||
r.raise_for_status()
|
||||
|
||||
# get the response as json
|
||||
j = r.json()
|
||||
|
||||
# check response status
|
||||
if j['Response'] == 'False':
|
||||
return None
|
||||
|
||||
return j
|
||||
|
||||
|
||||
omdb_client = OMDBClient(headers={'User-Agent': 'Subliminal/%s' % __short_version__})
|
||||
|
||||
|
||||
@region.cache_on_arguments(expiration_time=REFINER_EXPIRATION_TIME)
|
||||
def search(title, type, year):
|
||||
results = omdb_client.search(title, type, year)
|
||||
if not results:
|
||||
return None
|
||||
|
||||
# fetch all paginated results
|
||||
all_results = results['Search']
|
||||
total_results = int(results['totalResults'])
|
||||
page = 1
|
||||
while total_results > page * 10:
|
||||
page += 1
|
||||
results = omdb_client.search(title, type, year, page=page)
|
||||
all_results.extend(results['Search'])
|
||||
|
||||
return all_results
|
||||
|
||||
|
||||
def refine(video, **kwargs):
|
||||
"""Refine a video by searching `OMDb API <http://omdbapi.com/>`_.
|
||||
|
||||
Several :class:`~subliminal.video.Episode` attributes can be found:
|
||||
|
||||
* :attr:`~subliminal.video.Episode.series`
|
||||
* :attr:`~subliminal.video.Episode.year`
|
||||
* :attr:`~subliminal.video.Episode.series_imdb_id`
|
||||
|
||||
Similarly, for a :class:`~subliminal.video.Movie`:
|
||||
|
||||
* :attr:`~subliminal.video.Movie.title`
|
||||
* :attr:`~subliminal.video.Movie.year`
|
||||
* :attr:`~subliminal.video.Video.imdb_id`
|
||||
|
||||
"""
|
||||
if isinstance(video, Episode):
|
||||
# exit if the information is complete
|
||||
if video.series_imdb_id:
|
||||
logger.debug('No need to search')
|
||||
return
|
||||
|
||||
# search the series
|
||||
results = search(video.series, 'series', video.year)
|
||||
if not results:
|
||||
logger.warning('No results for series')
|
||||
return
|
||||
logger.debug('Found %d results', len(results))
|
||||
|
||||
# filter the results
|
||||
results = [r for r in results if sanitize(r['Title']) == sanitize(video.series)]
|
||||
if not results:
|
||||
logger.warning('No matching series found')
|
||||
return
|
||||
|
||||
# process the results
|
||||
found = False
|
||||
for result in sorted(results, key=operator.itemgetter('Year')):
|
||||
if video.original_series and video.year is None:
|
||||
logger.debug('Found result for original series without year')
|
||||
found = True
|
||||
break
|
||||
if video.year == int(result['Year'].split(u'\u2013')[0]):
|
||||
logger.debug('Found result with matching year')
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
logger.warning('No matching series found')
|
||||
return
|
||||
|
||||
# add series information
|
||||
logger.debug('Found series %r', result)
|
||||
video.series = result['Title']
|
||||
video.year = int(result['Year'].split(u'\u2013')[0])
|
||||
video.series_imdb_id = result['imdbID']
|
||||
|
||||
elif isinstance(video, Movie):
|
||||
# exit if the information is complete
|
||||
if video.imdb_id:
|
||||
return
|
||||
|
||||
# search the movie
|
||||
results = search(video.title, 'movie', video.year)
|
||||
if not results:
|
||||
logger.warning('No results')
|
||||
return
|
||||
logger.debug('Found %d results', len(results))
|
||||
|
||||
# filter the results
|
||||
results = [r for r in results if sanitize(r['Title']) == sanitize(video.title)]
|
||||
if not results:
|
||||
logger.warning('No matching movie found')
|
||||
return
|
||||
|
||||
# process the results
|
||||
found = False
|
||||
for result in results:
|
||||
if video.year is None:
|
||||
logger.debug('Found result for movie without year')
|
||||
found = True
|
||||
break
|
||||
if video.year == int(result['Year']):
|
||||
logger.debug('Found result with matching year')
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
logger.warning('No matching movie found')
|
||||
return
|
||||
|
||||
# add movie information
|
||||
logger.debug('Found movie %r', result)
|
||||
video.title = result['Title']
|
||||
video.year = int(result['Year'].split(u'\u2013')[0])
|
||||
video.imdb_id = result['imdbID']
|
||||
@@ -0,0 +1,350 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
import logging
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from .. import __short_version__
|
||||
from ..cache import REFINER_EXPIRATION_TIME, region
|
||||
from ..utils import sanitize
|
||||
from ..video import Episode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
series_re = re.compile(r'^(?P<series>.*?)(?: \((?:(?P<year>\d{4})|(?P<country>[A-Z]{2}))\))?$')
|
||||
|
||||
|
||||
def requires_auth(func):
|
||||
"""Decorator for :class:`TVDBClient` methods that require authentication"""
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
if self.token is None or self.token_expired:
|
||||
self.login()
|
||||
elif self.token_needs_refresh:
|
||||
self.refresh_token()
|
||||
return func(self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
class TVDBClient(object):
|
||||
"""TVDB REST API Client
|
||||
|
||||
:param str apikey: API key to use.
|
||||
:param str username: username to use.
|
||||
:param str password: password to use.
|
||||
:param str language: language of the responses.
|
||||
:param session: session object to use.
|
||||
:type session: :class:`requests.sessions.Session` or compatible.
|
||||
:param dict headers: additional headers.
|
||||
:param int timeout: timeout for the requests.
|
||||
|
||||
"""
|
||||
#: Base URL of the API
|
||||
base_url = 'https://api.thetvdb.com'
|
||||
|
||||
#: Token lifespan
|
||||
token_lifespan = timedelta(hours=1)
|
||||
|
||||
#: Minimum token age before a :meth:`refresh_token` is triggered
|
||||
refresh_token_every = timedelta(minutes=30)
|
||||
|
||||
def __init__(self, apikey=None, username=None, password=None, language='en', session=None, headers=None,
|
||||
timeout=10):
|
||||
#: API key
|
||||
self.apikey = apikey
|
||||
|
||||
#: Username
|
||||
self.username = username
|
||||
|
||||
#: Password
|
||||
self.password = password
|
||||
|
||||
#: Last token acquisition date
|
||||
self.token_date = datetime.utcnow() - self.token_lifespan
|
||||
|
||||
#: Session for the requests
|
||||
self.session = session or requests.Session()
|
||||
self.session.timeout = timeout
|
||||
self.session.headers.update(headers or {})
|
||||
self.session.headers['Content-Type'] = 'application/json'
|
||||
self.session.headers['Accept-Language'] = language
|
||||
|
||||
@property
|
||||
def language(self):
|
||||
return self.session.headers['Accept-Language']
|
||||
|
||||
@language.setter
|
||||
def language(self, value):
|
||||
self.session.headers['Accept-Language'] = value
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
if 'Authorization' not in self.session.headers:
|
||||
return None
|
||||
return self.session.headers['Authorization'][7:]
|
||||
|
||||
@property
|
||||
def token_expired(self):
|
||||
return datetime.utcnow() - self.token_date > self.token_lifespan
|
||||
|
||||
@property
|
||||
def token_needs_refresh(self):
|
||||
return datetime.utcnow() - self.token_date > self.refresh_token_every
|
||||
|
||||
def login(self):
|
||||
"""Login"""
|
||||
# perform the request
|
||||
data = {'apikey': self.apikey, 'username': self.username, 'password': self.password}
|
||||
r = self.session.post(self.base_url + '/login', json=data)
|
||||
r.raise_for_status()
|
||||
|
||||
# set the Authorization header
|
||||
self.session.headers['Authorization'] = 'Bearer ' + r.json()['token']
|
||||
|
||||
# update token_date
|
||||
self.token_date = datetime.utcnow()
|
||||
|
||||
def refresh_token(self):
|
||||
"""Refresh token"""
|
||||
# perform the request
|
||||
r = self.session.get(self.base_url + '/refresh_token')
|
||||
r.raise_for_status()
|
||||
|
||||
# set the Authorization header
|
||||
self.session.headers['Authorization'] = 'Bearer ' + r.json()['token']
|
||||
|
||||
# update token_date
|
||||
self.token_date = datetime.utcnow()
|
||||
|
||||
@requires_auth
|
||||
def search_series(self, name=None, imdb_id=None, zap2it_id=None):
|
||||
"""Search series"""
|
||||
# perform the request
|
||||
params = {'name': name, 'imdbId': imdb_id, 'zap2itId': zap2it_id}
|
||||
r = self.session.get(self.base_url + '/search/series', params=params)
|
||||
if r.status_code == 404:
|
||||
return None
|
||||
r.raise_for_status()
|
||||
|
||||
return r.json()['data']
|
||||
|
||||
@requires_auth
|
||||
def get_series(self, id):
|
||||
"""Get series"""
|
||||
# perform the request
|
||||
r = self.session.get(self.base_url + '/series/{}'.format(id))
|
||||
if r.status_code == 404:
|
||||
return None
|
||||
r.raise_for_status()
|
||||
|
||||
return r.json()['data']
|
||||
|
||||
@requires_auth
|
||||
def get_series_actors(self, id):
|
||||
"""Get series actors"""
|
||||
# perform the request
|
||||
r = self.session.get(self.base_url + '/series/{}/actors'.format(id))
|
||||
if r.status_code == 404:
|
||||
return None
|
||||
r.raise_for_status()
|
||||
|
||||
return r.json()['data']
|
||||
|
||||
@requires_auth
|
||||
def get_series_episodes(self, id, page=1):
|
||||
"""Get series episodes"""
|
||||
# perform the request
|
||||
params = {'page': page}
|
||||
r = self.session.get(self.base_url + '/series/{}/episodes'.format(id), params=params)
|
||||
if r.status_code == 404:
|
||||
return None
|
||||
r.raise_for_status()
|
||||
|
||||
return r.json()
|
||||
|
||||
@requires_auth
|
||||
def query_series_episodes(self, id, absolute_number=None, aired_season=None, aired_episode=None, dvd_season=None,
|
||||
dvd_episode=None, imdb_id=None, page=1):
|
||||
"""Query series episodes"""
|
||||
# perform the request
|
||||
params = {'absoluteNumber': absolute_number, 'airedSeason': aired_season, 'airedEpisode': aired_episode,
|
||||
'dvdSeason': dvd_season, 'dvdEpisode': dvd_episode, 'imdbId': imdb_id, 'page': page}
|
||||
r = self.session.get(self.base_url + '/series/{}/episodes/query'.format(id), params=params)
|
||||
if r.status_code == 404:
|
||||
return None
|
||||
r.raise_for_status()
|
||||
|
||||
return r.json()
|
||||
|
||||
@requires_auth
|
||||
def get_episode(self, id):
|
||||
"""Get episode"""
|
||||
# perform the request
|
||||
r = self.session.get(self.base_url + '/episodes/{}'.format(id))
|
||||
if r.status_code == 404:
|
||||
return None
|
||||
r.raise_for_status()
|
||||
|
||||
return r.json()['data']
|
||||
|
||||
|
||||
#: Configured instance of :class:`TVDBClient`
|
||||
tvdb_client = TVDBClient('5EC930FB90DA1ADA', headers={'User-Agent': 'Subliminal/%s' % __short_version__})
|
||||
|
||||
|
||||
@region.cache_on_arguments(expiration_time=REFINER_EXPIRATION_TIME)
|
||||
def search_series(name):
|
||||
"""Search series.
|
||||
|
||||
:param str name: name of the series.
|
||||
:return: the search results.
|
||||
:rtype: list
|
||||
|
||||
"""
|
||||
return tvdb_client.search_series(name)
|
||||
|
||||
|
||||
@region.cache_on_arguments(expiration_time=REFINER_EXPIRATION_TIME)
|
||||
def get_series(id):
|
||||
"""Get series.
|
||||
|
||||
:param int id: id of the series.
|
||||
:return: the series data.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
return tvdb_client.get_series(id)
|
||||
|
||||
|
||||
@region.cache_on_arguments(expiration_time=REFINER_EXPIRATION_TIME)
|
||||
def get_series_episode(series_id, season, episode):
|
||||
"""Get an episode of a series.
|
||||
|
||||
:param int series_id: id of the series.
|
||||
:param int season: season number of the episode.
|
||||
:param int episode: episode number of the episode.
|
||||
:return: the episode data.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
result = tvdb_client.query_series_episodes(series_id, aired_season=season, aired_episode=episode)
|
||||
if result:
|
||||
return tvdb_client.get_episode(result['data'][0]['id'])
|
||||
|
||||
|
||||
def refine(video, **kwargs):
|
||||
"""Refine a video by searching `TheTVDB <http://thetvdb.com/>`_.
|
||||
|
||||
.. note::
|
||||
|
||||
This refiner only work for instances of :class:`~subliminal.video.Episode`.
|
||||
|
||||
Several attributes can be found:
|
||||
|
||||
* :attr:`~subliminal.video.Episode.series`
|
||||
* :attr:`~subliminal.video.Episode.year`
|
||||
* :attr:`~subliminal.video.Episode.series_imdb_id`
|
||||
* :attr:`~subliminal.video.Episode.series_tvdb_id`
|
||||
* :attr:`~subliminal.video.Episode.title`
|
||||
* :attr:`~subliminal.video.Video.imdb_id`
|
||||
* :attr:`~subliminal.video.Episode.tvdb_id`
|
||||
|
||||
"""
|
||||
# only deal with Episode videos
|
||||
if not isinstance(video, Episode):
|
||||
logger.error('Cannot refine episodes')
|
||||
return
|
||||
|
||||
# exit if the information is complete
|
||||
if video.series_tvdb_id and video.tvdb_id:
|
||||
logger.debug('No need to search')
|
||||
return
|
||||
|
||||
# search the series
|
||||
logger.info('Searching series %r', video.series)
|
||||
results = search_series(video.series.lower())
|
||||
if not results:
|
||||
logger.warning('No results for series')
|
||||
return
|
||||
logger.debug('Found %d results', len(results))
|
||||
|
||||
# search for exact matches
|
||||
matching_results = []
|
||||
for result in results:
|
||||
matching_result = {}
|
||||
|
||||
# use seriesName and aliases
|
||||
series_names = [result['seriesName']]
|
||||
series_names.extend(result['aliases'])
|
||||
|
||||
# parse the original series as series + year or country
|
||||
original_match = series_re.match(result['seriesName']).groupdict()
|
||||
|
||||
# parse series year
|
||||
series_year = None
|
||||
if result['firstAired']:
|
||||
series_year = datetime.strptime(result['firstAired'], '%Y-%m-%d').year
|
||||
|
||||
# discard mismatches on year
|
||||
if video.year and series_year and video.year != series_year:
|
||||
logger.debug('Discarding series %r mismatch on year %d', result['seriesName'], series_year)
|
||||
continue
|
||||
|
||||
# iterate over series names
|
||||
for series_name in series_names:
|
||||
# parse as series and year
|
||||
series, year, country = series_re.match(series_name).groups()
|
||||
if year:
|
||||
year = int(year)
|
||||
|
||||
# discard mismatches on year
|
||||
if year and (video.original_series or video.year != year):
|
||||
logger.debug('Discarding series name %r mismatch on year %d', series, year)
|
||||
continue
|
||||
|
||||
# match on sanitized series name
|
||||
if sanitize(series) == sanitize(video.series):
|
||||
logger.debug('Found exact match on series %r', series_name)
|
||||
matching_result['match'] = {'series': original_match['series'], 'year': series_year,
|
||||
'original_series': original_match['year'] is None}
|
||||
break
|
||||
|
||||
# add the result on match
|
||||
if matching_result:
|
||||
matching_result['data'] = result
|
||||
matching_results.append(matching_result)
|
||||
|
||||
# exit if we don't have exactly 1 matching result
|
||||
if not matching_results:
|
||||
logger.error('No matching series found')
|
||||
return
|
||||
if len(matching_results) > 1:
|
||||
logger.error('Multiple matches found')
|
||||
return
|
||||
|
||||
# get the series
|
||||
matching_result = matching_results[0]
|
||||
series = get_series(matching_result['data']['id'])
|
||||
|
||||
# add series information
|
||||
logger.debug('Found series %r', series)
|
||||
video.series = matching_result['match']['series']
|
||||
video.year = matching_result['match']['year']
|
||||
video.original_series = matching_result['match']['original_series']
|
||||
video.series_tvdb_id = series['id']
|
||||
video.series_imdb_id = series['imdbId'] or None
|
||||
|
||||
# get the episode
|
||||
logger.info('Getting series episode %dx%d', video.season, video.episode)
|
||||
episode = get_series_episode(video.series_tvdb_id, video.season, video.episode)
|
||||
if not episode:
|
||||
logger.warning('No results for episode')
|
||||
return
|
||||
|
||||
# add episode information
|
||||
logger.debug('Found episode %r', episode)
|
||||
video.tvdb_id = episode['id']
|
||||
video.title = episode['episodeName'] or None
|
||||
video.imdb_id = episode['imdbId'] or None
|
||||
Executable
+213
@@ -0,0 +1,213 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
This module provides the default implementation of the `compute_score` parameter in
|
||||
:meth:`~subliminal.core.ProviderPool.download_best_subtitles` and :func:`~subliminal.core.download_best_subtitles`.
|
||||
|
||||
.. note::
|
||||
|
||||
To avoid unnecessary dependency on `sympy <http://www.sympy.org/>`_ and boost subliminal's import time, the
|
||||
resulting scores are hardcoded here and manually updated when the set of equations change.
|
||||
|
||||
Available matches:
|
||||
|
||||
* hash
|
||||
* title
|
||||
* year
|
||||
* series
|
||||
* season
|
||||
* episode
|
||||
* release_group
|
||||
* format
|
||||
* audio_codec
|
||||
* resolution
|
||||
* hearing_impaired
|
||||
* video_codec
|
||||
* series_imdb_id
|
||||
* imdb_id
|
||||
* tvdb_id
|
||||
|
||||
"""
|
||||
from __future__ import division, print_function
|
||||
import logging
|
||||
|
||||
from .video import Episode, Movie
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
#: Scores for episodes
|
||||
episode_scores = {'hash': 359, 'series': 180, 'year': 90, 'season': 30, 'episode': 30, 'release_group': 15,
|
||||
'format': 7, 'audio_codec': 3, 'resolution': 2, 'video_codec': 2, 'hearing_impaired': 1}
|
||||
|
||||
#: Scores for movies
|
||||
movie_scores = {'hash': 119, 'title': 60, 'year': 30, 'release_group': 15,
|
||||
'format': 7, 'audio_codec': 3, 'resolution': 2, 'video_codec': 2, 'hearing_impaired': 1}
|
||||
|
||||
|
||||
def get_scores(video):
|
||||
"""Get the scores dict for the given `video`.
|
||||
|
||||
This will return either :data:`episode_scores` or :data:`movie_scores` based on the type of the `video`.
|
||||
|
||||
:param video: the video to compute the score against.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:return: the scores dict.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
if isinstance(video, Episode):
|
||||
return episode_scores
|
||||
elif isinstance(video, Movie):
|
||||
return movie_scores
|
||||
|
||||
raise ValueError('video must be an instance of Episode or Movie')
|
||||
|
||||
|
||||
def compute_score(subtitle, video, hearing_impaired=None):
|
||||
"""Compute the score of the `subtitle` against the `video` with `hearing_impaired` preference.
|
||||
|
||||
:func:`compute_score` uses the :meth:`Subtitle.get_matches <subliminal.subtitle.Subtitle.get_matches>` method and
|
||||
applies the scores (either from :data:`episode_scores` or :data:`movie_scores`) after some processing.
|
||||
|
||||
:param subtitle: the subtitle to compute the score of.
|
||||
:type subtitle: :class:`~subliminal.subtitle.Subtitle`
|
||||
:param video: the video to compute the score against.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:param bool hearing_impaired: hearing impaired preference.
|
||||
:return: score of the subtitle.
|
||||
:rtype: int
|
||||
|
||||
"""
|
||||
logger.info('Computing score of %r for video %r with %r', subtitle, video, dict(hearing_impaired=hearing_impaired))
|
||||
|
||||
# get the scores dict
|
||||
scores = get_scores(video)
|
||||
logger.debug('Using scores %r', scores)
|
||||
|
||||
# get the matches
|
||||
matches = subtitle.get_matches(video)
|
||||
logger.debug('Found matches %r', matches)
|
||||
|
||||
# on hash match, discard everything else
|
||||
if 'hash' in matches:
|
||||
logger.debug('Keeping only hash match')
|
||||
matches &= {'hash'}
|
||||
|
||||
# handle equivalent matches
|
||||
if isinstance(video, Episode):
|
||||
if 'title' in matches:
|
||||
logger.debug('Adding title match equivalent')
|
||||
matches.add('episode')
|
||||
if 'series_imdb_id' in matches:
|
||||
logger.debug('Adding series_imdb_id match equivalent')
|
||||
matches |= {'series', 'year'}
|
||||
if 'imdb_id' in matches:
|
||||
logger.debug('Adding imdb_id match equivalents')
|
||||
matches |= {'series', 'year', 'season', 'episode'}
|
||||
if 'tvdb_id' in matches:
|
||||
logger.debug('Adding tvdb_id match equivalents')
|
||||
matches |= {'series', 'year'}
|
||||
elif isinstance(video, Movie):
|
||||
if 'imdb_id' in matches:
|
||||
logger.debug('Adding imdb_id match equivalents')
|
||||
matches |= {'title', 'year'}
|
||||
|
||||
# handle hearing impaired
|
||||
if hearing_impaired is not None and subtitle.hearing_impaired == hearing_impaired:
|
||||
logger.debug('Matched hearing_impaired')
|
||||
matches.add('hearing_impaired')
|
||||
|
||||
# compute the score
|
||||
score = sum((scores.get(match, 0) for match in matches))
|
||||
logger.info('Computed score %r with final matches %r', score, matches)
|
||||
|
||||
# ensure score is within valid bounds
|
||||
assert 0 <= score <= scores['hash'] + scores['hearing_impaired']
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def solve_episode_equations():
|
||||
from sympy import Eq, solve, symbols
|
||||
|
||||
hash, series, year, season, episode, release_group = symbols('hash series year season episode release_group')
|
||||
format, audio_codec, resolution, video_codec = symbols('format audio_codec resolution video_codec')
|
||||
hearing_impaired = symbols('hearing_impaired')
|
||||
|
||||
equations = [
|
||||
# hash is best
|
||||
Eq(hash, series + year + season + episode + release_group + format + audio_codec + resolution + video_codec),
|
||||
|
||||
# series counts for the most part in the total score
|
||||
Eq(series, year + season + episode + release_group + format + audio_codec + resolution + video_codec + 1),
|
||||
|
||||
# year is the second most important part
|
||||
Eq(year, season + episode + release_group + format + audio_codec + resolution + video_codec + 1),
|
||||
|
||||
# season is important too
|
||||
Eq(season, release_group + format + audio_codec + resolution + video_codec + 1),
|
||||
|
||||
# episode is equally important to season
|
||||
Eq(episode, season),
|
||||
|
||||
# release group is the next most wanted match
|
||||
Eq(release_group, format + audio_codec + resolution + video_codec + 1),
|
||||
|
||||
# format counts as much as audio_codec, resolution and video_codec
|
||||
Eq(format, audio_codec + resolution + video_codec),
|
||||
|
||||
# audio_codec is more valuable than video_codec
|
||||
Eq(audio_codec, video_codec + 1),
|
||||
|
||||
# resolution counts as much as video_codec
|
||||
Eq(resolution, video_codec),
|
||||
|
||||
# video_codec is the least valuable match but counts more than the sum of all scoring increasing matches
|
||||
Eq(video_codec, hearing_impaired + 1),
|
||||
|
||||
# hearing impaired is only used for score increasing, so put it to 1
|
||||
Eq(hearing_impaired, 1),
|
||||
]
|
||||
|
||||
return solve(equations, [hash, series, year, season, episode, release_group, format, audio_codec, resolution,
|
||||
hearing_impaired, video_codec])
|
||||
|
||||
|
||||
def solve_movie_equations():
|
||||
from sympy import Eq, solve, symbols
|
||||
|
||||
hash, title, year, release_group = symbols('hash title year release_group')
|
||||
format, audio_codec, resolution, video_codec = symbols('format audio_codec resolution video_codec')
|
||||
hearing_impaired = symbols('hearing_impaired')
|
||||
|
||||
equations = [
|
||||
# hash is best
|
||||
Eq(hash, title + year + release_group + format + audio_codec + resolution + video_codec),
|
||||
|
||||
# title counts for the most part in the total score
|
||||
Eq(title, year + release_group + format + audio_codec + resolution + video_codec + 1),
|
||||
|
||||
# year is the second most important part
|
||||
Eq(year, release_group + format + audio_codec + resolution + video_codec + 1),
|
||||
|
||||
# release group is the next most wanted match
|
||||
Eq(release_group, format + audio_codec + resolution + video_codec + 1),
|
||||
|
||||
# format counts as much as audio_codec, resolution and video_codec
|
||||
Eq(format, audio_codec + resolution + video_codec),
|
||||
|
||||
# audio_codec is more valuable than video_codec
|
||||
Eq(audio_codec, video_codec + 1),
|
||||
|
||||
# resolution counts as much as video_codec
|
||||
Eq(resolution, video_codec),
|
||||
|
||||
# video_codec is the least valuable match but counts more than the sum of all scoring increasing matches
|
||||
Eq(video_codec, hearing_impaired + 1),
|
||||
|
||||
# hearing impaired is only used for score increasing, so put it to 1
|
||||
Eq(hearing_impaired, 1),
|
||||
]
|
||||
|
||||
return solve(equations, [hash, title, year, release_group, format, audio_codec, resolution, hearing_impaired,
|
||||
video_codec])
|
||||
@@ -1,325 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import threading
|
||||
from itertools import groupby
|
||||
from classes import DownloadTask, ListTask, StopTask, LanguageError, PluginError, BadStateError, WrongTaskError, DownloadFailedError
|
||||
import Queue
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import plugins
|
||||
|
||||
|
||||
# be nice
|
||||
try:
|
||||
from logging import NullHandler
|
||||
except ImportError:
|
||||
|
||||
class NullHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
pass
|
||||
logger = logging.getLogger('subliminal')
|
||||
logger.addHandler(NullHandler())
|
||||
|
||||
# const
|
||||
FORMATS = ['video/x-msvideo', 'video/quicktime', 'video/x-matroska', 'video/mp4']
|
||||
EXTENSIONS = set(['srt', 'sub'])
|
||||
LANGUAGES = set(['aa', 'ab', 'ae', 'af', 'ak', 'am', 'an', 'ar', 'as', 'av', 'ay', 'az', 'ba', 'be', 'bg', 'bh', 'bi',
|
||||
'bm', 'bn', 'bo', 'br', 'bs', 'ca', 'ce', 'ch', 'co', 'cr', 'cs', 'cu', 'cv', 'cy', 'da', 'de', 'dv',
|
||||
'dz', 'ee', 'el', 'en', 'eo', 'es', 'et', 'eu', 'fa', 'ff', 'fi', 'fj', 'fo', 'fr', 'fy', 'ga', 'gd',
|
||||
'gl', 'gn', 'gu', 'gv', 'ha', 'he', 'hi', 'ho', 'hr', 'ht', 'hu', 'hy', 'hz', 'ia', 'id', 'ie', 'ig',
|
||||
'ii', 'ik', 'io', 'is', 'it', 'iu', 'ja', 'jv', 'ka', 'kg', 'ki', 'kj', 'kk', 'kl', 'km', 'kn', 'ko',
|
||||
'kr', 'ks', 'ku', 'kv', 'kw', 'ky', 'la', 'lb', 'lg', 'li', 'ln', 'lo', 'lt', 'lu', 'lv', 'mg', 'mh',
|
||||
'mi', 'mk', 'ml', 'mn', 'mo', 'mr', 'ms', 'mt', 'my', 'na', 'nb', 'nd', 'ne', 'ng', 'nl', 'nn', 'no',
|
||||
'nr', 'nv', 'ny', 'oc', 'oj', 'om', 'or', 'os', 'pa', 'pi', 'pl', 'ps', 'pt', 'qu', 'rm', 'rn', 'ro',
|
||||
'ru', 'rw', 'sa', 'sc', 'sd', 'se', 'sg', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'ss', 'st',
|
||||
'su', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tn', 'to', 'tr', 'ts', 'tt', 'tw', 'ty',
|
||||
'ug', 'uk', 'ur', 'uz', 've', 'vi', 'vo', 'wa', 'wo', 'xh', 'yi', 'yo', 'za', 'zh', 'zu']) # ISO 639-1
|
||||
PLUGINS = ['BierDopje', 'OpenSubtitles', 'SubsWiki', 'Subtitulos', 'TheSubDB']
|
||||
API_PLUGINS = filter(lambda p: getattr(plugins, p).api_based, PLUGINS)
|
||||
IDLE = 0
|
||||
RUNNING = 1
|
||||
PAUSED = 2
|
||||
|
||||
|
||||
class Subliminal(object):
|
||||
"""Main Subliminal class"""
|
||||
|
||||
def __init__(self, cache_dir=None, workers=4, multi=False, force=False, max_depth=3, files_mode=-1):
|
||||
self.multi = multi
|
||||
self.force = force
|
||||
self.max_depth = max_depth
|
||||
self.cache_dir = None
|
||||
self.taskQueue = Queue.PriorityQueue()
|
||||
self.listResultQueue = Queue.Queue()
|
||||
self.downloadResultQueue = Queue.Queue()
|
||||
self._languages = []
|
||||
self._plugins = API_PLUGINS
|
||||
self.workers = workers
|
||||
self.files_mode = files_mode
|
||||
self.state = IDLE
|
||||
try:
|
||||
if cache_dir:
|
||||
self.cache_dir = cache_dir
|
||||
if not os.path.isdir(self.cache_dir):
|
||||
os.makedirs(self.cache_dir)
|
||||
logger.debug(u'Creating cache directory: %r' % self.cache_dir)
|
||||
except:
|
||||
self.cache_dir = None
|
||||
logger.error(u'Failed to use the cache directory, continue without it')
|
||||
|
||||
@property
|
||||
def languages(self):
|
||||
"""Getter for languages"""
|
||||
return self._languages
|
||||
|
||||
@languages.setter
|
||||
def languages(self, languages):
|
||||
"""Setter for languages"""
|
||||
logger.debug(u'Setting languages to %r' % languages)
|
||||
self._languages = []
|
||||
for l in languages:
|
||||
if l not in LANGUAGES:
|
||||
raise LanguageError(l)
|
||||
if not l in self._languages:
|
||||
self._languages.append(l)
|
||||
|
||||
@property
|
||||
def plugins(self):
|
||||
"""Getter for plugins"""
|
||||
return self._plugins
|
||||
|
||||
@plugins.setter
|
||||
def plugins(self, plugins):
|
||||
"""Setter for plugins"""
|
||||
logger.debug(u'Setting plugins to %r' % plugins)
|
||||
self._plugins = []
|
||||
for p in plugins:
|
||||
if p not in PLUGINS:
|
||||
raise PluginError(p)
|
||||
if not p in self._plugins:
|
||||
self._plugins.append(p)
|
||||
|
||||
def listSubtitles(self, entries, auto=True):
|
||||
"""
|
||||
Search subtitles within the plugins and return all found subtitles in a list of Subtitle object.
|
||||
|
||||
Attributes:
|
||||
entries -- filepath or folderpath of video file or a list of that
|
||||
auto -- automaticaly manage workers"""
|
||||
if auto:
|
||||
if self.state != IDLE:
|
||||
raise BadStateError(self.state, IDLE)
|
||||
self.startWorkers()
|
||||
if isinstance(entries, basestring):
|
||||
entries = [entries]
|
||||
scan_result = []
|
||||
for e in entries:
|
||||
if not isinstance(e, unicode):
|
||||
logger.warning(u'Entry %r is not unicode' % e)
|
||||
if not os.path.exists(e):
|
||||
scan_result.append((e, set(), False))
|
||||
continue
|
||||
scan_result.extend(scan(e))
|
||||
task_count = 0
|
||||
for filepath, languages, has_single in scan_result:
|
||||
wanted_languages = set(self._languages)
|
||||
if not wanted_languages:
|
||||
wanted_languages = LANGUAGES
|
||||
if not self.force and self.multi:
|
||||
wanted_languages = set(wanted_languages) - languages
|
||||
if not wanted_languages:
|
||||
logger.debug(u'No need to list multi subtitles %r for %r because %r subtitles detected' % (self._languages, filepath, languages))
|
||||
continue
|
||||
if not self.force and not self.multi and has_single:
|
||||
logger.debug(u'No need to list single subtitles %r for %r because one detected' % (self._languages, filepath))
|
||||
continue
|
||||
logger.debug(u'Listing subtitles %r for %r with %r' % (wanted_languages, filepath, self._plugins))
|
||||
for plugin in self._plugins:
|
||||
self.taskQueue.put((5, ListTask(filepath, wanted_languages, plugin, self.getConfigDict())))
|
||||
task_count += 1
|
||||
subtitles = []
|
||||
for _ in range(task_count):
|
||||
subtitles.extend(self.listResultQueue.get())
|
||||
if auto:
|
||||
self.stopWorkers()
|
||||
return subtitles
|
||||
|
||||
def downloadSubtitles(self, entries, auto=True):
|
||||
"""
|
||||
Download subtitles using the plugins preferences and languages. Also use internal algorithm to find
|
||||
the best match inside a plugin.
|
||||
|
||||
Attributes:
|
||||
entries -- filepath or folderpath of video file or a list of that
|
||||
auto -- automaticaly manage workers"""
|
||||
if auto:
|
||||
if self.state != IDLE:
|
||||
raise BadStateError(self.state, IDLE)
|
||||
self.startWorkers()
|
||||
subtitles = self.listSubtitles(entries, False)
|
||||
task_count = 0
|
||||
for _, subsByVideoPath in groupby(sorted(subtitles, key=lambda x: x.video_path), lambda x: x.video_path):
|
||||
if not self.multi:
|
||||
self.taskQueue.put((5, DownloadTask(sorted(list(subsByVideoPath), cmp=self.cmpSubtitles))))
|
||||
task_count += 1
|
||||
continue
|
||||
for __, subsByVideoPathByLanguage in groupby(sorted(subsByVideoPath, key=lambda x: x.language), lambda x: x.language):
|
||||
self.taskQueue.put((5, DownloadTask(sorted(list(subsByVideoPathByLanguage), cmp=self.cmpSubtitles))))
|
||||
task_count += 1
|
||||
downloaded = []
|
||||
for _ in range(task_count):
|
||||
downloaded.extend(self.downloadResultQueue.get())
|
||||
if auto:
|
||||
self.stopWorkers()
|
||||
return downloaded
|
||||
|
||||
def cmpSubtitles(self, x, y):
|
||||
"""Compares 2 subtitles elements x and y using video_path, languages and plugin"""
|
||||
video_paths = sorted([x.video_path, y.video_path])
|
||||
if x.video_path != y.video_path and video_paths.index(x.video_path) < video_paths(y.video_path):
|
||||
return - 1
|
||||
if x.video_path != y.video_path and video_paths.index(x.video_path) > video_paths(y.video_path):
|
||||
return 1
|
||||
if self._languages and self._languages.index(x.language) < self._languages.index(y.language):
|
||||
return - 1
|
||||
if self._languages and self._languages.index(x.language) > self._languages.index(y.language):
|
||||
return 1
|
||||
if self._plugins.index(x.plugin) < self._plugins.index(y.plugin):
|
||||
return - 1
|
||||
if self._plugins.index(x.plugin) > self._plugins.index(y.plugin):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def startWorkers(self):
|
||||
"""Create a pool of workers and start them"""
|
||||
self.pool = []
|
||||
for _ in range(self.workers):
|
||||
worker = PluginWorker(self.taskQueue, self.listResultQueue, self.downloadResultQueue)
|
||||
worker.start()
|
||||
self.pool.append(worker)
|
||||
logger.debug(u'Worker %s added to the pool' % worker.name)
|
||||
self.state = RUNNING
|
||||
|
||||
def stopWorkers(self):
|
||||
"""Stop workers using a lowest priority stop signal and wait for them to terminate properly"""
|
||||
for _ in range(self.workers):
|
||||
self.taskQueue.put((10, StopTask()))
|
||||
for worker in self.pool:
|
||||
worker.join()
|
||||
self.state = IDLE
|
||||
|
||||
def pauseWorkers(self):
|
||||
"""Pause workers using a highest priority stop signal and wait for them to terminate properly"""
|
||||
for _ in range(self.workers):
|
||||
self.taskQueue.put((0, StopTask()))
|
||||
for worker in self.pool:
|
||||
worker.join()
|
||||
self.state = PAUSED
|
||||
if self.taskQueue.empty():
|
||||
self.state = STOPPED
|
||||
|
||||
def getConfigDict(self):
|
||||
"""Produce a dict with configuration items. Used by plugins to read configuration"""
|
||||
config = {}
|
||||
config['multi'] = self.multi
|
||||
config['cache_dir'] = self.cache_dir
|
||||
config['files_mode'] = self.files_mode
|
||||
return config
|
||||
|
||||
def addTask(self, task):
|
||||
if not isinstance(task, Task) or isinstance(task, StopTask):
|
||||
raise WrongTaskError()
|
||||
self.taskQueue.put((5, task))
|
||||
|
||||
|
||||
class PluginWorker(threading.Thread):
|
||||
"""Threaded plugin worker"""
|
||||
def __init__(self, taskQueue, listResultQueue, downloadResultQueue):
|
||||
threading.Thread.__init__(self)
|
||||
self.taskQueue = taskQueue
|
||||
self.listResultQueue = listResultQueue
|
||||
self.downloadResultQueue = downloadResultQueue
|
||||
self.logger = logging.getLogger('subliminal.worker')
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
task = self.taskQueue.get()[1]
|
||||
if isinstance(task, StopTask):
|
||||
self.logger.debug(u'Poison pill received, terminating thread %s' % self.name)
|
||||
self.taskQueue.task_done()
|
||||
break
|
||||
result = []
|
||||
try:
|
||||
if isinstance(task, ListTask):
|
||||
plugin = getattr(plugins, task.plugin)(task.config)
|
||||
result = plugin.list(task.filepath, task.languages)
|
||||
elif isinstance(task, DownloadTask):
|
||||
for subtitle in task.subtitles:
|
||||
plugin = getattr(plugins, subtitle.plugin)()
|
||||
try:
|
||||
result = [plugin.download(subtitle)]
|
||||
break
|
||||
except DownloadFailedError as e:
|
||||
self.logger.warning(u'Could not download subtitle %r, trying next' % subtitle)
|
||||
continue
|
||||
if not result:
|
||||
self.logger.error(u'No subtitles could be downloaded for file %r' % subtitle.video_path)
|
||||
except:
|
||||
self.logger.error(u'Exception raised in worker %s' % self.name, exc_info=True)
|
||||
finally:
|
||||
if isinstance(task, ListTask):
|
||||
self.listResultQueue.put(result)
|
||||
elif isinstance(task, DownloadTask):
|
||||
self.downloadResultQueue.put(result)
|
||||
self.taskQueue.task_done()
|
||||
self.logger.debug(u'Thread %s terminated' % self.name)
|
||||
|
||||
|
||||
def scan(entry, depth=0, max_depth=3):
|
||||
"""Scan a path and return a list of tuples (filepath, set(languages), has single)"""
|
||||
if depth > max_depth and max_depth != 0: # we do not want to search the whole file system except if max_depth = 0
|
||||
return []
|
||||
if depth == 0:
|
||||
entry = os.path.abspath(entry)
|
||||
if os.path.isfile(entry): # a file? scan it
|
||||
if depth != 0: # trust the user: only check for valid format if recursing
|
||||
mimetypes.add_type('video/x-matroska', '.mkv')
|
||||
if mimetypes.guess_type(entry)[0] not in FORMATS:
|
||||
return []
|
||||
# check for .lg.ext and .ext
|
||||
available_languages = set()
|
||||
has_single = False
|
||||
basepath = os.path.splitext(entry)[0]
|
||||
for l in LANGUAGES:
|
||||
for e in EXTENSIONS:
|
||||
if os.path.exists(basepath + '.%s.%s' % (l, e)):
|
||||
available_languages.add(l)
|
||||
if os.path.exists(basepath + '.%s' % e):
|
||||
has_single = True
|
||||
return [(os.path.normpath(entry), available_languages, has_single)]
|
||||
if os.path.isdir(entry): # a dir? recurse
|
||||
result = []
|
||||
for e in os.listdir(entry):
|
||||
result.extend(scan(os.path.join(entry, e), depth + 1))
|
||||
return result
|
||||
return [] # anything else
|
||||
@@ -0,0 +1,253 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import codecs
|
||||
import logging
|
||||
import os
|
||||
|
||||
import chardet
|
||||
import pysrt
|
||||
|
||||
from .video import Episode, Movie
|
||||
from .utils import sanitize, sanitize_release_group
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Subtitle extensions
|
||||
SUBTITLE_EXTENSIONS = ('.srt', '.sub', '.smi', '.txt', '.ssa', '.ass', '.mpl')
|
||||
|
||||
|
||||
class Subtitle(object):
|
||||
"""Base class for subtitle.
|
||||
|
||||
:param language: language of the subtitle.
|
||||
:type language: :class:`~babelfish.language.Language`
|
||||
:param bool hearing_impaired: whether or not the subtitle is hearing impaired.
|
||||
:param page_link: URL of the web page from which the subtitle can be downloaded.
|
||||
:type page_link: str
|
||||
:param encoding: Text encoding of the subtitle.
|
||||
:type encoding: str
|
||||
|
||||
"""
|
||||
#: Name of the provider that returns that class of subtitle
|
||||
provider_name = ''
|
||||
|
||||
def __init__(self, language, hearing_impaired=False, page_link=None, encoding=None):
|
||||
#: Language of the subtitle
|
||||
self.language = language
|
||||
|
||||
#: Whether or not the subtitle is hearing impaired
|
||||
self.hearing_impaired = hearing_impaired
|
||||
|
||||
#: URL of the web page from which the subtitle can be downloaded
|
||||
self.page_link = page_link
|
||||
|
||||
#: Content as bytes
|
||||
self.content = None
|
||||
|
||||
#: Encoding to decode with when accessing :attr:`text`
|
||||
self.encoding = None
|
||||
|
||||
# validate the encoding
|
||||
if encoding:
|
||||
try:
|
||||
self.encoding = codecs.lookup(encoding).name
|
||||
except (TypeError, LookupError):
|
||||
logger.debug('Unsupported encoding %s', encoding)
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
"""Unique identifier of the subtitle"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
"""Content as string
|
||||
|
||||
If :attr:`encoding` is None, the encoding is guessed with :meth:`guess_encoding`
|
||||
|
||||
"""
|
||||
if not self.content:
|
||||
return
|
||||
|
||||
if self.encoding:
|
||||
return self.content.decode(self.encoding, errors='replace')
|
||||
|
||||
return self.content.decode(self.guess_encoding(), errors='replace')
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if a :attr:`text` is a valid SubRip format.
|
||||
|
||||
:return: whether or not the subtitle is valid.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
if not self.text:
|
||||
return False
|
||||
|
||||
try:
|
||||
pysrt.from_string(self.text, error_handling=pysrt.ERROR_RAISE)
|
||||
except pysrt.Error as e:
|
||||
if e.args[0] < 80:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def guess_encoding(self):
|
||||
"""Guess encoding using the language, falling back on chardet.
|
||||
|
||||
:return: the guessed encoding.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
logger.info('Guessing encoding for language %s', self.language)
|
||||
|
||||
# always try utf-8 first
|
||||
encodings = ['utf-8']
|
||||
|
||||
# add language-specific encodings
|
||||
if self.language.alpha3 == 'zho':
|
||||
encodings.extend(['gb18030', 'big5'])
|
||||
elif self.language.alpha3 == 'jpn':
|
||||
encodings.append('shift-jis')
|
||||
elif self.language.alpha3 == 'ara':
|
||||
encodings.append('windows-1256')
|
||||
elif self.language.alpha3 == 'heb':
|
||||
encodings.append('windows-1255')
|
||||
elif self.language.alpha3 == 'tur':
|
||||
encodings.extend(['iso-8859-9', 'windows-1254'])
|
||||
elif self.language.alpha3 == 'pol':
|
||||
# Eastern European Group 1
|
||||
encodings.extend(['windows-1250'])
|
||||
elif self.language.alpha3 == 'bul':
|
||||
# Eastern European Group 2
|
||||
encodings.extend(['windows-1251'])
|
||||
else:
|
||||
# Western European (windows-1252)
|
||||
encodings.append('latin-1')
|
||||
|
||||
# try to decode
|
||||
logger.debug('Trying encodings %r', encodings)
|
||||
for encoding in encodings:
|
||||
try:
|
||||
self.content.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
else:
|
||||
logger.info('Guessed encoding %s', encoding)
|
||||
return encoding
|
||||
|
||||
logger.warning('Could not guess encoding from language')
|
||||
|
||||
# fallback on chardet
|
||||
encoding = chardet.detect(self.content)['encoding']
|
||||
logger.info('Chardet found encoding %s', encoding)
|
||||
|
||||
return encoding
|
||||
|
||||
def get_matches(self, video):
|
||||
"""Get the matches against the `video`.
|
||||
|
||||
:param video: the video to get the matches with.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:return: matches of the subtitle.
|
||||
:rtype: set
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.provider_name + '-' + self.id)
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s %r [%s]>' % (self.__class__.__name__, self.id, self.language)
|
||||
|
||||
|
||||
def get_subtitle_path(video_path, language=None, extension='.srt'):
|
||||
"""Get the subtitle path using the `video_path` and `language`.
|
||||
|
||||
:param str video_path: path to the video.
|
||||
:param language: language of the subtitle to put in the path.
|
||||
:type language: :class:`~babelfish.language.Language`
|
||||
:param str extension: extension of the subtitle.
|
||||
:return: path of the subtitle.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
subtitle_root = os.path.splitext(video_path)[0]
|
||||
|
||||
if language:
|
||||
subtitle_root += '.' + str(language)
|
||||
|
||||
return subtitle_root + extension
|
||||
|
||||
|
||||
def guess_matches(video, guess, partial=False):
|
||||
"""Get matches between a `video` and a `guess`.
|
||||
|
||||
If a guess is `partial`, the absence information won't be counted as a match.
|
||||
|
||||
:param video: the video.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:param guess: the guess.
|
||||
:type guess: dict
|
||||
:param bool partial: whether or not the guess is partial.
|
||||
:return: matches between the `video` and the `guess`.
|
||||
:rtype: set
|
||||
|
||||
"""
|
||||
matches = set()
|
||||
if isinstance(video, Episode):
|
||||
# series
|
||||
if video.series and 'title' in guess and sanitize(guess['title']) == sanitize(video.series):
|
||||
matches.add('series')
|
||||
# title
|
||||
if video.title and 'episode_title' in guess and sanitize(guess['episode_title']) == sanitize(video.title):
|
||||
matches.add('title')
|
||||
# season
|
||||
if video.season and 'season' in guess and guess['season'] == video.season:
|
||||
matches.add('season')
|
||||
# episode
|
||||
if video.episode and 'episode' in guess and guess['episode'] == video.episode:
|
||||
matches.add('episode')
|
||||
# year
|
||||
if video.year and 'year' in guess and guess['year'] == video.year:
|
||||
matches.add('year')
|
||||
# count "no year" as an information
|
||||
if not partial and video.original_series and 'year' not in guess:
|
||||
matches.add('year')
|
||||
elif isinstance(video, Movie):
|
||||
# year
|
||||
if video.year and 'year' in guess and guess['year'] == video.year:
|
||||
matches.add('year')
|
||||
# title
|
||||
if video.title and 'title' in guess and sanitize(guess['title']) == sanitize(video.title):
|
||||
matches.add('title')
|
||||
# release_group
|
||||
if (video.release_group and 'release_group' in guess and
|
||||
sanitize_release_group(guess['release_group']) == sanitize_release_group(video.release_group)):
|
||||
matches.add('release_group')
|
||||
# resolution
|
||||
if video.resolution and 'screen_size' in guess and guess['screen_size'] == video.resolution:
|
||||
matches.add('resolution')
|
||||
# format
|
||||
if video.format and 'format' in guess and guess['format'].lower() == video.format.lower():
|
||||
matches.add('format')
|
||||
# video_codec
|
||||
if video.video_codec and 'video_codec' in guess and guess['video_codec'] == video.video_codec:
|
||||
matches.add('video_codec')
|
||||
# audio_codec
|
||||
if video.audio_codec and 'audio_codec' in guess and guess['audio_codec'] == video.audio_codec:
|
||||
matches.add('audio_codec')
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def fix_line_ending(content):
|
||||
"""Fix line ending of `content` by changing it to \n.
|
||||
|
||||
:param bytes content: content of the subtitle.
|
||||
:return: the content with fixed line endings.
|
||||
:rtype: bytes
|
||||
|
||||
"""
|
||||
return content.replace(b'\r\n', b'\n').replace(b'\r', b'\n')
|
||||
@@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
|
||||
|
||||
def hash_opensubtitles(video_path):
|
||||
"""Compute a hash using OpenSubtitles' algorithm.
|
||||
|
||||
:param str video_path: path of the video.
|
||||
:return: the hash.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
bytesize = struct.calcsize(b'<q')
|
||||
with open(video_path, 'rb') as f:
|
||||
filesize = os.path.getsize(video_path)
|
||||
filehash = filesize
|
||||
if filesize < 65536 * 2:
|
||||
return
|
||||
for _ in range(65536 // bytesize):
|
||||
filebuffer = f.read(bytesize)
|
||||
(l_value,) = struct.unpack(b'<q', filebuffer)
|
||||
filehash += l_value
|
||||
filehash &= 0xFFFFFFFFFFFFFFFF # to remain as 64bit number
|
||||
f.seek(max(0, filesize - 65536), 0)
|
||||
for _ in range(65536 // bytesize):
|
||||
filebuffer = f.read(bytesize)
|
||||
(l_value,) = struct.unpack(b'<q', filebuffer)
|
||||
filehash += l_value
|
||||
filehash &= 0xFFFFFFFFFFFFFFFF
|
||||
returnedhash = '%016x' % filehash
|
||||
|
||||
return returnedhash
|
||||
|
||||
|
||||
def hash_thesubdb(video_path):
|
||||
"""Compute a hash using TheSubDB's algorithm.
|
||||
|
||||
:param str video_path: path of the video.
|
||||
:return: the hash.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
readsize = 64 * 1024
|
||||
if os.path.getsize(video_path) < readsize:
|
||||
return
|
||||
with open(video_path, 'rb') as f:
|
||||
data = f.read(readsize)
|
||||
f.seek(-readsize, os.SEEK_END)
|
||||
data += f.read(readsize)
|
||||
|
||||
return hashlib.md5(data).hexdigest()
|
||||
|
||||
|
||||
def hash_napiprojekt(video_path):
|
||||
"""Compute a hash using NapiProjekt's algorithm.
|
||||
|
||||
:param str video_path: path of the video.
|
||||
:return: the hash.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
readsize = 1024 * 1024 * 10
|
||||
with open(video_path, 'rb') as f:
|
||||
data = f.read(readsize)
|
||||
return hashlib.md5(data).hexdigest()
|
||||
|
||||
|
||||
def hash_shooter(video_path):
|
||||
"""Compute a hash using Shooter's algorithm
|
||||
|
||||
:param string video_path: path of the video
|
||||
:return: the hash
|
||||
:rtype: string
|
||||
|
||||
"""
|
||||
filesize = os.path.getsize(video_path)
|
||||
readsize = 4096
|
||||
if os.path.getsize(video_path) < readsize * 2:
|
||||
return None
|
||||
offsets = (readsize, filesize // 3 * 2, filesize // 3, filesize - readsize * 2)
|
||||
filehash = []
|
||||
with open(video_path, 'rb') as f:
|
||||
for offset in offsets:
|
||||
f.seek(offset)
|
||||
filehash.append(hashlib.md5(f.read(readsize)).hexdigest())
|
||||
return ';'.join(filehash)
|
||||
|
||||
|
||||
def sanitize(string, ignore_characters=None):
|
||||
"""Sanitize a string to strip special characters.
|
||||
|
||||
:param str string: the string to sanitize.
|
||||
:param set ignore_characters: characters to ignore.
|
||||
:return: the sanitized string.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
# only deal with strings
|
||||
if string is None:
|
||||
return
|
||||
|
||||
ignore_characters = ignore_characters or set()
|
||||
|
||||
# replace some characters with one space
|
||||
characters = {'-', ':', '(', ')', '.'} - ignore_characters
|
||||
if characters:
|
||||
string = re.sub(r'[%s]' % re.escape(''.join(characters)), ' ', string)
|
||||
|
||||
# remove some characters
|
||||
characters = {'\''} - ignore_characters
|
||||
if characters:
|
||||
string = re.sub(r'[%s]' % re.escape(''.join(characters)), '', string)
|
||||
|
||||
# replace multiple spaces with one
|
||||
string = re.sub(r'\s+', ' ', string)
|
||||
|
||||
# strip and lower case
|
||||
return string.strip().lower()
|
||||
|
||||
|
||||
def sanitize_release_group(string):
|
||||
"""Sanitize a `release_group` string to remove content in square brackets.
|
||||
|
||||
:param str string: the release group to sanitize.
|
||||
:return: the sanitized release group.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
# only deal with strings
|
||||
if string is None:
|
||||
return
|
||||
|
||||
# remove content in square brackets
|
||||
string = re.sub(r'\[\w+\]', '', string)
|
||||
|
||||
# strip and lower case
|
||||
return string.strip().lower()
|
||||
|
||||
|
||||
def timestamp(date):
|
||||
"""Get the timestamp of the `date`, python2/3 compatible
|
||||
|
||||
:param datetime.datetime date: the utc date.
|
||||
:return: the timestamp of the date.
|
||||
:rtype: float
|
||||
|
||||
"""
|
||||
return (date - datetime(1970, 1, 1)).total_seconds()
|
||||
@@ -1,22 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
__version__ = '0.4'
|
||||
@@ -0,0 +1,221 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import division
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import os
|
||||
|
||||
from guessit import guessit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Video extensions
|
||||
VIDEO_EXTENSIONS = ('.3g2', '.3gp', '.3gp2', '.3gpp', '.60d', '.ajp', '.asf', '.asx', '.avchd', '.avi', '.bik',
|
||||
'.bix', '.box', '.cam', '.dat', '.divx', '.dmf', '.dv', '.dvr-ms', '.evo', '.flc', '.fli',
|
||||
'.flic', '.flv', '.flx', '.gvi', '.gvp', '.h264', '.m1v', '.m2p', '.m2ts', '.m2v', '.m4e',
|
||||
'.m4v', '.mjp', '.mjpeg', '.mjpg', '.mkv', '.moov', '.mov', '.movhd', '.movie', '.movx', '.mp4',
|
||||
'.mpe', '.mpeg', '.mpg', '.mpv', '.mpv2', '.mxf', '.nsv', '.nut', '.ogg', '.ogm', '.omf', '.ps',
|
||||
'.qt', '.ram', '.rm', '.rmvb', '.swf', '.ts', '.vfw', '.vid', '.video', '.viv', '.vivo', '.vob',
|
||||
'.vro', '.wm', '.wmv', '.wmx', '.wrap', '.wvx', '.wx', '.x264', '.xvid')
|
||||
|
||||
|
||||
class Video(object):
|
||||
"""Base class for videos.
|
||||
|
||||
Represent a video, existing or not.
|
||||
|
||||
:param str name: name or path of the video.
|
||||
:param str format: format of the video (HDTV, WEB-DL, BluRay, ...).
|
||||
:param str release_group: release group of the video.
|
||||
:param str resolution: resolution of the video stream (480p, 720p, 1080p or 1080i).
|
||||
:param str video_codec: codec of the video stream.
|
||||
:param str audio_codec: codec of the main audio stream.
|
||||
:param str imdb_id: IMDb id of the video.
|
||||
:param dict hashes: hashes of the video file by provider names.
|
||||
:param int size: size of the video file in bytes.
|
||||
:param set subtitle_languages: existing subtitle languages.
|
||||
|
||||
"""
|
||||
def __init__(self, name, format=None, release_group=None, resolution=None, video_codec=None, audio_codec=None,
|
||||
imdb_id=None, hashes=None, size=None, subtitle_languages=None):
|
||||
#: Name or path of the video
|
||||
self.name = name
|
||||
|
||||
#: Format of the video (HDTV, WEB-DL, BluRay, ...)
|
||||
self.format = format
|
||||
|
||||
#: Release group of the video
|
||||
self.release_group = release_group
|
||||
|
||||
#: Resolution of the video stream (480p, 720p, 1080p or 1080i)
|
||||
self.resolution = resolution
|
||||
|
||||
#: Codec of the video stream
|
||||
self.video_codec = video_codec
|
||||
|
||||
#: Codec of the main audio stream
|
||||
self.audio_codec = audio_codec
|
||||
|
||||
#: IMDb id of the video
|
||||
self.imdb_id = imdb_id
|
||||
|
||||
#: Hashes of the video file by provider names
|
||||
self.hashes = hashes or {}
|
||||
|
||||
#: Size of the video file in bytes
|
||||
self.size = size
|
||||
|
||||
#: Existing subtitle languages
|
||||
self.subtitle_languages = subtitle_languages or set()
|
||||
|
||||
@property
|
||||
def exists(self):
|
||||
"""Test whether the video exists"""
|
||||
return os.path.exists(self.name)
|
||||
|
||||
@property
|
||||
def age(self):
|
||||
"""Age of the video"""
|
||||
if self.exists:
|
||||
return datetime.utcnow() - datetime.utcfromtimestamp(os.path.getmtime(self.name))
|
||||
|
||||
return timedelta()
|
||||
|
||||
@classmethod
|
||||
def fromguess(cls, name, guess):
|
||||
"""Create an :class:`Episode` or a :class:`Movie` with the given `name` based on the `guess`.
|
||||
|
||||
:param str name: name of the video.
|
||||
:param dict guess: guessed data.
|
||||
:raise: :class:`ValueError` if the `type` of the `guess` is invalid
|
||||
|
||||
"""
|
||||
if guess['type'] == 'episode':
|
||||
return Episode.fromguess(name, guess)
|
||||
|
||||
if guess['type'] == 'movie':
|
||||
return Movie.fromguess(name, guess)
|
||||
|
||||
raise ValueError('The guess must be an episode or a movie guess')
|
||||
|
||||
@classmethod
|
||||
def fromname(cls, name):
|
||||
"""Shortcut for :meth:`fromguess` with a `guess` guessed from the `name`.
|
||||
|
||||
:param str name: name of the video.
|
||||
|
||||
"""
|
||||
return cls.fromguess(name, guessit(name))
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s [%r]>' % (self.__class__.__name__, self.name)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.name)
|
||||
|
||||
|
||||
class Episode(Video):
|
||||
"""Episode :class:`Video`.
|
||||
|
||||
:param str series: series of the episode.
|
||||
:param int season: season number of the episode.
|
||||
:param int episode: episode number of the episode.
|
||||
:param str title: title of the episode.
|
||||
:param int year: year of the series.
|
||||
:param bool original_series: whether the series is the first with this name.
|
||||
:param int tvdb_id: TVDB id of the episode.
|
||||
:param \*\*kwargs: additional parameters for the :class:`Video` constructor.
|
||||
|
||||
"""
|
||||
def __init__(self, name, series, season, episode, title=None, year=None, original_series=True, tvdb_id=None,
|
||||
series_tvdb_id=None, series_imdb_id=None, **kwargs):
|
||||
super(Episode, self).__init__(name, **kwargs)
|
||||
|
||||
#: Series of the episode
|
||||
self.series = series
|
||||
|
||||
#: Season number of the episode
|
||||
self.season = season
|
||||
|
||||
#: Episode number of the episode
|
||||
self.episode = episode
|
||||
|
||||
#: Title of the episode
|
||||
self.title = title
|
||||
|
||||
#: Year of series
|
||||
self.year = year
|
||||
|
||||
#: The series is the first with this name
|
||||
self.original_series = original_series
|
||||
|
||||
#: TVDB id of the episode
|
||||
self.tvdb_id = tvdb_id
|
||||
|
||||
#: TVDB id of the series
|
||||
self.series_tvdb_id = series_tvdb_id
|
||||
|
||||
#: IMDb id of the series
|
||||
self.series_imdb_id = series_imdb_id
|
||||
|
||||
@classmethod
|
||||
def fromguess(cls, name, guess):
|
||||
if guess['type'] != 'episode':
|
||||
raise ValueError('The guess must be an episode guess')
|
||||
|
||||
if 'title' not in guess or 'episode' not in guess:
|
||||
raise ValueError('Insufficient data to process the guess')
|
||||
|
||||
return cls(name, guess['title'], guess.get('season', 1), guess['episode'], title=guess.get('episode_title'),
|
||||
year=guess.get('year'), format=guess.get('format'), original_series='year' not in guess,
|
||||
release_group=guess.get('release_group'), resolution=guess.get('screen_size'),
|
||||
video_codec=guess.get('video_codec'), audio_codec=guess.get('audio_codec'))
|
||||
|
||||
@classmethod
|
||||
def fromname(cls, name):
|
||||
return cls.fromguess(name, guessit(name, {'type': 'episode'}))
|
||||
|
||||
def __repr__(self):
|
||||
if self.year is None:
|
||||
return '<%s [%r, %dx%d]>' % (self.__class__.__name__, self.series, self.season, self.episode)
|
||||
|
||||
return '<%s [%r, %d, %dx%d]>' % (self.__class__.__name__, self.series, self.year, self.season, self.episode)
|
||||
|
||||
|
||||
class Movie(Video):
|
||||
"""Movie :class:`Video`.
|
||||
|
||||
:param str title: title of the movie.
|
||||
:param int year: year of the movie.
|
||||
:param \*\*kwargs: additional parameters for the :class:`Video` constructor.
|
||||
|
||||
"""
|
||||
def __init__(self, name, title, year=None, **kwargs):
|
||||
super(Movie, self).__init__(name, **kwargs)
|
||||
|
||||
#: Title of the movie
|
||||
self.title = title
|
||||
|
||||
#: Year of the movie
|
||||
self.year = year
|
||||
|
||||
@classmethod
|
||||
def fromguess(cls, name, guess):
|
||||
if guess['type'] != 'movie':
|
||||
raise ValueError('The guess must be a movie guess')
|
||||
|
||||
if 'title' not in guess:
|
||||
raise ValueError('Insufficient data to process the guess')
|
||||
|
||||
return cls(name, guess['title'], format=guess.get('format'), release_group=guess.get('release_group'),
|
||||
resolution=guess.get('screen_size'), video_codec=guess.get('video_codec'),
|
||||
audio_codec=guess.get('audio_codec'), year=guess.get('year'))
|
||||
|
||||
@classmethod
|
||||
def fromname(cls, name):
|
||||
return cls.fromguess(name, guessit(name, {'type': 'movie'}))
|
||||
|
||||
def __repr__(self):
|
||||
if self.year is None:
|
||||
return '<%s [%r]>' % (self.__class__.__name__, self.title)
|
||||
|
||||
return '<%s [%r, %d]>' % (self.__class__.__name__, self.title, self.year)
|
||||
@@ -1,152 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import unittest
|
||||
import logging
|
||||
import os
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG, format='%(name)-24s %(levelname)-8s %(message)s')
|
||||
test_folder = u'/your/path/here/videos/'
|
||||
test_file = u'/your/path/here/videos/the.big.bang.theory.s04e01.hdtv.xvid-fqm.avi'
|
||||
cache_dir = u'/tmp/sublicache'
|
||||
if not os.path.exists(cache_dir):
|
||||
os.mkdir(cache_dir)
|
||||
|
||||
|
||||
class Addic7edTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from subliminal.plugins import Addic7ed
|
||||
self.config = {'multi': True, 'cache_dir': cache_dir, 'files_mode': -1}
|
||||
self.plugin = Addic7ed(self.config)
|
||||
self.languages = set(['en', 'fr'])
|
||||
|
||||
def test_list(self):
|
||||
list = self.plugin.list(test_file, self.languages)
|
||||
assert list
|
||||
|
||||
def test_download(self):
|
||||
subtitle = self.plugin.list(test_file, self.languages)[0]
|
||||
if os.path.exists(subtitle.path):
|
||||
os.remove(subtitle.path)
|
||||
download = self.plugin.download(subtitle)
|
||||
assert download
|
||||
|
||||
|
||||
class BierDopjeTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from subliminal.plugins import BierDopje
|
||||
self.config = {'multi': True, 'cache_dir': cache_dir, 'files_mode': -1}
|
||||
self.plugin = BierDopje(self.config)
|
||||
self.languages = set(['en', 'fr'])
|
||||
|
||||
def test_list(self):
|
||||
list = self.plugin.list(test_file, self.languages)
|
||||
assert list
|
||||
|
||||
def test_download(self):
|
||||
subtitle = self.plugin.list(test_file, self.languages)[0]
|
||||
if os.path.exists(subtitle.path):
|
||||
os.remove(subtitle.path)
|
||||
download = self.plugin.download(subtitle)
|
||||
assert download
|
||||
|
||||
|
||||
class OpenSubtitlesTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from subliminal.plugins import OpenSubtitles
|
||||
self.config = {'multi': True, 'cache_dir': cache_dir, 'files_mode': -1}
|
||||
self.plugin = OpenSubtitles(self.config)
|
||||
self.languages = set(['en', 'fr'])
|
||||
|
||||
def test_list(self):
|
||||
list = self.plugin.list(test_file, self.languages)
|
||||
assert list
|
||||
|
||||
def test_download(self):
|
||||
subtitle = self.plugin.list(test_file, self.languages)[0]
|
||||
if os.path.exists(subtitle.path):
|
||||
os.remove(subtitle.path)
|
||||
download = self.plugin.download(subtitle)
|
||||
assert download
|
||||
|
||||
|
||||
class SubsWikiTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from subliminal.plugins import SubsWiki
|
||||
self.config = {'multi': True, 'cache_dir': cache_dir, 'files_mode': -1}
|
||||
self.plugin = SubsWiki(self.config)
|
||||
self.languages = set(['en', 'fr', 'es', 'pt'])
|
||||
|
||||
def test_list(self):
|
||||
list = self.plugin.list(test_file, self.languages)
|
||||
assert list
|
||||
|
||||
def test_download(self):
|
||||
subtitle = self.plugin.list(test_file, self.languages)[0]
|
||||
if os.path.exists(subtitle.path):
|
||||
os.remove(subtitle.path)
|
||||
download = self.plugin.download(subtitle)
|
||||
assert download
|
||||
|
||||
|
||||
class SubtitulosTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from subliminal.plugins import Subtitulos
|
||||
self.config = {'multi': True, 'cache_dir': cache_dir, 'files_mode': -1}
|
||||
self.plugin = Subtitulos(self.config)
|
||||
self.languages = set(['en', 'fr', 'es', 'pt'])
|
||||
|
||||
def test_list(self):
|
||||
list = self.plugin.list(test_file, self.languages)
|
||||
assert list
|
||||
|
||||
def test_download(self):
|
||||
subtitle = self.plugin.list(test_file, self.languages)[0]
|
||||
if os.path.exists(subtitle.path):
|
||||
os.remove(subtitle.path)
|
||||
download = self.plugin.download(subtitle)
|
||||
assert download
|
||||
|
||||
|
||||
class TheSubDBTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from subliminal.plugins import TheSubDB
|
||||
self.config = {'multi': True, 'cache_dir': cache_dir, 'files_mode': -1}
|
||||
self.plugin = TheSubDB(self.config)
|
||||
self.languages = set(['en', 'fr', 'es', 'pt'])
|
||||
|
||||
def test_list(self):
|
||||
list = self.plugin.list(test_file, self.languages)
|
||||
assert list
|
||||
|
||||
def test_download(self):
|
||||
subtitle = self.plugin.list(test_file, self.languages)[0]
|
||||
if os.path.exists(subtitle.path):
|
||||
os.remove(subtitle.path)
|
||||
download = self.plugin.download(subtitle)
|
||||
assert download
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Subliminal - Subtitles, faster than your thoughts
|
||||
# Copyright (c) 2011 Antoine Bertin <diaoulael@gmail.com>
|
||||
#
|
||||
# This file is part of Subliminal.
|
||||
#
|
||||
# Subliminal is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the Lesser GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Subliminal is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# Lesser GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the Lesser GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
import unittest
|
||||
import logging
|
||||
import os
|
||||
import subliminal
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG, format='%(name)-24s %(levelname)-8s %(message)s')
|
||||
test_folder = u'/your/path/here/videos/'
|
||||
test_file = u'/your/path/here/videos/the.big.bang.theory.s04e01.hdtv.xvid-fqm.avi'
|
||||
cache_dir = u'/tmp/sublicache'
|
||||
if not os.path.exists(cache_dir):
|
||||
os.mkdir(cache_dir)
|
||||
|
||||
|
||||
class FileTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.subli = subliminal.Subliminal(cache_dir=cache_dir, workers=4, multi=False, force=True, max_depth=3, files_mode=-1)
|
||||
self.subli.languages = ['en', 'fr', 'es', 'pt']
|
||||
self.subli.plugins = subliminal.PLUGINS
|
||||
|
||||
def test_list(self):
|
||||
results = self.subli.listSubtitles(test_file)
|
||||
self.assertTrue(len(results) > 0)
|
||||
|
||||
def test_download(self):
|
||||
results = self.subli.downloadSubtitles(test_file)
|
||||
self.assertTrue(len(results) > 0)
|
||||
|
||||
|
||||
class ErrorTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.subli = subliminal.Subliminal(cache_dir=cache_dir, workers=4, multi=False, force=True, max_depth=3, files_mode=-1)
|
||||
|
||||
def test_language(self):
|
||||
with self.assertRaises(subliminal.classes.LanguageError):
|
||||
self.subli.languages = ['en', 'fr', 'zz', 'pt']
|
||||
|
||||
def test_plugin(self):
|
||||
with self.assertRaises(subliminal.classes.PluginError):
|
||||
self.subli.plugins = ['WrongPlugin']
|
||||
|
||||
|
||||
class PriorityQueueTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.subli = subliminal.Subliminal(cache_dir=cache_dir, workers=4, multi=False, force=True, max_depth=3, files_mode=-1)
|
||||
self.subli.languages = ['en', 'fr', 'es', 'pt']
|
||||
self.subli.plugins = subliminal.PLUGINS
|
||||
|
||||
def test_bad_state_error(self):
|
||||
with self.assertRaises(subliminal.classes.BadStateError):
|
||||
self.subli.startWorkers()
|
||||
results = self.subli.listSubtitles(test_folder)
|
||||
self.subli.stopWorkers()
|
||||
|
||||
def test_manual_list(self):
|
||||
self.subli.taskQueue.put((5, subliminal.classes.ListTask(test_file, set(self.subli.languages), 'OpenSubtitles', self.subli.getConfigDict())))
|
||||
self.subli.startWorkers()
|
||||
# parallel stuff...
|
||||
self.subli.stopWorkers()
|
||||
result = self.subli.listResultQueue.get()
|
||||
self.assertTrue(len(result) > 0)
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
interactions:
|
||||
- request:
|
||||
body: username=subliminal&password=subliminal&Submit=Log+in
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
Content-Length: ['53']
|
||||
Content-Type: [application/x-www-form-urlencoded]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: POST
|
||||
uri: http://www.addic7ed.com/dologin.php
|
||||
response:
|
||||
body: {string: !!python/unicode "\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0\
|
||||
\ Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\"\
|
||||
>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n<meta http-equiv=\"\
|
||||
Content-Type\" content=\"text/html; charset=utf-8\" />\n<title>Addic7ed.com\
|
||||
\ - For all those TV Series Addic7s: Subtitles, Tv Series and Movies Talk,\
|
||||
\ Forum and more -</title>\n<link href=\"css/wikisubtitles.css\" rel=\"stylesheet\"\
|
||||
\ title=\"default\" type=\"text/css\" media=\"screen\"/>\n</head>\n\n<body>\n\
|
||||
<center><br />\n<table border=\"0\">\n<tr>\n <td rowspan=\"9\"><a href=\"\
|
||||
/\"><img height=\"200\" width=\"350\" src=\"http://www.addic7ed.com/images/addic7edheader.jpg\"\
|
||||
\ border=\"0\" title=\"Addic7ed.com - Quality Subtitles for TV Shows and\
|
||||
\ movies\" alt=\"Addic7ed.com - Quality Subtitles for TV Shows and movies\"\
|
||||
\ /></a></td>\n</tr>\n<tr><td align=\"center\" colspan=\"2\">\n<h1><small>Download\
|
||||
\ free subtitles for TV Shows and Movies.</small> \n<select name=\"\
|
||||
applang\" class=\"inputCool\" onchange=\"changeAppLang();\" id=\"comboLang\"\
|
||||
><option value=\"ar\">Arabic</option><option value=\"ca\">Catala</option><option\
|
||||
\ selected=\"selected\" value=\"en\">English</option><option value=\"eu\"\
|
||||
>Euskera</option><option value=\"fr\">French</option><option value=\"ga\"\
|
||||
>Galician</option><option value=\"de\">German</option><option value=\"gr\"\
|
||||
>Greek</option><option value=\"hu\">Hungarian</option><option value=\"it\"\
|
||||
>Italian</option><option value=\"fa\">Persian</option><option value=\"pl\"\
|
||||
>Polish</option><option value=\"pt\">Portuguese</option><option value=\"br\"\
|
||||
>Portuguese (Brazilian)</option><option value=\"ro\">Romanian</option><option\
|
||||
\ value=\"ru\">Russian</option><option value=\"es\">Spanish</option><option\
|
||||
\ value=\"se\">Swedish</option></select></h1>\n</td></tr>\n<tr><td align=\"\
|
||||
center\" colspan=\"2\">\n\n<script language=\"javascript\">\nvar url=\"/msgspopup.php?count=1\"\
|
||||
;\t\teditwin = window.open(url, \"msgswin\", 'height=200,width=350,toolbar=0,location=0,statusbar=0,menubar=0');\
|
||||
\ \n\t\tif (editwin.focus) {editwin.focus()}\n</script>\n<div id=\"hBar\"\
|
||||
>\n\t\t\t <ul>\n\t\t\t\t<li><a class=\"button white\" href=\"/panel.php\"\
|
||||
>My Panel</a></li><li><a class=\"button white\" href=\"/newsub.php\">Upload</a></li>\t\
|
||||
\t\t<li><a class=\"button white\" href=\"/shows.php\">Shows</a></li>\n\t\t\
|
||||
\t\t<li><a class=\"button white\" href=\"/allshows/a\">Air dates</a></li>\n\
|
||||
\t\t\t\t<li><a class=\"button white\" href=\"http://www.sub-talk.net\">Forum</a></li>\n\
|
||||
\t\t\t\t<li><a class=\"button white\" href=\"/logout.php\">Logout</a></li>\n\
|
||||
\t\t\t </ul>\n\t\t\t </div>\n</td></tr> \n<tr>\n <td>\n</td><td>\n\t<g:plusone\
|
||||
\ size=\"medium\"></g:plusone>\n <a href=\"http://twitter.com/addic7ed\"\
|
||||
\ target=\"_blank\"><img width=\"32\" height=\"32\" src=\"http://www.addic7ed.com/images/twitter_right.png\"\
|
||||
\ alt=\"Twitter\" border=\"0\" /></a>\n\t<a href=\"irc://irc.efnet.net:6667/addic7ed\"\
|
||||
><img width=\"32\" height=\"32\" src=\"http://www.addic7ed.com/images/irc-right.png\"\
|
||||
\ alt=\"IRC\" border=\"0\" /></a>\n<div style=\"float: right; padding-right:10%;\"\
|
||||
>\n\n </td>\n </tr>\n <tr>\n <td colspan=2><iframe src=\"http://www.facebook.com/plugins/like.php?href=https%3A%2F%2Fwww.facebook.com%2FAddic7ed&send=false&layout=button_count&width=450&show_faces=false&action=like&colorscheme=light&font=tahoma&height=21&appId=121322186712\"\
|
||||
\ scrolling=\"no\" frameborder=\"0\" style=\"border:none; overflow:hidden;\
|
||||
\ width:80px; height:21px;\" allowTransparency=\"true\"></iframe>\n </td>\n\
|
||||
\ </tr>\n</table>\n</center>\n\n<center>\n\n<!--[if lt IE 7]>\n <style type=\"\
|
||||
text/css\">\n div, img { behavior: url(http://www.addic7ed.com/js/iepngfix.htc)\
|
||||
\ }\n </style>\n<![endif]-->\n\n<center><table border=\"0\" width=\"90%\"\
|
||||
>\n<tr>\n<td class=\"NewsTitle\"><img width=\"20\" height=\"20\" src=\"http://www.addic7ed.com/images/television.png\"\
|
||||
\ alt=\"TV\" /><img src=\"http://www.addic7ed.com/images/invisible.gif\" alt=\"\
|
||||
\ \" />Addic7ed</td>\n<td class=\"NewsTitle\"><img width=\"20\" height=\"\
|
||||
20\" src=\"http://www.addic7ed.com/images/television.png\" alt=\"TV\" /><img\
|
||||
\ src=\"http://www.addic7ed.com/images/invisible.gif\" alt=\" \" />Popular\
|
||||
\ Shows</td>\n<td class=\"NewsTitle\"><img width=\"20\" height=\"20\" src=\"\
|
||||
http://www.addic7ed.com/images/television.png\" alt=\"TV\" /><img src=\"http://www.addic7ed.com/images/invisible.gif\"\
|
||||
\ alt=\" \" />Useful</td>\n<td class=\"NewsTitle\"><img width=\"20\" height=\"\
|
||||
20\" src=\"http://www.addic7ed.com/images/television.png\" alt=\"TV\" /><img\
|
||||
\ src=\"http://www.addic7ed.com/images/invisible.gif\" alt=\" \" />Forums</td>\n\
|
||||
</tr>\n<tr>\n<td><div id=\"footermenu\"><a href=\"/shows.php\">Browse By Shows</a></div></td>\n\
|
||||
<td><div id=\"footermenu\"><a href=\"/show/4906\">12 Monkeys</a></div></td>\n\
|
||||
<td><div id=\"footermenu\"><a href=\"/shows-schedule\">TV Shows Schedule</a></div></td>\n\
|
||||
<td><div id=\"footermenu\"><a href=\"http://www.sub-talk.net/topic/1031-changelog/\"\
|
||||
>Site Changelog</a></div></td>\n</tr>\n<tr>\n<td><div id=\"footermenu\"><a\
|
||||
\ href=\"/movie-subtitles\">Browse By Movies</a></div></td>\n<td><div id=\"\
|
||||
footermenu\"><a href=\"/show/1812\">Homeland</a></div></td>\n<td><div id=\"\
|
||||
footermenu\"><a href=\"http://www.sub-talk.net/topic/2784-frequently-asked-questions/\"\
|
||||
>Frequently Asked Questions</a></div></td>\n<td><div id=\"footermenu\">Support\
|
||||
\ Us</div></td>\n</tr>\n<tr>\n<td><div id=\"footermenu\"><a href=\"/top-uploaders\"\
|
||||
>Top Uploaders</a></div></td>\n<td><div id=\"footermenu\"><a href=\"/show/620\"\
|
||||
>Modern Family</a></div></td>\n<td><div id=\"footermenu\">RSS Feeds</div></td>\n\
|
||||
<td><div id=\"footermenu\">Premium Accounts</div></td>\n</tr>\n<tr>\n<td><div\
|
||||
\ id=\"footermenu\"><a href=\"/log.php?mode=downloaded\">Top Downloads</a></div></td>\n\
|
||||
<td><div id=\"footermenu\"><a href=\"/show/466\">Glee</a></div></td>\n<td\
|
||||
\ class=\"NewsTitle\"><img width=\"20\" height=\"20\" src=\"http://www.addic7ed.com/images/television.png\"\
|
||||
\ alt=\"TV\" /><img src=\"http://www.addic7ed.com/images/invisible.gif\" alt=\"\
|
||||
\ \"/>Tutorials</td>\n<td><div id=\"footermenu\"><a href=\"http://sub-talk.net/thread-6-1-1.html\"\
|
||||
>Video Formats</a></div></td>\n</tr>\n<tr>\n<td><div id=\"footermenu\"><a\
|
||||
\ href=\"/log.php?mode=news\">All News</a></div></td>\n<td><div id=\"footermenu\"\
|
||||
><a href=\"/show/450\">Parks and Recreation</a></div></td>\n<td><div id=\"\
|
||||
footermenu\"><a href=\"http://www.sub-talk.net/topic/338-guide-to-syncing-with-subtitleedit/page__p__1485__hl__%2B+%2Bsync__fromsearch__1#entry1485\"\
|
||||
>How to Synchronize Subtitles</a></div></td>\n<td><div id=\"footermenu\">Frequently\
|
||||
\ Asked Questions</div></td>\n</tr> \n<tr>\n<td><div id=\"footermenu\"><a\
|
||||
\ href=\"http://www.sub-talk.net\">Sub-Talk Forums</a></div></td>\n<td><div\
|
||||
\ id=\"footermenu\"><a href=\"/show/1277\">Shameless (US)</a></div></td>\n\
|
||||
<td><div id=\"footermenu\">What Are Subtitles</div></td>\n<td><div id=\"footermenu\"\
|
||||
><a href=\"http://sub-talk.net/index.php?gid=7\">TV Shows Talk</a></div></td>\n\
|
||||
</tr>\n<tr>\n<td><div id=\"footermenu\"><a href=\"/latest_comments.php\">Latest\
|
||||
\ Comments</a></div></td>\n<td><div id=\"footermenu\"><a href=\"/show/126\"\
|
||||
>The Big Bang Theory</a></div></td>\n<td><div id=\"footermenu\">New Translation\
|
||||
\ Tutorial</div></td>\n<td><div id=\"footermenu\"><a href=\"http://sub-talk.net/index.php?gid=22\"\
|
||||
>Movies Talk</a></div></td>\n</tr>\n<tr>\n<td><div id=\"footermenu\"><a href=\"\
|
||||
http://www.vreaubagaj.ro/troler/\" title=\"Trolere ieftine\" alt=\"Trolere\
|
||||
\ ieftine\">Trolere ieftine</a></div></td>\n<td><div id=\"footermenu\"><a\
|
||||
\ href=\"/show/130\">Family Guy</a></div></td>\n<td><div id=\"footermenu\"\
|
||||
>Upload a New Subtitle Tutorial</div></td>\n<td class=\"NewsTitle\"><img width=\"\
|
||||
20\" height=\"20\" src=\"http://www.addic7ed.com/images/television.png\" alt=\"\
|
||||
TV\" /><img src=\"http://www.addic7ed.com/images/invisible.gif\" alt=\" \"\
|
||||
\ />Stats</td>\n</tr>\n<tr>\n<td><div id=\"footermenu\">Terms of Service</div></td>\n\
|
||||
<td><div id=\"footermenu\"><a href=\"/show/1799\">American Horror Story</a></div></td>\n\
|
||||
<td><div id=\"footermenu\"><a href=\"http://sub-talk.net/viewthread.php?tid=294\"\
|
||||
>How to have an Avatar</a></div></td>\n<td align=\"left\">.\n\t\t\t\t</td>\n\
|
||||
</tr>\n<tr>\n<td><div id=\"footermenu\"><a href=\"/contact.php\">Contact</a></div></td>\n\
|
||||
<td><div id=\"footermenu\"><a href=\"/show/15\">House</a></div></td>\n<td><div\
|
||||
\ id=\"footermenu\"><a href=\"http://www.vreaubagaj.ro/\" alt=\"Trolere\"\
|
||||
\ title=\"Trolere\">Trolere</a></div></td>\n<td>\n</td>\n</tr>\n</table></center>\n\
|
||||
</center>\n\n<script type=\"text/javascript\">\nvar gaJsHost = ((\"https:\"\
|
||||
\ == document.location.protocol) ? \"https://ssl.\" : \"http://www.\");\n\
|
||||
document.write(unescape(\"%3Cscript src='\" + gaJsHost + \"google-analytics.com/ga.js'\
|
||||
\ type='text/javascript'%3E%3C/script%3E\"));\n</script>\n<script type=\"\
|
||||
text/javascript\">\ntry {\nvar pageTracker = _gat._getTracker(\"UA-10775680-1\"\
|
||||
);\npageTracker._trackPageview();\n} catch(err) {}</script>\n\n\n<script type=\"\
|
||||
text/javascript\" src=\"http://apis.google.com/js/plusone.js\"></script>\n\
|
||||
\ \
|
||||
\ \n</body>\n</html>\n"}
|
||||
headers:
|
||||
cache-control: ['no-store, no-cache, must-revalidate, post-check=0, pre-check=0']
|
||||
connection: [keep-alive]
|
||||
content-type: [text/html]
|
||||
date: ['Tue, 09 Feb 2016 15:57:53 GMT']
|
||||
expires: ['Thu, 19 Nov 1981 08:52:00 GMT']
|
||||
location: [/]
|
||||
pragma: [no-cache]
|
||||
server: [nginx]
|
||||
set-cookie: [PHPSESSID=rk916i9m3r5gl5hmvs37l6m5p0; path=/]
|
||||
x-powered-by: [PHP/5.3.3]
|
||||
status: {code: 302, message: Moved Temporarily}
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
Cookie: [PHPSESSID=rk916i9m3r5gl5hmvs37l6m5p0]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: http://www.addic7ed.com/panel.php
|
||||
response:
|
||||
body:
|
||||
string: !!binary |
|
||||
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
|
||||
headers:
|
||||
cache-control: ['no-store, no-cache, must-revalidate, post-check=0, pre-check=0']
|
||||
connection: [keep-alive]
|
||||
content-encoding: [gzip]
|
||||
content-type: [text/html]
|
||||
date: ['Tue, 09 Feb 2016 15:57:53 GMT']
|
||||
expires: ['Thu, 19 Nov 1981 08:52:00 GMT']
|
||||
pragma: [no-cache]
|
||||
server: [nginx]
|
||||
x-powered-by: [PHP/5.3.3]
|
||||
status: {code: 200, message: OK}
|
||||
version: 1
|
||||
@@ -0,0 +1,75 @@
|
||||
interactions:
|
||||
- request:
|
||||
body: username=subliminal&password=lanimilbus&Submit=Log+in
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
Content-Length: ['53']
|
||||
Content-Type: [application/x-www-form-urlencoded]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: POST
|
||||
uri: http://www.addic7ed.com/dologin.php
|
||||
response:
|
||||
body:
|
||||
string: !!binary |
|
||||
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
|
||||
headers:
|
||||
cache-control: ['no-store, no-cache, must-revalidate, post-check=0, pre-check=0']
|
||||
connection: [keep-alive]
|
||||
content-encoding: [gzip]
|
||||
content-type: [text/html]
|
||||
date: ['Tue, 09 Feb 2016 15:57:53 GMT']
|
||||
expires: ['Thu, 19 Nov 1981 08:52:00 GMT']
|
||||
pragma: [no-cache]
|
||||
server: [nginx]
|
||||
set-cookie: [PHPSESSID=4ep7cufhhkte78hs23ijqj12u4; path=/]
|
||||
x-powered-by: [PHP/5.3.3]
|
||||
status: {code: 200, message: OK}
|
||||
version: 1
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
interactions:
|
||||
- request:
|
||||
body: null
|
||||
headers:
|
||||
Accept: ['*/*']
|
||||
Accept-Encoding: ['gzip, deflate']
|
||||
Connection: [keep-alive]
|
||||
User-Agent: [Subliminal/2.0]
|
||||
method: GET
|
||||
uri: http://www.addic7ed.com/search.php?search=The+Big+Bang+Theory&Submit=Search
|
||||
response:
|
||||
body:
|
||||
string: !!binary |
|
||||
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
|
||||
headers:
|
||||
cache-control: ['no-store, no-cache, must-revalidate, post-check=0, pre-check=0']
|
||||
connection: [keep-alive]
|
||||
content-encoding: [gzip]
|
||||
content-type: [text/html]
|
||||
date: ['Tue, 09 Feb 2016 15:57:54 GMT']
|
||||
expires: ['Thu, 19 Nov 1981 08:52:00 GMT']
|
||||
pragma: [no-cache]
|
||||
server: [nginx]
|
||||
set-cookie: [PHPSESSID=vtudob3ke7abdfsgve42bidls4; path=/]
|
||||
x-powered-by: [PHP/5.3.3]
|
||||
status: {code: 200, message: OK}
|
||||
version: 1
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user