Compare commits
799 commits
master
...
translatio
Author | SHA1 | Date | |
---|---|---|---|
|
fdffc53d75 | ||
|
27c2aa4d08 | ||
|
c0a600ef46 | ||
|
e225be2d40 | ||
|
8f30688e18 | ||
|
16ad89f7d4 | ||
|
790add3911 | ||
|
4c03ee943f | ||
|
9dc9c9c15f | ||
|
d118ef1d04 | ||
|
66fe5e2ea5 | ||
|
323f9c27bc | ||
|
693bb5f152 | ||
|
fcec9683f7 | ||
|
e79f9fcc3c | ||
|
a1c916089f | ||
|
b161aca55f | ||
|
49fce53bfc | ||
|
09bd646dc1 | ||
|
f61c5d16ee | ||
|
947bf57b91 | ||
|
8f5786a044 | ||
|
c10d556d12 | ||
|
9147cd5453 | ||
|
d89ff61c1b | ||
|
011134d0ca | ||
|
5cdcbae830 | ||
|
1488276269 | ||
|
b0eb7d88f1 | ||
|
08cc184772 | ||
|
46db59ebd4 | ||
|
a947997f8f | ||
|
d743ebd39c | ||
|
825ef6cab4 | ||
|
74bc85ba47 | ||
|
d050b5b04d | ||
|
eaf2445e1e | ||
|
47f6ac1133 | ||
|
59e3937e4a | ||
|
dcdf9c615d | ||
|
06de0c7994 | ||
|
253eb2474b | ||
|
af57dedc80 | ||
|
dbfb0e9c63 | ||
|
af0ffc79c2 | ||
|
8e5dc549c9 | ||
|
ecbf5172e1 | ||
|
b987de646d | ||
|
f0f86a516c | ||
|
75c851ba03 | ||
|
5ec86bd0f6 | ||
|
654d424486 | ||
|
60bef060be | ||
|
f54418d08c | ||
|
5ab52e6347 | ||
|
e7f75bee03 | ||
|
7aa9b6c4a1 | ||
|
4a6fe4ba53 | ||
|
3cceb7ec48 | ||
|
01c932b792 | ||
|
8deed5179e | ||
|
b9d53ff94c | ||
|
7638564e13 | ||
|
c68710c650 | ||
|
fcc981c216 | ||
|
15ac259eec | ||
|
e57f1661bc | ||
|
2e1b9f4e0c | ||
|
58fdcc00fe | ||
|
0a2a1c4041 | ||
|
a7a3cb0ace | ||
|
a2867d2806 | ||
|
8ae7eac24a | ||
|
12b6fd8efd | ||
|
fae3647407 | ||
|
056285f7ae | ||
|
bc36231454 | ||
|
8f7cfb8178 | ||
|
848e155122 | ||
|
9727899d11 | ||
|
571289b880 | ||
|
b10309837a | ||
|
70052d77b5 | ||
|
62423838b8 | ||
|
0fd65b7ee6 | ||
|
2e9935dbd7 | ||
|
4360548e5e | ||
|
fe952afa3e | ||
|
d7178e278a | ||
|
361f38f334 | ||
|
eb4d6b000d | ||
|
0407a75c79 | ||
|
b29879006e | ||
|
10b7f33078 | ||
|
fa6d95aa61 | ||
|
c33565af4c | ||
|
80fdc999a9 | ||
|
7bc1fea65b | ||
|
a2bc3fe502 | ||
|
ac758c730d | ||
|
4eec33993c | ||
|
8cf6a7ddf7 | ||
|
183807c755 | ||
|
251554182e | ||
|
8bd51b68a4 | ||
|
5b2cab5b48 | ||
|
26931a92ea | ||
|
0f292c2799 | ||
|
849530de4f | ||
|
5915bbdb83 | ||
|
3c4865c425 | ||
|
b421b21375 | ||
|
64c6afad92 | ||
|
5e9cc78d71 | ||
|
e6fe9a9fb4 | ||
|
9fdab58cdb | ||
|
9d2c8bbb10 | ||
|
6983c89e5f | ||
|
4cbb1dc7bc | ||
|
1dd201e46e | ||
|
4ff15db2af | ||
|
fcfb18226b | ||
|
9a178d9545 | ||
|
06db33c349 | ||
|
4dc42ce7cf | ||
|
4d930b16e2 | ||
|
1808d0b609 | ||
|
2443bbdd50 | ||
|
df89419a25 | ||
|
92576875e4 | ||
|
96df618447 | ||
|
bce36b10ee | ||
|
a577850cbd | ||
|
8619e87220 | ||
|
6c3a7e5204 | ||
|
7b9d35e028 | ||
|
6dc08d6e6e | ||
|
1fc99a3996 | ||
|
5c986652e0 | ||
|
6fa0d51354 | ||
|
d8af31e519 | ||
|
674151bd2a | ||
|
f16e77afa9 | ||
|
203a55e0df | ||
|
763cc8ebd3 | ||
|
1f17a67724 | ||
|
1fd00fc9b4 | ||
|
2267256b5d | ||
|
a3ff330988 | ||
|
d08a410d36 | ||
|
6dc5cf6155 | ||
|
4c1534d2fa | ||
|
a2203e11c1 | ||
|
14ff8c587f | ||
|
fb0358ae09 | ||
|
a6bb30a790 | ||
|
ca09247c80 | ||
|
f90821ddd7 | ||
|
c606595816 | ||
|
278c547df0 | ||
|
3aa4d47726 | ||
|
6b8fd018f3 | ||
|
209c2fc232 | ||
|
d3b7297010 | ||
|
93bbc7c26b | ||
|
151f48417c | ||
|
4ae0501a89 | ||
|
50ab018da2 | ||
|
f905376a7f | ||
|
3532953b81 | ||
|
80ae07dbc6 | ||
|
021b235211 | ||
|
e1c663949d | ||
|
48c46d6a47 | ||
|
0982c6e381 | ||
|
af18885254 | ||
|
8060eccc9f | ||
|
abeddbf4c1 | ||
|
814bc38fd5 | ||
|
d407a4818c | ||
|
b6f13482fd | ||
|
afa2463a95 | ||
|
7ea89a39d5 | ||
|
3b38ebef19 | ||
|
1d257a78d7 | ||
|
cf2cdb0de5 | ||
|
8c667885dd | ||
|
318c01deb9 | ||
|
f8fe6b6659 | ||
|
ccfb32bc0d | ||
|
d344a36b6e | ||
|
277774f73f | ||
|
b5965c3ac5 | ||
|
ffeac69718 | ||
|
b229f4d2a9 | ||
|
f4ab7f8fd8 | ||
|
e07f5ffa53 | ||
|
a487ed2697 | ||
|
ab53fdba94 | ||
|
54566f9763 | ||
|
02a13a80e8 | ||
|
ef0dbfdc49 | ||
|
9e74600acb | ||
|
f733c677d5 | ||
|
a62fedcb71 | ||
|
797b6f51a7 | ||
|
a5af348b1c | ||
|
c0ae00ac9a | ||
|
c57d28c483 | ||
|
bf65de2806 | ||
|
7472d1e5df | ||
|
281145dc68 | ||
|
940816b327 | ||
|
aca3b25ed9 | ||
|
d337cf7fca | ||
|
956e445441 | ||
|
648090a516 | ||
|
4b27509102 | ||
|
7d65e3d7fc | ||
|
984d4baace | ||
|
03137c8d9e | ||
|
5132042498 | ||
|
8e9be8d7f5 | ||
|
ff1366a916 | ||
|
b0779499ab | ||
|
6bcfb528f0 | ||
|
27a0a50c72 | ||
|
2736979856 | ||
|
6da8e913c2 | ||
|
bce06583f3 | ||
|
c1b7babe34 | ||
|
990e466a22 | ||
|
74373d5e88 | ||
|
e63dba2aa5 | ||
|
3ac93455ea | ||
|
f5cf8c51b9 | ||
|
77ff5e0ae6 | ||
|
c3e15e83c0 | ||
|
c7ef5bb6a6 | ||
|
6dd0a448b3 | ||
|
b6b9798c26 | ||
|
890bf1f0a2 | ||
|
523a29be5e | ||
|
d58dec404f | ||
|
1f5fa804e5 | ||
|
5856c11438 | ||
|
d606d80052 | ||
|
0a1727496e | ||
|
bef1339cf3 | ||
|
b6bde683c3 | ||
|
940f077cb7 | ||
|
c51aaa4267 | ||
|
9a70416ba8 | ||
|
0e5ebf45de | ||
|
145bb743e3 | ||
|
37e7c22309 | ||
|
e77532ac28 | ||
|
52af6f09b7 | ||
|
663cb17bb6 | ||
|
fefd47d8a8 | ||
|
9615feb135 | ||
|
b30e7ae9fa | ||
|
5311884c73 | ||
|
4fbe690993 | ||
|
08d4eb63cb | ||
|
8276f2897a | ||
|
6768f57f9e | ||
|
32db8224a6 | ||
|
c58394d894 | ||
|
bd4ea41af3 | ||
|
5585ac6a24 | ||
|
210c7c5d87 | ||
|
4bb02f7afd | ||
|
9b1730ff4b | ||
|
6cfe6842e7 | ||
|
c68571db4f | ||
|
2df8ae75c8 | ||
|
572bb4cb97 | ||
|
a4ef15df45 | ||
|
38223df931 | ||
|
c13eb94075 | ||
|
3124de6a46 | ||
|
48b2294cf2 | ||
|
585c95d781 | ||
|
7d14fe8e93 | ||
|
a88c925b6e | ||
|
04be54c428 | ||
|
a17b2b8f81 | ||
|
92d3ac2394 | ||
|
ddc27de1a9 | ||
|
3b3e7db05b | ||
|
bdb9cdceb1 | ||
|
09b279ea2a | ||
|
791f711e57 | ||
|
db8335e400 | ||
|
53a2f491e3 | ||
|
eb92e8ae65 | ||
|
e47e4ce618 | ||
|
e50c1a49fe | ||
|
059c1f0169 | ||
|
774e9cbf02 | ||
|
49971417c4 | ||
|
1b9cc47781 | ||
|
ec44ef82b7 | ||
|
036abbc14c | ||
|
4a3aecdc03 | ||
|
72dfa7bb2d | ||
|
2960a64ead | ||
|
99748c5905 | ||
|
0849f8413b | ||
|
73f12d725b | ||
|
650b53a5cb | ||
|
38a5840221 | ||
|
8d311866d8 | ||
|
fecb532f92 | ||
|
7227963c83 | ||
|
b009d8661b | ||
|
11e5e0e2ee | ||
|
ba955cde70 | ||
|
b517c706f5 | ||
|
54b9982ea0 | ||
|
b7ff805bb0 | ||
|
4a3af53507 | ||
|
763567277a | ||
|
bf7fc7583a | ||
|
3d9f486ae2 | ||
|
0721aa9062 | ||
|
103082dc1d | ||
|
f0bf8d122c | ||
|
bc0aa70e40 | ||
|
ff5b360d2b | ||
|
5c9be50c5e | ||
|
e60effa948 | ||
|
713804cca6 | ||
|
21705fe3b0 | ||
|
414b700267 | ||
|
e4ca6535e0 | ||
|
5777bb8073 | ||
|
9f1fe23687 | ||
|
f5b70b1f9f | ||
|
a0f2334e7b | ||
|
312e1e667c | ||
|
69ff8ac530 | ||
|
7f3e787652 | ||
|
b04437a274 | ||
|
53a344bb91 | ||
|
5f300de112 | ||
|
b92dd90921 | ||
|
bcc2cc59ef | ||
|
f36a2ad85b | ||
|
817e1eda84 | ||
|
89df1e9190 | ||
|
2c06f1b087 | ||
|
282787aedf | ||
|
e742090b04 | ||
|
b47f8f4798 | ||
|
8a93d43ac1 | ||
|
98781f05e9 | ||
|
40dc343fbc | ||
|
38408446a7 | ||
|
f6099573be | ||
|
d9d9623189 | ||
|
015b7c2a4c | ||
|
5e7bd364cc | ||
|
f54b71375b | ||
|
139bb77ef5 | ||
|
68da99c5f1 | ||
|
25211be097 | ||
|
b3270bbfaa | ||
|
efc0a87cce | ||
|
2f87c9705a | ||
|
6e538eb00c | ||
|
53c57af08b | ||
|
a911617273 | ||
|
0c6ae7f368 | ||
|
0ab114e5d4 | ||
|
dff4c218d2 | ||
|
4fb6f9bc7a | ||
|
6073b9db09 | ||
|
82325e3827 | ||
|
c59720f104 | ||
|
90a6c4a497 | ||
|
28cfddc5cb | ||
|
2d86383c5a | ||
|
3d3255d365 | ||
|
09237a2c2f | ||
|
1d424cf547 | ||
|
fad5b69551 | ||
|
91e3d24658 | ||
|
d8cc3acf36 | ||
|
1e90a3d888 | ||
|
bd4e97a5de | ||
|
542bc98802 | ||
|
ef0c4f2ee3 | ||
|
f839d0aee6 | ||
|
7dade0429e | ||
|
9b220e4b17 | ||
|
3667b1dc62 | ||
|
2601ef29d7 | ||
|
e6ab88a323 | ||
|
cd7bbf6949 | ||
|
7e4a15bd0f | ||
|
9c4820ab51 | ||
|
07955b784a | ||
|
e207c9b48f | ||
|
076264e1c0 | ||
|
8ba867762b | ||
|
c43f6dad26 | ||
|
305f0d44ba | ||
|
95350af7eb | ||
|
e707bea310 | ||
|
b54f87c01f | ||
|
3adf890b96 | ||
|
d32307d264 | ||
|
e2bad22b0e | ||
|
bf15602569 | ||
|
d9b3bdf140 | ||
|
b656c0da2b | ||
|
423305700a | ||
|
19bf9b63a8 | ||
|
db82920db3 | ||
|
999611285d | ||
|
3c4c12cddd | ||
|
11d89c588a | ||
|
f3fe69aca8 | ||
|
f45fec36ce | ||
|
ed77081a81 | ||
|
bd1ceb1804 | ||
|
d57d55d070 | ||
|
98a5357bda | ||
|
7e507da1b4 | ||
|
2f92e1bc45 | ||
|
100e8d3099 | ||
|
f4b5e0a449 | ||
|
d3b1a65a7d | ||
|
66670fdcb5 | ||
|
0c9967f75a | ||
|
140693b288 | ||
|
339a0f0e60 | ||
|
f2a16cba02 | ||
|
f73596c704 | ||
|
1cf8e6e0ac | ||
|
38f03fd98c | ||
|
902556f40f | ||
|
2646ca32df | ||
|
d115d69a5e | ||
|
a5e1704fe4 | ||
|
cbf14b8724 | ||
|
688b625e13 | ||
|
a30b29a7ed | ||
|
8868fdebb8 | ||
|
556dbcaf1f | ||
|
57d4d175e0 | ||
|
bd8b238001 | ||
|
cb6715ad43 | ||
|
d535043559 | ||
|
8b5b79331a | ||
|
73cde8e262 | ||
|
28d114cb14 | ||
|
5bd0dfeea8 | ||
|
bb864dd6c8 | ||
|
46a95217ee | ||
|
19bbb0c0a6 | ||
|
bc77e56f31 | ||
|
a8ce822da6 | ||
|
a28c0121cf | ||
|
baf4bc43d9 | ||
|
d603f4ee3b | ||
|
f3b6c1cda6 | ||
|
9e112a79b0 | ||
|
968de605c2 | ||
|
54f9d5ece9 | ||
|
70034af9df | ||
|
f06c9725e6 | ||
|
29b6fe6667 | ||
|
75d930d21b | ||
|
0097aa7acc | ||
|
69d29725b7 | ||
|
461e67d071 | ||
|
40a49340b3 | ||
|
04585ad9b1 | ||
|
b8294dc794 | ||
|
3a3979ecd1 | ||
|
085272c196 | ||
|
535e4e054a | ||
|
7f397006ec | ||
|
bd7358144b | ||
|
064f885272 | ||
|
1f3900bae9 | ||
|
104688cbfe | ||
|
4822f520e4 | ||
|
a80a679d26 | ||
|
0e6a99991a | ||
|
8468a5329a | ||
|
f9e3a1d6a8 | ||
|
4268ab46bb | ||
|
d809d87cfc | ||
|
7183444e1a | ||
|
9d8d6af58b | ||
|
c878c9df17 | ||
|
9e925d9900 | ||
|
84dc60b377 | ||
|
d3adddfaed | ||
|
499a5625e8 | ||
|
7b001aea78 | ||
|
29d4389b69 | ||
|
a0fb597f3a | ||
|
29b0601ca1 | ||
|
c245a2fe30 | ||
|
6bf5e894b4 | ||
|
f2abe84fce | ||
|
68741c13c8 | ||
|
79100c2698 | ||
|
c663779fde | ||
|
5f91580e76 | ||
|
ab9fc288e2 | ||
|
972aae552f | ||
|
3b05d9bc46 | ||
|
03b855515e | ||
|
50d02d05bb | ||
|
759be8bf8b | ||
|
d1399e91ef | ||
|
90b943ca9a | ||
|
2ebb07bf11 | ||
|
6fa129ce03 | ||
|
7f0be4f94e | ||
|
719b5ffcad | ||
|
4cac355d94 | ||
|
d9d3f5040b | ||
|
d0f00ff279 | ||
|
b12bc88f27 | ||
|
a713acf2e8 | ||
|
aab8aaeca5 | ||
|
cfe03fa147 | ||
|
2c14379764 | ||
|
942b2bf4b7 | ||
|
1e7d0a114e | ||
|
0177154323 | ||
|
459410dc28 | ||
|
7c37bd639f | ||
|
d90c9ab113 | ||
|
54532169cd | ||
|
e40e3fbc3a | ||
|
93d7b4fde4 | ||
|
1b20c13ac8 | ||
|
867592a0ac | ||
|
ce9a7a7931 | ||
|
e769999060 | ||
|
dfdf50457b | ||
|
1c1a49ff73 | ||
|
eacb00aa01 | ||
|
f34c360d6b | ||
|
73cd7a533e | ||
|
9163330986 | ||
|
b5d2318ed9 | ||
|
b19cc6bbdc | ||
|
5476965d25 | ||
|
cf180c66ac | ||
|
95901f5559 | ||
|
199c21aeb6 | ||
|
5a57e6201d | ||
|
678dc0630a | ||
|
600c7e59ef | ||
|
58d0e2eccc | ||
|
281275cb19 | ||
|
72167df167 | ||
|
a415596bde | ||
|
27d3818186 | ||
|
1ace6c62ec | ||
|
fb2e220557 | ||
|
3fba8fc6ee | ||
|
2b3938ed96 | ||
|
fbe1fde2cb | ||
|
2bfa238903 | ||
|
15a62eca9b | ||
|
227d53828d | ||
|
02c7983829 | ||
|
c72c42d987 | ||
|
cb41add911 | ||
|
dd7b7afd3f | ||
|
05cb53ab18 | ||
|
a5e43f439f | ||
|
7111725bef | ||
|
d5edee1e28 | ||
|
5a08c1c4a2 | ||
|
0e95fcb78f | ||
|
e3f5888651 | ||
|
6871a09ba3 | ||
|
23b391c19c | ||
|
d0f47d04eb | ||
|
9ba8ec1284 | ||
|
8019ff652d | ||
|
7629409562 | ||
|
78be780faf | ||
|
542bcd96ef | ||
|
eb643627b6 | ||
|
f1750718a0 | ||
|
c58dfbca14 | ||
|
56b758883e | ||
|
0269e6bc02 | ||
|
1f5112cebb | ||
|
cb76c22ed2 | ||
|
7bffca3c69 | ||
|
8e76e4bcfc | ||
|
2db77e46e2 | ||
|
d242e66991 | ||
|
43ede54afe | ||
|
c3b87b1d04 | ||
|
56fa706b1c | ||
|
182c0cd97a | ||
|
8510713721 | ||
|
8876000bc7 | ||
|
d3d1017391 | ||
|
baf4e5f220 | ||
|
a103d91d18 | ||
|
c2c44b3edd | ||
|
a9bd260295 | ||
|
9a60904b75 | ||
|
c87264e63d | ||
|
d99437b3fb | ||
|
a5ee18c840 | ||
|
f86e718c29 | ||
|
e74ea431ce | ||
|
e59556a7cb | ||
|
3b43f7048a | ||
|
a374efb40b | ||
|
438659a6ea | ||
|
614f9d4a99 | ||
|
626e67cab1 | ||
|
24ebc1bf04 | ||
|
caff6730bd | ||
|
6710781cb7 | ||
|
20d9f9f7d5 | ||
|
4cccca0b23 | ||
|
ac9c44c2bb | ||
|
f6a3204aaa | ||
|
6316b2ce44 | ||
|
567213d515 | ||
|
0fdc9964b8 | ||
|
08a78b86d1 | ||
|
82a2f38f72 | ||
|
150f4f5bf2 | ||
|
767569df08 | ||
|
948cef3358 | ||
|
6d009028fc | ||
|
e5217e1e9f | ||
|
b0a4ec4688 | ||
|
9c2927f684 | ||
|
c781d6b17c | ||
|
16aee4be21 | ||
|
41a95112e5 | ||
|
5f7eb0bd74 | ||
|
abca0f1502 | ||
|
d4877a79f2 | ||
|
ed628da200 | ||
|
50d4937ddf | ||
|
b5477d7141 | ||
|
cadeb1e814 | ||
|
2fea039b15 | ||
|
6cb5828954 | ||
|
ce7afa7b24 | ||
|
53978e33f6 | ||
|
c168bad50b | ||
|
5f0349ddf6 | ||
|
4c2c081173 | ||
|
cfc9af1745 | ||
|
c73ff1ffdc | ||
|
03edfc79e1 | ||
|
eef7c29d56 | ||
|
60ec330234 | ||
|
4b7bfa22ee | ||
|
3df8edb6f7 | ||
|
479ba036e7 | ||
|
3a8d7b56ef | ||
|
312688fbec | ||
|
d7374a7490 | ||
|
2c97f6a383 | ||
|
2db99e2b94 | ||
|
65566937f4 | ||
|
3024ccf39e | ||
|
a52de0f449 | ||
|
814d18c125 | ||
|
7169d6e58a | ||
|
25f810923f | ||
|
56c8e89eb9 | ||
|
c5701a5dd6 | ||
|
060e94d432 | ||
|
c44bcf62b9 | ||
|
1c0386bc60 | ||
|
5f88210839 | ||
|
2e509c6bcb | ||
|
c5274cbf95 | ||
|
aa304108ea | ||
|
2f79b339d8 | ||
|
3094ed3fc2 | ||
|
d1a1b491d4 | ||
|
f5044fcb55 | ||
|
0b2678be13 | ||
|
5868567265 | ||
|
1448407d03 | ||
|
e1a65a0af1 | ||
|
ff741dbb1e | ||
|
e621daaf51 | ||
|
44b7eb26ab | ||
|
fa3feebded | ||
|
12f41160d7 | ||
|
617bcf2c3b | ||
|
e740d2b0e8 | ||
|
072097be3e | ||
|
e1eccb2b70 | ||
|
f6b722c803 | ||
|
ff3581dd4d | ||
|
75b59e4fdc | ||
|
d895891da5 | ||
|
cea4c8cc33 | ||
|
be17a47dc7 | ||
|
587f2ea832 | ||
|
20b5dc1ca6 | ||
|
8da3d07789 | ||
|
1c8c94bc82 | ||
|
705dee8414 | ||
|
1849764412 | ||
|
f16fcc1d7d | ||
|
78dbbed0d3 | ||
|
f61bcc1009 | ||
|
22f4e0660f | ||
|
c8f084be13 | ||
|
ce70dca3e1 | ||
|
303b484c41 | ||
|
cbf29007fe | ||
|
cb0f5d7577 | ||
|
3938497a8f | ||
|
52bc9b7d92 | ||
|
4aabe9ca6e | ||
|
1f646dd92f | ||
|
79391373c3 | ||
|
0bb96d9939 | ||
|
4956ee4139 | ||
|
402ed1d35c | ||
|
c2bbcb22ef | ||
|
29ff4dc06c | ||
|
e72ff31ed2 | ||
|
18dba99649 | ||
|
e71a9094a1 | ||
|
a54257e6ba | ||
|
4daa71e598 | ||
|
3c09846790 | ||
|
a7704b3cfd | ||
|
021885d60a | ||
|
905fb2cf63 | ||
|
4817d4b9bf | ||
|
5490352e70 | ||
|
feb3b1a490 | ||
|
2dd4282337 | ||
|
b89100fe8e | ||
|
6c01952c7f | ||
|
472e5b4a2a | ||
|
346d0859b7 | ||
|
28478697db | ||
|
33f4f81dd4 | ||
|
e251687db3 | ||
|
f5240cc67a | ||
|
2ebdd203c9 | ||
|
7f8e1ded47 | ||
|
61cadf911f | ||
|
5f363f1bd2 | ||
|
d34ef6d381 | ||
|
b0afdb241e | ||
|
83a739d36a | ||
|
376fb3b579 | ||
|
e86c4c8d26 | ||
|
d3cf4574e8 | ||
|
6273f7967f | ||
|
a9ba52d707 | ||
|
3a045601c9 | ||
|
0ca17efd4b | ||
|
33d939af07 | ||
|
de73bfb760 | ||
|
bb96998de9 | ||
|
74fbe511b2 | ||
|
7876b30183 | ||
|
6d93d67604 | ||
|
b675d37ae3 | ||
|
3ca1d2a9a1 | ||
|
c10c194626 | ||
|
b89daa25bc | ||
|
3f3ae9f6e5 | ||
|
591c342049 | ||
|
dd53c3bd0c | ||
|
3d597ab24c | ||
|
4cc12f1ac8 | ||
|
199ae3b89e | ||
|
42ecdab712 | ||
|
30dc011d7e | ||
|
d168f6729e | ||
|
41976ed5c6 | ||
|
adb908570b | ||
|
d35dd29928 | ||
|
a6a975223e |
496 changed files with 15481 additions and 70168 deletions
|
@ -1,5 +0,0 @@
|
||||||
exclude_paths:
|
|
||||||
- 'resources/lib/watchdog/**'
|
|
||||||
- 'resources/lib/pathtools/**'
|
|
||||||
- 'resources/lib/pathtools/**'
|
|
||||||
- 'resources/lib/defused_etree.py'
|
|
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
|
@ -1 +0,0 @@
|
||||||
ko_fi: A8182EB
|
|
41
.github/ISSUE_TEMPLATE/bug_report.md
vendored
41
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -1,41 +0,0 @@
|
||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve. Please read the instructions carefully.
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Help yourself
|
|
||||||
* I did try to restart Kodi :-)
|
|
||||||
* I checked the [PKC Frequently Asked Questions on the PKC wiki](https://github.com/croneter/PlexKodiConnect/wiki/faq)
|
|
||||||
* I did try to reset the Kodi database by going to `PKC Settings -> Advanced -> "Reset the database and optionally reset PlexKodiConnect"` and then hitting YES, NO
|
|
||||||
* I did check the [existing issues on Github](https://github.com/croneter/PlexKodiConnect/issues)
|
|
||||||
|
|
||||||
## Describe the bug
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
## To Reproduce
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
## Expected behavior
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
## You need to attach a KODI LOG FILE!
|
|
||||||
A Kodi debug log file is needed that you recorded while you reproduced the bug. **Do clean your log of all Plex tokens (="Plex passwords")!!!** .
|
|
||||||
1. Activate Kodi's debug logging by going to the Kodi `Settings` -> `System` -> `Logging`. Then toggle the `Enable debug logging` setting.
|
|
||||||
2. Restart Kodi to start with a "fresh" log file.
|
|
||||||
3. Reproduce the bug.
|
|
||||||
4. Follow the [Kodi instructions](http://kodi.wiki/view/Log_file/Easy) to grab/share the Kodi log file. Usually only `kodi.log` is needed
|
|
||||||
* You can [find the log file here](http://kodi.wiki/view/Log_file/Advanced#Location)
|
|
||||||
5. **Delete all references to any of your Plex tokens** by searching for `X-Plex-Token` and `accesstoken` and replacing the strings just after that!
|
|
||||||
* It's easiest if you copy your token, then use Search&Replace for the entire log file
|
|
||||||
* You don't want others to have access to your Plex installation....
|
|
||||||
6. Drop your log file here in this issue. Or use a free pasting-service like https://pastebin.com and include the link to it here
|
|
||||||
|
|
||||||
I am aware that I can delete Plex tokens that I accidentially posted by following the [instructions on the PKC wiki](https://github.com/croneter/PlexKodiConnect/wiki/How-to-Report-A-Bug#i-published-my-plex-token-to-some-forum-or-github-anyone-can-now-access-my-plex-server)
|
|
10
.tx/config
Normal file
10
.tx/config
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
[main]
|
||||||
|
host = https://www.transifex.com
|
||||||
|
|
||||||
|
[pkc.stringspo]
|
||||||
|
file_filter = resources/language/resource.language.<lang>/strings.po
|
||||||
|
minimum_perc = 60
|
||||||
|
source_file = resources/language/resource.language.en_gb/strings.po
|
||||||
|
source_lang = en_gb
|
||||||
|
type = PO
|
||||||
|
|
94
.tx/import_strings_to_addon_xml.py
Normal file
94
.tx/import_strings_to_addon_xml.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import os
|
||||||
|
import xml.etree.ElementTree as etree
|
||||||
|
|
||||||
|
languages = [
|
||||||
|
'nl_NL',
|
||||||
|
'fr_CA',
|
||||||
|
'fr_FR',
|
||||||
|
'de_DE',
|
||||||
|
'pt_PT',
|
||||||
|
'pt_BR',
|
||||||
|
'es_ES',
|
||||||
|
'es_AR',
|
||||||
|
'es_MX',
|
||||||
|
'cs_CZ',
|
||||||
|
'zh_CN',
|
||||||
|
'zh_TW',
|
||||||
|
'da_DK',
|
||||||
|
'it_IT',
|
||||||
|
'no_NO',
|
||||||
|
'el_GR',
|
||||||
|
'pl_PL',
|
||||||
|
# 'sv_SE',
|
||||||
|
'hu_HU',
|
||||||
|
'ru_RU',
|
||||||
|
'uk_UA',
|
||||||
|
'lv_LV',
|
||||||
|
'sv_SE',
|
||||||
|
'lt_LT',
|
||||||
|
'ko_KR'
|
||||||
|
]
|
||||||
|
|
||||||
|
tmp_file = r'C:\Users\Kat\Desktop\addon.xml'
|
||||||
|
PKC_dir = r'C:\Users\Kat\Documents\GitHub\PlexKodiConnect'
|
||||||
|
|
||||||
|
addon = {
|
||||||
|
'msgctxt "#39703"': 'summary',
|
||||||
|
'msgctxt "#39704"': 'description',
|
||||||
|
'msgctxt "#39705"': 'disclaimer'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def indent(elem, level=0):
|
||||||
|
"""
|
||||||
|
Prettifies xml trees. Pass the etree root in
|
||||||
|
"""
|
||||||
|
i = "\n" + level*" "
|
||||||
|
if len(elem):
|
||||||
|
if not elem.text or not elem.text.strip():
|
||||||
|
elem.text = i + " "
|
||||||
|
if not elem.tail or not elem.tail.strip():
|
||||||
|
elem.tail = i
|
||||||
|
for elem in elem:
|
||||||
|
indent(elem, level+1)
|
||||||
|
if not elem.tail or not elem.tail.strip():
|
||||||
|
elem.tail = i
|
||||||
|
else:
|
||||||
|
if level and (not elem.tail or not elem.tail.strip()):
|
||||||
|
elem.tail = i
|
||||||
|
|
||||||
|
|
||||||
|
root = etree.Element('addon')
|
||||||
|
for lang in languages:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(PKC_dir,
|
||||||
|
'resources',
|
||||||
|
'language',
|
||||||
|
'resource.language.%s' % lang,
|
||||||
|
'strings.po'), 'r', encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
if line.strip() in addon:
|
||||||
|
msg = ''
|
||||||
|
key = line.strip()
|
||||||
|
# Advance to the line msgstr ""
|
||||||
|
part = ''
|
||||||
|
while not part.startswith('msgstr'):
|
||||||
|
part = next(f)
|
||||||
|
msg += part.replace('msgstr', '').replace('"', '').strip()
|
||||||
|
part = None
|
||||||
|
while part != '':
|
||||||
|
part = next(f).strip()
|
||||||
|
msg += part
|
||||||
|
msg = msg.replace('"', '').replace('\r', '').replace('\n', '')
|
||||||
|
print(msg)
|
||||||
|
etree.SubElement(root,
|
||||||
|
addon[key],
|
||||||
|
attrib={'lang': lang}).text = msg
|
||||||
|
except IOError:
|
||||||
|
print('Missing file %s' % os.path.join(PKC_dir,
|
||||||
|
'resources',
|
||||||
|
'language',
|
||||||
|
'resource.language.%s' % lang,
|
||||||
|
'strings.po'))
|
||||||
|
indent(root)
|
||||||
|
etree.ElementTree(root).write(tmp_file, encoding="UTF-8")
|
36
.tx/pull_languages.py
Normal file
36
.tx/pull_languages.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
path = "C:\\Users\\kat\\AppData\\Local\\Continuum\\anaconda3\\envs\\kodi\\Scripts"
|
||||||
|
command = os.path.join(path, "tx.exe")
|
||||||
|
languages = [
|
||||||
|
'nl_NL',
|
||||||
|
'fr_CA',
|
||||||
|
'fr_FR',
|
||||||
|
'de_DE',
|
||||||
|
'pt_PT',
|
||||||
|
'pt_BR',
|
||||||
|
'es_ES',
|
||||||
|
'es_AR',
|
||||||
|
'es_MX',
|
||||||
|
'cs_CZ',
|
||||||
|
'zh_CN',
|
||||||
|
'zh_TW',
|
||||||
|
'da_DK',
|
||||||
|
'it_IT',
|
||||||
|
'no_NO',
|
||||||
|
'el_GR',
|
||||||
|
'pl_PL',
|
||||||
|
# 'sv_SE',
|
||||||
|
'hu_HU',
|
||||||
|
'ru_RU',
|
||||||
|
'uk_UA',
|
||||||
|
'lv_LV',
|
||||||
|
'sv_SE',
|
||||||
|
'lt_LT',
|
||||||
|
'ko_KR'
|
||||||
|
]
|
||||||
|
|
||||||
|
os.system("cd ..")
|
||||||
|
|
||||||
|
for lang in languages:
|
||||||
|
os.system(command + " pull --minimum-perc=10 -f -l %s" % lang)
|
48
README.md
48
README.md
|
@ -1,12 +1,9 @@
|
||||||
[![Kodi Leia stable version](https://img.shields.io/badge/Kodi_Leia_STABLE-latest-blue.svg?maxAge=60&style=flat) ](https://croneter.github.io/pkc-source/repository.plexkodiconnect.Kodi-Leia.STABLE.zip)
|
[![stable version](https://img.shields.io/badge/stable_version-1.8.18-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip)
|
||||||
[![Kodi Leia beta version](https://img.shields.io/badge/Kodi_Leia_BETA-latest-red.svg?maxAge=60&style=flat) ](https://croneter.github.io/pkc-source/repository.plexkodiconnect.Kodi-Leia.BETA.zip)
|
[![beta version](https://img.shields.io/badge/beta_version-2.0.26-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip)
|
||||||
[![Kodi Matrix stable version](https://img.shields.io/badge/Kodi_Matrix_STABLE-latest-blue.svg?maxAge=60&style=flat) ](https://croneter.github.io/pkc-source/repository.plexkodiconnect.Kodi-Matrix.STABLE.zip)
|
|
||||||
[![Kodi Matrix beta version](https://img.shields.io/badge/Kodi_Matrix_BETA-latest-red.svg?maxAge=60&style=flat) ](https://croneter.github.io/pkc-source/repository.plexkodiconnect.Kodi-Matrix.BETA.zip)
|
|
||||||
|
|
||||||
[![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation)
|
[![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation)
|
||||||
[![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq)
|
[![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq)
|
||||||
[![Forum](https://img.shields.io/badge/forum-plex-orange.svg?maxAge=60&style=flat)](https://forums.plex.tv/discussion/210023/plexkodiconnect-let-kodi-talk-to-your-plex)
|
[![Forum](https://img.shields.io/badge/forum-plex-orange.svg?maxAge=60&style=flat)](https://forums.plex.tv/discussion/210023/plexkodiconnect-let-kodi-talk-to-your-plex)
|
||||||
[![Donate](https://img.shields.io/badge/donate-kofi-blue.svg)](https://ko-fi.com/A8182EB)
|
|
||||||
|
|
||||||
[![GitHub issues](https://img.shields.io/github/issues/croneter/PlexKodiConnect.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/issues) [![GitHub pull requests](https://img.shields.io/github/issues-pr/croneter/PlexKodiConnect.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/pulls) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/a66870f19ced4fb98f94d9fd56e34e87)](https://www.codacy.com/app/croneter/PlexKodiConnect?utm_source=github.com&utm_medium=referral&utm_content=croneter/PlexKodiConnect&utm_campaign=Badge_Grade)
|
[![GitHub issues](https://img.shields.io/github/issues/croneter/PlexKodiConnect.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/issues) [![GitHub pull requests](https://img.shields.io/github/issues-pr/croneter/PlexKodiConnect.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/pulls) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/a66870f19ced4fb98f94d9fd56e34e87)](https://www.codacy.com/app/croneter/PlexKodiConnect?utm_source=github.com&utm_medium=referral&utm_content=croneter/PlexKodiConnect&utm_campaign=Badge_Grade)
|
||||||
|
|
||||||
|
@ -14,12 +11,7 @@
|
||||||
# PlexKodiConnect (PKC)
|
# PlexKodiConnect (PKC)
|
||||||
**Combine the best frontend media player Kodi with the best multimedia backend server Plex**
|
**Combine the best frontend media player Kodi with the best multimedia backend server Plex**
|
||||||
|
|
||||||
PKC synchronizes your media from your Plex server to the native Kodi database. Hence:
|
PKC combines the best of Kodi - ultra smooth navigation, beautiful and highly customizable user interfaces and playback of any file under the sun - and the Plex Media Server.
|
||||||
- Use virtually any other Kodi add-on
|
|
||||||
- Use any Kodi skin, completely customize Kodi's look
|
|
||||||
- Browse your media very fluently (cached artwork)
|
|
||||||
- Automatically get additional artwork (more than Plex offers)
|
|
||||||
- Use Plex features with a Kodi interface
|
|
||||||
|
|
||||||
Have a look at [some screenshots](https://github.com/croneter/PlexKodiConnect/wiki/Some-PKC-Screenshots) to see what's possible.
|
Have a look at [some screenshots](https://github.com/croneter/PlexKodiConnect/wiki/Some-PKC-Screenshots) to see what's possible.
|
||||||
|
|
||||||
|
@ -29,6 +21,7 @@ Unfortunately, the PKC Kodi repository had to move because it stopped working (t
|
||||||
|
|
||||||
### Content
|
### Content
|
||||||
* [**Download and Installation**](#download-and-installation)
|
* [**Download and Installation**](#download-and-installation)
|
||||||
|
* [**What does PKC do?**](#what-does-pkc-do)
|
||||||
* [**Warning**](#warning)
|
* [**Warning**](#warning)
|
||||||
* [**PKC Features**](#pkc-features)
|
* [**PKC Features**](#pkc-features)
|
||||||
* [**Additional Artwork**](#additional-artwork)
|
* [**Additional Artwork**](#additional-artwork)
|
||||||
|
@ -39,7 +32,19 @@ Unfortunately, the PKC Kodi repository had to move because it stopped working (t
|
||||||
|
|
||||||
### Download and Installation
|
### Download and Installation
|
||||||
|
|
||||||
Using the Kodi file manager, add [https://croneter.github.io/pkc-source](https://croneter.github.io/pkc-source) as a new Kodi `Web server directory (HTTPS)` source, then install the PlexKodiConnect repository from this new source "from ZIP file". See the [github wiki installation manual](https://github.com/croneter/PlexKodiConnect/wiki/Installation) for a detailed guide. Kodi will update PKC automatically.
|
Install PKC via the PlexKodiConnect Kodi repository download button just below (do NOT use the standard GitHub download!). See the [github wiki installation manual](https://github.com/croneter/PlexKodiConnect/wiki/Installation) for a detailed guide. Please use the stable version except if you really know what you're doing. Kodi will update PKC automatically.
|
||||||
|
|
||||||
|
| Stable version | Beta version |
|
||||||
|
|----------------|--------------|
|
||||||
|
| [![stable version](https://img.shields.io/badge/stable_version-latest-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip) | [![beta version](https://img.shields.io/badge/beta_version-latest-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip) |
|
||||||
|
|
||||||
|
### What does PKC do?
|
||||||
|
PKC synchronizes your media from your Plex server to the native Kodi database. Hence:
|
||||||
|
- Use virtually any other Kodi add-on
|
||||||
|
- Use any Kodi skin, completely customize Kodi's look
|
||||||
|
- Browse your media at full speed (cached artwork)
|
||||||
|
- Automatically get additional artwork (more than Plex offers)
|
||||||
|
- Enjoy Plex features using the Kodi interface
|
||||||
|
|
||||||
### Warning
|
### Warning
|
||||||
Use at your own risk! This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases as this plugin directly changes them. Don't worry if you want Plex to manage all your media (like you should ;-)).
|
Use at your own risk! This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases as this plugin directly changes them. Don't worry if you want Plex to manage all your media (like you should ;-)).
|
||||||
|
@ -48,20 +53,17 @@ Some people argue that PKC is 'hacky' because of the way it directly accesses th
|
||||||
|
|
||||||
### PKC Features
|
### PKC Features
|
||||||
|
|
||||||
- Support for Kodi 18 Leia and Kodi 19 Matrix
|
- Support of Kodi 18 Leia (and Kodi 17 Krypton)
|
||||||
- Preliminary support for Kodi 20 Nexus. Keep in mind that development for Kodi Nexus has not even officially reached alpha stage - any issues you encounter are probably caused by that
|
|
||||||
- [Skip intros](https://support.plex.tv/articles/skip-content/)
|
|
||||||
- [Amazon Alexa voice recognition](https://www.plex.tv/apps/streaming-devices/amazon-alexa)
|
- [Amazon Alexa voice recognition](https://www.plex.tv/apps/streaming-devices/amazon-alexa)
|
||||||
- [Cinema Trailers & Extras](https://support.plex.tv/articles/202934883-cinema-trailers-extras/)
|
- [Cinema Trailers & Extras](https://support.plex.tv/articles/202934883-cinema-trailers-extras/)
|
||||||
- [Plex Watch Later / Plex It!](https://support.plex.tv/hc/en-us/sections/200211783-Plex-It-)
|
- [Plex Watch Later / Plex It!](https://support.plex.tv/hc/en-us/sections/200211783-Plex-It-)
|
||||||
- [Plex Companion](https://support.plex.tv/hc/en-us/sections/200276908-Plex-Companion): fling Plex media (or anything else) from other Plex devices to PlexKodiConnect
|
- [Plex Companion](https://support.plex.tv/hc/en-us/sections/200276908-Plex-Companion): fling Plex media (or anything else) from other Plex devices to PlexKodiConnect
|
||||||
- Automatically sync Plex playlists to Kodi playlists and vice-versa
|
|
||||||
- [Plex Transcoding](https://support.plex.tv/hc/en-us/articles/200250377-Transcoding-Media)
|
- [Plex Transcoding](https://support.plex.tv/hc/en-us/articles/200250377-Transcoding-Media)
|
||||||
- Automatically download more artwork from [Fanart.tv](https://fanart.tv/), just like the Kodi addon [Artwork Downloader](http://kodi.wiki/view/Add-on:Artwork_Downloader)
|
- Automatically download more artwork from [Fanart.tv](https://fanart.tv/), just like the Kodi addon [Artwork Downloader](http://kodi.wiki/view/Add-on:Artwork_Downloader)
|
||||||
- Automatically group movies into [movie sets](http://kodi.wiki/view/movie_sets)
|
- Automatically group movies into [movie sets](http://kodi.wiki/view/movie_sets)
|
||||||
- [Direct play](https://github.com/croneter/PlexKodiConnect/wiki/Direct-Play) from network paths (e.g. "\\\\server\\Plex\\movie.mkv"), something unique to PKC
|
- [Direct play](https://github.com/croneter/PlexKodiConnect/wiki/Direct-Play) from network paths (e.g. "\\\\server\\Plex\\movie.mkv"), something unique to PKC
|
||||||
- Delete PMS items from the Kodi context menu
|
- Delete PMS items from the Kodi context menu
|
||||||
- PKC is available in the following languages. [Please help and easily translate PKC!](https://www.transifex.com/croneter/pkc)
|
- PKC is available in the following languages:
|
||||||
+ English
|
+ English
|
||||||
+ German
|
+ German
|
||||||
+ Czech, thanks @Pavuucek
|
+ Czech, thanks @Pavuucek
|
||||||
|
@ -76,9 +78,7 @@ Some people argue that PKC is 'hacky' because of the way it directly accesses th
|
||||||
+ Portuguese, thanks @goncalo532
|
+ Portuguese, thanks @goncalo532
|
||||||
+ Russian, thanks @UncleStark
|
+ Russian, thanks @UncleStark
|
||||||
+ Hungarian, thanks @savage93
|
+ Hungarian, thanks @savage93
|
||||||
+ Ukrainian, thanks @uniss
|
+ [You can easily help to translate PKC!](https://www.transifex.com/croneter/pkc)
|
||||||
+ Lithuanian, thanks @egidusm
|
|
||||||
+ Korean, thanks @so-o-bima
|
|
||||||
|
|
||||||
### Additional Artwork
|
### Additional Artwork
|
||||||
PKC uses additional artwork for free from [TheMovieDB](https://www.themoviedb.org). Many thanks for lettings us use the API, guys!
|
PKC uses additional artwork for free from [TheMovieDB](https://www.themoviedb.org). Many thanks for lettings us use the API, guys!
|
||||||
|
@ -89,11 +89,13 @@ I'm not in any way affiliated with Plex. Thank you very much for a small donatio
|
||||||
**Full disclaimer:** I will see your name and address if you use PayPal. Rest assured that I will not share this with anyone.
|
**Full disclaimer:** I will see your name and address if you use PayPal. Rest assured that I will not share this with anyone.
|
||||||
|
|
||||||
[![Donations](https://az743702.vo.msecnd.net/cdn/kofi1.png?v=a)](https://ko-fi.com/A8182EB)
|
[![Donations](https://az743702.vo.msecnd.net/cdn/kofi1.png?v=a)](https://ko-fi.com/A8182EB)
|
||||||
|
|
||||||
**Ethereum address for donations:
|
![ETH-Donations](https://chart.googleapis.com/chart?chs=150x150&cht=qr&chld=L0&chl=0x0f57D98E08e617292D8bC0B3448dd79BF4Cf8e7F)
|
||||||
|
**Ethereum address:
|
||||||
0x0f57D98E08e617292D8bC0B3448dd79BF4Cf8e7F**
|
0x0f57D98E08e617292D8bC0B3448dd79BF4Cf8e7F**
|
||||||
|
|
||||||
**Bitcoin address for donations:
|
![BTX-Donations](https://chart.googleapis.com/chart?chs=150x150&cht=qr&chld=L0&chl=3BhwvUsqAGtAZodGUx4mTP7pTECjf1AejT)
|
||||||
|
**Bitcoin address:
|
||||||
3BhwvUsqAGtAZodGUx4mTP7pTECjf1AejT**
|
3BhwvUsqAGtAZodGUx4mTP7pTECjf1AejT**
|
||||||
|
|
||||||
|
|
||||||
|
|
564
addon.xml
564
addon.xml
|
@ -1,12 +1,10 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="2.15.0" provider-name="croneter">
|
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="2.0.26" provider-name="croneter">
|
||||||
<requires>
|
<requires>
|
||||||
<import addon="xbmc.python" version="2.1.0"/>
|
<import addon="xbmc.python" version="2.1.0"/>
|
||||||
<import addon="script.module.requests" version="2.9.1" />
|
<import addon="script.module.requests" version="2.9.1" />
|
||||||
<import addon="script.module.defusedxml" version="0.5.0"/>
|
<import addon="plugin.video.plexkodiconnect.movies" version="2.0.4" />
|
||||||
<import addon="script.module.six" />
|
<import addon="plugin.video.plexkodiconnect.tvshows" version="2.0.4" />
|
||||||
<import addon="plugin.video.plexkodiconnect.movies" version="2.1.3" />
|
|
||||||
<import addon="plugin.video.plexkodiconnect.tvshows" version="2.1.3" />
|
|
||||||
</requires>
|
</requires>
|
||||||
<extension point="xbmc.python.pluginsource" library="default.py">
|
<extension point="xbmc.python.pluginsource" library="default.py">
|
||||||
<provides>video audio image</provides>
|
<provides>video audio image</provides>
|
||||||
|
@ -21,9 +19,6 @@
|
||||||
</item>
|
</item>
|
||||||
</extension>
|
</extension>
|
||||||
<extension point="xbmc.addon.metadata">
|
<extension point="xbmc.addon.metadata">
|
||||||
<summary lang="en">Native Integration of Plex into Kodi</summary>
|
|
||||||
<description lang="en">Connect Kodi to your Plex Media Server. This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases (as this plugin directly changes them). Use at your own risk!</description>
|
|
||||||
<disclaimer lang="en">Use at your own risk</disclaimer>
|
|
||||||
<platform>all</platform>
|
<platform>all</platform>
|
||||||
<license>GNU GENERAL PUBLIC LICENSE. Version 2, June 1991</license>
|
<license>GNU GENERAL PUBLIC LICENSE. Version 2, June 1991</license>
|
||||||
<forum>https://forums.plex.tv</forum>
|
<forum>https://forums.plex.tv</forum>
|
||||||
|
@ -78,6 +73,8 @@
|
||||||
<summary lang="uk_UA">Нативна інтеграція Plex в Kodi</summary>
|
<summary lang="uk_UA">Нативна інтеграція Plex в Kodi</summary>
|
||||||
<description lang="uk_UA">Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик!</description>
|
<description lang="uk_UA">Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик!</description>
|
||||||
<disclaimer lang="uk_UA">Використовуйте на свій ризик</disclaimer>
|
<disclaimer lang="uk_UA">Використовуйте на свій ризик</disclaimer>
|
||||||
|
<summary lang="lv_LV" />
|
||||||
|
<description lang="lv_LV" />
|
||||||
<disclaimer lang="lv_LV">Lieto uz savu atbildību</disclaimer>
|
<disclaimer lang="lv_LV">Lieto uz savu atbildību</disclaimer>
|
||||||
<summary lang="sv_SE">Inbyggd integrering av Plex i Kodi</summary>
|
<summary lang="sv_SE">Inbyggd integrering av Plex i Kodi</summary>
|
||||||
<description lang="sv_SE">Anslut Kodi till din Plex Media Server. Detta tillägg antar att du hanterar alla dina filmer med Plex (och ingen med Kodi). Du kan förlora data redan sparad i Kodis video och musik databaser (eftersom detta tillägg direkt ändrar dem). Använd på egen risk!</description>
|
<description lang="sv_SE">Anslut Kodi till din Plex Media Server. Detta tillägg antar att du hanterar alla dina filmer med Plex (och ingen med Kodi). Du kan förlora data redan sparad i Kodis video och musik databaser (eftersom detta tillägg direkt ändrar dem). Använd på egen risk!</description>
|
||||||
|
@ -88,193 +85,436 @@
|
||||||
<summary lang="ko_KR">Plex를 Kodi에 기본 통합</summary>
|
<summary lang="ko_KR">Plex를 Kodi에 기본 통합</summary>
|
||||||
<description lang="ko_KR">Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오!</description>
|
<description lang="ko_KR">Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오!</description>
|
||||||
<disclaimer lang="ko_KR">자신의 책임하에 사용</disclaimer>
|
<disclaimer lang="ko_KR">자신의 책임하에 사용</disclaimer>
|
||||||
<news>version 2.15.0:
|
|
||||||
- versions 2.14.3-2.14.4 for everyone
|
|
||||||
- Direct Paths: Fix TypeError: "element indices must be integers" on playback startup [backport]
|
|
||||||
- Refactor stream code and fix Kodi not activating subtitle when it should [backport]
|
|
||||||
- Add playback settings to let the user choose whether Plex or Kodi provides the default audio or subtitle stream on playback start [backport]
|
|
||||||
- Update translations from Transifex [backport]
|
|
||||||
|
|
||||||
version 2.14.4 (beta only):
|
<news>version 2.0.26 (beta only):
|
||||||
- Tell the PMS if a video's audio stream or potentially subtitle stream has changed. For subtitles, this functionality is broken due to a Kodi bug
|
- Reduce CPU strain for artwork caching progress
|
||||||
- Transcoding: Fix Plex burning-in subtitles when it should not
|
- Fallback connection if plex.direct does not resolve
|
||||||
- Fix logging if fanart.tv lookup fails: be less verbose
|
- Prettify Plex context menu, thanks @dazedcrazy
|
||||||
- Large refactoring of playlist and playqueue code
|
- Default to not show image caching notifications
|
||||||
- Refactor usage of a media part's id
|
|
||||||
|
|
||||||
version 2.14.3 (beta only):
|
version 2.0.25 (beta only):
|
||||||
- Implement "Reset resume position" from the Kodi context menu
|
- Fix migration not working correctly for re-connecting PMS
|
||||||
|
- Fix PMS showing up twice
|
||||||
|
- Improve artwork caching counter in PKC settings
|
||||||
|
|
||||||
version 2.14.2:
|
version 2.0.24 (beta only):
|
||||||
- version 2.14.1 for everyone
|
- WARNING: You will need to reset the Kodi database! Sorry for that...
|
||||||
|
- PKC will force you to re-connect with your PMS
|
||||||
|
- Use plex.direct url instead of local ip to use correct SSL certificate; thus fix artwork caching
|
||||||
|
- Revert "Increase timeout between syncing images"
|
||||||
|
- Don't ask user for DB reset if forced by PKC
|
||||||
|
- Ensure movies and tv shows are synced before music
|
||||||
|
- Ensure a later migration if user downgraded PKC
|
||||||
|
|
||||||
version 2.14.1 (beta only):
|
version 2.0.23 (beta only):
|
||||||
- Use Plex settings for audio and subtitle stream selection. This is a best guess regarding subtitles as Plex and Kodi are not sharing much info
|
- WARNING: You will need to reset the Kodi database!
|
||||||
- Fix PlexKodiConnect setting the Plex subtitle to None
|
- Finally support for Extras!
|
||||||
- Download landscape artwork from fanart.tv, thanks @geropan
|
- Fix context menu not working for shows in library view
|
||||||
- Revert "Fix PlexKodiConnect changing subtitles for all videos on the PMS"
|
- Fix Plex Companion music playstate status for iOS
|
||||||
|
- Show caching progress and FanartTV lookup progress in PKC settings
|
||||||
|
- Fix rare library sync errors
|
||||||
|
- Fix ValueError for third party add-ons calling PKC
|
||||||
|
- Tweak PKC settings
|
||||||
|
|
||||||
version 2.14.0:
|
version 2.0.22 (beta only):
|
||||||
- Fix PlexKodiConnect changing or removing subtitles for every video on the PMS
|
- Fix Recently Added for tv shows not working
|
||||||
- version 2.13.1-2.13.2 for everyone
|
- Fix PKC crashing on startup
|
||||||
|
|
||||||
version 2.13.2 (beta only):
|
version 2.0.21 (beta only):
|
||||||
- Fix a racing condition that could lead to the sync getting stuck
|
- Fix TV show artwork Kodi native library (reset Kodi DB!)
|
||||||
- Fix RecursionError: maximum recursion depth exceeded
|
- Cache missing posters and backgrounds/fanart on Kodi startup
|
||||||
- Websocket Fix AttributeError: 'NoneType' object has no attribute 'is_ssl'
|
- Add toggle to deactivate image caching during playback
|
||||||
|
- Increase timeout between syncing images
|
||||||
|
- Fix music database if new music is added in the background
|
||||||
|
|
||||||
version 2.13.1 (beta only):
|
version 2.0.20 (beta only):
|
||||||
- Fix a racing condition that could lead to the sync process getting stuck
|
- Fix missing episode poster in certain views. You will have to manually reset your Kodi database to benefit
|
||||||
- Fix likelyhood of `database is locked` error occuring
|
- Fix episode artwork sometimes not being complete
|
||||||
|
- Fix IndexError for certain Plex channels
|
||||||
|
- Kodi Leia: Fix playback failing
|
||||||
|
- Fix TV On Deck direct paths asking to choose between different media
|
||||||
|
|
||||||
version 2.13.0:
|
version 2.0.19 (beta only):
|
||||||
- Support for the Plex HAMA agent to let Kodi identify animes (using Kodi's uniqueID 'anidb')
|
- Fix PKC playback startup getting caught in infinity loop
|
||||||
- Support forced HAMA IDs when using tvdb uniqueID
|
- Rewire library sync, suspend sync during playback
|
||||||
- version 2.12.26 for everyone
|
- Fix playback failing in certain cases
|
||||||
|
- Fix PKC not working anymore after using context menu on songs
|
||||||
|
- Fix deletion of Plex music items
|
||||||
|
- Code cleanup
|
||||||
|
|
||||||
version 2.12.26 (beta only):
|
version 2.0.18 (beta only):
|
||||||
- Add an additional Plex Hub "PKC Continue Watching" that merges the Plex Continue Watching with On Deck
|
- Fix some playqueue inconsistencies using Plex Companion
|
||||||
- Fix auto-picking of video stream if several video versions are available
|
- Direct paths: fix replaying item where playback was started via PMS
|
||||||
|
- Fix Plex trailers screwing up playqueue
|
||||||
|
- Fix IndexError when emptying Kodi playqueue
|
||||||
|
- Incorporate PKC player in kodimonitor module
|
||||||
|
- Fix pretty printing of PKC playqueues not working
|
||||||
|
- Code cleanups
|
||||||
|
|
||||||
|
version 2.0.17 (beta only):
|
||||||
|
- Finally make PKC compatible with Kodi 18 Leia Alpha 1
|
||||||
|
- Fix information screen and Plex option not working
|
||||||
|
- Activate Kodi background updates to hide "Compressing Database"
|
||||||
|
- Update translations
|
||||||
|
- Remove most strings not being used by PKC
|
||||||
|
- Remove some legacy settings
|
||||||
|
|
||||||
|
version 2.0.16 (beta only):
|
||||||
|
- Do NOT delete playstates before getting new ones from the PMS
|
||||||
|
|
||||||
|
version 2.0.15 (beta only):
|
||||||
|
- Fix Plex Companion thinking video is playing again
|
||||||
|
- Warn if "Play next video automatically" is enabled, cause it breaks PKC playback report
|
||||||
|
- Don't clean the Kodi file table
|
||||||
|
- Only remember which player has been active if we got a Plex id
|
||||||
|
- Hopefully fix ValueError for datetime.utcnow()
|
||||||
|
|
||||||
|
version 2.0.14 (beta only):
|
||||||
|
- Fix resetting PKC player state - should fix PKC telling the PMS that an old, just-played item is playing
|
||||||
|
- Play the selected element first, then add the Kodi playqueue to the Plex playqueue
|
||||||
|
- Ensure that playstate for ended (not stopped) video is recorded correctly
|
||||||
|
- Make sure that LOCK is released after adding one element
|
||||||
|
|
||||||
|
version 2.0.13 (beta only):
|
||||||
|
- Fix resume for On Deck and browse by folder
|
||||||
|
- Fix PKC sometimes telling wrong item being played
|
||||||
|
- Don't tell PMS last item is playing if non-Plex item is played
|
||||||
|
- Fix rare KeyError for playback including trailers
|
||||||
|
- Use an empty video file to "fail" playback
|
||||||
|
- Use identical add-on paths for On Deck and browsing folders
|
||||||
|
|
||||||
|
version 2.0.12 (beta only):
|
||||||
|
- Fix resume not working for some Kodi interface languages
|
||||||
|
- Fix widget navigating to entire TV show not working
|
||||||
|
- Fix library sync crash TypeError
|
||||||
|
- Revert "Revert "Fix for "In Progress" not appearing""
|
||||||
|
- Simplify error message
|
||||||
|
|
||||||
|
version 2.0.11 (beta only):
|
||||||
|
- WARNING: You will need to reset the Kodi database!
|
||||||
|
- Fix playback for add-on paths
|
||||||
|
- Fix artwork for episodes for add-on paths
|
||||||
|
- Revert "Fix for "In Progress" not appearing"
|
||||||
|
- Fix playback resuming potentially too often
|
||||||
|
|
||||||
|
version 2.0.10 (beta only):
|
||||||
|
- Fix wrong item being reported using direct paths
|
||||||
|
- Direct paths: correctly clean up after context menu play
|
||||||
|
- Always resume playback if playback initiated via context menu
|
||||||
|
- Do not play trailers for resumable movies using playback via PMS
|
||||||
|
- Fix for "In Progress" widget not appearing
|
||||||
|
- Fix correctly recording ended (not stopped!) video
|
||||||
|
- Don't record last played date if state unwatched
|
||||||
|
- Clean Kodi DB more thoroughly after playback start via PMS
|
||||||
|
|
||||||
|
version 2.0.9 (beta only):
|
||||||
|
- Fix AttributeError on playback start
|
||||||
|
|
||||||
|
version 2.0.8 (beta only):
|
||||||
|
- Fix videos not being correctly marked as played
|
||||||
|
- Improve playback startup resiliance
|
||||||
|
- Fix playerstates not being copied/reset correctly
|
||||||
|
- Fix tv shows not being correctly deleted
|
||||||
|
- Fix episode rating not being correct
|
||||||
|
- Make generally sure that we're correctly deleting videos from the Kodi DB
|
||||||
|
- Fix disabling of background sync (websockets)
|
||||||
|
|
||||||
|
version 2.0.7 (beta only):
|
||||||
|
- Fix another UnicodeDecodeError for playlists
|
||||||
|
- Hardcode plugin-calls instead of using urlencode
|
||||||
|
- Fix Kodi 18 log warnings by declaring all settings variables
|
||||||
|
|
||||||
|
version 2.0.6 (beta only):
|
||||||
|
- Addon paths playback was basically broken - hope it works again!
|
||||||
|
- Fixes to add-on paths playback startup
|
||||||
|
- Fix UnicodeDecodeError for playqueue logging
|
||||||
|
|
||||||
|
version 2.0.5 (beta only):
|
||||||
|
- WARNING: You will need to reset the Kodi database!
|
||||||
|
- Fix art and show info not showing for addon paths
|
||||||
|
- Fix episode information not working
|
||||||
|
- Big Kodi DB overhaul - ensure video metadata updates/deletes correctly
|
||||||
|
- Artwork code overhaul
|
||||||
|
- Greatly speed up switch of PMS
|
||||||
|
- And a lot of other stuff
|
||||||
|
|
||||||
|
version 2.0.4 (beta only):
|
||||||
|
- WARNING: You will need to reset the Kodi database!
|
||||||
|
- Many improvements to the Kodi database handling which should get rid of some weird bugs
|
||||||
|
- Many improvements to playback startup
|
||||||
|
- Fix info screen and actors not working
|
||||||
|
- Fix Companion displaying and selecting wrong subtitle
|
||||||
|
- Don't cache subtitles if direct playing
|
||||||
|
- Wipe all existing resume point, e.g. on user switch
|
||||||
|
- Don't mess with Kodi's screensaver settings
|
||||||
|
- Inhibit idle shutdown only during initial sync
|
||||||
|
- Fix KeyError for server discovery
|
||||||
|
- Increase Python requests dependency to version 2.9.1
|
||||||
|
- Re-introduce PlexKodiConnect dependency add-ons for movies and tv shows
|
||||||
|
- And a lot of other stuff
|
||||||
|
|
||||||
|
version 2.0.3 (beta only):
|
||||||
|
- Fix Alexa playback
|
||||||
|
- Fix Kodi boot loop
|
||||||
|
- Fix playback being reported to the wrong Plex user
|
||||||
|
- Fix GB/BBFC content ratings
|
||||||
|
- Fix KeyError when browsing On Deck
|
||||||
|
- Make sure that empty XML elements get deleted
|
||||||
|
- Code optimizations
|
||||||
|
|
||||||
|
version 2.0.2 (beta only):
|
||||||
|
- Fix playback reporting not starting up correctly
|
||||||
|
- Fix playback cleanup if PKC causes stop
|
||||||
|
- Always detect if user resumes playback
|
||||||
|
- Enable resume within a playqueue
|
||||||
|
- Compare playqueue items more reliably
|
||||||
|
|
||||||
|
version 2.0.1 (beta only):
|
||||||
|
- Fix empty On Deck for tv shows
|
||||||
|
- Fix trailers not playing
|
||||||
|
|
||||||
|
version 2.0.0 (beta only):
|
||||||
|
- HUGE code overhaul - Remember that you can go back to earlier version ;-)
|
||||||
|
- Completely rewritten Plex Companion
|
||||||
|
- Completely rewritten playback startup
|
||||||
|
- Tons of fixes, see the Github changelog for more details
|
||||||
|
- WARNING: You will need to reset the Kodi database!
|
||||||
|
|
||||||
|
version 1.8.18:
|
||||||
|
- Russian translation, thanks @UncleStark, @xom2000, @AlexFreit
|
||||||
|
- Fix Plex context menu not showing up
|
||||||
|
- Deal better with missing stream info (e.g. channels)
|
||||||
|
- Fix AttributeError if Plex key is missing
|
||||||
|
|
||||||
|
version 1.8.17:
|
||||||
|
- Hopefully fix stable repo
|
||||||
|
- Fix subtitles not working or showing up as Unknown
|
||||||
|
- Enable channels for Plex home users
|
||||||
|
- Remove obsolete PKC settings show contextmenu
|
||||||
|
|
||||||
|
version 1.8.16:
|
||||||
|
- Add premiere dates for movies, thanks @dazedcrazy
|
||||||
|
- Fix items not getting marked as fully watched
|
||||||
|
|
||||||
|
version 1.8.15:
|
||||||
|
- version 1.8.14 for everyone
|
||||||
- Update translations
|
- Update translations
|
||||||
|
|
||||||
version 2.12.25:
|
version 1.8.14 (beta only):
|
||||||
- Update websocket client to 0.59.0. Fix threading issues and AttributeErrors
|
- Greatly speed up displaying context menu
|
||||||
|
- Fix IndexError e.g. for channels if stream info missing
|
||||||
|
- Sleep a bit before marking item as fully watched
|
||||||
|
- Don't sleep before updating playstate to fully watched (if you watch on another Plex client)
|
||||||
|
- Fix KeyError for TV live channels for getGeople
|
||||||
|
|
||||||
version 2.12.24:
|
version 1.8.13 (beta only):
|
||||||
- version 2.12.23 for everyone
|
- Background sync now picks up more PMS changes
|
||||||
|
- Detect Plex item deletion more reliably
|
||||||
|
- Fix changed Plex metadata not synced repeatedly
|
||||||
|
- Detect (some, not all) changes to PKC settings and apply them on-the-fly
|
||||||
|
- Fix resuming interrupted sync
|
||||||
|
- PKC logging now uses Kodi log levels
|
||||||
|
- Further code optimizations
|
||||||
|
|
||||||
version 2.12.23 (beta only):
|
version 1.8.12:
|
||||||
- Fix Alexa and RuntimeError: dictionary keys changed during iteration
|
- Fix library sync crashing trying to display an error
|
||||||
- Fix a rare AttributeError when using playlists
|
|
||||||
|
|
||||||
version 2.12.22:
|
version 1.8.11:
|
||||||
- version 2.12.20 and 2.12.21 for everyone
|
- version 1.8.10 for everybody
|
||||||
|
|
||||||
version 2.12.21 (beta only):
|
version 1.8.10 (beta only):
|
||||||
- Switch to new websocket implementation
|
- Vastly improve sync speed for music
|
||||||
- Hopefully fix RuntimeError: no add-on id "plugin.video.plexkodiconnect"
|
- Never show library sync dialog if media is playing
|
||||||
|
- Improvements to sync dialog
|
||||||
|
- Fix stop synching if path not found
|
||||||
|
- Resume aborted sync on PKC settings change
|
||||||
|
- Don't quit library sync if failed repeatedly
|
||||||
|
- Verify path for every Plex library on install sync
|
||||||
|
- More descriptive downloadable subtitles
|
||||||
|
- More code fixes and optimization
|
||||||
|
|
||||||
|
version 1.8.9
|
||||||
|
- Fix playback not starting in some circumstances
|
||||||
|
- Deactivate some annoying popups on install
|
||||||
|
|
||||||
|
version 1.8.8
|
||||||
|
- Fix playback not starting in some circumstances
|
||||||
|
- Fix first artist "missing" tag (Reset your DB!)
|
||||||
|
- Update Czech translation
|
||||||
|
|
||||||
|
version 1.8.7 (beta only):
|
||||||
|
- Some fixes to playstate reporting, thanks @RickDB
|
||||||
|
- Add Kodi info screen for episodes in context menu
|
||||||
|
- Fix PKC asking for trailers not working
|
||||||
|
- Fix PKC not automatically updating
|
||||||
|
|
||||||
|
version 1.8.6:
|
||||||
|
- Portuguese translation, thanks @goncalo532
|
||||||
|
- Updated other translations
|
||||||
|
|
||||||
|
version 1.8.5:
|
||||||
|
- version 1.8.4 for everyone
|
||||||
|
|
||||||
|
version 1.8.5:
|
||||||
|
- version 1.8.4 for everyone
|
||||||
|
|
||||||
|
version 1.8.4 (beta only):
|
||||||
|
- Plex cloud should now work: Request pictures with transcoding API
|
||||||
|
- Fix Plex companion feedback for Android
|
||||||
- Update translations
|
- Update translations
|
||||||
|
|
||||||
version 2.12.20 (beta only):
|
version 1.8.3:
|
||||||
- Add information to PKC settings for background sync and Alexa whether a connection has been successfully made
|
- Fix Kodi playlists being empty
|
||||||
|
|
||||||
version 2.12.19:
|
version 1.8.2:
|
||||||
- 2.12.17 and 2.12.18 for everyone
|
- Choose to replace user ratings with the number of available versions of a media file
|
||||||
- Rename skip intro skin file
|
- More collection artwork: use TheMovieDB art
|
||||||
|
- Support new Companion command "refreshPlayQueue"
|
||||||
version 2.12.18 (beta only):
|
- Use https for TheMovieDB
|
||||||
- Quickly sync recently watched items before synching the playstates of the entire Plex library
|
|
||||||
- Improve logging for websocket JSON loads
|
|
||||||
|
|
||||||
version 2.12.17 (beta only):
|
|
||||||
- Sync name and user rating of a TV show season to Kodi
|
|
||||||
- Fix rare TypeError: expected string or buffer on playback start
|
|
||||||
|
|
||||||
version 2.12.16:
|
|
||||||
- versions 2.12.14 and 2.12.15 for everyone
|
|
||||||
|
|
||||||
version 2.12.15 (beta only):
|
|
||||||
- Fix skip intros sometimes not working due to a RuntimeError
|
|
||||||
- Update translations
|
- Update translations
|
||||||
|
|
||||||
version 2.12.14:
|
version 1.8.1:
|
||||||
- Add skip intro functionality
|
- Fix library sync crash due to UnicodeDecodeError
|
||||||
|
- Fix fanart for collections
|
||||||
|
- Comply with themoviedb.org terms of use
|
||||||
|
- Add some translations
|
||||||
|
|
||||||
version 2.12.13:
|
version 1.8.0
|
||||||
- Fix KeyError: u'game' if Plex Arcade has been activated
|
Featuring:
|
||||||
- Fix AttributeError: 'App' object has no attribute 'threads' when sync is cancelled
|
- Major music overhaul: Direct Paths should now work! Many thanks @Memesa for the pointers! Don't forget to reset your database
|
||||||
|
- Big transcoding overhaul
|
||||||
|
- Many Plex Companion fixes
|
||||||
|
- Add support to Kodi 18.0-alpha1 (thanks @CotzaDev)
|
||||||
|
|
||||||
version 2.12.12:
|
version 1.7.22 (beta only)
|
||||||
- Hopefully fix rare case when sync would get stuck indefinitely
|
- Fix playback stop not being recognized by the PMS
|
||||||
- Fix ValueError: invalid literal for int() for invalid dates sent by Plex
|
- Better way to sync progress to another account
|
||||||
- version 2.12.11 for everyone
|
|
||||||
|
|
||||||
version 2.12.11 (beta only):
|
version 1.7.21 (beta only)
|
||||||
- Fix PKC not auto-picking audio/subtitle stream when transcoding
|
- Fix Playback and watched status not syncing
|
||||||
- Fix ValueError when deleting a music album
|
- Fix PKC syncing progress to wrong account
|
||||||
- Fix OSError: Invalid argument when Plex returns an invalid timestamp
|
- Warn user if a xml cannot be parsed
|
||||||
|
|
||||||
version 2.12.10:
|
version 1.7.20 (beta only)
|
||||||
- Fix pictures from Plex picture libraries not working/displaying
|
- Fix for Windows usernames with non-ASCII chars
|
||||||
|
- Companion: Fix TypeError
|
||||||
|
- Use SSL settings when checking server connection
|
||||||
|
- Fix TypeError when PMS connection lost
|
||||||
|
- Increase timeout
|
||||||
|
|
||||||
version 2.12.9:
|
version 1.7.19 (beta only)
|
||||||
- Fix Local variable 'user' referenced before assignement
|
- Big code refactoring
|
||||||
|
- Many Plex Companion fixes
|
||||||
|
- Fix WindowsError or alike when deleting video nodes
|
||||||
|
- Remove restart on first setup
|
||||||
|
- Only set advancedsettings tweaks if Music enabled
|
||||||
|
|
||||||
version 2.12.8:
|
version 1.7.18 (beta only)
|
||||||
- version 2.12.7 for everyone
|
- Fix OperationalError when resetting PKC
|
||||||
|
- Fix possible OperationalErrors
|
||||||
|
- Companion: ensure sockets get closed
|
||||||
|
- Fix TypeError for Plex Companion
|
||||||
|
- Update Czech
|
||||||
|
|
||||||
version 2.12.7 (beta only):
|
version 1.7.17 (beta only)
|
||||||
- Fix PKC suddenly using main Plex user's credentials, e.g. when the PMS address changed
|
- Don't add media by other add-ons to queue
|
||||||
- Fix missing Kodi tags for movie collections/sets
|
- Fix KeyError for Plex Companion
|
||||||
|
- Repace Kodi mkdirs with os.makedirs
|
||||||
|
- Use xbmcvfs exists instead of os.path.exists
|
||||||
|
|
||||||
version 2.12.6:
|
version 1.7.16 (beta only)
|
||||||
- Fix rare KeyError when using PKC widgets
|
- Fix PKC complaining about files not found
|
||||||
- Fix suspension of artwork caching and PKC becoming unresponsive
|
- Fix multiple subtitles per language not showing
|
||||||
|
- Update Czech translation
|
||||||
|
- Fix too many arguments when marking 100% watched
|
||||||
|
- More small fixes
|
||||||
|
|
||||||
|
version 1.7.15 (beta only)
|
||||||
|
- Fix companion for "Playback via PMS"
|
||||||
|
- Change sleeping behavior for playqueue client
|
||||||
|
- Plex Companion: add itemType to playstate
|
||||||
|
- Less logging
|
||||||
|
|
||||||
|
version 1.7.14 (beta only)
|
||||||
|
- Fix TypeError, but for real now
|
||||||
|
|
||||||
|
version 1.7.13 (beta only)
|
||||||
|
- Fix TypeError with AdvancedSettings.xml missing
|
||||||
|
|
||||||
|
version 1.7.12 (beta only)
|
||||||
|
- Major music overhaul: Direct Paths should now work! Many thanks @Memesa for the pointers! Don't forget to reset your database
|
||||||
|
- Some Plex Companion fixes
|
||||||
|
- Fix UnicodeDecodeError on user switch
|
||||||
|
- Remove link to Crowdin.com
|
||||||
|
- Update Readme
|
||||||
|
|
||||||
|
version 1.7.11 (beta only)
|
||||||
|
- Add support to Kodi 18.0-alpha1 (thanks @CotzaDev)
|
||||||
|
- Fix PKC not storing network credentials correctly
|
||||||
|
|
||||||
|
version 1.7.10 (beta only)
|
||||||
|
- Avoid xbmcvfs entirely; use encoded paths
|
||||||
|
- Update Czech translation
|
||||||
|
|
||||||
|
version 1.7.9 (beta only)
|
||||||
|
- Big transcoding overhaul
|
||||||
|
- Fix for not detecting external subtitle language
|
||||||
|
- Change Plex transcoding profile to Android
|
||||||
|
- Use Kodi video cache setting for transcoding
|
||||||
|
- Fix TheTVDB ID for TV shows
|
||||||
|
- Account for missing IMDB ids for movies
|
||||||
|
- Account for missing TheTVDB ids
|
||||||
|
- Fix UnicodeDecodeError on user switch
|
||||||
|
- Update English, Spanish and German
|
||||||
|
|
||||||
|
version 1.7.8 (beta only)
|
||||||
|
- Fix IMDB id for movies (resync by going to the PKC settings, Advanced, then Repair Local Database)
|
||||||
|
- Increase timeouts for PMS, should fix some connection issues
|
||||||
|
- Move translations to new strings.po system
|
||||||
|
- Fix some TypeErrors
|
||||||
|
- Some code refactoring
|
||||||
|
|
||||||
|
version 1.7.7
|
||||||
|
- Chinese Traditional, thanks @old2tan
|
||||||
|
- Chinese Simplified, thanks @everdream
|
||||||
|
- Browse by folder: also sort by Date Added
|
||||||
|
- Update addon.xml
|
||||||
|
|
||||||
|
version 1.7.6
|
||||||
|
- Hotfix: Revert Cache missing artwork on PKC startup. This should help with slow PKC startup, videos not being started, lagging PKC, etc.
|
||||||
|
|
||||||
|
version 1.7.5
|
||||||
|
- Dutch translation, thanks @mvanbaak
|
||||||
|
|
||||||
|
version 1.7.4 (beta only)
|
||||||
|
- Show menu item only for appropriate Kodi library: Be careful to start video content through Videos - Video Addons - ... and pictures through Pictures - Picture Addons - ...
|
||||||
|
- Fix playback error popup when using Alexa
|
||||||
|
- New Italian translations, thanks @nikkux, @chicco83
|
||||||
- Update translations
|
- Update translations
|
||||||
- Versions 2.12.4 and 2.12.5 for everyone
|
- Rewire Kodi ListItem stuff
|
||||||
|
- Fix TypeError for setting ListItem streams
|
||||||
|
- Fix Kodi setContent for images
|
||||||
|
- Fix AttributeError due to missing Kodi sort methods
|
||||||
|
|
||||||
version 2.12.5 (beta only):
|
version 1.7.3 (beta only)
|
||||||
- Greatly improve matching logic for The Movie Database if Plex does not provide an appropriate id
|
- Fix KeyError for channels if no media streams
|
||||||
- Fix high transcoding resolutions not being available for Win10
|
- Move plex node navigation, playback to main thread
|
||||||
- Fix rare playback progress report failing and KeyError: u'containerKey'
|
- Fix TypeError for malformed browsing xml
|
||||||
- Fix rare KeyError: None when trying to sync playlists
|
- Fix IndexError if we can't get a valid xml from PMS
|
||||||
- Fix TypeError when canceling Plex sync section dialog
|
- Pass 'None' instead of empty string in url args
|
||||||
|
|
||||||
version 2.12.4 (beta only):
|
version 1.7.2
|
||||||
- Hopefully fix freeze during sync: Don't assign multiple sets/collections for a specific movie
|
- Fix for some channels not starting playback
|
||||||
- Support metadata provider ids (e.g. for IMDB) for the new Plex Movie Agent
|
|
||||||
|
|
||||||
version 2.12.3:
|
version 1.7.1
|
||||||
- Fix playback failing due to caching of subtitles with non-ascii chars
|
- Fix Alexa not doing anything
|
||||||
- Fix ValueError: invalid literal for int() with base 10 during show sync
|
|
||||||
- Fix UnboundLocalError when certain Plex sections are deleted or being un-synched
|
|
||||||
- New method to install PlexKodiConnect directly via an URL. You thus do not need to upload a ZIP file to Kodi anymore.
|
|
||||||
|
|
||||||
version 2.12.2:
|
version 1.7.0
|
||||||
- version 2.12.0 and 2.12.1 for everyone
|
- Amazon Alexa support! Be sure to check the Plex Alexa forum first if you encounter issues; there are still many bugs completely unrelated to PKC
|
||||||
- Fix regression: sync dialog not showing up when it should
|
- Plex Channels!
|
||||||
|
- Browse video nodes by folder/path
|
||||||
version 2.12.1 (beta only):
|
- Fix IndexError for playqueues
|
||||||
- Fix PKC shutdown on Kodi profile switch
|
|
||||||
- Fix Kodi content type for images/photos
|
|
||||||
- Added support for custom set of safe characters when escaping paths (thanks @geropan)
|
|
||||||
- Revert "Don't allow spaces in devicename"
|
|
||||||
- Fix sync dialog showing in certain cases even though user opted out
|
|
||||||
|
|
||||||
version 2.12.0 (beta only):
|
|
||||||
- Fix websocket threads; enable PKC background sync for all Plex Home users!
|
|
||||||
- Fix PKC incorrectly marking a video as unwatched if an external player has been used
|
|
||||||
- Update translations
|
- Update translations
|
||||||
|
- Code optimization</news>
|
||||||
version 2.11.7:
|
|
||||||
- Fix PKC crashing on devices running Microsoft UWP, e.g. XBox
|
|
||||||
|
|
||||||
version 2.11.6:
|
|
||||||
- Fix rare sync crash when queue was full
|
|
||||||
- Set "Auto-adjust transcoding quality" to false by default
|
|
||||||
|
|
||||||
version 2.11.5:
|
|
||||||
- Versions 2.11.0-2.11.4 for everyone
|
|
||||||
|
|
||||||
version 2.11.4 (beta only):
|
|
||||||
- Fix another TypeError: 'NoneType' object has no attribute '__getitem__', e.g. when trying to play trailers
|
|
||||||
|
|
||||||
version 2.11.3 (beta only):
|
|
||||||
- Fix TypeError: 'NoneType' object has no attribute '__getitem__', e.g. when displaying albums
|
|
||||||
|
|
||||||
version 2.11.2 (beta only):
|
|
||||||
- Refactor direct and add-on paths. Enables use of Plex music playlists synched to Kodi
|
|
||||||
|
|
||||||
version 2.11.1 (beta only):
|
|
||||||
- Rewire the set-up of audio and subtitle streams, esp. before starting a transcoding session. Fixes playback not starting at all
|
|
||||||
|
|
||||||
version 2.11.0 (beta only):
|
|
||||||
- Fix PKC not burning in (and thus not showing) subtitles when transcoding
|
|
||||||
- When transcoding, only let user choose to burn-in subtitles that can't be displayed otherwise by Kodi
|
|
||||||
- Improve PKC automatically connecting to local PMS
|
|
||||||
- Ensure that our only video transcoding target is h264
|
|
||||||
- Fix adjusted subtitle size not working when burning in subtitles
|
|
||||||
- Fix regression: burn-in subtitles picking up the last user setting instead of the current one
|
|
||||||
</news>
|
|
||||||
</extension>
|
</extension>
|
||||||
</addon>
|
</addon>
|
||||||
|
|
991
changelog.txt
991
changelog.txt
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,5 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
###############################################################################
|
###############################################################################
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from sys import listitem
|
from sys import listitem
|
||||||
from urllib import urlencode
|
from urllib import urlencode
|
||||||
|
|
||||||
|
@ -40,10 +39,9 @@ def main():
|
||||||
'kodi_id': kodi_id,
|
'kodi_id': kodi_id,
|
||||||
'kodi_type': kodi_type
|
'kodi_type': kodi_type
|
||||||
}
|
}
|
||||||
while window.getProperty('plexkodiconnect.command'):
|
while window.getProperty('plex_command'):
|
||||||
sleep(20)
|
sleep(20)
|
||||||
window.setProperty('plexkodiconnect.command',
|
window.setProperty('plex_command', 'CONTEXT_menu?%s' % urlencode(args))
|
||||||
'CONTEXT_menu?%s' % urlencode(args))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
238
default.py
238
default.py
|
@ -1,22 +1,49 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
import logging
|
import logging
|
||||||
from sys import argv
|
from os import path as os_path
|
||||||
|
from sys import path as sys_path, argv
|
||||||
from urlparse import parse_qsl
|
from urlparse import parse_qsl
|
||||||
|
|
||||||
import xbmc
|
from xbmc import translatePath, sleep, executebuiltin
|
||||||
import xbmcgui
|
from xbmcaddon import Addon
|
||||||
import xbmcplugin
|
from xbmcgui import ListItem
|
||||||
|
from xbmcplugin import setResolvedUrl
|
||||||
|
|
||||||
from resources.lib import entrypoint, utils, transfer, variables as v, loghandler
|
_addon = Addon(id='plugin.video.plexkodiconnect')
|
||||||
from resources.lib.tools import unicode_paths
|
try:
|
||||||
|
_addon_path = _addon.getAddonInfo('path').decode('utf-8')
|
||||||
|
except TypeError:
|
||||||
|
_addon_path = _addon.getAddonInfo('path').decode()
|
||||||
|
try:
|
||||||
|
_base_resource = translatePath(os_path.join(
|
||||||
|
_addon_path,
|
||||||
|
'resources',
|
||||||
|
'lib')).decode('utf-8')
|
||||||
|
except TypeError:
|
||||||
|
_base_resource = translatePath(os_path.join(
|
||||||
|
_addon_path,
|
||||||
|
'resources',
|
||||||
|
'lib')).decode()
|
||||||
|
sys_path.append(_base_resource)
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
import entrypoint
|
||||||
|
from utils import window, reset, passwords_xml, language as lang, dialog, \
|
||||||
|
plex_command
|
||||||
|
from pickler import unpickle_me, pickl_window
|
||||||
|
from PKC_listitem import convert_PKC_to_listitem
|
||||||
|
import variables as v
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
import loghandler
|
||||||
|
|
||||||
loghandler.config()
|
loghandler.config()
|
||||||
LOG = logging.getLogger('PLEX.default')
|
log = logging.getLogger('PLEX.default')
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
@ -27,15 +54,9 @@ class Main():
|
||||||
# MAIN ENTRY POINT
|
# MAIN ENTRY POINT
|
||||||
# @utils.profiling()
|
# @utils.profiling()
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
LOG.debug('Full sys.argv received: %s', argv)
|
log.debug('Full sys.argv received: %s' % argv)
|
||||||
# Parse parameters
|
# Parse parameters
|
||||||
params = dict(parse_qsl(argv[2][1:]))
|
params = dict(parse_qsl(argv[2][1:]))
|
||||||
arguments = unicode_paths.decode(argv[2])
|
|
||||||
path = unicode_paths.decode(argv[0])
|
|
||||||
# Ensure unicode
|
|
||||||
for key, value in params.iteritems():
|
|
||||||
params[key.decode('utf-8')] = params.pop(key)
|
|
||||||
params[key] = value.decode('utf-8')
|
|
||||||
mode = params.get('mode', '')
|
mode = params.get('mode', '')
|
||||||
itemid = params.get('id', '')
|
itemid = params.get('id', '')
|
||||||
|
|
||||||
|
@ -45,117 +66,108 @@ class Main():
|
||||||
elif mode == 'plex_node':
|
elif mode == 'plex_node':
|
||||||
self.play()
|
self.play()
|
||||||
|
|
||||||
|
elif mode == 'ondeck':
|
||||||
|
entrypoint.getOnDeck(itemid,
|
||||||
|
params.get('type'),
|
||||||
|
params.get('tagname'),
|
||||||
|
int(params.get('limit')))
|
||||||
|
|
||||||
|
elif mode == 'recentepisodes':
|
||||||
|
entrypoint.getRecentEpisodes(itemid,
|
||||||
|
params.get('type'),
|
||||||
|
params.get('tagname'),
|
||||||
|
int(params.get('limit')))
|
||||||
|
|
||||||
|
elif mode == 'nextup':
|
||||||
|
entrypoint.getNextUpEpisodes(params['tagname'],
|
||||||
|
int(params['limit']))
|
||||||
|
|
||||||
|
elif mode == 'inprogressepisodes':
|
||||||
|
entrypoint.getInProgressEpisodes(params['tagname'],
|
||||||
|
int(params['limit']))
|
||||||
|
|
||||||
elif mode == 'browseplex':
|
elif mode == 'browseplex':
|
||||||
entrypoint.browse_plex(key=params.get('key'),
|
entrypoint.browse_plex(key=params.get('key'),
|
||||||
plex_type=params.get('plex_type'),
|
plex_section_id=params.get('id'))
|
||||||
section_id=params.get('section_id'),
|
|
||||||
synched=params.get('synched') != 'false',
|
|
||||||
prompt=params.get('prompt'),
|
|
||||||
query=params.get('query'))
|
|
||||||
|
|
||||||
elif mode == 'show_section':
|
elif mode == 'getsubfolders':
|
||||||
entrypoint.show_section(params.get('section_index'))
|
entrypoint.GetSubFolders(itemid)
|
||||||
|
|
||||||
elif mode == 'watchlater':
|
elif mode == 'watchlater':
|
||||||
entrypoint.watchlater()
|
entrypoint.watchlater()
|
||||||
|
|
||||||
elif mode == 'channels':
|
elif mode == 'channels':
|
||||||
entrypoint.browse_plex(key='/channels/all')
|
entrypoint.channels()
|
||||||
|
|
||||||
elif mode == 'search':
|
|
||||||
# "Search"
|
|
||||||
entrypoint.browse_plex(key='/hubs/search',
|
|
||||||
args={'includeCollections': 1,
|
|
||||||
'includeExternalMedia': 1},
|
|
||||||
prompt=utils.lang(137),
|
|
||||||
query=params.get('query'))
|
|
||||||
|
|
||||||
elif mode == 'route_to_extras':
|
|
||||||
# Hack so we can store this path in the Kodi DB
|
|
||||||
handle = ('plugin://%s?mode=extras&plex_id=%s'
|
|
||||||
% (v.ADDON_ID, params.get('plex_id')))
|
|
||||||
if xbmcgui.getCurrentWindowId() == 10025:
|
|
||||||
# Video Window
|
|
||||||
xbmc.executebuiltin('Container.Update(\"%s\")' % handle)
|
|
||||||
else:
|
|
||||||
xbmc.executebuiltin('ActivateWindow(videos, \"%s\")' % handle)
|
|
||||||
|
|
||||||
elif mode == 'extras':
|
elif mode == 'extras':
|
||||||
entrypoint.extras(plex_id=params.get('plex_id'))
|
entrypoint.extras(plex_id=params.get('plex_id'))
|
||||||
|
|
||||||
elif mode == 'settings':
|
elif mode == 'settings':
|
||||||
xbmc.executebuiltin('Addon.OpenSettings(%s)' % v.ADDON_ID)
|
executebuiltin('Addon.OpenSettings(%s)' % v.ADDON_ID)
|
||||||
|
|
||||||
elif mode == 'enterPMS':
|
elif mode == 'enterPMS':
|
||||||
LOG.info('Request to manually enter new PMS address')
|
entrypoint.enterPMS()
|
||||||
transfer.plex_command('enter_new_pms_address')
|
|
||||||
|
|
||||||
elif mode == 'reset':
|
elif mode == 'reset':
|
||||||
transfer.plex_command('RESET-PKC')
|
reset()
|
||||||
|
|
||||||
elif mode == 'togglePlexTV':
|
elif mode == 'togglePlexTV':
|
||||||
LOG.info('Toggle of Plex.tv sign-in requested')
|
entrypoint.togglePlexTV()
|
||||||
transfer.plex_command('toggle_plex_tv_sign_in')
|
|
||||||
|
elif mode == 'resetauth':
|
||||||
|
entrypoint.resetAuth()
|
||||||
|
|
||||||
elif mode == 'passwords':
|
elif mode == 'passwords':
|
||||||
from resources.lib.windows import direct_path_sources
|
passwords_xml()
|
||||||
direct_path_sources.start()
|
|
||||||
|
|
||||||
elif mode == 'switchuser':
|
elif mode == 'switchuser':
|
||||||
LOG.info('Plex home user switch requested')
|
entrypoint.switchPlexUser()
|
||||||
transfer.plex_command('switch_plex_user')
|
|
||||||
|
|
||||||
elif mode in ('manualsync', 'repair'):
|
elif mode in ('manualsync', 'repair'):
|
||||||
if mode == 'repair':
|
if window('plex_online') != 'true':
|
||||||
LOG.info('Requesting repair lib sync')
|
# Server is not online, do not run the sync
|
||||||
transfer.plex_command('repair-scan')
|
dialog('ok', lang(29999), lang(39205))
|
||||||
elif mode == 'manualsync':
|
log.error('Not connected to a PMS.')
|
||||||
LOG.info('Requesting full library scan')
|
else:
|
||||||
transfer.plex_command('full-scan')
|
if mode == 'repair':
|
||||||
|
log.info('Requesting repair lib sync')
|
||||||
|
plex_command('RUN_LIB_SCAN', 'repair')
|
||||||
|
elif mode == 'manualsync':
|
||||||
|
log.info('Requesting full library scan')
|
||||||
|
plex_command('RUN_LIB_SCAN', 'full')
|
||||||
|
|
||||||
elif mode == 'texturecache':
|
elif mode == 'texturecache':
|
||||||
LOG.info('Requesting texture caching of all textures')
|
log.info('Requesting texture caching of all textures')
|
||||||
transfer.plex_command('textures-scan')
|
plex_command('RUN_LIB_SCAN', 'textures')
|
||||||
|
|
||||||
elif mode == 'chooseServer':
|
elif mode == 'chooseServer':
|
||||||
LOG.info("Choosing PMS server requested, starting")
|
entrypoint.chooseServer()
|
||||||
transfer.plex_command('choose_pms_server')
|
|
||||||
|
elif mode == 'refreshplaylist':
|
||||||
|
log.info('Requesting playlist/nodes refresh')
|
||||||
|
plex_command('RUN_LIB_SCAN', 'views')
|
||||||
|
|
||||||
elif mode == 'deviceid':
|
elif mode == 'deviceid':
|
||||||
self.deviceid()
|
self.deviceid()
|
||||||
|
|
||||||
elif mode == 'fanart':
|
elif mode == 'fanart':
|
||||||
LOG.info('User requested fanarttv refresh')
|
log.info('User requested fanarttv refresh')
|
||||||
transfer.plex_command('fanart-scan')
|
plex_command('RUN_LIB_SCAN', 'fanart')
|
||||||
|
|
||||||
elif '/extrafanart' in path:
|
elif '/extrafanart' in argv[0]:
|
||||||
plexpath = arguments[1:]
|
plexpath = argv[2][1:]
|
||||||
plexid = itemid
|
plexid = itemid
|
||||||
entrypoint.extra_fanart(plexid, plexpath)
|
entrypoint.getExtraFanArt(plexid, plexpath)
|
||||||
entrypoint.get_video_files(plexid, plexpath)
|
entrypoint.getVideoFiles(plexid, plexpath)
|
||||||
|
|
||||||
# Called by e.g. 3rd party plugin video extras
|
# Called by e.g. 3rd party plugin video extras
|
||||||
elif ('/Extras' in path or '/VideoFiles' in path or
|
elif ('/Extras' in argv[0] or '/VideoFiles' in argv[0] or
|
||||||
'/Extras' in arguments):
|
'/Extras' in argv[2]):
|
||||||
plexId = itemid or None
|
plexId = itemid or None
|
||||||
entrypoint.get_video_files(plexId, params)
|
entrypoint.getVideoFiles(plexId, params)
|
||||||
|
|
||||||
elif mode == 'playlists':
|
|
||||||
entrypoint.playlists(params.get('content_type'))
|
|
||||||
|
|
||||||
elif mode == 'hub':
|
|
||||||
entrypoint.hub(params.get('content_type'))
|
|
||||||
|
|
||||||
elif mode == 'select-libraries':
|
|
||||||
LOG.info('User requested to select Plex libraries')
|
|
||||||
transfer.plex_command('select-libraries')
|
|
||||||
|
|
||||||
elif mode == 'refreshplaylist':
|
|
||||||
LOG.info('User requested to refresh Kodi playlists and nodes')
|
|
||||||
transfer.plex_command('refreshplaylist')
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
entrypoint.show_main_menu(content_type=params.get('content_type'))
|
entrypoint.doMainListing(content_type=params.get('content_type'))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def play():
|
def play():
|
||||||
|
@ -164,46 +176,44 @@ class Main():
|
||||||
"""
|
"""
|
||||||
request = '%s&handle=%s' % (argv[2], HANDLE)
|
request = '%s&handle=%s' % (argv[2], HANDLE)
|
||||||
# Put the request into the 'queue'
|
# Put the request into the 'queue'
|
||||||
transfer.plex_command('PLAY-%s' % request)
|
plex_command('PLAY', request)
|
||||||
if HANDLE == -1:
|
if HANDLE == -1:
|
||||||
# Handle -1 received, not waiting for main thread
|
# Handle -1 received, not waiting for main thread
|
||||||
return
|
return
|
||||||
# Wait for the result from the main PKC thread
|
# Wait for the result
|
||||||
result = transfer.wait_for_transfer(source='main')
|
while not pickl_window('plex_result'):
|
||||||
if result is True:
|
sleep(50)
|
||||||
xbmcplugin.setResolvedUrl(HANDLE, False, xbmcgui.ListItem())
|
result = unpickle_me()
|
||||||
# Tell main thread that we're done
|
if result is None:
|
||||||
transfer.send(True, target='main')
|
log.error('Error encountered, aborting')
|
||||||
else:
|
dialog('notification',
|
||||||
# Received a xbmcgui.ListItem()
|
heading='{plex}',
|
||||||
xbmcplugin.setResolvedUrl(HANDLE, True, result)
|
message=lang(30128),
|
||||||
|
icon='{error}',
|
||||||
|
time=3000)
|
||||||
|
setResolvedUrl(HANDLE, False, ListItem())
|
||||||
|
elif result.listitem:
|
||||||
|
listitem = convert_PKC_to_listitem(result.listitem)
|
||||||
|
setResolvedUrl(HANDLE, True, listitem)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def deviceid():
|
def deviceid():
|
||||||
window = xbmcgui.Window(10000)
|
deviceId_old = window('plex_client_Id')
|
||||||
deviceId_old = window.getProperty('plex_client_Id')
|
from clientinfo import getDeviceId
|
||||||
from resources.lib import clientinfo
|
|
||||||
try:
|
try:
|
||||||
deviceId = clientinfo.getDeviceId(reset=True)
|
deviceId = getDeviceId(reset=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error('Failed to generate a new device Id: %s' % e)
|
log.error('Failed to generate a new device Id: %s' % e)
|
||||||
utils.messageDialog(utils.lang(29999), utils.lang(33032))
|
dialog('ok', lang(29999), lang(33032))
|
||||||
else:
|
else:
|
||||||
LOG.info('Successfully removed old device ID: %s New deviceId:'
|
log.info('Successfully removed old device ID: %s New deviceId:'
|
||||||
'%s' % (deviceId_old, deviceId))
|
'%s' % (deviceId_old, deviceId))
|
||||||
# 'Kodi will now restart to apply the changes'
|
# 'Kodi will now restart to apply the changes'
|
||||||
utils.messageDialog(utils.lang(29999), utils.lang(33033))
|
dialog('ok', lang(29999), lang(33033))
|
||||||
xbmc.executebuiltin('RestartApp')
|
executebuiltin('RestartApp')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
LOG.info('%s started' % v.ADDON_ID)
|
log.info('%s started' % v.ADDON_ID)
|
||||||
try:
|
Main()
|
||||||
v.database_paths()
|
log.info('%s stopped' % v.ADDON_ID)
|
||||||
except RuntimeError as err:
|
|
||||||
# Database does not exists
|
|
||||||
LOG.error('The current Kodi version is incompatible')
|
|
||||||
LOG.error('Error: %s', err)
|
|
||||||
else:
|
|
||||||
Main()
|
|
||||||
LOG.info('%s stopped' % v.ADDON_ID)
|
|
||||||
|
|
BIN
empty_video.mp4
Normal file
BIN
empty_video.mp4
Normal file
Binary file not shown.
|
@ -155,6 +155,11 @@ msgctxt "#30028"
|
||||||
msgid "PKC-only image caching completed"
|
msgid "PKC-only image caching completed"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
# Warning shown when PKC switches to the Kodi default skin Estuary
|
||||||
|
msgctxt "#30029"
|
||||||
|
msgid "To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to use Kodi's default skin \"Estuary\" for initial set-up and for possible database resets. Continue?"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgctxt "#30030"
|
msgctxt "#30030"
|
||||||
msgid "Port Number"
|
msgid "Port Number"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
@ -1093,6 +1098,11 @@ msgctxt "#39074"
|
||||||
msgid "TV Shows"
|
msgid "TV Shows"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
# PKC Settings - Sync
|
||||||
|
msgctxt "#39075"
|
||||||
|
msgid "Verify access to media files while synching"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
# Pop-up during initial sync
|
# Pop-up during initial sync
|
||||||
msgctxt "#39076"
|
msgctxt "#39076"
|
||||||
msgid "If you use several Plex libraries of one kind, e.g. \"Kids Movies\" and \"Parents Movies\", be sure to check the Wiki: https://goo.gl/JFtQV9"
|
msgid "If you use several Plex libraries of one kind, e.g. \"Kids Movies\" and \"Parents Movies\", be sure to check the Wiki: https://goo.gl/JFtQV9"
|
||||||
|
|
|
@ -1,160 +1,16 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
###############################################################################
|
||||||
Used to shovel data from separate Kodi Python instances to the main thread
|
from xbmcgui import ListItem
|
||||||
and vice versa.
|
|
||||||
"""
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
import json
|
|
||||||
|
|
||||||
import xbmc
|
|
||||||
import xbmcgui
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.transfer')
|
|
||||||
WINDOW = xbmcgui.Window(10000)
|
|
||||||
WINDOW_UPSTREAM = 'plexkodiconnect.result.upstream'.encode('utf-8')
|
|
||||||
WINDOW_DOWNSTREAM = 'plexkodiconnect.result.downstream'.encode('utf-8')
|
|
||||||
WINDOW_COMMAND = 'plexkodiconnect.command'.encode('utf-8')
|
|
||||||
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
|
|
||||||
|
|
||||||
|
|
||||||
def cast(func, value):
|
def convert_PKC_to_listitem(PKC_listitem):
|
||||||
"""
|
"""
|
||||||
Cast the specified value to the specified type (returned by func). Currently
|
Insert a PKC_listitem and you will receive a valid XBMC listitem
|
||||||
this only support int, float, bool. Should be extended if needed.
|
|
||||||
Parameters:
|
|
||||||
func (func): Calback function to used cast to type (int, bool, float).
|
|
||||||
value (any): value to be cast and returned.
|
|
||||||
|
|
||||||
Returns None if something goes wrong
|
|
||||||
"""
|
"""
|
||||||
if value is None:
|
data = PKC_listitem.data
|
||||||
return value
|
listitem = ListItem(label=data.get('label'),
|
||||||
elif func == bool:
|
label2=data.get('label2'),
|
||||||
return bool(int(value))
|
path=data.get('path'))
|
||||||
elif func == unicode:
|
|
||||||
if isinstance(value, (int, long, float)):
|
|
||||||
return unicode(value)
|
|
||||||
elif isinstance(value, unicode):
|
|
||||||
return value
|
|
||||||
else:
|
|
||||||
return value.decode('utf-8')
|
|
||||||
elif func == str:
|
|
||||||
if isinstance(value, (int, long, float)):
|
|
||||||
return str(value)
|
|
||||||
elif isinstance(value, str):
|
|
||||||
return value
|
|
||||||
else:
|
|
||||||
return value.encode('utf-8')
|
|
||||||
elif func == int:
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
# Converting e.g. '8.0' fails; need to convert to float first
|
|
||||||
return int(float(value))
|
|
||||||
except ValueError:
|
|
||||||
return
|
|
||||||
elif func == float:
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except ValueError:
|
|
||||||
return
|
|
||||||
return func(value)
|
|
||||||
|
|
||||||
|
|
||||||
def kodi_window(property, value=None, clear=False):
|
|
||||||
"""
|
|
||||||
Get or set window property - thread safe! value must be string
|
|
||||||
"""
|
|
||||||
if clear:
|
|
||||||
WINDOW.clearProperty(property)
|
|
||||||
elif value is not None:
|
|
||||||
WINDOW.setProperty(property, value)
|
|
||||||
else:
|
|
||||||
return WINDOW.getProperty(property)
|
|
||||||
|
|
||||||
|
|
||||||
def plex_command(value):
|
|
||||||
"""
|
|
||||||
Used to funnel states between different Python instances. NOT really thread
|
|
||||||
safe - let's hope the Kodi user can't click fast enough
|
|
||||||
"""
|
|
||||||
while kodi_window(WINDOW_COMMAND):
|
|
||||||
xbmc.sleep(50)
|
|
||||||
kodi_window(WINDOW_COMMAND, value=value)
|
|
||||||
|
|
||||||
|
|
||||||
def serialize(obj):
|
|
||||||
if isinstance(obj, PKCListItem):
|
|
||||||
return {'type': 'PKCListItem', 'data': obj.data}
|
|
||||||
else:
|
|
||||||
return {'type': 'other', 'data': obj}
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def de_serialize(answ):
|
|
||||||
if answ['type'] == 'PKCListItem':
|
|
||||||
result = PKCListItem()
|
|
||||||
result.data = answ['data']
|
|
||||||
return convert_pkc_to_listitem(result)
|
|
||||||
elif answ['type'] == 'other':
|
|
||||||
return answ['data']
|
|
||||||
else:
|
|
||||||
raise NotImplementedError('Not implemented: %s' % answ)
|
|
||||||
|
|
||||||
|
|
||||||
def send(pkc_listitem, target='default'):
|
|
||||||
"""
|
|
||||||
Pickles the obj to the window variable. Use to transfer Python
|
|
||||||
objects between different PKC python instances (e.g. if default.py is
|
|
||||||
called and you'd want to use the service.py instance)
|
|
||||||
|
|
||||||
obj can be pretty much any Python object. However, classes and
|
|
||||||
functions won't work. See the Pickle documentation
|
|
||||||
|
|
||||||
Set target='default' if you send data TO another Python default.py
|
|
||||||
instance, 'main' if your default.py needs to send to the main thread
|
|
||||||
"""
|
|
||||||
window = WINDOW_DOWNSTREAM if target == 'default' else WINDOW_UPSTREAM
|
|
||||||
LOG.debug('Sending: %s', pkc_listitem)
|
|
||||||
kodi_window(window,
|
|
||||||
value=json.dumps(serialize(pkc_listitem)))
|
|
||||||
|
|
||||||
|
|
||||||
def wait_for_transfer(source='main'):
|
|
||||||
"""
|
|
||||||
Set source='default' if you wait for data FROM another Python default.py
|
|
||||||
instance, 'main' if your default.py needs to wait for the main thread
|
|
||||||
"""
|
|
||||||
LOG.debug('Waiting for transfer from %s', source)
|
|
||||||
window = WINDOW_DOWNSTREAM if source == 'main' else WINDOW_UPSTREAM
|
|
||||||
result = ''
|
|
||||||
while not result:
|
|
||||||
result = kodi_window(window)
|
|
||||||
if result:
|
|
||||||
kodi_window(window, clear=True)
|
|
||||||
LOG.debug('Received')
|
|
||||||
result = json.loads(result)
|
|
||||||
return de_serialize(result)
|
|
||||||
xbmc.sleep(50)
|
|
||||||
|
|
||||||
|
|
||||||
def convert_pkc_to_listitem(pkc_listitem):
|
|
||||||
"""
|
|
||||||
Insert a PKCListItem() and you will receive a valid XBMC listitem
|
|
||||||
"""
|
|
||||||
data = pkc_listitem.data
|
|
||||||
if KODIVERSION >= 18:
|
|
||||||
listitem = xbmcgui.ListItem(label=data.get('label'),
|
|
||||||
label2=data.get('label2'),
|
|
||||||
path=data.get('path'),
|
|
||||||
offscreen=True)
|
|
||||||
else:
|
|
||||||
listitem = xbmcgui.ListItem(label=data.get('label'),
|
|
||||||
label2=data.get('label2'),
|
|
||||||
path=data.get('path'))
|
|
||||||
if data['info']:
|
if data['info']:
|
||||||
listitem.setInfo(**data['info'])
|
listitem.setInfo(**data['info'])
|
||||||
for stream in data['stream_info']:
|
for stream in data['stream_info']:
|
||||||
|
@ -164,22 +20,20 @@ def convert_pkc_to_listitem(pkc_listitem):
|
||||||
if data['art']:
|
if data['art']:
|
||||||
listitem.setArt(data['art'])
|
listitem.setArt(data['art'])
|
||||||
for key, value in data['property'].iteritems():
|
for key, value in data['property'].iteritems():
|
||||||
listitem.setProperty(key, cast(str, value))
|
listitem.setProperty(key, value)
|
||||||
if data['subtitles']:
|
if data['subtitles']:
|
||||||
listitem.setSubtitles(data['subtitles'])
|
listitem.setSubtitles(data['subtitles'])
|
||||||
if data['contextmenu']:
|
|
||||||
listitem.addContextMenuItems(data['contextmenu'])
|
|
||||||
return listitem
|
return listitem
|
||||||
|
|
||||||
|
|
||||||
class PKCListItem(object):
|
class PKC_ListItem(object):
|
||||||
"""
|
"""
|
||||||
Imitates xbmcgui.ListItem and its functions. Pass along PKC_Listitem().data
|
Imitates xbmcgui.ListItem and its functions. Pass along PKC_Listitem().data
|
||||||
when pickling!
|
when pickling!
|
||||||
|
|
||||||
WARNING: set/get path only via setPath and getPath! (not getProperty)
|
WARNING: set/get path only via setPath and getPath! (not getProperty)
|
||||||
"""
|
"""
|
||||||
def __init__(self, label=None, label2=None, path=None, offscreen=True):
|
def __init__(self, label=None, label2=None, path=None):
|
||||||
self.data = {
|
self.data = {
|
||||||
'stream_info': [], # (type, values: dict { label: value })
|
'stream_info': [], # (type, values: dict { label: value })
|
||||||
'art': {}, # dict
|
'art': {}, # dict
|
||||||
|
@ -189,10 +43,9 @@ class PKCListItem(object):
|
||||||
'path': path, # string
|
'path': path, # string
|
||||||
'property': {}, # (key, value)
|
'property': {}, # (key, value)
|
||||||
'subtitles': [], # strings
|
'subtitles': [], # strings
|
||||||
'contextmenu': None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def addContextMenuItems(self, items):
|
def addContextMenuItems(self, items, replaceItems):
|
||||||
"""
|
"""
|
||||||
Adds item(s) to the context menu for media lists.
|
Adds item(s) to the context menu for media lists.
|
||||||
|
|
||||||
|
@ -210,7 +63,7 @@ class PKCListItem(object):
|
||||||
|
|
||||||
Once you use a keyword, all following arguments require the keyword.
|
Once you use a keyword, all following arguments require the keyword.
|
||||||
"""
|
"""
|
||||||
self.data['contextmenu'] = items
|
raise NotImplementedError
|
||||||
|
|
||||||
def addStreamInfo(self, type, values):
|
def addStreamInfo(self, type, values):
|
||||||
"""
|
"""
|
1638
resources/lib/PlexAPI.py
Normal file
1638
resources/lib/PlexAPI.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,82 +1,36 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
"""
|
||||||
The Plex Companion master python file
|
The Plex Companion master python file
|
||||||
"""
|
"""
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from Queue import Empty
|
from Queue import Empty
|
||||||
from socket import SHUT_RDWR
|
from socket import SHUT_RDWR
|
||||||
from xbmc import executebuiltin
|
from urllib import urlencode
|
||||||
|
|
||||||
from .plexbmchelper import listener, plexgdm, subscribers, httppersist
|
from xbmc import sleep, executebuiltin, Player
|
||||||
from .plex_api import API
|
|
||||||
from . import utils
|
from utils import settings, thread_methods, language as lang, dialog
|
||||||
from . import plex_functions as PF
|
from plexbmchelper import listener, plexgdm, subscribers, httppersist
|
||||||
from . import playlist_func as PL
|
from plexbmchelper.subscribers import LOCKER
|
||||||
from . import playback
|
from PlexFunctions import ParseContainerKey, GetPlexMetadata, DownloadChunks
|
||||||
from . import json_rpc as js
|
from PlexAPI import API
|
||||||
from . import playqueue as PQ
|
from playlist_func import get_pms_playqueue, get_plextype_from_xml, \
|
||||||
from . import variables as v
|
get_playlist_details_from_xml
|
||||||
from . import backgroundthread
|
from playback import playback_triage, play_xml
|
||||||
from . import app
|
import json_rpc as js
|
||||||
from . import exceptions
|
import variables as v
|
||||||
|
import state
|
||||||
|
import playqueue as PQ
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
LOG = getLogger('PLEX.plex_companion')
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
def update_playqueue_from_PMS(playqueue,
|
@thread_methods(add_suspends=['PMS_STATUS'])
|
||||||
playqueue_id=None,
|
class PlexCompanion(Thread):
|
||||||
repeat=None,
|
|
||||||
offset=None,
|
|
||||||
transient_token=None,
|
|
||||||
start_plex_id=None):
|
|
||||||
"""
|
|
||||||
Completely updates the Kodi playqueue with the new Plex playqueue. Pass
|
|
||||||
in playqueue_id if we need to fetch a new playqueue
|
|
||||||
|
|
||||||
repeat = 0, 1, 2
|
|
||||||
offset = time offset in Plextime (milliseconds)
|
|
||||||
"""
|
|
||||||
LOG.info('New playqueue %s received from Plex companion with offset '
|
|
||||||
'%s, repeat %s, start_plex_id %s',
|
|
||||||
playqueue_id, offset, repeat, start_plex_id)
|
|
||||||
# Safe transient token from being deleted
|
|
||||||
if transient_token is None:
|
|
||||||
transient_token = playqueue.plex_transient_token
|
|
||||||
with app.APP.lock_playqueues:
|
|
||||||
try:
|
|
||||||
xml = PL.get_PMS_playlist(playqueue, playqueue_id)
|
|
||||||
except exceptions.PlaylistError:
|
|
||||||
LOG.error('Could now download playqueue %s', playqueue_id)
|
|
||||||
return
|
|
||||||
if playqueue.id == playqueue_id:
|
|
||||||
# This seems to be happening ONLY if a Plex Companion device
|
|
||||||
# reconnects and Kodi is already playing something - silly, really
|
|
||||||
# For all other cases, a new playqueue is generated by Plex
|
|
||||||
LOG.debug('Update for existing playqueue detected')
|
|
||||||
return
|
|
||||||
playqueue.clear()
|
|
||||||
# Get new metadata for the playqueue first
|
|
||||||
try:
|
|
||||||
PL.get_playlist_details_from_xml(playqueue, xml)
|
|
||||||
except exceptions.PlaylistError:
|
|
||||||
LOG.error('Could not get playqueue ID %s', playqueue_id)
|
|
||||||
return
|
|
||||||
playqueue.repeat = 0 if not repeat else int(repeat)
|
|
||||||
playqueue.plex_transient_token = transient_token
|
|
||||||
playback.play_xml(playqueue,
|
|
||||||
xml,
|
|
||||||
offset=offset,
|
|
||||||
start_plex_id=start_plex_id)
|
|
||||||
|
|
||||||
|
|
||||||
class PlexCompanion(backgroundthread.KillableThread):
|
|
||||||
"""
|
"""
|
||||||
Plex Companion monitoring class. Invoke only once
|
Plex Companion monitoring class. Invoke only once
|
||||||
"""
|
"""
|
||||||
|
@ -87,82 +41,75 @@ class PlexCompanion(backgroundthread.KillableThread):
|
||||||
self.client = plexgdm.plexgdm()
|
self.client = plexgdm.plexgdm()
|
||||||
self.client.clientDetails()
|
self.client.clientDetails()
|
||||||
LOG.debug("Registration string is:\n%s", self.client.getClientDetails())
|
LOG.debug("Registration string is:\n%s", self.client.getClientDetails())
|
||||||
|
# kodi player instance
|
||||||
|
self.player = Player()
|
||||||
self.httpd = False
|
self.httpd = False
|
||||||
self.subscription_manager = None
|
self.subscription_manager = None
|
||||||
super(PlexCompanion, self).__init__()
|
Thread.__init__(self)
|
||||||
|
|
||||||
@staticmethod
|
@LOCKER.lockthis
|
||||||
def _process_alexa(data):
|
def _process_alexa(self, data):
|
||||||
if 'key' not in data or 'containerKey' not in data:
|
xml = GetPlexMetadata(data['key'])
|
||||||
LOG.error('Received malformed Alexa data: %s', data)
|
|
||||||
return
|
|
||||||
xml = PF.GetPlexMetadata(data['key'])
|
|
||||||
try:
|
try:
|
||||||
xml[0].attrib
|
xml[0].attrib
|
||||||
except (AttributeError, IndexError, TypeError):
|
except (AttributeError, IndexError, TypeError):
|
||||||
LOG.error('Could not download Plex metadata for: %s', data)
|
LOG.error('Could not download Plex metadata for: %s', data)
|
||||||
return
|
return
|
||||||
api = API(xml[0])
|
api = API(xml[0])
|
||||||
if api.plex_type == v.PLEX_TYPE_ALBUM:
|
if api.plex_type() == v.PLEX_TYPE_ALBUM:
|
||||||
LOG.debug('Plex music album detected')
|
LOG.debug('Plex music album detected')
|
||||||
PQ.init_playqueue_from_plex_children(
|
PQ.init_playqueue_from_plex_children(
|
||||||
api.plex_id,
|
api.plex_id(),
|
||||||
transient_token=data.get('token'))
|
transient_token=data.get('token'))
|
||||||
elif data['containerKey'].startswith('/playQueues/'):
|
elif data['containerKey'].startswith('/playQueues/'):
|
||||||
_, container_key, _ = PF.ParseContainerKey(data['containerKey'])
|
_, container_key, _ = ParseContainerKey(data['containerKey'])
|
||||||
xml = PF.DownloadChunks('{server}/playQueues/%s' % container_key)
|
xml = DownloadChunks('{server}/playQueues/%s?' % container_key)
|
||||||
if xml is None:
|
if xml is None:
|
||||||
# "Play error"
|
# "Play error"
|
||||||
utils.dialog('notification',
|
dialog('notification', lang(29999), lang(30128), icon='{error}')
|
||||||
utils.lang(29999),
|
|
||||||
utils.lang(30128),
|
|
||||||
icon='{error}')
|
|
||||||
return
|
return
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = PQ.get_playqueue_from_type(
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()])
|
||||||
playqueue.clear()
|
playqueue.clear()
|
||||||
PL.get_playlist_details_from_xml(playqueue, xml)
|
get_playlist_details_from_xml(playqueue, xml)
|
||||||
playqueue.plex_transient_token = data.get('token')
|
playqueue.plex_transient_token = data.get('token')
|
||||||
if data.get('offset') != '0':
|
if data.get('offset') != '0':
|
||||||
offset = float(data['offset']) / 1000.0
|
offset = float(data['offset']) / 1000.0
|
||||||
else:
|
else:
|
||||||
offset = None
|
offset = None
|
||||||
playback.play_xml(playqueue, xml, offset)
|
play_xml(playqueue, xml, offset)
|
||||||
else:
|
else:
|
||||||
app.CONN.plex_transient_token = data.get('token')
|
state.PLEX_TRANSIENT_TOKEN = data.get('token')
|
||||||
playback.playback_triage(api.plex_id,
|
if data.get('offset') != '0':
|
||||||
api.plex_type,
|
state.RESUMABLE = True
|
||||||
resolve=False,
|
state.RESUME_PLAYBACK = True
|
||||||
resume=data.get('offset') not in ('0', None))
|
playback_triage(api.plex_id(), api.plex_type(), resolve=False)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _process_node(data):
|
def _process_node(data):
|
||||||
"""
|
"""
|
||||||
E.g. watch later initiated by Companion. Basically navigating Plex
|
E.g. watch later initiated by Companion. Basically navigating Plex
|
||||||
"""
|
"""
|
||||||
app.CONN.plex_transient_token = data.get('key')
|
state.PLEX_TRANSIENT_TOKEN = data.get('key')
|
||||||
params = {
|
params = {
|
||||||
'mode': 'plex_node',
|
'mode': 'plex_node',
|
||||||
'key': '{server}%s' % data.get('key'),
|
'key': '{server}%s' % data.get('key'),
|
||||||
'offset': data.get('offset')
|
'offset': data.get('offset')
|
||||||
}
|
}
|
||||||
handle = 'RunPlugin(plugin://%s)' % utils.extend_url(v.ADDON_ID, params)
|
executebuiltin('RunPlugin(plugin://%s?%s)'
|
||||||
executebuiltin(handle.encode('utf-8'))
|
% (v.ADDON_ID, urlencode(params)))
|
||||||
|
|
||||||
@staticmethod
|
@LOCKER.lockthis
|
||||||
def _process_playlist(data):
|
def _process_playlist(self, data):
|
||||||
if 'containerKey' not in data:
|
|
||||||
LOG.error('Received malformed playlist data: %s', data)
|
|
||||||
return
|
|
||||||
# Get the playqueue ID
|
# Get the playqueue ID
|
||||||
_, container_key, query = PF.ParseContainerKey(data['containerKey'])
|
_, container_key, query = ParseContainerKey(data['containerKey'])
|
||||||
try:
|
try:
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = PQ.get_playqueue_from_type(
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# E.g. Plex web does not supply the media type
|
# E.g. Plex web does not supply the media type
|
||||||
# Still need to figure out the type (video vs. music vs. pix)
|
# Still need to figure out the type (video vs. music vs. pix)
|
||||||
xml = PF.GetPlexMetadata(data['key'])
|
xml = GetPlexMetadata(data['key'])
|
||||||
try:
|
try:
|
||||||
xml[0].attrib
|
xml[0].attrib
|
||||||
except (AttributeError, IndexError, TypeError):
|
except (AttributeError, IndexError, TypeError):
|
||||||
|
@ -170,56 +117,47 @@ class PlexCompanion(backgroundthread.KillableThread):
|
||||||
return
|
return
|
||||||
api = API(xml[0])
|
api = API(xml[0])
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = PQ.get_playqueue_from_type(
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()])
|
||||||
key = data.get('key')
|
PQ.update_playqueue_from_PMS(
|
||||||
if key:
|
playqueue,
|
||||||
_, key, _ = PF.ParseContainerKey(key)
|
playqueue_id=container_key,
|
||||||
update_playqueue_from_PMS(playqueue,
|
repeat=query.get('repeat'),
|
||||||
playqueue_id=container_key,
|
offset=data.get('offset'),
|
||||||
repeat=query.get('repeat'),
|
transient_token=data.get('token'))
|
||||||
offset=utils.cast(int, data.get('offset')),
|
|
||||||
transient_token=data.get('token'),
|
|
||||||
start_plex_id=key)
|
|
||||||
|
|
||||||
@staticmethod
|
@LOCKER.lockthis
|
||||||
def _process_streams(data):
|
def _process_streams(self, data):
|
||||||
"""
|
"""
|
||||||
Plex Companion client adjusted audio or subtitle stream
|
Plex Companion client adjusted audio or subtitle stream
|
||||||
"""
|
"""
|
||||||
if 'type' not in data:
|
|
||||||
LOG.error('Received malformed stream data: %s', data)
|
|
||||||
return
|
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = PQ.get_playqueue_from_type(
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
|
||||||
pos = js.get_position(playqueue.playlistid)
|
pos = js.get_position(playqueue.playlistid)
|
||||||
if 'audioStreamID' in data:
|
if 'audioStreamID' in data:
|
||||||
index = playqueue.items[pos].kodi_stream_index(
|
index = playqueue.items[pos].kodi_stream_index(
|
||||||
data['audioStreamID'], 'audio')
|
data['audioStreamID'], 'audio')
|
||||||
app.APP.player.setAudioStream(index)
|
self.player.setAudioStream(index)
|
||||||
elif 'subtitleStreamID' in data:
|
elif 'subtitleStreamID' in data:
|
||||||
if data['subtitleStreamID'] == '0':
|
if data['subtitleStreamID'] == '0':
|
||||||
app.APP.player.showSubtitles(False)
|
self.player.showSubtitles(False)
|
||||||
else:
|
else:
|
||||||
index = playqueue.items[pos].kodi_stream_index(
|
index = playqueue.items[pos].kodi_stream_index(
|
||||||
data['subtitleStreamID'], 'subtitle')
|
data['subtitleStreamID'], 'subtitle')
|
||||||
app.APP.player.setSubtitleStream(index)
|
self.player.setSubtitleStream(index)
|
||||||
else:
|
else:
|
||||||
LOG.error('Unknown setStreams command: %s', data)
|
LOG.error('Unknown setStreams command: %s', data)
|
||||||
|
|
||||||
@staticmethod
|
@LOCKER.lockthis
|
||||||
def _process_refresh(data):
|
def _process_refresh(self, data):
|
||||||
"""
|
"""
|
||||||
example data: {'playQueueID': '8475', 'commandID': '11'}
|
example data: {'playQueueID': '8475', 'commandID': '11'}
|
||||||
"""
|
"""
|
||||||
if 'playQueueID' not in data:
|
xml = get_pms_playqueue(data['playQueueID'])
|
||||||
LOG.error('Received malformed refresh data: %s', data)
|
|
||||||
return
|
|
||||||
xml = PL.get_pms_playqueue(data['playQueueID'])
|
|
||||||
if xml is None:
|
if xml is None:
|
||||||
return
|
return
|
||||||
if len(xml) == 0:
|
if len(xml) == 0:
|
||||||
LOG.debug('Empty playqueue received - clearing playqueue')
|
LOG.debug('Empty playqueue received - clearing playqueue')
|
||||||
plex_type = PL.get_plextype_from_xml(xml)
|
plex_type = get_plextype_from_xml(xml)
|
||||||
if plex_type is None:
|
if plex_type is None:
|
||||||
return
|
return
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = PQ.get_playqueue_from_type(
|
||||||
|
@ -228,7 +166,7 @@ class PlexCompanion(backgroundthread.KillableThread):
|
||||||
return
|
return
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = PQ.get_playqueue_from_type(
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']])
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']])
|
||||||
update_playqueue_from_PMS(playqueue, data['playQueueID'])
|
PQ.update_playqueue_from_PMS(playqueue, data['playQueueID'])
|
||||||
|
|
||||||
def _process_tasks(self, task):
|
def _process_tasks(self, task):
|
||||||
"""
|
"""
|
||||||
|
@ -248,28 +186,21 @@ class PlexCompanion(backgroundthread.KillableThread):
|
||||||
LOG.debug('Processing: %s', task)
|
LOG.debug('Processing: %s', task)
|
||||||
data = task['data']
|
data = task['data']
|
||||||
if task['action'] == 'alexa':
|
if task['action'] == 'alexa':
|
||||||
with app.APP.lock_playqueues:
|
self._process_alexa(data)
|
||||||
self._process_alexa(data)
|
|
||||||
elif (task['action'] == 'playlist' and
|
elif (task['action'] == 'playlist' and
|
||||||
data.get('address') == 'node.plexapp.com'):
|
data.get('address') == 'node.plexapp.com'):
|
||||||
self._process_node(data)
|
self._process_node(data)
|
||||||
elif task['action'] == 'playlist':
|
elif task['action'] == 'playlist':
|
||||||
with app.APP.lock_playqueues:
|
self._process_playlist(data)
|
||||||
self._process_playlist(data)
|
|
||||||
elif task['action'] == 'refreshPlayQueue':
|
elif task['action'] == 'refreshPlayQueue':
|
||||||
with app.APP.lock_playqueues:
|
self._process_refresh(data)
|
||||||
self._process_refresh(data)
|
|
||||||
elif task['action'] == 'setStreams':
|
elif task['action'] == 'setStreams':
|
||||||
try:
|
self._process_streams(data)
|
||||||
self._process_streams(data)
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""
|
"""
|
||||||
Ensure that sockets will be closed no matter what
|
Ensure that sockets will be closed no matter what
|
||||||
"""
|
"""
|
||||||
app.APP.register_thread(self)
|
|
||||||
try:
|
try:
|
||||||
self._run()
|
self._run()
|
||||||
finally:
|
finally:
|
||||||
|
@ -282,21 +213,22 @@ class PlexCompanion(backgroundthread.KillableThread):
|
||||||
self.httpd.socket.close()
|
self.httpd.socket.close()
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pass
|
pass
|
||||||
app.APP.deregister_thread(self)
|
LOG.info("----===## Plex Companion stopped ##===----")
|
||||||
LOG.info("----===## Plex Companion stopped ##===----")
|
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
httpd = self.httpd
|
httpd = self.httpd
|
||||||
# Cache for quicker while loops
|
# Cache for quicker while loops
|
||||||
client = self.client
|
client = self.client
|
||||||
|
stopped = self.stopped
|
||||||
|
suspended = self.suspended
|
||||||
|
|
||||||
# Start up instances
|
# Start up instances
|
||||||
request_mgr = httppersist.RequestMgr()
|
request_mgr = httppersist.RequestMgr()
|
||||||
subscription_manager = subscribers.SubscriptionMgr(request_mgr,
|
subscription_manager = subscribers.SubscriptionMgr(request_mgr,
|
||||||
app.APP.player)
|
self.player)
|
||||||
self.subscription_manager = subscription_manager
|
self.subscription_manager = subscription_manager
|
||||||
|
|
||||||
if utils.settings('plexCompanion') == 'true':
|
if settings('plexCompanion') == 'true':
|
||||||
# Start up httpd
|
# Start up httpd
|
||||||
start_count = 0
|
start_count = 0
|
||||||
while True:
|
while True:
|
||||||
|
@ -306,13 +238,13 @@ class PlexCompanion(backgroundthread.KillableThread):
|
||||||
subscription_manager,
|
subscription_manager,
|
||||||
('', v.COMPANION_PORT),
|
('', v.COMPANION_PORT),
|
||||||
listener.MyHandler)
|
listener.MyHandler)
|
||||||
httpd.timeout = 10.0
|
httpd.timeout = 0.95
|
||||||
break
|
break
|
||||||
except Exception:
|
except:
|
||||||
LOG.error("Unable to start PlexCompanion. Traceback:")
|
LOG.error("Unable to start PlexCompanion. Traceback:")
|
||||||
import traceback
|
import traceback
|
||||||
LOG.error(traceback.print_exc())
|
LOG.error(traceback.print_exc())
|
||||||
app.APP.monitor.waitForAbort(3)
|
sleep(3000)
|
||||||
if start_count == 3:
|
if start_count == 3:
|
||||||
LOG.error("Error: Unable to start web helper.")
|
LOG.error("Error: Unable to start web helper.")
|
||||||
httpd = False
|
httpd = False
|
||||||
|
@ -325,13 +257,14 @@ class PlexCompanion(backgroundthread.KillableThread):
|
||||||
if httpd:
|
if httpd:
|
||||||
thread = Thread(target=httpd.handle_request)
|
thread = Thread(target=httpd.handle_request)
|
||||||
|
|
||||||
while not self.should_cancel():
|
while not stopped():
|
||||||
# If we are not authorized, sleep
|
# If we are not authorized, sleep
|
||||||
# Otherwise, we trigger a download which leads to a
|
# Otherwise, we trigger a download which leads to a
|
||||||
# re-authorizations
|
# re-authorizations
|
||||||
if self.should_suspend():
|
while suspended():
|
||||||
if self.wait_while_suspended():
|
if stopped():
|
||||||
break
|
break
|
||||||
|
sleep(1000)
|
||||||
try:
|
try:
|
||||||
message_count += 1
|
message_count += 1
|
||||||
if httpd:
|
if httpd:
|
||||||
|
@ -355,21 +288,21 @@ class PlexCompanion(backgroundthread.KillableThread):
|
||||||
subscription_manager.notify()
|
subscription_manager.notify()
|
||||||
if not httpd:
|
if not httpd:
|
||||||
message_count = 0
|
message_count = 0
|
||||||
except Exception:
|
except:
|
||||||
LOG.warn("Error in loop, continuing anyway. Traceback:")
|
LOG.warn("Error in loop, continuing anyway. Traceback:")
|
||||||
import traceback
|
import traceback
|
||||||
LOG.warn(traceback.format_exc())
|
LOG.warn(traceback.format_exc())
|
||||||
# See if there's anything we need to process
|
# See if there's anything we need to process
|
||||||
try:
|
try:
|
||||||
task = app.APP.companion_queue.get(block=False)
|
task = state.COMPANION_QUEUE.get(block=False)
|
||||||
except Empty:
|
except Empty:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# Got instructions, process them
|
# Got instructions, process them
|
||||||
self._process_tasks(task)
|
self._process_tasks(task)
|
||||||
app.APP.companion_queue.task_done()
|
state.COMPANION_QUEUE.task_done()
|
||||||
# Don't sleep
|
# Don't sleep
|
||||||
continue
|
continue
|
||||||
self.sleep(0.05)
|
sleep(50)
|
||||||
subscription_manager.signal_stop()
|
subscription_manager.signal_stop()
|
||||||
client.stop_all()
|
client.stop_all()
|
828
resources/lib/PlexFunctions.py
Normal file
828
resources/lib/PlexFunctions.py
Normal file
|
@ -0,0 +1,828 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from logging import getLogger
|
||||||
|
from urllib import urlencode, quote_plus
|
||||||
|
from ast import literal_eval
|
||||||
|
from urlparse import urlparse, parse_qsl
|
||||||
|
from re import compile as re_compile
|
||||||
|
from copy import deepcopy
|
||||||
|
from time import time
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
from xbmc import sleep
|
||||||
|
|
||||||
|
from downloadutils import DownloadUtils as DU
|
||||||
|
from utils import settings, try_encode, try_decode
|
||||||
|
from variables import PLEX_TO_KODI_TIMEFACTOR
|
||||||
|
import plex_tv
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
|
CONTAINERSIZE = int(settings('limitindex'))
|
||||||
|
REGEX_PLEX_KEY = re_compile(r'''/(.+)/(\d+)$''')
|
||||||
|
REGEX_PLEX_DIRECT = re_compile(r'''\.plex\.direct:\d+$''')
|
||||||
|
|
||||||
|
# For discovery of PMS in the local LAN
|
||||||
|
PLEX_GDM_IP = '239.0.0.250' # multicast to PMS
|
||||||
|
PLEX_GDM_PORT = 32414
|
||||||
|
PLEX_GDM_MSG = 'M-SEARCH * HTTP/1.0'
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
def ConvertPlexToKodiTime(plexTime):
|
||||||
|
"""
|
||||||
|
Converts Plextime to Koditime. Returns an int (in seconds).
|
||||||
|
"""
|
||||||
|
if plexTime is None:
|
||||||
|
return None
|
||||||
|
return int(float(plexTime) * PLEX_TO_KODI_TIMEFACTOR)
|
||||||
|
|
||||||
|
|
||||||
|
def GetPlexKeyNumber(plexKey):
|
||||||
|
"""
|
||||||
|
Deconstructs e.g. '/library/metadata/xxxx' to the tuple
|
||||||
|
|
||||||
|
('library/metadata', 'xxxx')
|
||||||
|
|
||||||
|
Returns ('','') if nothing is found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = REGEX_PLEX_KEY.findall(plexKey)[0]
|
||||||
|
except IndexError:
|
||||||
|
result = ('', '')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ParseContainerKey(containerKey):
|
||||||
|
"""
|
||||||
|
Parses e.g. /playQueues/3045?own=1&repeat=0&window=200 to:
|
||||||
|
'playQueues', '3045', {'window': '200', 'own': '1', 'repeat': '0'}
|
||||||
|
|
||||||
|
Output hence: library, key, query (str, str, dict)
|
||||||
|
"""
|
||||||
|
result = urlparse(containerKey)
|
||||||
|
library, key = GetPlexKeyNumber(result.path)
|
||||||
|
query = dict(parse_qsl(result.query))
|
||||||
|
return library, key, query
|
||||||
|
|
||||||
|
|
||||||
|
def LiteralEval(string):
|
||||||
|
"""
|
||||||
|
Turns a string e.g. in a dict, safely :-)
|
||||||
|
"""
|
||||||
|
return literal_eval(string)
|
||||||
|
|
||||||
|
|
||||||
|
def GetMethodFromPlexType(plexType):
|
||||||
|
methods = {
|
||||||
|
'movie': 'add_update',
|
||||||
|
'episode': 'add_updateEpisode',
|
||||||
|
'show': 'add_update',
|
||||||
|
'season': 'add_updateSeason',
|
||||||
|
'track': 'add_updateSong',
|
||||||
|
'album': 'add_updateAlbum',
|
||||||
|
'artist': 'add_updateArtist'
|
||||||
|
}
|
||||||
|
return methods[plexType]
|
||||||
|
|
||||||
|
|
||||||
|
def GetPlexLoginFromSettings():
|
||||||
|
"""
|
||||||
|
Returns a dict:
|
||||||
|
'plexLogin': settings('plexLogin'),
|
||||||
|
'plexToken': settings('plexToken'),
|
||||||
|
'plexhome': settings('plexhome'),
|
||||||
|
'plexid': settings('plexid'),
|
||||||
|
'myplexlogin': settings('myplexlogin'),
|
||||||
|
'plexAvatar': settings('plexAvatar'),
|
||||||
|
'plexHomeSize': settings('plexHomeSize')
|
||||||
|
|
||||||
|
Returns strings or unicode
|
||||||
|
|
||||||
|
Returns empty strings '' for a setting if not found.
|
||||||
|
|
||||||
|
myplexlogin is 'true' if user opted to log into plex.tv (the default)
|
||||||
|
plexhome is 'true' if plex home is used (the default)
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'plexLogin': settings('plexLogin'),
|
||||||
|
'plexToken': settings('plexToken'),
|
||||||
|
'plexhome': settings('plexhome'),
|
||||||
|
'plexid': settings('plexid'),
|
||||||
|
'myplexlogin': settings('myplexlogin'),
|
||||||
|
'plexAvatar': settings('plexAvatar'),
|
||||||
|
'plexHomeSize': settings('plexHomeSize')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_connection(url, token=None, verifySSL=None):
|
||||||
|
"""
|
||||||
|
Checks connection to a Plex server, available at url. Can also be used
|
||||||
|
to check for connection with plex.tv.
|
||||||
|
|
||||||
|
Override SSL to skip the check by setting verifySSL=False
|
||||||
|
if 'None', SSL will be checked (standard requests setting)
|
||||||
|
if 'True', SSL settings from file settings are used (False/True)
|
||||||
|
|
||||||
|
Input:
|
||||||
|
url URL to Plex server (e.g. https://192.168.1.1:32400)
|
||||||
|
token appropriate token to access server. If None is passed,
|
||||||
|
the current token is used
|
||||||
|
Output:
|
||||||
|
False if server could not be reached or timeout occured
|
||||||
|
200 if connection was successfull
|
||||||
|
int or other HTML status codes as received from the server
|
||||||
|
"""
|
||||||
|
# Add '/clients' to URL because then an authentication is necessary
|
||||||
|
# If a plex.tv URL was passed, this does not work.
|
||||||
|
header_options = None
|
||||||
|
if token is not None:
|
||||||
|
header_options = {'X-Plex-Token': token}
|
||||||
|
if verifySSL is True:
|
||||||
|
verifySSL = None if settings('sslverify') == 'true' else False
|
||||||
|
if 'plex.tv' in url:
|
||||||
|
url = 'https://plex.tv/api/home/users'
|
||||||
|
else:
|
||||||
|
url = url + '/library/onDeck'
|
||||||
|
LOG.debug("Checking connection to server %s with verifySSL=%s",
|
||||||
|
url, verifySSL)
|
||||||
|
answer = DU().downloadUrl(url,
|
||||||
|
authenticate=False,
|
||||||
|
headerOptions=header_options,
|
||||||
|
verifySSL=verifySSL,
|
||||||
|
timeout=10)
|
||||||
|
if answer is None:
|
||||||
|
LOG.debug("Could not connect to %s", url)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
# xml received?
|
||||||
|
answer.attrib
|
||||||
|
except AttributeError:
|
||||||
|
if answer is True:
|
||||||
|
# Maybe no xml but connection was successful nevertheless
|
||||||
|
answer = 200
|
||||||
|
else:
|
||||||
|
# Success - we downloaded an xml!
|
||||||
|
answer = 200
|
||||||
|
# We could connect but maybe were not authenticated. No worries
|
||||||
|
LOG.debug("Checking connection successfull. Answer: %s", answer)
|
||||||
|
return answer
|
||||||
|
|
||||||
|
|
||||||
|
def discover_pms(token=None):
|
||||||
|
"""
|
||||||
|
Optional parameter:
|
||||||
|
token token for plex.tv
|
||||||
|
|
||||||
|
Returns a list of available PMS to connect to, one entry is the dict:
|
||||||
|
{
|
||||||
|
'machineIdentifier' [str] unique identifier of the PMS
|
||||||
|
'name' [str] name of the PMS
|
||||||
|
'token' [str] token needed to access that PMS
|
||||||
|
'ownername' [str] name of the owner of this PMS or None if
|
||||||
|
the owner itself supplied tries to connect
|
||||||
|
'product' e.g. 'Plex Media Server' or None
|
||||||
|
'version' e.g. '1.11.2.4772-3e...' or None
|
||||||
|
'device': e.g. 'PC' or 'Windows' or None
|
||||||
|
'platform': e.g. 'Windows', 'Android' or None
|
||||||
|
'local' [bool] True if plex.tv supplied
|
||||||
|
'publicAddressMatches'='1'
|
||||||
|
or if found using Plex GDM in the local LAN
|
||||||
|
'owned' [bool] True if it's the owner's PMS
|
||||||
|
'relay' [bool] True if plex.tv supplied 'relay'='1'
|
||||||
|
'presence' [bool] True if plex.tv supplied 'presence'='1'
|
||||||
|
'httpsRequired' [bool] True if plex.tv supplied
|
||||||
|
'httpsRequired'='1'
|
||||||
|
'scheme' [str] either 'http' or 'https'
|
||||||
|
'ip': [str] IP of the PMS, e.g. '192.168.1.1'
|
||||||
|
'port': [str] Port of the PMS, e.g. '32400'
|
||||||
|
'baseURL': [str] <scheme>://<ip>:<port> of the PMS
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
LOG.info('Start discovery of Plex Media Servers')
|
||||||
|
# Look first for local PMS in the LAN
|
||||||
|
local_pms_list = _plex_gdm()
|
||||||
|
LOG.debug('PMS found in the local LAN using Plex GDM: %s', local_pms_list)
|
||||||
|
# Get PMS from plex.tv
|
||||||
|
if token:
|
||||||
|
LOG.info('Checking with plex.tv for more PMS to connect to')
|
||||||
|
plex_pms_list = _pms_list_from_plex_tv(token)
|
||||||
|
LOG.debug('PMS found on plex.tv: %s', plex_pms_list)
|
||||||
|
else:
|
||||||
|
LOG.info('No plex token supplied, only checked LAN for available PMS')
|
||||||
|
plex_pms_list = []
|
||||||
|
|
||||||
|
# Add PMS found only in the LAN to the Plex.tv PMS list
|
||||||
|
for pms in local_pms_list:
|
||||||
|
for plex_pms in plex_pms_list:
|
||||||
|
if pms['machineIdentifier'] == plex_pms['machineIdentifier']:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Only found PMS using GDM - add it to the PMS from plex.tv
|
||||||
|
https = _pms_https_enabled('%s:%s' % (pms['ip'], pms['port']))
|
||||||
|
if https is None:
|
||||||
|
# Error contacting url. Skip and ignore this PMS for now
|
||||||
|
LOG.error('Could not contact PMS %s but we should have', pms)
|
||||||
|
continue
|
||||||
|
elif https is True:
|
||||||
|
pms['scheme'] = 'https'
|
||||||
|
else:
|
||||||
|
pms['scheme'] = 'http'
|
||||||
|
pms['baseURL'] = '%s://%s:%s' % (pms['scheme'],
|
||||||
|
pms['ip'],
|
||||||
|
pms['port'])
|
||||||
|
plex_pms_list.append(pms)
|
||||||
|
LOG.debug('Found the following PMS in total: %s', plex_pms_list)
|
||||||
|
return plex_pms_list
|
||||||
|
|
||||||
|
|
||||||
|
def _plex_gdm():
|
||||||
|
"""
|
||||||
|
PlexGDM - looks for PMS in the local LAN and returns a list of the PMS found
|
||||||
|
"""
|
||||||
|
# Import here because we might not need to do gdm because we already
|
||||||
|
# connected to a PMS successfully in the past
|
||||||
|
import struct
|
||||||
|
import socket
|
||||||
|
|
||||||
|
# setup socket for discovery -> multicast message
|
||||||
|
gdm = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
gdm.settimeout(2.0)
|
||||||
|
# Set the time-to-live for messages to 2 for local network
|
||||||
|
ttl = struct.pack('b', 2)
|
||||||
|
gdm.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
|
||||||
|
|
||||||
|
return_data = []
|
||||||
|
try:
|
||||||
|
# Send data to the multicast group
|
||||||
|
gdm.sendto(PLEX_GDM_MSG, (PLEX_GDM_IP, PLEX_GDM_PORT))
|
||||||
|
|
||||||
|
# Look for responses from all recipients
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data, server = gdm.recvfrom(1024)
|
||||||
|
return_data.append({'from': server, 'data': data})
|
||||||
|
except socket.timeout:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
# Probably error: (101, 'Network is unreachable')
|
||||||
|
LOG.error(e)
|
||||||
|
import traceback
|
||||||
|
LOG.error("Traceback:\n%s", traceback.format_exc())
|
||||||
|
finally:
|
||||||
|
gdm.close()
|
||||||
|
LOG.debug('Plex GDM returned the data: %s', return_data)
|
||||||
|
pms_list = []
|
||||||
|
for response in return_data:
|
||||||
|
# Check if we had a positive HTTP response
|
||||||
|
if '200 OK' not in response['data']:
|
||||||
|
continue
|
||||||
|
pms = {
|
||||||
|
'ip': response['from'][0],
|
||||||
|
'scheme': None,
|
||||||
|
'local': True, # Since we found it using GDM
|
||||||
|
'product': None,
|
||||||
|
'baseURL': None,
|
||||||
|
'name': None,
|
||||||
|
'version': None,
|
||||||
|
'token': None,
|
||||||
|
'ownername': None,
|
||||||
|
'device': None,
|
||||||
|
'platform': None,
|
||||||
|
'owned': None,
|
||||||
|
'relay': None,
|
||||||
|
'presence': True, # Since we're talking to the PMS
|
||||||
|
'httpsRequired': None,
|
||||||
|
}
|
||||||
|
for line in response['data'].split('\n'):
|
||||||
|
if 'Content-Type:' in line:
|
||||||
|
pms['product'] = try_decode(line.split(':')[1].strip())
|
||||||
|
elif 'Host:' in line:
|
||||||
|
pms['baseURL'] = line.split(':')[1].strip()
|
||||||
|
elif 'Name:' in line:
|
||||||
|
pms['name'] = try_decode(line.split(':')[1].strip())
|
||||||
|
elif 'Port:' in line:
|
||||||
|
pms['port'] = line.split(':')[1].strip()
|
||||||
|
elif 'Resource-Identifier:' in line:
|
||||||
|
pms['machineIdentifier'] = line.split(':')[1].strip()
|
||||||
|
elif 'Version:' in line:
|
||||||
|
pms['version'] = line.split(':')[1].strip()
|
||||||
|
pms_list.append(pms)
|
||||||
|
return pms_list
|
||||||
|
|
||||||
|
|
||||||
|
def _pms_list_from_plex_tv(token):
|
||||||
|
"""
|
||||||
|
get Plex media Server List from plex.tv/pms/resources
|
||||||
|
"""
|
||||||
|
xml = DU().downloadUrl('https://plex.tv/api/resources',
|
||||||
|
authenticate=False,
|
||||||
|
parameters={'includeHttps': 1},
|
||||||
|
headerOptions={'X-Plex-Token': token})
|
||||||
|
try:
|
||||||
|
xml.attrib
|
||||||
|
except AttributeError:
|
||||||
|
LOG.error('Could not get list of PMS from plex.tv')
|
||||||
|
return
|
||||||
|
|
||||||
|
from Queue import Queue
|
||||||
|
queue = Queue()
|
||||||
|
thread_queue = []
|
||||||
|
|
||||||
|
max_age_in_seconds = 2*60*60*24
|
||||||
|
for device in xml.findall('Device'):
|
||||||
|
if 'server' not in device.get('provides'):
|
||||||
|
# No PMS - skip
|
||||||
|
continue
|
||||||
|
if device.find('Connection') is None:
|
||||||
|
# no valid connection - skip
|
||||||
|
continue
|
||||||
|
# check MyPlex data age - skip if >2 days
|
||||||
|
info_age = time() - int(device.get('lastSeenAt'))
|
||||||
|
if info_age > max_age_in_seconds:
|
||||||
|
LOG.debug("Skip server %s not seen for 2 days", device.get('name'))
|
||||||
|
continue
|
||||||
|
pms = {
|
||||||
|
'machineIdentifier': device.get('clientIdentifier'),
|
||||||
|
'name': device.get('name'),
|
||||||
|
'token': device.get('accessToken'),
|
||||||
|
'ownername': device.get('sourceTitle'),
|
||||||
|
'product': device.get('product'), # e.g. 'Plex Media Server'
|
||||||
|
'version': device.get('productVersion'), # e.g. '1.11.2.4772-3e...'
|
||||||
|
'device': device.get('device'), # e.g. 'PC' or 'Windows'
|
||||||
|
'platform': device.get('platform'), # e.g. 'Windows', 'Android'
|
||||||
|
'local': device.get('publicAddressMatches') == '1',
|
||||||
|
'owned': device.get('owned') == '1',
|
||||||
|
'relay': device.get('relay') == '1',
|
||||||
|
'presence': device.get('presence') == '1',
|
||||||
|
'httpsRequired': device.get('httpsRequired') == '1',
|
||||||
|
'connections': []
|
||||||
|
}
|
||||||
|
# Try a local connection first, no matter what plex.tv tells us
|
||||||
|
for connection in device.findall('Connection'):
|
||||||
|
if connection.get('local') == '1':
|
||||||
|
pms['connections'].append(connection)
|
||||||
|
# Then try non-local
|
||||||
|
for connection in device.findall('Connection'):
|
||||||
|
if connection.get('local') != '1':
|
||||||
|
pms['connections'].append(connection)
|
||||||
|
# Spawn threads to ping each PMS simultaneously
|
||||||
|
thread = Thread(target=_poke_pms, args=(pms, queue))
|
||||||
|
thread_queue.append(thread)
|
||||||
|
|
||||||
|
max_threads = 5
|
||||||
|
threads = []
|
||||||
|
# poke PMS, own thread for each PMS
|
||||||
|
while True:
|
||||||
|
# Remove finished threads
|
||||||
|
for thread in threads:
|
||||||
|
if not thread.isAlive():
|
||||||
|
threads.remove(thread)
|
||||||
|
if len(threads) < max_threads:
|
||||||
|
try:
|
||||||
|
thread = thread_queue.pop()
|
||||||
|
except IndexError:
|
||||||
|
# We have done our work
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
thread.start()
|
||||||
|
threads.append(thread)
|
||||||
|
else:
|
||||||
|
sleep(50)
|
||||||
|
# wait for requests being answered
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
# declare new PMSs
|
||||||
|
pms_list = []
|
||||||
|
while not queue.empty():
|
||||||
|
pms = queue.get()
|
||||||
|
del pms['connections']
|
||||||
|
pms_list.append(pms)
|
||||||
|
queue.task_done()
|
||||||
|
return pms_list
|
||||||
|
|
||||||
|
|
||||||
|
def _poke_pms(pms, queue):
|
||||||
|
data = pms['connections'][0].attrib
|
||||||
|
url = data['uri']
|
||||||
|
if data['local'] == '1' and REGEX_PLEX_DIRECT.findall(url):
|
||||||
|
# In case DNS resolve of plex.direct does not work, append a new
|
||||||
|
# connection that will directly access the local IP (e.g. internet down)
|
||||||
|
conn = deepcopy(pms['connections'][0])
|
||||||
|
# Overwrite plex.direct
|
||||||
|
conn.attrib['uri'] = '%s://%s:%s' % (data['protocol'],
|
||||||
|
data['address'],
|
||||||
|
data['port'])
|
||||||
|
pms['connections'].insert(1, conn)
|
||||||
|
protocol, address, port = url.split(':', 2)
|
||||||
|
address = address.replace('/', '')
|
||||||
|
xml = DU().downloadUrl('%s/identity' % url,
|
||||||
|
authenticate=False,
|
||||||
|
headerOptions={'X-Plex-Token': pms['token']},
|
||||||
|
verifySSL=False,
|
||||||
|
timeout=10)
|
||||||
|
try:
|
||||||
|
xml.attrib['machineIdentifier']
|
||||||
|
except (AttributeError, KeyError):
|
||||||
|
# No connection, delete the one we just tested
|
||||||
|
del pms['connections'][0]
|
||||||
|
if pms['connections']:
|
||||||
|
# Still got connections left, try them
|
||||||
|
return _poke_pms(pms, queue)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# Connection successful - correct pms?
|
||||||
|
if xml.get('machineIdentifier') == pms['machineIdentifier']:
|
||||||
|
# process later
|
||||||
|
pms['baseURL'] = url
|
||||||
|
pms['scheme'] = protocol
|
||||||
|
pms['ip'] = address
|
||||||
|
pms['port'] = port
|
||||||
|
queue.put(pms)
|
||||||
|
return
|
||||||
|
LOG.info('Found a pms at %s, but the expected machineIdentifier of '
|
||||||
|
'%s did not match the one we found: %s',
|
||||||
|
url, pms['uuid'], xml.get('machineIdentifier'))
|
||||||
|
|
||||||
|
|
||||||
|
def GetPlexMetadata(key):
|
||||||
|
"""
|
||||||
|
Returns raw API metadata for key as an etree XML.
|
||||||
|
|
||||||
|
Can be called with either Plex key '/library/metadata/xxxx'metadata
|
||||||
|
OR with the digits 'xxxx' only.
|
||||||
|
|
||||||
|
Returns None or 401 if something went wrong
|
||||||
|
"""
|
||||||
|
key = str(key)
|
||||||
|
if '/library/metadata/' in key:
|
||||||
|
url = "{server}" + key
|
||||||
|
else:
|
||||||
|
url = "{server}/library/metadata/" + key
|
||||||
|
arguments = {
|
||||||
|
'checkFiles': 0,
|
||||||
|
'includeExtras': 1, # Trailers and Extras => Extras
|
||||||
|
'includeReviews': 1,
|
||||||
|
'includeRelated': 0, # Similar movies => Video -> Related
|
||||||
|
# 'includeRelatedCount': 0,
|
||||||
|
# 'includeOnDeck': 1,
|
||||||
|
# 'includeChapters': 1,
|
||||||
|
# 'includePopularLeaves': 1,
|
||||||
|
# 'includeConcerts': 1
|
||||||
|
}
|
||||||
|
url = url + '?' + urlencode(arguments)
|
||||||
|
xml = DU().downloadUrl(url)
|
||||||
|
if xml == 401:
|
||||||
|
# Either unauthorized (taken care of by doUtils) or PMS under strain
|
||||||
|
return 401
|
||||||
|
# Did we receive a valid XML?
|
||||||
|
try:
|
||||||
|
xml.attrib
|
||||||
|
# Nope we did not receive a valid XML
|
||||||
|
except AttributeError:
|
||||||
|
LOG.error("Error retrieving metadata for %s", url)
|
||||||
|
xml = None
|
||||||
|
return xml
|
||||||
|
|
||||||
|
|
||||||
|
def GetAllPlexChildren(key):
|
||||||
|
"""
|
||||||
|
Returns a list (raw xml API dump) of all Plex children for the key.
|
||||||
|
(e.g. /library/metadata/194853/children pointing to a season)
|
||||||
|
|
||||||
|
Input:
|
||||||
|
key Key to a Plex item, e.g. 12345
|
||||||
|
"""
|
||||||
|
return DownloadChunks("{server}/library/metadata/%s/children?" % key)
|
||||||
|
|
||||||
|
|
||||||
|
def GetPlexSectionResults(viewId, args=None):
|
||||||
|
"""
|
||||||
|
Returns a list (XML API dump) of all Plex items in the Plex
|
||||||
|
section with key = viewId.
|
||||||
|
|
||||||
|
Input:
|
||||||
|
args: optional dict to be urlencoded
|
||||||
|
|
||||||
|
Returns None if something went wrong
|
||||||
|
"""
|
||||||
|
url = "{server}/library/sections/%s/all?" % viewId
|
||||||
|
if args:
|
||||||
|
url += urlencode(args) + '&'
|
||||||
|
return DownloadChunks(url)
|
||||||
|
|
||||||
|
|
||||||
|
def DownloadChunks(url):
|
||||||
|
"""
|
||||||
|
Downloads PMS url in chunks of CONTAINERSIZE.
|
||||||
|
|
||||||
|
url MUST end with '?' (if no other url encoded args are present) or '&'
|
||||||
|
|
||||||
|
Returns a stitched-together xml or None.
|
||||||
|
"""
|
||||||
|
xml = None
|
||||||
|
pos = 0
|
||||||
|
error_counter = 0
|
||||||
|
while error_counter < 10:
|
||||||
|
args = {
|
||||||
|
'X-Plex-Container-Size': CONTAINERSIZE,
|
||||||
|
'X-Plex-Container-Start': pos
|
||||||
|
}
|
||||||
|
xmlpart = DU().downloadUrl(url + urlencode(args))
|
||||||
|
# If something went wrong - skip in the hope that it works next time
|
||||||
|
try:
|
||||||
|
xmlpart.attrib
|
||||||
|
except AttributeError:
|
||||||
|
LOG.error('Error while downloading chunks: %s',
|
||||||
|
url + urlencode(args))
|
||||||
|
pos += CONTAINERSIZE
|
||||||
|
error_counter += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Very first run: starting xml (to retain data in xml's root!)
|
||||||
|
if xml is None:
|
||||||
|
xml = deepcopy(xmlpart)
|
||||||
|
if len(xmlpart) < CONTAINERSIZE:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
pos += CONTAINERSIZE
|
||||||
|
continue
|
||||||
|
# Build answer xml - containing the entire library
|
||||||
|
for child in xmlpart:
|
||||||
|
xml.append(child)
|
||||||
|
# Done as soon as we don't receive a full complement of items
|
||||||
|
if len(xmlpart) < CONTAINERSIZE:
|
||||||
|
break
|
||||||
|
pos += CONTAINERSIZE
|
||||||
|
if error_counter == 10:
|
||||||
|
LOG.error('Fatal error while downloading chunks for %s', url)
|
||||||
|
return None
|
||||||
|
return xml
|
||||||
|
|
||||||
|
|
||||||
|
def GetAllPlexLeaves(viewId, lastViewedAt=None, updatedAt=None):
|
||||||
|
"""
|
||||||
|
Returns a list (raw XML API dump) of all Plex subitems for the key.
|
||||||
|
(e.g. /library/sections/2/allLeaves pointing to all TV shows)
|
||||||
|
|
||||||
|
Input:
|
||||||
|
viewId Id of Plex library, e.g. '2'
|
||||||
|
lastViewedAt Unix timestamp; only retrieves PMS items viewed
|
||||||
|
since that point of time until now.
|
||||||
|
updatedAt Unix timestamp; only retrieves PMS items updated
|
||||||
|
by the PMS since that point of time until now.
|
||||||
|
|
||||||
|
If lastViewedAt and updatedAt=None, ALL PMS items are returned.
|
||||||
|
|
||||||
|
Warning: lastViewedAt and updatedAt are combined with AND by the PMS!
|
||||||
|
|
||||||
|
Relevant "master time": PMS server. I guess this COULD lead to problems,
|
||||||
|
e.g. when server and client are in different time zones.
|
||||||
|
"""
|
||||||
|
args = []
|
||||||
|
url = "{server}/library/sections/%s/allLeaves" % viewId
|
||||||
|
|
||||||
|
if lastViewedAt:
|
||||||
|
args.append('lastViewedAt>=%s' % lastViewedAt)
|
||||||
|
if updatedAt:
|
||||||
|
args.append('updatedAt>=%s' % updatedAt)
|
||||||
|
if args:
|
||||||
|
url += '?' + '&'.join(args) + '&'
|
||||||
|
else:
|
||||||
|
url += '?'
|
||||||
|
return DownloadChunks(url)
|
||||||
|
|
||||||
|
|
||||||
|
def GetPlexOnDeck(viewId):
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
return DownloadChunks("{server}/library/sections/%s/onDeck?" % viewId)
|
||||||
|
|
||||||
|
|
||||||
|
def get_plex_sections():
|
||||||
|
"""
|
||||||
|
Returns all Plex sections (libraries) of the PMS as an etree xml
|
||||||
|
"""
|
||||||
|
return DU().downloadUrl('{server}/library/sections')
|
||||||
|
|
||||||
|
|
||||||
|
def init_plex_playqueue(itemid, librarySectionUUID, mediatype='movie',
|
||||||
|
trailers=False):
|
||||||
|
"""
|
||||||
|
Returns raw API metadata XML dump for a playlist with e.g. trailers.
|
||||||
|
"""
|
||||||
|
url = "{server}/playQueues"
|
||||||
|
args = {
|
||||||
|
'type': mediatype,
|
||||||
|
'uri': ('library://' + librarySectionUUID +
|
||||||
|
'/item/%2Flibrary%2Fmetadata%2F' + itemid),
|
||||||
|
'includeChapters': '1',
|
||||||
|
'shuffle': '0',
|
||||||
|
'repeat': '0'
|
||||||
|
}
|
||||||
|
if trailers is True:
|
||||||
|
args['extrasPrefixCount'] = settings('trailerNumber')
|
||||||
|
xml = DU().downloadUrl(url + '?' + urlencode(args), action_type="POST")
|
||||||
|
try:
|
||||||
|
xml[0].tag
|
||||||
|
except (IndexError, TypeError, AttributeError):
|
||||||
|
LOG.error("Error retrieving metadata for %s", url)
|
||||||
|
return
|
||||||
|
return xml
|
||||||
|
|
||||||
|
|
||||||
|
def _pms_https_enabled(url):
|
||||||
|
"""
|
||||||
|
Returns True if the PMS can talk https, False otherwise.
|
||||||
|
None if error occured, e.g. the connection timed out
|
||||||
|
|
||||||
|
Call with e.g. url='192.168.0.1:32400' (NO http/https)
|
||||||
|
|
||||||
|
This is done by GET /identity (returns an error if https is enabled and we
|
||||||
|
are trying to use http)
|
||||||
|
|
||||||
|
Prefers HTTPS over HTTP
|
||||||
|
"""
|
||||||
|
res = DU().downloadUrl('https://%s/identity' % url,
|
||||||
|
authenticate=False,
|
||||||
|
verifySSL=False)
|
||||||
|
try:
|
||||||
|
res.attrib
|
||||||
|
except AttributeError:
|
||||||
|
# Might have SSL deactivated. Try with http
|
||||||
|
res = DU().downloadUrl('http://%s/identity' % url,
|
||||||
|
authenticate=False,
|
||||||
|
verifySSL=False)
|
||||||
|
try:
|
||||||
|
res.attrib
|
||||||
|
except AttributeError:
|
||||||
|
LOG.error("Could not contact PMS %s", url)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# Received a valid XML. Server wants to talk HTTP
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# Received a valid XML. Server wants to talk HTTPS
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def GetMachineIdentifier(url):
|
||||||
|
"""
|
||||||
|
Returns the unique PMS machine identifier of url
|
||||||
|
|
||||||
|
Returns None if something went wrong
|
||||||
|
"""
|
||||||
|
xml = DU().downloadUrl('%s/identity' % url,
|
||||||
|
authenticate=False,
|
||||||
|
verifySSL=False,
|
||||||
|
timeout=10)
|
||||||
|
try:
|
||||||
|
machineIdentifier = xml.attrib['machineIdentifier']
|
||||||
|
except (AttributeError, KeyError):
|
||||||
|
LOG.error('Could not get the PMS machineIdentifier for %s', url)
|
||||||
|
return None
|
||||||
|
LOG.debug('Found machineIdentifier %s for the PMS %s',
|
||||||
|
machineIdentifier, url)
|
||||||
|
return machineIdentifier
|
||||||
|
|
||||||
|
|
||||||
|
def GetPMSStatus(token):
|
||||||
|
"""
|
||||||
|
token: Needs to be authorized with a master Plex token
|
||||||
|
(not a managed user token)!
|
||||||
|
Calls /status/sessions on currently active PMS. Returns a dict with:
|
||||||
|
|
||||||
|
'sessionKey':
|
||||||
|
{
|
||||||
|
'userId': Plex ID of the user (if applicable, otherwise '')
|
||||||
|
'username': Plex name (if applicable, otherwise '')
|
||||||
|
'ratingKey': Unique Plex id of item being played
|
||||||
|
}
|
||||||
|
|
||||||
|
or an empty dict.
|
||||||
|
"""
|
||||||
|
answer = {}
|
||||||
|
xml = DU().downloadUrl('{server}/status/sessions',
|
||||||
|
headerOptions={'X-Plex-Token': token})
|
||||||
|
try:
|
||||||
|
xml.attrib
|
||||||
|
except AttributeError:
|
||||||
|
return answer
|
||||||
|
for item in xml:
|
||||||
|
ratingKey = item.attrib.get('ratingKey')
|
||||||
|
sessionKey = item.attrib.get('sessionKey')
|
||||||
|
userId = item.find('User')
|
||||||
|
username = ''
|
||||||
|
if userId is not None:
|
||||||
|
username = userId.attrib.get('title', '')
|
||||||
|
userId = userId.attrib.get('id', '')
|
||||||
|
else:
|
||||||
|
userId = ''
|
||||||
|
answer[sessionKey] = {
|
||||||
|
'userId': userId,
|
||||||
|
'username': username,
|
||||||
|
'ratingKey': ratingKey
|
||||||
|
}
|
||||||
|
return answer
|
||||||
|
|
||||||
|
|
||||||
|
def scrobble(ratingKey, state):
|
||||||
|
"""
|
||||||
|
Tells the PMS to set an item's watched state to state="watched" or
|
||||||
|
state="unwatched"
|
||||||
|
"""
|
||||||
|
args = {
|
||||||
|
'key': ratingKey,
|
||||||
|
'identifier': 'com.plexapp.plugins.library'
|
||||||
|
}
|
||||||
|
if state == "watched":
|
||||||
|
url = "{server}/:/scrobble?" + urlencode(args)
|
||||||
|
elif state == "unwatched":
|
||||||
|
url = "{server}/:/unscrobble?" + urlencode(args)
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
DU().downloadUrl(url)
|
||||||
|
LOG.info("Toggled watched state for Plex item %s", ratingKey)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_item_from_pms(plexid):
|
||||||
|
"""
|
||||||
|
Deletes the item plexid from the Plex Media Server (and the harddrive!).
|
||||||
|
Do make sure that the currently logged in user has the credentials
|
||||||
|
|
||||||
|
Returns True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
if DU().downloadUrl('{server}/library/metadata/%s' % plexid,
|
||||||
|
action_type="DELETE") is True:
|
||||||
|
LOG.info('Successfully deleted Plex id %s from the PMS', plexid)
|
||||||
|
return True
|
||||||
|
LOG.error('Could not delete Plex id %s from the PMS', plexid)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_PMS_settings(url, token):
|
||||||
|
"""
|
||||||
|
Retrieve the PMS' settings via <url>/:/prefs
|
||||||
|
|
||||||
|
Call with url: scheme://ip:port
|
||||||
|
"""
|
||||||
|
return DU().downloadUrl(
|
||||||
|
'%s/:/prefs' % url,
|
||||||
|
authenticate=False,
|
||||||
|
verifySSL=False,
|
||||||
|
headerOptions={'X-Plex-Token': token} if token else None)
|
||||||
|
|
||||||
|
|
||||||
|
def GetUserArtworkURL(username):
|
||||||
|
"""
|
||||||
|
Returns the URL for the user's Avatar. Or False if something went
|
||||||
|
wrong.
|
||||||
|
"""
|
||||||
|
users = plex_tv.list_home_users(settings('plexToken'))
|
||||||
|
url = ''
|
||||||
|
# If an error is encountered, set to False
|
||||||
|
if not users:
|
||||||
|
LOG.info("Couldnt get user from plex.tv. No URL for user avatar")
|
||||||
|
return False
|
||||||
|
for user in users:
|
||||||
|
if username in user['title']:
|
||||||
|
url = user['thumb']
|
||||||
|
LOG.debug("Avatar url for user %s is: %s", username, url)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def transcode_image_path(key, AuthToken, path, width, height):
|
||||||
|
"""
|
||||||
|
Transcode Image support
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
key
|
||||||
|
AuthToken
|
||||||
|
path - source path of current XML: path[srcXML]
|
||||||
|
width
|
||||||
|
height
|
||||||
|
result:
|
||||||
|
final path to image file
|
||||||
|
"""
|
||||||
|
# external address - can we get a transcoding request for external images?
|
||||||
|
if key.startswith('http://') or key.startswith('https://'):
|
||||||
|
path = key
|
||||||
|
elif key.startswith('/'): # internal full path.
|
||||||
|
path = 'http://127.0.0.1:32400' + key
|
||||||
|
else: # internal path, add-on
|
||||||
|
path = 'http://127.0.0.1:32400' + path + '/' + key
|
||||||
|
path = try_encode(path)
|
||||||
|
# This is bogus (note the extra path component) but ATV is stupid when it
|
||||||
|
# comes to caching images, it doesn't use querystrings. Fortunately PMS is
|
||||||
|
# lenient...
|
||||||
|
transcode_path = ('/photo/:/transcode/%sx%s/%s'
|
||||||
|
% (width, height, quote_plus(path)))
|
||||||
|
args = {
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'url': path
|
||||||
|
}
|
||||||
|
if AuthToken:
|
||||||
|
args['X-Plex-Token'] = AuthToken
|
||||||
|
return transcode_path + '?' + urlencode(args)
|
|
@ -1,40 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Used to save PKC's application state and share between modules. Be careful
|
|
||||||
if you invoke another PKC Python instance (!!) when e.g. PKC.movies is called
|
|
||||||
"""
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from .account import Account
|
|
||||||
from .application import App
|
|
||||||
from .connection import Connection
|
|
||||||
from .libsync import Sync
|
|
||||||
from .playstate import PlayState
|
|
||||||
|
|
||||||
ACCOUNT = None
|
|
||||||
APP = None
|
|
||||||
CONN = None
|
|
||||||
SYNC = None
|
|
||||||
PLAYSTATE = None
|
|
||||||
|
|
||||||
|
|
||||||
def init(entrypoint=False):
|
|
||||||
"""
|
|
||||||
entrypoint=True initiates only the bare minimum - for other PKC python
|
|
||||||
instances
|
|
||||||
"""
|
|
||||||
global ACCOUNT, APP, CONN, SYNC, PLAYSTATE
|
|
||||||
APP = App(entrypoint)
|
|
||||||
CONN = Connection(entrypoint)
|
|
||||||
ACCOUNT = Account(entrypoint)
|
|
||||||
SYNC = Sync(entrypoint)
|
|
||||||
if not entrypoint:
|
|
||||||
PLAYSTATE = PlayState()
|
|
||||||
|
|
||||||
def reload():
|
|
||||||
"""
|
|
||||||
Reload PKC settings from xml file, e.g. on user-switch
|
|
||||||
"""
|
|
||||||
global APP, SYNC
|
|
||||||
APP.reload()
|
|
||||||
SYNC.reload()
|
|
|
@ -1,147 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from .. import utils
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.account')
|
|
||||||
|
|
||||||
|
|
||||||
class Account(object):
|
|
||||||
def __init__(self, entrypoint=False):
|
|
||||||
self.plex_login = None
|
|
||||||
self.plex_login_id = None
|
|
||||||
self.plex_username = None
|
|
||||||
self.plex_user_id = None
|
|
||||||
self.plex_token = None
|
|
||||||
# Personal access token per specific user and PMS
|
|
||||||
# As a rule of thumb, always use this token!
|
|
||||||
self.pms_token = None
|
|
||||||
self.avatar = None
|
|
||||||
self.myplexlogin = None
|
|
||||||
self.restricted_user = None
|
|
||||||
self.force_login = None
|
|
||||||
self._session = None
|
|
||||||
self.authenticated = False
|
|
||||||
if entrypoint:
|
|
||||||
self.load_entrypoint()
|
|
||||||
else:
|
|
||||||
utils.window('plex_authenticated', clear=True)
|
|
||||||
self.load()
|
|
||||||
|
|
||||||
def set_authenticated(self):
|
|
||||||
self.authenticated = True
|
|
||||||
utils.window('plex_authenticated', value='true')
|
|
||||||
|
|
||||||
def set_unauthenticated(self):
|
|
||||||
self.authenticated = False
|
|
||||||
utils.window('plex_authenticated', clear=True)
|
|
||||||
|
|
||||||
def reset_session(self):
|
|
||||||
try:
|
|
||||||
self._session.stopSession()
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
from .. import downloadutils
|
|
||||||
self._session = downloadutils.DownloadUtils()
|
|
||||||
self._session.startSession(reset=True)
|
|
||||||
|
|
||||||
def load(self):
|
|
||||||
LOG.debug('Loading account settings')
|
|
||||||
# User name we used to sign in to plex.tv
|
|
||||||
self.plex_login = utils.settings('plexLogin') or None
|
|
||||||
self.plex_login_id = utils.settings('plexid') or None
|
|
||||||
# plex.tv username
|
|
||||||
self.plex_username = utils.settings('username') or None
|
|
||||||
# Plex ID of that user (e.g. for plex.tv) as a STRING
|
|
||||||
self.plex_user_id = utils.settings('userid') or None
|
|
||||||
# Token for that user for plex.tv
|
|
||||||
self.plex_token = utils.settings('plexToken') or None
|
|
||||||
# Plex token for the active PMS for the active user
|
|
||||||
# (might be diffent to plex_token)
|
|
||||||
self.pms_token = utils.settings('accessToken') or None
|
|
||||||
self.avatar = utils.settings('plexAvatar') or None
|
|
||||||
self.myplexlogin = utils.settings('myplexlogin') == 'true'
|
|
||||||
|
|
||||||
# Plex home user? Then "False"
|
|
||||||
self.restricted_user = utils.settings('plex_restricteduser') == 'true'
|
|
||||||
# Force user to enter Pin if set?
|
|
||||||
self.force_login = utils.settings('enforceUserLogin') == 'true'
|
|
||||||
|
|
||||||
# Also load these settings to Kodi window variables - they'll be
|
|
||||||
# available for other PKC Python instances
|
|
||||||
utils.window('plex_restricteduser',
|
|
||||||
value='true' if self.restricted_user else 'false')
|
|
||||||
utils.window('plex_token', value=self.plex_token or '')
|
|
||||||
utils.window('pms_token', value=self.pms_token or '')
|
|
||||||
utils.window('plexAvatar', value=self.avatar or '')
|
|
||||||
|
|
||||||
# Start download session
|
|
||||||
self.reset_session()
|
|
||||||
|
|
||||||
LOG.debug('Loaded user %s, %s with plex token %s... and pms token %s...',
|
|
||||||
self.plex_username, self.plex_user_id,
|
|
||||||
self.plex_token[:5] if self.plex_token else None,
|
|
||||||
self.pms_token[:5] if self.pms_token else None)
|
|
||||||
LOG.debug('User is restricted Home user: %s', self.restricted_user)
|
|
||||||
|
|
||||||
def load_entrypoint(self):
|
|
||||||
self.pms_token = utils.settings('accessToken') or None
|
|
||||||
|
|
||||||
def log_out(self):
|
|
||||||
LOG.debug('Logging-out user %s', self.plex_username)
|
|
||||||
self.plex_username = None
|
|
||||||
self.plex_user_id = None
|
|
||||||
self.pms_token = None
|
|
||||||
self.avatar = None
|
|
||||||
self.restricted_user = None
|
|
||||||
self.authenticated = False
|
|
||||||
try:
|
|
||||||
self._session.stopSession()
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
self._session = None
|
|
||||||
|
|
||||||
utils.settings('username', value='')
|
|
||||||
utils.settings('userid', value='')
|
|
||||||
utils.settings('plex_restricteduser', value='')
|
|
||||||
utils.settings('accessToken', value='')
|
|
||||||
utils.settings('plexAvatar', value='')
|
|
||||||
|
|
||||||
utils.window('plex_restricteduser', clear=True)
|
|
||||||
utils.window('pms_token', clear=True)
|
|
||||||
utils.window('plexAvatar', clear=True)
|
|
||||||
utils.window('plex_authenticated', clear=True)
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
LOG.debug('Clearing account settings')
|
|
||||||
self.plex_username = None
|
|
||||||
self.plex_user_id = None
|
|
||||||
self.plex_token = None
|
|
||||||
self.pms_token = None
|
|
||||||
self.avatar = None
|
|
||||||
self.restricted_user = None
|
|
||||||
self.authenticated = False
|
|
||||||
self.plex_login = None
|
|
||||||
self.plex_login_id = None
|
|
||||||
try:
|
|
||||||
self._session.stopSession()
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
self._session = None
|
|
||||||
|
|
||||||
utils.settings('username', value='')
|
|
||||||
utils.settings('userid', value='')
|
|
||||||
utils.settings('plex_restricteduser', value='')
|
|
||||||
utils.settings('plexToken', value='')
|
|
||||||
utils.settings('accessToken', value='')
|
|
||||||
utils.settings('plexAvatar', value='')
|
|
||||||
utils.settings('plexLogin', value='')
|
|
||||||
utils.settings('plexid', value='')
|
|
||||||
|
|
||||||
utils.window('plex_restricteduser', clear=True)
|
|
||||||
utils.window('plex_token', clear=True)
|
|
||||||
utils.window('pms_token', clear=True)
|
|
||||||
utils.window('plexAvatar', clear=True)
|
|
||||||
utils.window('plex_authenticated', clear=True)
|
|
|
@ -1,173 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
import Queue
|
|
||||||
from threading import Lock, RLock
|
|
||||||
|
|
||||||
import xbmc
|
|
||||||
|
|
||||||
from .. import utils
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.app')
|
|
||||||
|
|
||||||
|
|
||||||
class App(object):
|
|
||||||
"""
|
|
||||||
This class is used to store variables across PKC modules
|
|
||||||
"""
|
|
||||||
def __init__(self, entrypoint=False):
|
|
||||||
self.fetch_pms_item_number = None
|
|
||||||
self.force_reload_skin = None
|
|
||||||
# All thread instances
|
|
||||||
self.threads = []
|
|
||||||
if entrypoint:
|
|
||||||
self.load_entrypoint()
|
|
||||||
else:
|
|
||||||
self.reload()
|
|
||||||
# Quit PKC?
|
|
||||||
self.stop_pkc = False
|
|
||||||
# This will suspend the main thread also
|
|
||||||
self.suspend = False
|
|
||||||
# Update Kodi widgets
|
|
||||||
self.update_widgets = False
|
|
||||||
# Need to lock all methods and functions messing with Plex Companion subscribers
|
|
||||||
self.lock_subscriber = RLock()
|
|
||||||
# Need to lock everything messing with Kodi/PKC playqueues
|
|
||||||
self.lock_playqueues = RLock()
|
|
||||||
# Necessary to temporarily hold back librarysync/websocket listener when doing
|
|
||||||
# a full sync
|
|
||||||
self.lock_playlists = Lock()
|
|
||||||
|
|
||||||
# Plex Companion Queue()
|
|
||||||
self.companion_queue = Queue.Queue(maxsize=100)
|
|
||||||
# Websocket_client queue to communicate with librarysync
|
|
||||||
self.websocket_queue = Queue.Queue()
|
|
||||||
# xbmc.Monitor() instance from kodimonitor.py
|
|
||||||
self.monitor = None
|
|
||||||
# xbmc.Player() instance
|
|
||||||
self.player = None
|
|
||||||
# Instance of FanartThread()
|
|
||||||
self.fanart_thread = None
|
|
||||||
# Instance of ImageCachingThread()
|
|
||||||
self.caching_thread = None
|
|
||||||
# Dialog to skip intro
|
|
||||||
self.skip_intro_dialog = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_playing(self):
|
|
||||||
return self.player.isPlaying() == 1
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_playing_video(self):
|
|
||||||
return self.player.isPlayingVideo() == 1
|
|
||||||
|
|
||||||
def register_fanart_thread(self, thread):
|
|
||||||
self.fanart_thread = thread
|
|
||||||
self.threads.append(thread)
|
|
||||||
|
|
||||||
def deregister_fanart_thread(self, thread):
|
|
||||||
self.fanart_thread.unblock_callers()
|
|
||||||
self.fanart_thread = None
|
|
||||||
self.threads.remove(thread)
|
|
||||||
|
|
||||||
def suspend_fanart_thread(self, block=True):
|
|
||||||
try:
|
|
||||||
self.fanart_thread.suspend(block=block)
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def resume_fanart_thread(self):
|
|
||||||
try:
|
|
||||||
self.fanart_thread.resume()
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def register_caching_thread(self, thread):
|
|
||||||
self.caching_thread = thread
|
|
||||||
self.threads.append(thread)
|
|
||||||
|
|
||||||
def deregister_caching_thread(self, thread):
|
|
||||||
self.caching_thread.unblock_callers()
|
|
||||||
self.caching_thread = None
|
|
||||||
self.threads.remove(thread)
|
|
||||||
|
|
||||||
def suspend_caching_thread(self, block=True):
|
|
||||||
try:
|
|
||||||
self.caching_thread.suspend(block=block)
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def resume_caching_thread(self):
|
|
||||||
try:
|
|
||||||
self.caching_thread.resume()
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def register_thread(self, thread):
|
|
||||||
"""
|
|
||||||
Hit with thread [backgroundthread.Killablethread instance] to register
|
|
||||||
any and all threads
|
|
||||||
"""
|
|
||||||
self.threads.append(thread)
|
|
||||||
|
|
||||||
def deregister_thread(self, thread):
|
|
||||||
"""
|
|
||||||
Sync thread has done it's work and is e.g. about to die
|
|
||||||
"""
|
|
||||||
thread.unblock_callers()
|
|
||||||
self.threads.remove(thread)
|
|
||||||
|
|
||||||
def suspend_threads(self, block=True):
|
|
||||||
"""
|
|
||||||
Suspend all threads' activity with or without blocking.
|
|
||||||
Returns True only if PKC shutdown requested
|
|
||||||
"""
|
|
||||||
LOG.debug('Suspending threads: %s', self.threads)
|
|
||||||
for thread in self.threads:
|
|
||||||
thread.suspend()
|
|
||||||
if block:
|
|
||||||
while True:
|
|
||||||
for thread in self.threads:
|
|
||||||
if not thread.is_suspended():
|
|
||||||
LOG.debug('Waiting for thread to suspend: %s', thread)
|
|
||||||
# Send suspend signal again in case self.threads
|
|
||||||
# changed
|
|
||||||
thread.suspend(block=True)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
return self.monitor.abortRequested()
|
|
||||||
|
|
||||||
def resume_threads(self):
|
|
||||||
"""
|
|
||||||
Resume all thread activity with or without blocking.
|
|
||||||
Returns True only if PKC shutdown requested
|
|
||||||
"""
|
|
||||||
LOG.debug('Resuming threads: %s', self.threads)
|
|
||||||
for thread in self.threads:
|
|
||||||
thread.resume()
|
|
||||||
return self.monitor.abortRequested()
|
|
||||||
|
|
||||||
def stop_threads(self, block=True):
|
|
||||||
"""
|
|
||||||
Stop all threads. Will block until all threads are stopped
|
|
||||||
Will NOT quit if PKC should exit!
|
|
||||||
"""
|
|
||||||
LOG.debug('Killing threads: %s', self.threads)
|
|
||||||
for thread in self.threads:
|
|
||||||
thread.cancel()
|
|
||||||
if block:
|
|
||||||
while self.threads:
|
|
||||||
LOG.debug('Waiting for threads to exit: %s', self.threads)
|
|
||||||
if xbmc.sleep(100):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def reload(self):
|
|
||||||
# Number of items to fetch and display in widgets
|
|
||||||
self.fetch_pms_item_number = int(utils.settings('fetch_pms_item_number'))
|
|
||||||
# Hack to force Kodi widget for "in progress" to show up if it was empty
|
|
||||||
# before
|
|
||||||
self.force_reload_skin = utils.settings('forceReloadSkinOnPlaybackStop') == 'true'
|
|
||||||
|
|
||||||
def load_entrypoint(self):
|
|
||||||
self.fetch_pms_item_number = int(utils.settings('fetch_pms_item_number'))
|
|
|
@ -1,98 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from .. import utils, json_rpc as js, variables as v
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.connection')
|
|
||||||
|
|
||||||
|
|
||||||
class Connection(object):
|
|
||||||
def __init__(self, entrypoint=False):
|
|
||||||
self.verify_ssl_cert = None
|
|
||||||
self.ssl_cert_path = None
|
|
||||||
self.machine_identifier = None
|
|
||||||
self.server_name = None
|
|
||||||
self.https = None
|
|
||||||
self.host = None
|
|
||||||
self.port = None
|
|
||||||
self.server = None
|
|
||||||
self.online = False
|
|
||||||
self.webserver_host = None
|
|
||||||
self.webserver_port = None
|
|
||||||
self.webserver_username = None
|
|
||||||
self.webserver_password = None
|
|
||||||
|
|
||||||
if entrypoint:
|
|
||||||
self.load_entrypoint()
|
|
||||||
else:
|
|
||||||
self.load_webserver()
|
|
||||||
self.load()
|
|
||||||
# Token passed along, e.g. if playback initiated by Plex Companion. Might be
|
|
||||||
# another user playing something! Token identifies user
|
|
||||||
self.plex_transient_token = None
|
|
||||||
|
|
||||||
def load_webserver(self):
|
|
||||||
"""
|
|
||||||
PKC needs Kodi webserver to work correctly
|
|
||||||
"""
|
|
||||||
LOG.debug('Loading Kodi webserver details')
|
|
||||||
# Kodi webserver details
|
|
||||||
if js.get_setting('services.webserver') in (None, False):
|
|
||||||
# Enable the webserver, it is disabled
|
|
||||||
js.set_setting('services.webserver', True)
|
|
||||||
self.webserver_host = 'localhost'
|
|
||||||
self.webserver_port = js.get_setting('services.webserverport')
|
|
||||||
self.webserver_username = js.get_setting('services.webserverusername')
|
|
||||||
self.webserver_password = js.get_setting('services.webserverpassword')
|
|
||||||
|
|
||||||
def load(self):
|
|
||||||
LOG.debug('Loading connection settings')
|
|
||||||
# Shall we verify SSL certificates? "None" will leave SSL enabled
|
|
||||||
# Ignore this setting for Kodi >= 18 as Kodi 18 is much stricter
|
|
||||||
# with checking SSL certs
|
|
||||||
self.verify_ssl_cert = None if v.KODIVERSION >= 18 or utils.settings('sslverify') == 'true' \
|
|
||||||
else False
|
|
||||||
# Do we have an ssl certificate for PKC we need to use?
|
|
||||||
self.ssl_cert_path = utils.settings('sslcert') \
|
|
||||||
if utils.settings('sslcert') != 'None' else None
|
|
||||||
|
|
||||||
self.machine_identifier = utils.settings('plex_machineIdentifier') or None
|
|
||||||
self.server_name = utils.settings('plex_servername') or None
|
|
||||||
self.https = utils.settings('https') == 'true'
|
|
||||||
self.host = utils.settings('ipaddress') or None
|
|
||||||
self.port = int(utils.settings('port')) if utils.settings('port') else None
|
|
||||||
if not self.host:
|
|
||||||
self.server = None
|
|
||||||
elif self.https:
|
|
||||||
self.server = 'https://%s:%s' % (self.host, self.port)
|
|
||||||
else:
|
|
||||||
self.server = 'http://%s:%s' % (self.host, self.port)
|
|
||||||
self.online = False
|
|
||||||
LOG.debug('Set server %s (%s) to %s',
|
|
||||||
self.server_name, self.machine_identifier, self.server)
|
|
||||||
|
|
||||||
def load_entrypoint(self):
|
|
||||||
self.verify_ssl_cert = None if v.KODIVERSION >= 18 or utils.settings('sslverify') == 'true' \
|
|
||||||
else False
|
|
||||||
self.ssl_cert_path = utils.settings('sslcert') \
|
|
||||||
if utils.settings('sslcert') != 'None' else None
|
|
||||||
self.https = utils.settings('https') == 'true'
|
|
||||||
self.host = utils.settings('ipaddress') or None
|
|
||||||
self.port = int(utils.settings('port')) if utils.settings('port') else None
|
|
||||||
if not self.host:
|
|
||||||
self.server = None
|
|
||||||
elif self.https:
|
|
||||||
self.server = 'https://%s:%s' % (self.host, self.port)
|
|
||||||
else:
|
|
||||||
self.server = 'http://%s:%s' % (self.host, self.port)
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
LOG.debug('Clearing connection settings')
|
|
||||||
self.machine_identifier = None
|
|
||||||
self.server_name = None
|
|
||||||
self.https = None
|
|
||||||
self.host = None
|
|
||||||
self.port = None
|
|
||||||
self.server = None
|
|
|
@ -1,130 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
from .. import utils
|
|
||||||
|
|
||||||
|
|
||||||
def remove_trailing_slash(path):
|
|
||||||
"""
|
|
||||||
Removes trailing slashes or backslashes from path [unicode], and is NOT
|
|
||||||
dependent on os.path
|
|
||||||
"""
|
|
||||||
if '/' in path:
|
|
||||||
path = path[:-1] if path.endswith('/') else path
|
|
||||||
else:
|
|
||||||
path = path[:-1] if path.endswith('\\') else path
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
class Sync(object):
|
|
||||||
def __init__(self, entrypoint=False):
|
|
||||||
# Direct Paths (True) or Addon Paths (False)?
|
|
||||||
self.direct_paths = None
|
|
||||||
# Is synching of Plex music enabled?
|
|
||||||
self.enable_music = None
|
|
||||||
# Do we sync artwork from the PMS to Kodi?
|
|
||||||
self.artwork = None
|
|
||||||
# Path remapping mechanism (e.g. smb paths)
|
|
||||||
# Do we replace \\myserver\path to smb://myserver/path?
|
|
||||||
self.replace_smb_path = None
|
|
||||||
# Do we generally remap?
|
|
||||||
self.remap_path = None
|
|
||||||
self.force_transcode_pix = None
|
|
||||||
# Mappings for REMAP_PATH:
|
|
||||||
self.remapSMBmovieOrg = None
|
|
||||||
self.remapSMBmovieNew = None
|
|
||||||
self.remapSMBtvOrg = None
|
|
||||||
self.remapSMBtvNew = None
|
|
||||||
self.remapSMBmusicOrg = None
|
|
||||||
self.remapSMBmusicNew = None
|
|
||||||
self.remapSMBphotoOrg = None
|
|
||||||
self.remapSMBphotoNew = None
|
|
||||||
# Escape path?
|
|
||||||
self.escape_path = None
|
|
||||||
self.escape_path_safe_chars = None
|
|
||||||
# Shall we replace custom user ratings with the number of versions available?
|
|
||||||
self.indicate_media_versions = None
|
|
||||||
# Will sync movie trailer differently: either play trailer directly or show
|
|
||||||
# all the Plex extras for the user to choose
|
|
||||||
self.show_extras_instead_of_playing_trailer = None
|
|
||||||
# Only sync specific Plex playlists to Kodi?
|
|
||||||
self.sync_specific_plex_playlists = None
|
|
||||||
# Only sync specific Kodi playlists to Plex?
|
|
||||||
self.sync_specific_kodi_playlists = None
|
|
||||||
# Shall we show Kodi dialogs when synching?
|
|
||||||
self.sync_dialog = None
|
|
||||||
|
|
||||||
# How often shall we sync?
|
|
||||||
self.full_sync_intervall = None
|
|
||||||
# How long shall we wait with synching a new item to make sure Plex got all
|
|
||||||
# metadata?
|
|
||||||
self.backgroundsync_saftymargin = None
|
|
||||||
# How many threads to download Plex metadata on sync?
|
|
||||||
self.sync_thread_number = None
|
|
||||||
|
|
||||||
# Shall Kodi show dialogs for syncing/caching images? (e.g. images left
|
|
||||||
# to sync)
|
|
||||||
self.image_sync_notifications = None
|
|
||||||
|
|
||||||
# Do we need to run a special library scan?
|
|
||||||
self.run_lib_scan = None
|
|
||||||
# Set if user decided to cancel sync
|
|
||||||
self.stop_sync = False
|
|
||||||
# Could we access the paths?
|
|
||||||
self.path_verified = False
|
|
||||||
|
|
||||||
# List of Section() items representing Plex library sections
|
|
||||||
self._sections = []
|
|
||||||
# List of section_ids we're synching to Kodi - will be automatically
|
|
||||||
# re-built if sections are set a-new
|
|
||||||
self.section_ids = set()
|
|
||||||
|
|
||||||
self.load()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sections(self):
|
|
||||||
return self._sections
|
|
||||||
|
|
||||||
@sections.setter
|
|
||||||
def sections(self, sections):
|
|
||||||
self._sections = sections
|
|
||||||
# Sets are faster when using "in" test than lists
|
|
||||||
self.section_ids = set([x.section_id for x in sections if x.sync_to_kodi])
|
|
||||||
|
|
||||||
def load(self):
|
|
||||||
self.direct_paths = utils.settings('useDirectPaths') == '1'
|
|
||||||
self.enable_music = utils.settings('enableMusic') == 'true'
|
|
||||||
self.artwork = utils.settings('usePlexArtwork') == 'true'
|
|
||||||
self.replace_smb_path = utils.settings('replaceSMB') == 'true'
|
|
||||||
self.remap_path = utils.settings('remapSMB') == 'true'
|
|
||||||
self.remapSMBmovieOrg = remove_trailing_slash(utils.settings('remapSMBmovieOrg'))
|
|
||||||
self.remapSMBmovieNew = remove_trailing_slash(utils.settings('remapSMBmovieNew'))
|
|
||||||
self.remapSMBtvOrg = remove_trailing_slash(utils.settings('remapSMBtvOrg'))
|
|
||||||
self.remapSMBtvNew = remove_trailing_slash(utils.settings('remapSMBtvNew'))
|
|
||||||
self.remapSMBmusicOrg = remove_trailing_slash(utils.settings('remapSMBmusicOrg'))
|
|
||||||
self.remapSMBmusicNew = remove_trailing_slash(utils.settings('remapSMBmusicNew'))
|
|
||||||
self.remapSMBphotoOrg = remove_trailing_slash(utils.settings('remapSMBphotoOrg'))
|
|
||||||
self.remapSMBphotoNew = remove_trailing_slash(utils.settings('remapSMBphotoNew'))
|
|
||||||
self.escape_path = utils.settings('escapePath') == 'true'
|
|
||||||
self.escape_path_safe_chars = utils.settings('escapePathSafeChars').encode('utf-8')
|
|
||||||
self.indicate_media_versions = utils.settings('indicate_media_versions') == "true"
|
|
||||||
self.sync_specific_plex_playlists = utils.settings('syncSpecificPlexPlaylists') == 'true'
|
|
||||||
self.sync_specific_kodi_playlists = utils.settings('syncSpecificKodiPlaylists') == 'true'
|
|
||||||
self.sync_thread_number = int(utils.settings('syncThreadNumber'))
|
|
||||||
self.reload()
|
|
||||||
|
|
||||||
def reload(self):
|
|
||||||
"""
|
|
||||||
Any settings unrelated to syncs to the Kodi database - can thus be
|
|
||||||
safely reset without a Kodi reboot
|
|
||||||
"""
|
|
||||||
self.sync_dialog = utils.settings('dbSyncIndicator') == 'true'
|
|
||||||
self.full_sync_intervall = int(utils.settings('fullSyncInterval')) * 60
|
|
||||||
self.backgroundsync_saftymargin = int(utils.settings('backgroundsync_saftyMargin'))
|
|
||||||
|
|
||||||
self.image_sync_notifications = utils.settings('imageSyncNotifications') == 'true'
|
|
||||||
|
|
||||||
self.force_transcode_pix = utils.settings('force_transcode_pix') == 'true'
|
|
||||||
# Trailers in Kodi DB will remain UNTIL DB is reset!
|
|
||||||
self.show_extras_instead_of_playing_trailer = utils.settings('showExtrasInsteadOfTrailer') == 'true'
|
|
|
@ -1,66 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
|
|
||||||
class PlayState(object):
|
|
||||||
# "empty" dict for the PLAYER_STATES above. Use copy.deepcopy to duplicate!
|
|
||||||
template = {
|
|
||||||
'type': None,
|
|
||||||
'time': {
|
|
||||||
'hours': 0,
|
|
||||||
'minutes': 0,
|
|
||||||
'seconds': 0,
|
|
||||||
'milliseconds': 0},
|
|
||||||
'totaltime': {
|
|
||||||
'hours': 0,
|
|
||||||
'minutes': 0,
|
|
||||||
'seconds': 0,
|
|
||||||
'milliseconds': 0},
|
|
||||||
'speed': 0,
|
|
||||||
'shuffled': False,
|
|
||||||
'repeat': 'off',
|
|
||||||
'position': None,
|
|
||||||
'playlistid': None,
|
|
||||||
'currentvideostream': -1,
|
|
||||||
'currentaudiostream': -1,
|
|
||||||
'subtitleenabled': False,
|
|
||||||
'currentsubtitle': -1,
|
|
||||||
'file': None,
|
|
||||||
'kodi_id': None,
|
|
||||||
'kodi_type': None,
|
|
||||||
'plex_id': None,
|
|
||||||
'plex_type': None,
|
|
||||||
'container_key': None,
|
|
||||||
'volume': 100,
|
|
||||||
'muted': False,
|
|
||||||
'playmethod': None,
|
|
||||||
'playcount': None,
|
|
||||||
'external_player': False, # bool - xbmc.Player().isExternalPlayer()
|
|
||||||
'intro_markers': [],
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
# Kodi player states - here, initial values are set
|
|
||||||
self.player_states = {
|
|
||||||
0: {},
|
|
||||||
1: {},
|
|
||||||
2: {}
|
|
||||||
}
|
|
||||||
# The LAST playstate once playback is finished
|
|
||||||
self.old_player_states = {
|
|
||||||
0: {},
|
|
||||||
1: {},
|
|
||||||
2: {}
|
|
||||||
}
|
|
||||||
self.played_info = {}
|
|
||||||
|
|
||||||
# Currently playing PKC item, a PlaylistItem()
|
|
||||||
self.item = None
|
|
||||||
|
|
||||||
# Was the playback initiated by the user using the Kodi context menu?
|
|
||||||
self.context_menu_play = False
|
|
||||||
# Set by context menu - shall we force-transcode the next playing item?
|
|
||||||
self.force_transcode = False
|
|
||||||
# Which Kodi player is/has been active? (either int 1, 2 or 3)
|
|
||||||
self.active_players = set()
|
|
|
@ -1,139 +1,346 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
###############################################################################
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
from Queue import Queue, Empty
|
||||||
|
from shutil import rmtree
|
||||||
|
from urllib import quote_plus, unquote
|
||||||
|
from threading import Thread
|
||||||
|
from os import makedirs
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .kodi_db import KodiVideoDB, KodiMusicDB, KodiTextureDB
|
from xbmc import sleep, translatePath
|
||||||
from . import app, backgroundthread, utils
|
from xbmcvfs import exists
|
||||||
|
|
||||||
LOG = getLogger('PLEX.artwork')
|
from utils import settings, language as lang, kodi_sql, try_encode, try_decode,\
|
||||||
|
thread_methods, dialog, exists_dir
|
||||||
|
import state
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
# Disable annoying requests warnings
|
# Disable annoying requests warnings
|
||||||
requests.packages.urllib3.disable_warnings()
|
requests.packages.urllib3.disable_warnings()
|
||||||
|
ARTWORK_QUEUE = Queue()
|
||||||
|
IMAGE_CACHING_SUSPENDS = ['SUSPEND_LIBRARY_THREAD', 'DB_SCAN', 'STOP_SYNC']
|
||||||
|
if not settings('imageSyncDuringPlayback') == 'true':
|
||||||
|
IMAGE_CACHING_SUSPENDS.append('SUSPEND_SYNC')
|
||||||
|
|
||||||
# Potentially issues with limited number of threads Hence let Kodi wait till
|
###############################################################################
|
||||||
# download is successful
|
|
||||||
TIMEOUT = (35.1, 35.1)
|
|
||||||
BATCH_SIZE = 500
|
|
||||||
|
|
||||||
|
|
||||||
def double_urlencode(text):
|
def double_urlencode(text):
|
||||||
return utils.quote_plus(utils.quote_plus(text))
|
return quote_plus(quote_plus(text))
|
||||||
|
|
||||||
|
|
||||||
def double_urldecode(text):
|
def double_urldecode(text):
|
||||||
return utils.unquote(utils.unquote(text))
|
return unquote(unquote(text))
|
||||||
|
|
||||||
|
|
||||||
class ImageCachingThread(backgroundthread.KillableThread):
|
@thread_methods(add_suspends=IMAGE_CACHING_SUSPENDS)
|
||||||
|
class Image_Cache_Thread(Thread):
|
||||||
|
sleep_between = 50
|
||||||
|
# Potentially issues with limited number of threads
|
||||||
|
# Hence let Kodi wait till download is successful
|
||||||
|
timeout = (35.1, 35.1)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(ImageCachingThread, self).__init__()
|
self.queue = ARTWORK_QUEUE
|
||||||
self.suspend_points = [(self, '_suspended')]
|
Thread.__init__(self)
|
||||||
if not utils.settings('imageSyncDuringPlayback') == 'true':
|
|
||||||
self.suspend_points.append((app.APP, 'is_playing_video'))
|
|
||||||
|
|
||||||
def should_suspend(self):
|
|
||||||
return any(getattr(obj, attrib) for obj, attrib in self.suspend_points)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _url_generator(kind, kodi_type):
|
|
||||||
"""
|
|
||||||
Main goal is to close DB connection between calls
|
|
||||||
"""
|
|
||||||
offset = 0
|
|
||||||
i = 0
|
|
||||||
while True:
|
|
||||||
batch = []
|
|
||||||
with kind(texture_db=True) as kodidb:
|
|
||||||
texture_db = KodiTextureDB(kodiconn=kodidb.kodiconn,
|
|
||||||
artconn=kodidb.artconn,
|
|
||||||
lock=False)
|
|
||||||
for i, url in enumerate(kodidb.artwork_generator(kodi_type,
|
|
||||||
BATCH_SIZE,
|
|
||||||
offset)):
|
|
||||||
if texture_db.url_not_yet_cached(url):
|
|
||||||
batch.append(url)
|
|
||||||
if len(batch) == BATCH_SIZE:
|
|
||||||
break
|
|
||||||
offset += i
|
|
||||||
for url in batch:
|
|
||||||
yield url
|
|
||||||
if i + 1 < BATCH_SIZE:
|
|
||||||
break
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
LOG.info("---===### Starting ImageCachingThread ###===---")
|
LOG.info("---===### Starting Image_Cache_Thread ###===---")
|
||||||
app.APP.register_caching_thread(self)
|
stopped = self.stopped
|
||||||
|
suspended = self.suspended
|
||||||
|
queue = self.queue
|
||||||
|
sleep_between = self.sleep_between
|
||||||
|
counter = 0
|
||||||
|
set_zero = False
|
||||||
|
while not stopped():
|
||||||
|
# In the event the server goes offline
|
||||||
|
while suspended():
|
||||||
|
# Set in service.py
|
||||||
|
if stopped():
|
||||||
|
# Abort was requested while waiting. We should exit
|
||||||
|
LOG.info("---===### Stopped Image_Cache_Thread ###===---")
|
||||||
|
return
|
||||||
|
sleep(1000)
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = queue.get(block=False)
|
||||||
|
except Empty:
|
||||||
|
if not set_zero:
|
||||||
|
# Avoid saving '0' all the time
|
||||||
|
set_zero = True
|
||||||
|
settings('caching_artwork_count', value='0')
|
||||||
|
sleep(1000)
|
||||||
|
continue
|
||||||
|
set_zero = False
|
||||||
|
if isinstance(url, ArtworkSyncMessage):
|
||||||
|
if state.IMAGE_SYNC_NOTIFICATIONS:
|
||||||
|
dialog('notification',
|
||||||
|
heading=lang(29999),
|
||||||
|
message=url.message,
|
||||||
|
icon='{plex}',
|
||||||
|
sound=False)
|
||||||
|
queue.task_done()
|
||||||
|
continue
|
||||||
|
url = double_urlencode(try_encode(url))
|
||||||
|
sleeptime = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
requests.head(
|
||||||
|
url="http://%s:%s/image/image://%s"
|
||||||
|
% (state.WEBSERVER_HOST,
|
||||||
|
state.WEBSERVER_PORT,
|
||||||
|
url),
|
||||||
|
auth=(state.WEBSERVER_USERNAME,
|
||||||
|
state.WEBSERVER_PASSWORD),
|
||||||
|
timeout=self.timeout)
|
||||||
|
except requests.Timeout:
|
||||||
|
# We don't need the result, only trigger Kodi to start the
|
||||||
|
# download. All is well
|
||||||
|
break
|
||||||
|
except requests.ConnectionError:
|
||||||
|
if stopped():
|
||||||
|
# Kodi terminated
|
||||||
|
break
|
||||||
|
# Server thinks its a DOS attack, ('error 10053')
|
||||||
|
# Wait before trying again
|
||||||
|
if sleeptime > 5:
|
||||||
|
LOG.error('Repeatedly got ConnectionError for url %s',
|
||||||
|
double_urldecode(url))
|
||||||
|
break
|
||||||
|
LOG.debug('Were trying too hard to download art, server '
|
||||||
|
'over-loaded. Sleep %s seconds before trying '
|
||||||
|
'again to download %s',
|
||||||
|
2**sleeptime, double_urldecode(url))
|
||||||
|
sleep((2**sleeptime) * 1000)
|
||||||
|
sleeptime += 1
|
||||||
|
continue
|
||||||
|
except Exception as err:
|
||||||
|
LOG.error('Unknown exception for url %s: %s'.
|
||||||
|
double_urldecode(url), err)
|
||||||
|
import traceback
|
||||||
|
LOG.error("Traceback:\n%s", traceback.format_exc())
|
||||||
|
break
|
||||||
|
# We did not even get a timeout
|
||||||
|
break
|
||||||
|
queue.task_done()
|
||||||
|
# Update the caching state in the PKC settings.
|
||||||
|
counter += 1
|
||||||
|
if counter > 20:
|
||||||
|
counter = 0
|
||||||
|
settings('caching_artwork_count', value=str(queue.qsize()))
|
||||||
|
# Sleep for a bit to reduce CPU strain
|
||||||
|
sleep(sleep_between)
|
||||||
|
LOG.info("---===### Stopped Image_Cache_Thread ###===---")
|
||||||
|
|
||||||
|
|
||||||
|
class Artwork():
|
||||||
|
enableTextureCache = settings('enableTextureCache') == "true"
|
||||||
|
if enableTextureCache:
|
||||||
|
queue = ARTWORK_QUEUE
|
||||||
|
|
||||||
|
def cache_major_artwork(self):
|
||||||
|
"""
|
||||||
|
Takes the existing Kodi library and caches posters and fanart.
|
||||||
|
Necessary because otherwise PKC caches artwork e.g. from fanart.tv
|
||||||
|
which basically blocks Kodi from getting needed artwork fast (e.g.
|
||||||
|
while browsing the library)
|
||||||
|
"""
|
||||||
|
if not self.enableTextureCache:
|
||||||
|
return
|
||||||
|
artworks = list()
|
||||||
|
# Get all posters and fanart/background for video and music
|
||||||
|
for kind in ('video', 'music'):
|
||||||
|
connection = kodi_sql(kind)
|
||||||
|
cursor = connection.cursor()
|
||||||
|
for typus in ('poster', 'fanart'):
|
||||||
|
cursor.execute('SELECT url FROM art WHERE type == ?',
|
||||||
|
(typus, ))
|
||||||
|
artworks.extend(cursor.fetchall())
|
||||||
|
connection.close()
|
||||||
|
artworks_to_cache = list()
|
||||||
|
connection = kodi_sql('texture')
|
||||||
|
cursor = connection.cursor()
|
||||||
|
for url in artworks:
|
||||||
|
query = 'SELECT url FROM texture WHERE url == ? LIMIT 1'
|
||||||
|
cursor.execute(query, (url[0], ))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
artworks_to_cache.append(url)
|
||||||
|
connection.close()
|
||||||
|
if not artworks_to_cache:
|
||||||
|
LOG.info('Caching of major images to Kodi texture cache done')
|
||||||
|
# Set to "None"
|
||||||
|
settings('caching_artwork_count', value=lang(30069))
|
||||||
|
return
|
||||||
|
length = len(artworks_to_cache)
|
||||||
|
LOG.info('Caching has not been completed - caching %s major images',
|
||||||
|
length)
|
||||||
|
settings('caching_artwork_count', value=str(length))
|
||||||
|
# Caching %s Plex images
|
||||||
|
self.queue.put(ArtworkSyncMessage(lang(30006) % length))
|
||||||
|
for i, url in enumerate(artworks_to_cache):
|
||||||
|
self.queue.put(url[0])
|
||||||
|
# Plex image caching done
|
||||||
|
self.queue.put(ArtworkSyncMessage(lang(30007)))
|
||||||
|
|
||||||
|
def fullTextureCacheSync(self):
|
||||||
|
"""
|
||||||
|
This method will sync all Kodi artwork to textures13.db
|
||||||
|
and cache them locally. This takes diskspace!
|
||||||
|
"""
|
||||||
|
if not dialog('yesno', "Image Texture Cache", lang(39250)):
|
||||||
|
return
|
||||||
|
|
||||||
|
LOG.info("Doing Image Cache Sync")
|
||||||
|
|
||||||
|
# ask to rest all existing or not
|
||||||
|
if dialog('yesno', "Image Texture Cache", lang(39251)):
|
||||||
|
LOG.info("Resetting all cache data first")
|
||||||
|
# Remove all existing textures first
|
||||||
|
path = try_decode(translatePath("special://thumbnails/"))
|
||||||
|
if exists_dir(path):
|
||||||
|
rmtree(path, ignore_errors=True)
|
||||||
|
self.restore_cache_directories()
|
||||||
|
|
||||||
|
# remove all existing data from texture DB
|
||||||
|
connection = kodi_sql('texture')
|
||||||
|
cursor = connection.cursor()
|
||||||
|
query = 'SELECT tbl_name FROM sqlite_master WHERE type=?'
|
||||||
|
cursor.execute(query, ('table', ))
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
for row in rows:
|
||||||
|
tableName = row[0]
|
||||||
|
if tableName != "version":
|
||||||
|
cursor.execute("DELETE FROM %s" % tableName)
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
# Cache all entries in video DB
|
||||||
|
connection = kodi_sql('video')
|
||||||
|
cursor = connection.cursor()
|
||||||
|
# dont include actors
|
||||||
|
query = "SELECT url FROM art WHERE media_type != ?"
|
||||||
|
cursor.execute(query, ('actor', ))
|
||||||
|
result = cursor.fetchall()
|
||||||
|
total = len(result)
|
||||||
|
LOG.info("Image cache sync about to process %s video images", total)
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
for url in result:
|
||||||
|
self.cache_texture(url[0])
|
||||||
|
# Cache all entries in music DB
|
||||||
|
connection = kodi_sql('music')
|
||||||
|
cursor = connection.cursor()
|
||||||
|
cursor.execute("SELECT url FROM art")
|
||||||
|
result = cursor.fetchall()
|
||||||
|
total = len(result)
|
||||||
|
LOG.info("Image cache sync about to process %s music images", total)
|
||||||
|
connection.close()
|
||||||
|
for url in result:
|
||||||
|
self.cache_texture(url[0])
|
||||||
|
|
||||||
|
def cache_texture(self, url):
|
||||||
|
'''
|
||||||
|
Cache a single image url to the texture cache. url: unicode
|
||||||
|
'''
|
||||||
|
if url and self.enableTextureCache:
|
||||||
|
self.queue.put(url)
|
||||||
|
|
||||||
|
def modify_artwork(self, artworks, kodi_id, kodi_type, cursor):
|
||||||
|
"""
|
||||||
|
Pass in an artworks dict (see PlexAPI) to set an items artwork.
|
||||||
|
"""
|
||||||
|
for kodi_art, url in artworks.iteritems():
|
||||||
|
self.modify_art(url, kodi_id, kodi_type, kodi_art, cursor)
|
||||||
|
|
||||||
|
def modify_art(self, url, kodi_id, kodi_type, kodi_art, cursor):
|
||||||
|
"""
|
||||||
|
Adds or modifies the artwork of kind kodi_art (e.g. 'poster') in the
|
||||||
|
Kodi art table for item kodi_id/kodi_type. Will also cache everything
|
||||||
|
except actor portraits.
|
||||||
|
"""
|
||||||
|
query = '''
|
||||||
|
SELECT url FROM art
|
||||||
|
WHERE media_id = ? AND media_type = ? AND type = ?
|
||||||
|
LIMIT 1
|
||||||
|
'''
|
||||||
|
cursor.execute(query, (kodi_id, kodi_type, kodi_art,))
|
||||||
try:
|
try:
|
||||||
self._run()
|
# Update the artwork
|
||||||
except Exception:
|
old_url = cursor.fetchone()[0]
|
||||||
utils.ERROR()
|
except TypeError:
|
||||||
|
# Add the artwork
|
||||||
|
LOG.debug('Adding Art Link for %s kodi_id %s, kodi_type %s: %s',
|
||||||
|
kodi_art, kodi_id, kodi_type, url)
|
||||||
|
query = '''
|
||||||
|
INSERT INTO art(media_id, media_type, type, url)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
'''
|
||||||
|
cursor.execute(query, (kodi_id, kodi_type, kodi_art, url))
|
||||||
|
else:
|
||||||
|
if url == old_url:
|
||||||
|
# Only cache artwork if it changed
|
||||||
|
return
|
||||||
|
self.delete_cached_artwork(old_url)
|
||||||
|
LOG.debug("Updating Art url for %s kodi_id %s, kodi_type %s to %s",
|
||||||
|
kodi_art, kodi_id, kodi_type, url)
|
||||||
|
query = '''
|
||||||
|
UPDATE art SET url = ?
|
||||||
|
WHERE media_id = ? AND media_type = ? AND type = ?
|
||||||
|
'''
|
||||||
|
cursor.execute(query, (url, kodi_id, kodi_type, kodi_art))
|
||||||
|
# Cache fanart and poster in Kodi texture cache
|
||||||
|
if kodi_type != 'actor':
|
||||||
|
self.cache_texture(url)
|
||||||
|
|
||||||
|
def delete_artwork(self, kodiId, mediaType, cursor):
|
||||||
|
query = 'SELECT url FROM art WHERE media_id = ? AND media_type = ?'
|
||||||
|
cursor.execute(query, (kodiId, mediaType,))
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
self.delete_cached_artwork(row[0])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_cached_artwork(url):
|
||||||
|
"""
|
||||||
|
Deleted the cached artwork with path url (if it exists)
|
||||||
|
"""
|
||||||
|
connection = kodi_sql('texture')
|
||||||
|
cursor = connection.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT cachedurl FROM texture WHERE url=? LIMIT 1",
|
||||||
|
(url,))
|
||||||
|
cachedurl = cursor.fetchone()[0]
|
||||||
|
except TypeError:
|
||||||
|
# Could not find cached url
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Delete thumbnail as well as the entry
|
||||||
|
path = translatePath("special://thumbnails/%s" % cachedurl)
|
||||||
|
LOG.debug("Deleting cached thumbnail: %s", path)
|
||||||
|
if exists(path):
|
||||||
|
rmtree(try_decode(path), ignore_errors=True)
|
||||||
|
cursor.execute("DELETE FROM texture WHERE url = ?", (url,))
|
||||||
|
connection.commit()
|
||||||
finally:
|
finally:
|
||||||
app.APP.deregister_caching_thread(self)
|
connection.close()
|
||||||
LOG.info("---===### Stopped ImageCachingThread ###===---")
|
|
||||||
|
|
||||||
def _loop(self):
|
@staticmethod
|
||||||
kinds = [KodiVideoDB]
|
def restore_cache_directories():
|
||||||
if app.SYNC.enable_music:
|
LOG.info("Restoring cache directories...")
|
||||||
kinds.append(KodiMusicDB)
|
paths = ("", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
|
||||||
for kind in kinds:
|
"a", "b", "c", "d", "e", "f",
|
||||||
for kodi_type in ('poster', 'fanart'):
|
"Video", "plex")
|
||||||
for url in self._url_generator(kind, kodi_type):
|
for path in paths:
|
||||||
if self.should_suspend() or self.should_cancel():
|
makedirs(try_decode(translatePath("special://thumbnails/%s"
|
||||||
return False
|
% path)))
|
||||||
cache_url(url, self.should_suspend)
|
|
||||||
# Toggles Image caching completed to Yes
|
|
||||||
utils.settings('plex_status_image_caching', value=utils.lang(107))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
while True:
|
|
||||||
if self._loop():
|
|
||||||
break
|
|
||||||
if self.wait_while_suspended():
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def cache_url(url, should_suspend=None):
|
class ArtworkSyncMessage(object):
|
||||||
url = double_urlencode(url)
|
"""
|
||||||
sleeptime = 0
|
Put in artwork queue to display the message as a Kodi notification
|
||||||
while True:
|
"""
|
||||||
try:
|
def __init__(self, message):
|
||||||
requests.head(
|
self.message = message
|
||||||
url="http://%s:%s/image/image://%s"
|
|
||||||
% (app.CONN.webserver_host,
|
|
||||||
app.CONN.webserver_port,
|
|
||||||
url),
|
|
||||||
auth=(app.CONN.webserver_username,
|
|
||||||
app.CONN.webserver_password),
|
|
||||||
timeout=TIMEOUT)
|
|
||||||
except requests.Timeout:
|
|
||||||
# We don't need the result, only trigger Kodi to start the
|
|
||||||
# download. All is well
|
|
||||||
break
|
|
||||||
except requests.ConnectionError:
|
|
||||||
if app.APP.stop_pkc or (should_suspend and should_suspend()):
|
|
||||||
break
|
|
||||||
# Server thinks its a DOS attack, ('error 10053')
|
|
||||||
# Wait before trying again
|
|
||||||
# OR: Kodi refuses Webserver connection (no password set)
|
|
||||||
if sleeptime > 5:
|
|
||||||
LOG.error('Repeatedly got ConnectionError for url %s',
|
|
||||||
double_urldecode(url))
|
|
||||||
break
|
|
||||||
LOG.debug('Were trying too hard to download art, server '
|
|
||||||
'over-loaded. Sleep %s seconds before trying '
|
|
||||||
'again to download %s',
|
|
||||||
2**sleeptime, double_urldecode(url))
|
|
||||||
app.APP.monitor.waitForAbort((2**sleeptime))
|
|
||||||
sleeptime += 1
|
|
||||||
continue
|
|
||||||
except Exception as err:
|
|
||||||
LOG.error('Unknown exception for url %s: %s'.
|
|
||||||
double_urldecode(url), err)
|
|
||||||
import traceback
|
|
||||||
LOG.error("Traceback:\n%s", traceback.format_exc())
|
|
||||||
break
|
|
||||||
# We did not even get a timeout
|
|
||||||
break
|
|
||||||
|
|
|
@ -1,520 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
from time import time as _time
|
|
||||||
import threading
|
|
||||||
import Queue
|
|
||||||
import heapq
|
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
from . import utils, app, variables as v
|
|
||||||
|
|
||||||
WORKER_COUNT = 3
|
|
||||||
LOG = getLogger('PLEX.threads')
|
|
||||||
|
|
||||||
|
|
||||||
class KillableThread(threading.Thread):
|
|
||||||
def __init__(self, group=None, target=None, name=None, args=(), kwargs={}):
|
|
||||||
self._canceled = False
|
|
||||||
self._suspended = False
|
|
||||||
self._is_not_suspended = threading.Event()
|
|
||||||
self._is_not_suspended.set()
|
|
||||||
self._suspension_reached = threading.Event()
|
|
||||||
self._is_not_asleep = threading.Event()
|
|
||||||
self._is_not_asleep.set()
|
|
||||||
self.suspension_timeout = None
|
|
||||||
super(KillableThread, self).__init__(group, target, name, args, kwargs)
|
|
||||||
|
|
||||||
def should_cancel(self):
|
|
||||||
"""
|
|
||||||
Returns True if the thread should be stopped immediately
|
|
||||||
"""
|
|
||||||
return self._canceled or app.APP.stop_pkc
|
|
||||||
|
|
||||||
def cancel(self):
|
|
||||||
"""
|
|
||||||
Call from another thread to stop this current thread
|
|
||||||
"""
|
|
||||||
self._canceled = True
|
|
||||||
# Make sure thread is running in order to exit quickly
|
|
||||||
self._is_not_asleep.set()
|
|
||||||
self._is_not_suspended.set()
|
|
||||||
|
|
||||||
def should_suspend(self):
|
|
||||||
"""
|
|
||||||
Returns True if the current thread should be suspended immediately
|
|
||||||
"""
|
|
||||||
return self._suspended
|
|
||||||
|
|
||||||
def suspend(self, block=False, timeout=None):
|
|
||||||
"""
|
|
||||||
Call from another thread to suspend the current thread. Provide a
|
|
||||||
timeout [float] in seconds optionally. block=True will block the caller
|
|
||||||
until the thread-to-be-suspended is indeed suspended
|
|
||||||
Will wake a thread that is asleep!
|
|
||||||
"""
|
|
||||||
self.suspension_timeout = timeout
|
|
||||||
self._suspended = True
|
|
||||||
self._is_not_suspended.clear()
|
|
||||||
# Make sure thread wakes up in order to suspend
|
|
||||||
self._is_not_asleep.set()
|
|
||||||
if block:
|
|
||||||
self._suspension_reached.wait()
|
|
||||||
|
|
||||||
def resume(self):
|
|
||||||
"""
|
|
||||||
Call from another thread to revive a suspended or asleep current thread
|
|
||||||
back to life
|
|
||||||
"""
|
|
||||||
self._suspended = False
|
|
||||||
self._is_not_asleep.set()
|
|
||||||
self._is_not_suspended.set()
|
|
||||||
|
|
||||||
def wait_while_suspended(self):
|
|
||||||
"""
|
|
||||||
Blocks until thread is not suspended anymore or the thread should
|
|
||||||
exit or for a period of self.suspension_timeout (set by the caller of
|
|
||||||
suspend())
|
|
||||||
Returns the value of should_cancel()
|
|
||||||
"""
|
|
||||||
self._suspension_reached.set()
|
|
||||||
self._is_not_suspended.wait(self.suspension_timeout)
|
|
||||||
self._suspension_reached.clear()
|
|
||||||
return self.should_cancel()
|
|
||||||
|
|
||||||
def is_suspended(self):
|
|
||||||
"""
|
|
||||||
Check from another thread whether the current thread is suspended
|
|
||||||
"""
|
|
||||||
return self._suspension_reached.is_set()
|
|
||||||
|
|
||||||
def sleep(self, timeout):
|
|
||||||
"""
|
|
||||||
Only call from the current thread in order to sleep for a period of
|
|
||||||
timeout [float, seconds]. Will unblock immediately if thread should
|
|
||||||
cancel (should_cancel()) or the thread should_suspend
|
|
||||||
"""
|
|
||||||
self._is_not_asleep.clear()
|
|
||||||
self._is_not_asleep.wait(timeout)
|
|
||||||
self._is_not_asleep.set()
|
|
||||||
|
|
||||||
def is_asleep(self):
|
|
||||||
"""
|
|
||||||
Check from another thread whether the current thread is asleep
|
|
||||||
"""
|
|
||||||
return not self._is_not_asleep.is_set()
|
|
||||||
|
|
||||||
def unblock_callers(self):
|
|
||||||
"""
|
|
||||||
Ensures that any other thread that requested this thread's suspension
|
|
||||||
is released
|
|
||||||
"""
|
|
||||||
self._suspension_reached.set()
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessingQueue(Queue.Queue, object):
|
|
||||||
"""
|
|
||||||
Queue of queues that processes a queue completely before moving on to the
|
|
||||||
next queue. There's one queue per Section(). You need to initialize each
|
|
||||||
section with add_section(section) first.
|
|
||||||
Put tuples (count, item) into this queue, with count being the respective
|
|
||||||
position of the item in the queue, starting with 0 (zero).
|
|
||||||
(None, None) is the sentinel for a single queue being exhausted, added by
|
|
||||||
add_sentinel()
|
|
||||||
"""
|
|
||||||
def _init(self, maxsize):
|
|
||||||
self.queue = deque()
|
|
||||||
self._sections = deque()
|
|
||||||
self._queues = deque()
|
|
||||||
self._current_section = None
|
|
||||||
self._current_queue = None
|
|
||||||
# Item-index for the currently active queue
|
|
||||||
self._counter = 0
|
|
||||||
|
|
||||||
def _qsize(self):
|
|
||||||
return self._current_queue._qsize() if self._current_queue else 0
|
|
||||||
|
|
||||||
def _put(self, item):
|
|
||||||
for i, section in enumerate(self._sections):
|
|
||||||
if item[1]['section'] == section:
|
|
||||||
self._queues[i]._put(item)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise RuntimeError('Could not find section for item %s' % item[1])
|
|
||||||
|
|
||||||
def add_sentinel(self, section):
|
|
||||||
"""
|
|
||||||
Adds a new empty section as a sentinel. Call with an empty Section()
|
|
||||||
object. Call this method immediately after having added all sections
|
|
||||||
with add_section().
|
|
||||||
Once the get()-method returns None, you've received the sentinel and
|
|
||||||
you've thus exhausted the queue
|
|
||||||
"""
|
|
||||||
with self.not_full:
|
|
||||||
section.number_of_items = 1
|
|
||||||
self._add_section(section)
|
|
||||||
# Add the actual sentinel to the queue we just added
|
|
||||||
self._queues[-1]._put((None, None))
|
|
||||||
self.unfinished_tasks += 1
|
|
||||||
self.not_empty.notify()
|
|
||||||
|
|
||||||
def add_section(self, section):
|
|
||||||
"""
|
|
||||||
Add a new Section() to this Queue. Each section will be entirely
|
|
||||||
processed before moving on to the next section.
|
|
||||||
|
|
||||||
Be sure to set section.number_of_items correctly as it will signal
|
|
||||||
when processing is completely done for a specific section!
|
|
||||||
"""
|
|
||||||
with self.mutex:
|
|
||||||
self._add_section(section)
|
|
||||||
|
|
||||||
def change_section_number_of_items(self, section, number_of_items):
|
|
||||||
"""
|
|
||||||
Hit this method if you've reset section.number_of_items to make
|
|
||||||
sure we're not blocking
|
|
||||||
"""
|
|
||||||
with self.mutex:
|
|
||||||
self._change_section_number_of_items(section, number_of_items)
|
|
||||||
|
|
||||||
def _change_section_number_of_items(self, section, number_of_items):
|
|
||||||
section.number_of_items = number_of_items
|
|
||||||
if (self._current_section == section
|
|
||||||
and self._counter == number_of_items):
|
|
||||||
# We were actually waiting for more items to come in - but there
|
|
||||||
# aren't any!
|
|
||||||
self._init_next_section()
|
|
||||||
if self._qsize() > 0:
|
|
||||||
self.not_empty.notify()
|
|
||||||
|
|
||||||
def _add_section(self, section):
|
|
||||||
self._sections.append(section)
|
|
||||||
self._queues.append(
|
|
||||||
OrderedQueue() if section.plex_type == v.PLEX_TYPE_ALBUM
|
|
||||||
else Queue.Queue())
|
|
||||||
if self._current_section is None:
|
|
||||||
self._activate_next_section()
|
|
||||||
|
|
||||||
def _init_next_section(self):
|
|
||||||
"""
|
|
||||||
Call only when a section has been completely exhausted
|
|
||||||
"""
|
|
||||||
self._sections.popleft()
|
|
||||||
self._queues.popleft()
|
|
||||||
self._activate_next_section()
|
|
||||||
|
|
||||||
def _activate_next_section(self):
|
|
||||||
self._counter = 0
|
|
||||||
self._current_section = self._sections[0] if self._sections else None
|
|
||||||
self._current_queue = self._queues[0] if self._queues else None
|
|
||||||
|
|
||||||
def _get(self):
|
|
||||||
item = self._current_queue._get()
|
|
||||||
self._counter += 1
|
|
||||||
if self._counter == self._current_section.number_of_items:
|
|
||||||
self._init_next_section()
|
|
||||||
return item[1]
|
|
||||||
|
|
||||||
|
|
||||||
class OrderedQueue(Queue.PriorityQueue, object):
|
|
||||||
"""
|
|
||||||
Queue that enforces an order on the items it returns. An item you push
|
|
||||||
onto the queue must be a tuple
|
|
||||||
(index, item)
|
|
||||||
where index=-1 is the item that will be returned first. The Queue will block
|
|
||||||
until index=-1, 0, 1, 2, 3, ... is then made available
|
|
||||||
|
|
||||||
maxsize will be rather fuzzy, as _qsize returns 0 if we're still waiting
|
|
||||||
for the next smalles index. put() thus might not block always when it
|
|
||||||
should.
|
|
||||||
"""
|
|
||||||
def __init__(self, maxsize=0):
|
|
||||||
self.next_index = 0
|
|
||||||
super(OrderedQueue, self).__init__(maxsize)
|
|
||||||
|
|
||||||
def _qsize(self, len=len):
|
|
||||||
try:
|
|
||||||
return len(self.queue) if self.queue[0][0] == self.next_index else 0
|
|
||||||
except IndexError:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def _get(self, heappop=heapq.heappop):
|
|
||||||
self.next_index += 1
|
|
||||||
return heappop(self.queue)
|
|
||||||
|
|
||||||
|
|
||||||
class Tasks(list):
|
|
||||||
def add(self, task):
|
|
||||||
for t in self:
|
|
||||||
if not t.isValid():
|
|
||||||
self.remove(t)
|
|
||||||
|
|
||||||
if isinstance(task, list):
|
|
||||||
self += task
|
|
||||||
else:
|
|
||||||
self.append(task)
|
|
||||||
|
|
||||||
def cancel(self):
|
|
||||||
while self:
|
|
||||||
self.pop().cancel()
|
|
||||||
|
|
||||||
|
|
||||||
class Task(object):
|
|
||||||
def __init__(self, priority=None):
|
|
||||||
self.priority = priority
|
|
||||||
self._canceled = False
|
|
||||||
self.finished = False
|
|
||||||
|
|
||||||
def __cmp__(self, other):
|
|
||||||
return self.priority - other.priority
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
BGThreader.addTask(self)
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
self.run()
|
|
||||||
self.finished = True
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def cancel(self):
|
|
||||||
self._canceled = True
|
|
||||||
|
|
||||||
def should_cancel(self):
|
|
||||||
return self._canceled or app.APP.monitor.abortRequested()
|
|
||||||
|
|
||||||
def isValid(self):
|
|
||||||
return not self.finished and not self._canceled
|
|
||||||
|
|
||||||
|
|
||||||
class ShutdownSentinel(Task):
|
|
||||||
def run(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class FunctionAsTask(Task):
|
|
||||||
def __init__(self, function, callback, *args, **kwargs):
|
|
||||||
self._function = function
|
|
||||||
self._callback = callback
|
|
||||||
self._args = args
|
|
||||||
self._kwargs = kwargs
|
|
||||||
super(FunctionAsTask, self).__init__()
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
result = self._function(*self._args, **self._kwargs)
|
|
||||||
if self._callback:
|
|
||||||
self._callback(result)
|
|
||||||
|
|
||||||
|
|
||||||
class MutablePriorityQueue(Queue.PriorityQueue):
|
|
||||||
def _get(self, heappop=heapq.heappop):
|
|
||||||
self.queue.sort()
|
|
||||||
return heappop(self.queue)
|
|
||||||
|
|
||||||
def lowest(self):
|
|
||||||
"""Return the lowest priority item in the queue (not reliable!)."""
|
|
||||||
self.mutex.acquire()
|
|
||||||
try:
|
|
||||||
lowest = self.queue and min(self.queue) or None
|
|
||||||
except Exception:
|
|
||||||
lowest = None
|
|
||||||
utils.ERROR(notify=True)
|
|
||||||
finally:
|
|
||||||
self.mutex.release()
|
|
||||||
return lowest
|
|
||||||
|
|
||||||
|
|
||||||
class BackgroundWorker(object):
|
|
||||||
def __init__(self, queue, name=None):
|
|
||||||
self._queue = queue
|
|
||||||
self.name = name
|
|
||||||
self._thread = None
|
|
||||||
self._abort = False
|
|
||||||
self._task = None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _runTask(task):
|
|
||||||
if task._canceled:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
task._run()
|
|
||||||
except Exception:
|
|
||||||
utils.ERROR(notify=True)
|
|
||||||
|
|
||||||
def abort(self):
|
|
||||||
self._abort = True
|
|
||||||
return self
|
|
||||||
|
|
||||||
def aborted(self):
|
|
||||||
return self._abort or app.APP.monitor.abortRequested()
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
if self._thread and self._thread.isAlive():
|
|
||||||
return
|
|
||||||
|
|
||||||
self._thread = KillableThread(target=self._queueLoop, name='BACKGROUND-WORKER({0})'.format(self.name))
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
def _queueLoop(self):
|
|
||||||
if self._queue.empty():
|
|
||||||
return
|
|
||||||
|
|
||||||
LOG.debug('(%s): Active', self.name)
|
|
||||||
try:
|
|
||||||
while not self.aborted():
|
|
||||||
self._task = self._queue.get_nowait()
|
|
||||||
self._runTask(self._task)
|
|
||||||
self._queue.task_done()
|
|
||||||
self._task = None
|
|
||||||
except Queue.Empty:
|
|
||||||
LOG.debug('(%s): Idle', self.name)
|
|
||||||
|
|
||||||
def shutdown(self, block=True):
|
|
||||||
self.abort()
|
|
||||||
|
|
||||||
if self._task:
|
|
||||||
self._task.cancel()
|
|
||||||
|
|
||||||
if block and self._thread and self._thread.isAlive():
|
|
||||||
LOG.debug('thread (%s): Waiting...', self.name)
|
|
||||||
self._thread.join()
|
|
||||||
LOG.debug('thread (%s): Done', self.name)
|
|
||||||
|
|
||||||
def working(self):
|
|
||||||
return self._thread and self._thread.isAlive()
|
|
||||||
|
|
||||||
|
|
||||||
class NonstoppingBackgroundWorker(BackgroundWorker):
|
|
||||||
def __init__(self, queue, name=None):
|
|
||||||
self._working = False
|
|
||||||
super(NonstoppingBackgroundWorker, self).__init__(queue, name)
|
|
||||||
|
|
||||||
def _queueLoop(self):
|
|
||||||
LOG.debug('Starting Worker %s', self.name)
|
|
||||||
while not self.aborted():
|
|
||||||
self._task = self._queue.get()
|
|
||||||
if self._task is ShutdownSentinel:
|
|
||||||
break
|
|
||||||
self._working = True
|
|
||||||
self._runTask(self._task)
|
|
||||||
self._working = False
|
|
||||||
self._queue.task_done()
|
|
||||||
self._task = None
|
|
||||||
LOG.debug('Exiting Worker %s', self.name)
|
|
||||||
|
|
||||||
def working(self):
|
|
||||||
return self._working
|
|
||||||
|
|
||||||
|
|
||||||
class BackgroundThreader:
|
|
||||||
def __init__(self, name=None, worker=BackgroundWorker, worker_count=6):
|
|
||||||
self.name = name
|
|
||||||
self._queue = MutablePriorityQueue()
|
|
||||||
self._abort = False
|
|
||||||
self.priority = -1
|
|
||||||
self.workers = [
|
|
||||||
worker(self._queue, 'queue.{0}:worker.{1}'.format(self.name, x))
|
|
||||||
for x in range(worker_count)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _nextPriority(self):
|
|
||||||
self.priority += 1
|
|
||||||
return self.priority
|
|
||||||
|
|
||||||
def abort(self):
|
|
||||||
self._abort = True
|
|
||||||
for w in self.workers:
|
|
||||||
w.abort()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def aborted(self):
|
|
||||||
return self._abort or app.APP.monitor.abortRequested()
|
|
||||||
|
|
||||||
def shutdown(self, block=True):
|
|
||||||
self.abort()
|
|
||||||
self.addTasksToFront([ShutdownSentinel() for _ in self.workers])
|
|
||||||
for w in self.workers:
|
|
||||||
w.shutdown(block)
|
|
||||||
|
|
||||||
def addTask(self, task):
|
|
||||||
task.priority = self._nextPriority()
|
|
||||||
self._queue.put(task)
|
|
||||||
self.startWorkers()
|
|
||||||
|
|
||||||
def addTasks(self, tasks):
|
|
||||||
for t in tasks:
|
|
||||||
t.priority = self._nextPriority()
|
|
||||||
self._queue.put(t)
|
|
||||||
|
|
||||||
self.startWorkers()
|
|
||||||
|
|
||||||
def addTasksToFront(self, tasks):
|
|
||||||
lowest = self.getLowestPrority()
|
|
||||||
if lowest is None:
|
|
||||||
return self.addTasks(tasks)
|
|
||||||
|
|
||||||
p = lowest - len(tasks)
|
|
||||||
for t in tasks:
|
|
||||||
t.priority = p
|
|
||||||
self._queue.put(t)
|
|
||||||
p += 1
|
|
||||||
|
|
||||||
self.startWorkers()
|
|
||||||
|
|
||||||
def startWorkers(self):
|
|
||||||
for w in self.workers:
|
|
||||||
w.start()
|
|
||||||
|
|
||||||
def working(self):
|
|
||||||
return not self._queue.empty() or self.hasTask()
|
|
||||||
|
|
||||||
def hasTask(self):
|
|
||||||
return any([w.working() for w in self.workers])
|
|
||||||
|
|
||||||
def getLowestPrority(self):
|
|
||||||
lowest = self._queue.lowest()
|
|
||||||
if not lowest:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return lowest.priority
|
|
||||||
|
|
||||||
def moveToFront(self, qitem):
|
|
||||||
lowest = self.getLowestPrority()
|
|
||||||
if lowest is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
qitem.priority = lowest - 1
|
|
||||||
|
|
||||||
|
|
||||||
class ThreaderManager:
|
|
||||||
def __init__(self,
|
|
||||||
worker=NonstoppingBackgroundWorker,
|
|
||||||
worker_count=WORKER_COUNT):
|
|
||||||
self.index = 0
|
|
||||||
self.abandoned = []
|
|
||||||
self._workerhandler = worker
|
|
||||||
self.threader = BackgroundThreader(name=str(self.index),
|
|
||||||
worker=worker,
|
|
||||||
worker_count=worker_count)
|
|
||||||
|
|
||||||
def __getattr__(self, name):
|
|
||||||
return getattr(self.threader, name)
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
if self.threader._queue.empty() and not self.threader.hasTask():
|
|
||||||
return
|
|
||||||
|
|
||||||
self.index += 1
|
|
||||||
self.abandoned.append(self.threader.abort())
|
|
||||||
self.threader = BackgroundThreader(name=str(self.index),
|
|
||||||
worker=self._workerhandler)
|
|
||||||
|
|
||||||
def shutdown(self, block=True):
|
|
||||||
self.threader.shutdown(block)
|
|
||||||
for a in self.abandoned:
|
|
||||||
a.shutdown(block)
|
|
||||||
|
|
||||||
|
|
||||||
BGThreader = ThreaderManager()
|
|
|
@ -1,16 +1,14 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
###############################################################################
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
import xbmc
|
from utils import window, settings
|
||||||
|
import variables as v
|
||||||
from . import utils
|
|
||||||
from . import variables as v
|
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
LOG = getLogger('PLEX.clientinfo')
|
log = getLogger("PLEX."+__name__)
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
@ -33,19 +31,20 @@ def getXArgsDeviceInfo(options=None, include_token=True):
|
||||||
'Connection': 'keep-alive',
|
'Connection': 'keep-alive',
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
# "Access-Control-Allow-Origin": "*",
|
# "Access-Control-Allow-Origin": "*",
|
||||||
'Accept-Language': xbmc.getLanguage(xbmc.ISO_639_1),
|
# 'X-Plex-Language': 'en',
|
||||||
'X-Plex-Device': v.DEVICE,
|
'X-Plex-Device': v.ADDON_NAME,
|
||||||
'X-Plex-Model': v.MODEL,
|
'X-Plex-Client-Platform': v.PLATFORM,
|
||||||
'X-Plex-Device-Name': v.DEVICENAME,
|
'X-Plex-Device-Name': v.DEVICENAME,
|
||||||
'X-Plex-Platform': v.PLATFORM,
|
'X-Plex-Platform': v.PLATFORM,
|
||||||
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
|
# 'X-Plex-Platform-Version': 'unknown',
|
||||||
|
# 'X-Plex-Model': 'unknown',
|
||||||
'X-Plex-Product': v.ADDON_NAME,
|
'X-Plex-Product': v.ADDON_NAME,
|
||||||
'X-Plex-Version': v.ADDON_VERSION,
|
'X-Plex-Version': v.ADDON_VERSION,
|
||||||
'X-Plex-Client-Identifier': getDeviceId(),
|
'X-Plex-Client-Identifier': getDeviceId(),
|
||||||
'X-Plex-Provides': 'client,controller,player,pubsub-player',
|
'X-Plex-Provides': 'client,controller,player,pubsub-player',
|
||||||
}
|
}
|
||||||
if include_token and utils.window('pms_token'):
|
if include_token and window('pms_token'):
|
||||||
xargs['X-Plex-Token'] = utils.window('pms_token')
|
xargs['X-Plex-Token'] = window('pms_token')
|
||||||
if options is not None:
|
if options is not None:
|
||||||
xargs.update(options)
|
xargs.update(options)
|
||||||
return xargs
|
return xargs
|
||||||
|
@ -61,26 +60,26 @@ def getDeviceId(reset=False):
|
||||||
"""
|
"""
|
||||||
if reset is True:
|
if reset is True:
|
||||||
v.PKC_MACHINE_IDENTIFIER = None
|
v.PKC_MACHINE_IDENTIFIER = None
|
||||||
utils.window('plex_client_Id', clear=True)
|
window('plex_client_Id', clear=True)
|
||||||
utils.settings('plex_client_Id', value="")
|
settings('plex_client_Id', value="")
|
||||||
|
|
||||||
client_id = v.PKC_MACHINE_IDENTIFIER
|
client_id = v.PKC_MACHINE_IDENTIFIER
|
||||||
if client_id:
|
if client_id:
|
||||||
return client_id
|
return client_id
|
||||||
|
|
||||||
client_id = utils.settings('plex_client_Id')
|
client_id = settings('plex_client_Id')
|
||||||
# Because Kodi appears to cache file settings!!
|
# Because Kodi appears to cache file settings!!
|
||||||
if client_id != "" and reset is False:
|
if client_id != "" and reset is False:
|
||||||
v.PKC_MACHINE_IDENTIFIER = client_id
|
v.PKC_MACHINE_IDENTIFIER = client_id
|
||||||
utils.window('plex_client_Id', value=client_id)
|
window('plex_client_Id', value=client_id)
|
||||||
LOG.info("Unique device Id plex_client_Id loaded: %s", client_id)
|
log.info("Unique device Id plex_client_Id loaded: %s", client_id)
|
||||||
return client_id
|
return client_id
|
||||||
|
|
||||||
LOG.info("Generating a new deviceid.")
|
log.info("Generating a new deviceid.")
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
client_id = str(uuid4())
|
client_id = str(uuid4())
|
||||||
utils.settings('plex_client_Id', value=client_id)
|
settings('plex_client_Id', value=client_id)
|
||||||
v.PKC_MACHINE_IDENTIFIER = client_id
|
v.PKC_MACHINE_IDENTIFIER = client_id
|
||||||
utils.window('plex_client_Id', value=client_id)
|
window('plex_client_Id', value=client_id)
|
||||||
LOG.info("Unique device Id plex_client_Id generated: %s", client_id)
|
log.info("Unique device Id plex_client_Id generated: %s", client_id)
|
||||||
return client_id
|
return client_id
|
||||||
|
|
68
resources/lib/command_pipeline.py
Normal file
68
resources/lib/command_pipeline.py
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
###############################################################################
|
||||||
|
import logging
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
from xbmc import sleep
|
||||||
|
|
||||||
|
from utils import window, thread_methods
|
||||||
|
import state
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
LOG = logging.getLogger("PLEX." + __name__)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
@thread_methods
|
||||||
|
class Monitor_Window(Thread):
|
||||||
|
"""
|
||||||
|
Monitors window('plex_command') for new entries that we need to take care
|
||||||
|
of, e.g. for new plays initiated on the Kodi side with addon paths.
|
||||||
|
|
||||||
|
Adjusts state.py accordingly
|
||||||
|
"""
|
||||||
|
def run(self):
|
||||||
|
stopped = self.stopped
|
||||||
|
queue = state.COMMAND_PIPELINE_QUEUE
|
||||||
|
LOG.info("----===## Starting Kodi_Play_Client ##===----")
|
||||||
|
while not stopped():
|
||||||
|
if window('plex_command'):
|
||||||
|
value = window('plex_command')
|
||||||
|
window('plex_command', clear=True)
|
||||||
|
if value.startswith('PLAY-'):
|
||||||
|
queue.put(value.replace('PLAY-', ''))
|
||||||
|
elif value == 'SUSPEND_LIBRARY_THREAD-True':
|
||||||
|
state.SUSPEND_LIBRARY_THREAD = True
|
||||||
|
elif value == 'SUSPEND_LIBRARY_THREAD-False':
|
||||||
|
state.SUSPEND_LIBRARY_THREAD = False
|
||||||
|
elif value == 'STOP_SYNC-True':
|
||||||
|
state.STOP_SYNC = True
|
||||||
|
elif value == 'STOP_SYNC-False':
|
||||||
|
state.STOP_SYNC = False
|
||||||
|
elif value == 'PMS_STATUS-Auth':
|
||||||
|
state.PMS_STATUS = 'Auth'
|
||||||
|
elif value == 'PMS_STATUS-401':
|
||||||
|
state.PMS_STATUS = '401'
|
||||||
|
elif value == 'SUSPEND_USER_CLIENT-True':
|
||||||
|
state.SUSPEND_USER_CLIENT = True
|
||||||
|
elif value == 'SUSPEND_USER_CLIENT-False':
|
||||||
|
state.SUSPEND_USER_CLIENT = False
|
||||||
|
elif value.startswith('PLEX_TOKEN-'):
|
||||||
|
state.PLEX_TOKEN = value.replace('PLEX_TOKEN-', '') or None
|
||||||
|
elif value.startswith('PLEX_USERNAME-'):
|
||||||
|
state.PLEX_USERNAME = \
|
||||||
|
value.replace('PLEX_USERNAME-', '') or None
|
||||||
|
elif value.startswith('RUN_LIB_SCAN-'):
|
||||||
|
state.RUN_LIB_SCAN = value.replace('RUN_LIB_SCAN-', '')
|
||||||
|
elif value.startswith('CONTEXT_menu?'):
|
||||||
|
queue.put('dummy?mode=context_menu&%s'
|
||||||
|
% value.replace('CONTEXT_menu?', ''))
|
||||||
|
elif value.startswith('NAVIGATE'):
|
||||||
|
queue.put(value.replace('NAVIGATE-', ''))
|
||||||
|
else:
|
||||||
|
raise NotImplementedError('%s not implemented' % value)
|
||||||
|
else:
|
||||||
|
sleep(50)
|
||||||
|
# Put one last item into the queue to let playback_starter end
|
||||||
|
queue.put(None)
|
||||||
|
LOG.info("----===## Kodi_Play_Client stopped ##===----")
|
|
@ -1,18 +1,19 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
"""
|
||||||
Processes Plex companion inputs from the plexbmchelper to Kodi commands
|
Processes Plex companion inputs from the plexbmchelper to Kodi commands
|
||||||
"""
|
"""
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from xbmc import Player
|
from xbmc import Player
|
||||||
|
|
||||||
from . import playqueue as PQ, plex_functions as PF
|
from variables import ALEXA_TO_COMPANION
|
||||||
from . import json_rpc as js, variables as v, app
|
import playqueue as PQ
|
||||||
|
from PlexFunctions import GetPlexKeyNumber
|
||||||
|
import json_rpc as js
|
||||||
|
import state
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
LOG = getLogger('PLEX.companion')
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
@ -24,7 +25,7 @@ def skip_to(params):
|
||||||
Does not seem to be implemented yet by Plex!
|
Does not seem to be implemented yet by Plex!
|
||||||
"""
|
"""
|
||||||
playqueue_item_id = params.get('playQueueItemID')
|
playqueue_item_id = params.get('playQueueItemID')
|
||||||
_, plex_id = PF.GetPlexKeyNumber(params.get('key'))
|
_, plex_id = GetPlexKeyNumber(params.get('key'))
|
||||||
LOG.debug('Skipping to playQueueItemID %s, plex_id %s',
|
LOG.debug('Skipping to playQueueItemID %s, plex_id %s',
|
||||||
playqueue_item_id, plex_id)
|
playqueue_item_id, plex_id)
|
||||||
found = True
|
found = True
|
||||||
|
@ -49,9 +50,9 @@ def convert_alexa_to_companion(dictionary):
|
||||||
"""
|
"""
|
||||||
The params passed by Alexa must first be converted to Companion talk
|
The params passed by Alexa must first be converted to Companion talk
|
||||||
"""
|
"""
|
||||||
for key in list(dictionary):
|
for key in dictionary:
|
||||||
if key in v.ALEXA_TO_COMPANION:
|
if key in ALEXA_TO_COMPANION:
|
||||||
dictionary[v.ALEXA_TO_COMPANION[key]] = dictionary[key]
|
dictionary[ALEXA_TO_COMPANION[key]] = dictionary[key]
|
||||||
del dictionary[key]
|
del dictionary[key]
|
||||||
|
|
||||||
|
|
||||||
|
@ -65,12 +66,12 @@ def process_command(request_path, params):
|
||||||
if request_path == 'player/playback/playMedia':
|
if request_path == 'player/playback/playMedia':
|
||||||
# We need to tell service.py
|
# We need to tell service.py
|
||||||
action = 'alexa' if params.get('deviceName') == 'Alexa' else 'playlist'
|
action = 'alexa' if params.get('deviceName') == 'Alexa' else 'playlist'
|
||||||
app.APP.companion_queue.put({
|
state.COMPANION_QUEUE.put({
|
||||||
'action': action,
|
'action': action,
|
||||||
'data': params
|
'data': params
|
||||||
})
|
})
|
||||||
elif request_path == 'player/playback/refreshPlayQueue':
|
elif request_path == 'player/playback/refreshPlayQueue':
|
||||||
app.APP.companion_queue.put({
|
state.COMPANION_QUEUE.put({
|
||||||
'action': 'refreshPlayQueue',
|
'action': 'refreshPlayQueue',
|
||||||
'data': params
|
'data': params
|
||||||
})
|
})
|
||||||
|
@ -112,7 +113,7 @@ def process_command(request_path, params):
|
||||||
elif request_path == "player/navigation/back":
|
elif request_path == "player/navigation/back":
|
||||||
js.input_back()
|
js.input_back()
|
||||||
elif request_path == "player/playback/setStreams":
|
elif request_path == "player/playback/setStreams":
|
||||||
app.APP.companion_queue.put({
|
state.COMPANION_QUEUE.put({
|
||||||
'action': 'setStreams',
|
'action': 'setStreams',
|
||||||
'data': params
|
'data': params
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
###############################################################################
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
import xbmcgui
|
from os.path import join
|
||||||
|
|
||||||
from . import utils
|
import xbmcgui
|
||||||
from . import path_ops
|
from xbmcaddon import Addon
|
||||||
from . import variables as v
|
|
||||||
|
from utils import window
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
LOG = getLogger('PLEX.context')
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
ADDON = Addon('plugin.video.plexkodiconnect')
|
||||||
|
|
||||||
ACTION_PARENT_DIR = 9
|
ACTION_PARENT_DIR = 9
|
||||||
ACTION_PREVIOUS_MENU = 10
|
ACTION_PREVIOUS_MENU = 10
|
||||||
|
@ -43,8 +44,8 @@ class ContextMenu(xbmcgui.WindowXMLDialog):
|
||||||
return self.selected_option
|
return self.selected_option
|
||||||
|
|
||||||
def onInit(self):
|
def onInit(self):
|
||||||
if utils.window('plexAvatar'):
|
if window('PlexUserImage'):
|
||||||
self.getControl(USER_IMAGE).setImage(utils.window('plexAvatar'))
|
self.getControl(USER_IMAGE).setImage(window('PlexUserImage'))
|
||||||
height = 479 + (len(self._options) * 55)
|
height = 479 + (len(self._options) * 55)
|
||||||
LOG.debug("options: %s", self._options)
|
LOG.debug("options: %s", self._options)
|
||||||
self.list_ = self.getControl(LIST)
|
self.list_ = self.getControl(LIST)
|
||||||
|
@ -60,16 +61,15 @@ class ContextMenu(xbmcgui.WindowXMLDialog):
|
||||||
if action in (ACTION_SELECT_ITEM, ACTION_MOUSE_LEFT_CLICK):
|
if action in (ACTION_SELECT_ITEM, ACTION_MOUSE_LEFT_CLICK):
|
||||||
if self.getFocusId() == LIST:
|
if self.getFocusId() == LIST:
|
||||||
option = self.list_.getSelectedItem()
|
option = self.list_.getSelectedItem()
|
||||||
self.selected_option = option.getLabel().decode('utf-8')
|
self.selected_option = option.getLabel()
|
||||||
LOG.info('option selected: %s', self.selected_option)
|
LOG.info('option selected: %s', self.selected_option)
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def _add_editcontrol(self, x, y, height, width, password=None):
|
def _add_editcontrol(self, x, y, height, width, password=None):
|
||||||
media = path_ops.path.join(
|
media = join(ADDON.getAddonInfo('path'),
|
||||||
v.ADDON_PATH, 'resources', 'skins', 'default', 'media')
|
'resources', 'skins', 'default', 'media')
|
||||||
filename = utils.try_encode(path_ops.path.join(media, 'white.png'))
|
|
||||||
control = xbmcgui.ControlImage(0, 0, 0, 0,
|
control = xbmcgui.ControlImage(0, 0, 0, 0,
|
||||||
filename=filename,
|
filename=join(media, "white.png"),
|
||||||
aspectRatio=0,
|
aspectRatio=0,
|
||||||
colorDiffuse="ff111111")
|
colorDiffuse="ff111111")
|
||||||
control.setPosition(x, y)
|
control.setPosition(x, y)
|
||||||
|
|
|
@ -1,29 +1,35 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
###############################################################################
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
import xbmc
|
|
||||||
import xbmcgui
|
|
||||||
|
|
||||||
from .plex_api import API
|
from xbmcaddon import Addon
|
||||||
from .plex_db import PlexDB
|
import xbmc
|
||||||
from . import context, plex_functions as PF, playqueue as PQ
|
import xbmcplugin
|
||||||
from . import utils, variables as v, app
|
import xbmcgui
|
||||||
|
import context
|
||||||
|
|
||||||
|
import plexdb_functions as plexdb
|
||||||
|
from utils import window, settings, dialog, language as lang
|
||||||
|
import PlexFunctions as PF
|
||||||
|
from PlexAPI import API
|
||||||
|
import playqueue as PQ
|
||||||
|
import variables as v
|
||||||
|
import state
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
LOG = getLogger('PLEX.context_entry')
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
OPTIONS = {
|
OPTIONS = {
|
||||||
'Refresh': utils.lang(30410),
|
'Refresh': lang(30410),
|
||||||
'Delete': utils.lang(30409),
|
'Delete': lang(30409),
|
||||||
'Addon': utils.lang(30408),
|
'Addon': lang(30408),
|
||||||
# 'AddFav': utils.lang(30405),
|
# 'AddFav': lang(30405),
|
||||||
# 'RemoveFav': utils.lang(30406),
|
# 'RemoveFav': lang(30406),
|
||||||
# 'RateSong': utils.lang(30407),
|
# 'RateSong': lang(30407),
|
||||||
'Transcode': utils.lang(30412),
|
'Transcode': lang(30412),
|
||||||
'PMS_Play': utils.lang(30415), # Use PMS to start playback
|
'PMS_Play': lang(30415), # Use PMS to start playback
|
||||||
'Extras': utils.lang(30235)
|
'Extras': lang(30235)
|
||||||
}
|
}
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
@ -60,15 +66,22 @@ class ContextMenu(object):
|
||||||
self.api = API(xml[0])
|
self.api = API(xml[0])
|
||||||
if self._select_menu():
|
if self._select_menu():
|
||||||
self._action_menu()
|
self._action_menu()
|
||||||
|
if self._selected_option in (OPTIONS['Delete'],
|
||||||
|
OPTIONS['Refresh']):
|
||||||
|
LOG.info("refreshing container")
|
||||||
|
xbmc.sleep(500)
|
||||||
|
xbmc.executebuiltin('Container.Refresh')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_plex_id(kodi_id, kodi_type):
|
def _get_plex_id(kodi_id, kodi_type):
|
||||||
plex_id = xbmc.getInfoLabel('ListItem.Property(plexid)') or None
|
plex_id = xbmc.getInfoLabel('ListItem.Property(plexid)') or None
|
||||||
if not plex_id and kodi_id and kodi_type:
|
if not plex_id and kodi_id and kodi_type:
|
||||||
with PlexDB() as plexdb:
|
with plexdb.Get_Plex_DB() as plexcursor:
|
||||||
item = plexdb.item_by_kodi_id(kodi_id, kodi_type)
|
item = plexcursor.getItem_byKodiId(kodi_id, kodi_type)
|
||||||
if item:
|
try:
|
||||||
plex_id = item['plex_id']
|
plex_id = item[0]
|
||||||
|
except TypeError:
|
||||||
|
LOG.info('Could not get the Plex id for context menu')
|
||||||
return plex_id
|
return plex_id
|
||||||
|
|
||||||
def _select_menu(self):
|
def _select_menu(self):
|
||||||
|
@ -79,20 +92,20 @@ class ContextMenu(object):
|
||||||
# if user uses direct paths, give option to initiate playback via PMS
|
# if user uses direct paths, give option to initiate playback via PMS
|
||||||
if self.api and self.api.extras():
|
if self.api and self.api.extras():
|
||||||
options.append(OPTIONS['Extras'])
|
options.append(OPTIONS['Extras'])
|
||||||
if app.SYNC.direct_paths and self.kodi_type in v.KODI_VIDEOTYPES:
|
if state.DIRECT_PATHS and self.kodi_type in v.KODI_VIDEOTYPES:
|
||||||
options.append(OPTIONS['PMS_Play'])
|
options.append(OPTIONS['PMS_Play'])
|
||||||
if self.kodi_type in v.KODI_VIDEOTYPES:
|
if self.kodi_type in v.KODI_VIDEOTYPES:
|
||||||
options.append(OPTIONS['Transcode'])
|
options.append(OPTIONS['Transcode'])
|
||||||
|
|
||||||
# Delete item, only if the Plex Home main user is logged in
|
# Delete item, only if the Plex Home main user is logged in
|
||||||
if (utils.window('plex_restricteduser') != 'true' and
|
if (window('plex_restricteduser') != 'true' and
|
||||||
utils.window('plex_allows_mediaDeletion') == 'true'):
|
window('plex_allows_mediaDeletion') == 'true'):
|
||||||
options.append(OPTIONS['Delete'])
|
options.append(OPTIONS['Delete'])
|
||||||
# Addon settings
|
# Addon settings
|
||||||
options.append(OPTIONS['Addon'])
|
options.append(OPTIONS['Addon'])
|
||||||
context_menu = context.ContextMenu(
|
context_menu = context.ContextMenu(
|
||||||
"script-plex-context.xml",
|
"script-plex-context.xml",
|
||||||
utils.try_encode(v.ADDON_PATH),
|
Addon('plugin.video.plexkodiconnect').getAddonInfo('path'),
|
||||||
"default",
|
"default",
|
||||||
"1080i")
|
"1080i")
|
||||||
context_menu.set_options(options)
|
context_menu.set_options(options)
|
||||||
|
@ -107,7 +120,7 @@ class ContextMenu(object):
|
||||||
"""
|
"""
|
||||||
selected = self._selected_option
|
selected = self._selected_option
|
||||||
if selected == OPTIONS['Transcode']:
|
if selected == OPTIONS['Transcode']:
|
||||||
app.PLAYSTATE.force_transcode = True
|
state.FORCE_TRANSCODE = True
|
||||||
self._PMS_play()
|
self._PMS_play()
|
||||||
elif selected == OPTIONS['PMS_Play']:
|
elif selected == OPTIONS['PMS_Play']:
|
||||||
self._PMS_play()
|
self._PMS_play()
|
||||||
|
@ -125,14 +138,14 @@ class ContextMenu(object):
|
||||||
Delete item on PMS
|
Delete item on PMS
|
||||||
"""
|
"""
|
||||||
delete = True
|
delete = True
|
||||||
if utils.settings('skipContextMenu') != "true":
|
if settings('skipContextMenu') != "true":
|
||||||
if not utils.dialog("yesno", heading="{plex}", line1=utils.lang(33041)):
|
if not dialog("yesno", heading="{plex}", line1=lang(33041)):
|
||||||
LOG.info("User skipped deletion for: %s", self.plex_id)
|
LOG.info("User skipped deletion for: %s", self.plex_id)
|
||||||
delete = False
|
delete = False
|
||||||
if delete:
|
if delete:
|
||||||
LOG.info("Deleting Plex item with id %s", self.plex_id)
|
LOG.info("Deleting Plex item with id %s", self.plex_id)
|
||||||
if PF.delete_item_from_pms(self.plex_id) is False:
|
if PF.delete_item_from_pms(self.plex_id) is False:
|
||||||
utils.dialog("ok", heading="{plex}", line1=utils.lang(30414))
|
dialog("ok", heading="{plex}", line1=lang(30414))
|
||||||
|
|
||||||
def _PMS_play(self):
|
def _PMS_play(self):
|
||||||
"""
|
"""
|
||||||
|
@ -141,10 +154,12 @@ class ContextMenu(object):
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = PQ.get_playqueue_from_type(
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_KODI_TYPE[self.kodi_type])
|
v.KODI_PLAYLIST_TYPE_FROM_KODI_TYPE[self.kodi_type])
|
||||||
playqueue.clear()
|
playqueue.clear()
|
||||||
app.PLAYSTATE.context_menu_play = True
|
state.CONTEXT_MENU_PLAY = True
|
||||||
handle = self.api.fullpath(force_addon=True)[0]
|
handle = ('plugin://%s/?plex_id=%s&plex_type=%s&mode=play'
|
||||||
handle = 'RunPlugin(%s)' % handle
|
% (v.ADDON_TYPE[self.plex_type],
|
||||||
xbmc.executebuiltin(handle.encode('utf-8'))
|
self.plex_id,
|
||||||
|
self.plex_type))
|
||||||
|
xbmc.executebuiltin('RunPlugin(%s)' % handle)
|
||||||
|
|
||||||
def _extras(self):
|
def _extras(self):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,97 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import sqlite3
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from . import variables as v, app
|
|
||||||
from .exceptions import LockedDatabase
|
|
||||||
|
|
||||||
DB_WRITE_ATTEMPTS = 100
|
|
||||||
DB_WRITE_ATTEMPTS_TIMEOUT = 1 # in seconds
|
|
||||||
DB_CONNECTION_TIMEOUT = 10
|
|
||||||
|
|
||||||
|
|
||||||
def catch_operationalerrors(method):
|
|
||||||
"""
|
|
||||||
sqlite.OperationalError is raised immediately if another DB connection
|
|
||||||
is open, reading something that we're trying to change
|
|
||||||
|
|
||||||
So let's catch it and try again
|
|
||||||
|
|
||||||
Also see https://github.com/mattn/go-sqlite3/issues/274
|
|
||||||
"""
|
|
||||||
@wraps(method)
|
|
||||||
def wrapper(self, *args, **kwargs):
|
|
||||||
attempts = DB_WRITE_ATTEMPTS
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
return method(self, *args, **kwargs)
|
|
||||||
except sqlite3.OperationalError as err:
|
|
||||||
if 'database is locked' not in err:
|
|
||||||
# Not an error we want to catch, so reraise it
|
|
||||||
raise
|
|
||||||
attempts -= 1
|
|
||||||
if attempts == 0:
|
|
||||||
# Reraise in order to NOT catch nested OperationalErrors
|
|
||||||
raise LockedDatabase('Database is locked')
|
|
||||||
# Need to close the transactions and begin new ones
|
|
||||||
self.kodiconn.commit()
|
|
||||||
if self.artconn:
|
|
||||||
self.artconn.commit()
|
|
||||||
if app.APP.monitor.waitForAbort(DB_WRITE_ATTEMPTS_TIMEOUT):
|
|
||||||
# PKC needs to quit
|
|
||||||
return
|
|
||||||
# Start new transactions
|
|
||||||
self.kodiconn.execute('BEGIN')
|
|
||||||
if self.artconn:
|
|
||||||
self.artconn.execute('BEGIN')
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
def _initial_db_connection_setup(conn):
|
|
||||||
"""
|
|
||||||
Set-up DB e.g. for WAL journal mode, if that hasn't already been done
|
|
||||||
before. Also start a transaction
|
|
||||||
"""
|
|
||||||
conn.execute('PRAGMA journal_mode = WAL;')
|
|
||||||
conn.execute('PRAGMA cache_size = -8000;')
|
|
||||||
conn.execute('PRAGMA synchronous = NORMAL;')
|
|
||||||
conn.execute('BEGIN')
|
|
||||||
|
|
||||||
|
|
||||||
def connect(media_type=None):
|
|
||||||
"""
|
|
||||||
Open a connection to the Kodi database.
|
|
||||||
media_type: 'video' (standard if not passed), 'plex', 'music', 'texture'
|
|
||||||
"""
|
|
||||||
if media_type == "plex":
|
|
||||||
db_path = v.DB_PLEX_PATH
|
|
||||||
elif media_type == 'plex-copy':
|
|
||||||
db_path = v.DB_PLEX_COPY_PATH
|
|
||||||
elif media_type == "music":
|
|
||||||
db_path = v.DB_MUSIC_PATH
|
|
||||||
elif media_type == "texture":
|
|
||||||
db_path = v.DB_TEXTURE_PATH
|
|
||||||
else:
|
|
||||||
db_path = v.DB_VIDEO_PATH
|
|
||||||
conn = sqlite3.connect(db_path,
|
|
||||||
timeout=DB_CONNECTION_TIMEOUT,
|
|
||||||
isolation_level=None)
|
|
||||||
attempts = DB_WRITE_ATTEMPTS
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
_initial_db_connection_setup(conn)
|
|
||||||
except sqlite3.OperationalError as err:
|
|
||||||
if 'database is locked' not in err:
|
|
||||||
# Not an error we want to catch, so reraise it
|
|
||||||
raise
|
|
||||||
attempts -= 1
|
|
||||||
if attempts == 0:
|
|
||||||
# Reraise in order to NOT catch nested OperationalErrors
|
|
||||||
raise LockedDatabase('Database is locked')
|
|
||||||
if app.APP.monitor.waitForAbort(0.05):
|
|
||||||
# PKC needs to quit
|
|
||||||
raise LockedDatabase('Database was locked and we need to exit')
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
return conn
|
|
|
@ -1,39 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
xml.etree.ElementTree tries to encode with text.encode('ascii') - which is
|
|
||||||
just plain BS. This etree will always return unicode, not string
|
|
||||||
"""
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
# Originally tried faster cElementTree, but does NOT work reliably with Kodi
|
|
||||||
from defusedxml.ElementTree import DefusedXMLParser, _generate_etree_functions
|
|
||||||
|
|
||||||
from xml.etree.ElementTree import TreeBuilder as _TreeBuilder
|
|
||||||
from xml.etree.ElementTree import parse as _parse
|
|
||||||
from xml.etree.ElementTree import iterparse as _iterparse
|
|
||||||
from xml.etree.ElementTree import tostring
|
|
||||||
|
|
||||||
|
|
||||||
class UnicodeXMLParser(DefusedXMLParser):
|
|
||||||
"""
|
|
||||||
PKC Hack to ensure we're always receiving unicode, not str
|
|
||||||
"""
|
|
||||||
@staticmethod
|
|
||||||
def _fixtext(text):
|
|
||||||
"""
|
|
||||||
Do NOT try to convert every entry to str with entry.encode('ascii')!
|
|
||||||
"""
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
# aliases
|
|
||||||
XMLTreeBuilder = XMLParse = UnicodeXMLParser
|
|
||||||
|
|
||||||
parse, iterparse, fromstring = _generate_etree_functions(UnicodeXMLParser,
|
|
||||||
_TreeBuilder, _parse,
|
|
||||||
_iterparse)
|
|
||||||
XML = fromstring
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['XML', 'XMLParse', 'XMLTreeBuilder', 'fromstring', 'iterparse',
|
|
||||||
'parse', 'tostring']
|
|
|
@ -1,11 +1,14 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
import requests
|
|
||||||
import requests.exceptions as exceptions
|
|
||||||
|
|
||||||
from . import utils, clientinfo, app
|
###############################################################################
|
||||||
|
from logging import getLogger
|
||||||
|
import xml.etree.ElementTree as etree
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from utils import window, language as lang, dialog
|
||||||
|
import clientinfo as client
|
||||||
|
|
||||||
|
import state
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
@ -13,7 +16,7 @@ from . import utils, clientinfo, app
|
||||||
import requests.packages.urllib3
|
import requests.packages.urllib3
|
||||||
requests.packages.urllib3.disable_warnings()
|
requests.packages.urllib3.disable_warnings()
|
||||||
|
|
||||||
LOG = getLogger('PLEX.download')
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
@ -29,23 +32,34 @@ class DownloadUtils():
|
||||||
_shared_state = {}
|
_shared_state = {}
|
||||||
|
|
||||||
# How many failed attempts before declaring PMS dead?
|
# How many failed attempts before declaring PMS dead?
|
||||||
connection_attempts = 1
|
connectionAttempts = 2
|
||||||
count_error = 0
|
|
||||||
# How many 401 returns before declaring unauthorized?
|
# How many 401 returns before declaring unauthorized?
|
||||||
unauthorized_attempts = 2
|
unauthorizedAttempts = 2
|
||||||
count_unauthorized = 0
|
|
||||||
# How long should we wait for an answer from the
|
# How long should we wait for an answer from the
|
||||||
timeout = 30.0
|
timeout = 30.0
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.__dict__ = self._shared_state
|
self.__dict__ = self._shared_state
|
||||||
|
|
||||||
def setSSL(self):
|
def setServer(self, server):
|
||||||
"""
|
"""
|
||||||
|
Reserved for userclient only
|
||||||
|
"""
|
||||||
|
self.server = server
|
||||||
|
LOG.debug("Set server: %s", server)
|
||||||
|
|
||||||
|
def setSSL(self, verifySSL=None, certificate=None):
|
||||||
|
"""
|
||||||
|
Reserved for userclient only
|
||||||
|
|
||||||
|
verifySSL must be 'true' to enable certificate validation
|
||||||
|
|
||||||
certificate must be path to certificate or 'None'
|
certificate must be path to certificate or 'None'
|
||||||
"""
|
"""
|
||||||
verifySSL = app.CONN.verify_ssl_cert
|
if verifySSL is None:
|
||||||
certificate = app.CONN.ssl_cert_path
|
verifySSL = state.VERIFY_SSL_CERT
|
||||||
|
if certificate is None:
|
||||||
|
certificate = state.SSL_CERT_PATH
|
||||||
# Set the session's parameters
|
# Set the session's parameters
|
||||||
self.s.verify = verifySSL
|
self.s.verify = verifySSL
|
||||||
if certificate:
|
if certificate:
|
||||||
|
@ -55,49 +69,53 @@ class DownloadUtils():
|
||||||
|
|
||||||
def startSession(self, reset=False):
|
def startSession(self, reset=False):
|
||||||
"""
|
"""
|
||||||
User should be authenticated when this method is called
|
User should be authenticated when this method is called (via
|
||||||
|
userclient)
|
||||||
"""
|
"""
|
||||||
# Start session
|
# Start session
|
||||||
self.s = requests.Session()
|
self.s = requests.Session()
|
||||||
|
|
||||||
self.deviceId = clientinfo.getDeviceId()
|
self.deviceId = client.getDeviceId()
|
||||||
# Attach authenticated header to the session
|
# Attach authenticated header to the session
|
||||||
self.s.headers = clientinfo.getXArgsDeviceInfo()
|
self.s.headers = client.getXArgsDeviceInfo()
|
||||||
self.s.encoding = 'utf-8'
|
self.s.encoding = 'utf-8'
|
||||||
# Set SSL settings
|
# Set SSL settings
|
||||||
self.setSSL()
|
self.setSSL()
|
||||||
|
|
||||||
|
# Set other stuff
|
||||||
|
self.setServer(window('pms_server'))
|
||||||
|
|
||||||
# Counters to declare PMS dead or unauthorized
|
# Counters to declare PMS dead or unauthorized
|
||||||
|
# Use window variables because start of movies will be called with a
|
||||||
|
# new plugin instance - it's impossible to share data otherwise
|
||||||
if reset is True:
|
if reset is True:
|
||||||
self.count_error = 0
|
window('countUnauthorized', value='0')
|
||||||
self.count_unauthorized = 0
|
window('countError', value='0')
|
||||||
|
|
||||||
# Retry connections to the server
|
# Retry connections to the server
|
||||||
self.s.mount("http://", requests.adapters.HTTPAdapter(max_retries=1))
|
self.s.mount("http://", requests.adapters.HTTPAdapter(max_retries=1))
|
||||||
self.s.mount("https://", requests.adapters.HTTPAdapter(max_retries=1))
|
self.s.mount("https://", requests.adapters.HTTPAdapter(max_retries=1))
|
||||||
|
|
||||||
LOG.debug("Requests session started on: %s", app.CONN.server)
|
LOG.info("Requests session started on: %s", self.server)
|
||||||
|
|
||||||
def stopSession(self):
|
def stopSession(self):
|
||||||
try:
|
try:
|
||||||
self.s.close()
|
self.s.close()
|
||||||
except Exception:
|
except:
|
||||||
LOG.info("Requests session already closed")
|
LOG.info("Requests session already closed")
|
||||||
try:
|
try:
|
||||||
del self.s
|
del self.s
|
||||||
except AttributeError:
|
except:
|
||||||
pass
|
pass
|
||||||
LOG.info('Request session stopped')
|
LOG.info('Request session stopped')
|
||||||
|
|
||||||
@staticmethod
|
def getHeader(self, options=None):
|
||||||
def getHeader(options=None):
|
header = client.getXArgsDeviceInfo()
|
||||||
header = clientinfo.getXArgsDeviceInfo()
|
|
||||||
if options is not None:
|
if options is not None:
|
||||||
header.update(options)
|
header.update(options)
|
||||||
return header
|
return header
|
||||||
|
|
||||||
@staticmethod
|
def _doDownload(self, s, action_type, **kwargs):
|
||||||
def _doDownload(s, action_type, **kwargs):
|
|
||||||
if action_type == "GET":
|
if action_type == "GET":
|
||||||
r = s.get(**kwargs)
|
r = s.get(**kwargs)
|
||||||
elif action_type == "POST":
|
elif action_type == "POST":
|
||||||
|
@ -113,7 +131,7 @@ class DownloadUtils():
|
||||||
def downloadUrl(self, url, action_type="GET", postBody=None,
|
def downloadUrl(self, url, action_type="GET", postBody=None,
|
||||||
parameters=None, authenticate=True, headerOptions=None,
|
parameters=None, authenticate=True, headerOptions=None,
|
||||||
verifySSL=True, timeout=None, return_response=False,
|
verifySSL=True, timeout=None, return_response=False,
|
||||||
headerOverride=None, reraise=False):
|
headerOverride=None):
|
||||||
"""
|
"""
|
||||||
Override SSL check with verifySSL=False
|
Override SSL check with verifySSL=False
|
||||||
|
|
||||||
|
@ -139,7 +157,7 @@ class DownloadUtils():
|
||||||
self.startSession()
|
self.startSession()
|
||||||
s = self.s
|
s = self.s
|
||||||
# Replace for the real values
|
# Replace for the real values
|
||||||
url = url.replace("{server}", app.CONN.server)
|
url = url.replace("{server}", self.server)
|
||||||
else:
|
else:
|
||||||
# User is not (yet) authenticated. Used to communicate with
|
# User is not (yet) authenticated. Used to communicate with
|
||||||
# plex.tv and to check for PMS servers
|
# plex.tv and to check for PMS servers
|
||||||
|
@ -148,9 +166,9 @@ class DownloadUtils():
|
||||||
headerOptions = self.getHeader(options=headerOptions)
|
headerOptions = self.getHeader(options=headerOptions)
|
||||||
else:
|
else:
|
||||||
headerOptions = headerOverride
|
headerOptions = headerOverride
|
||||||
kwargs['verify'] = app.CONN.verify_ssl_cert
|
kwargs['verify'] = state.VERIFY_SSL_CERT
|
||||||
if app.CONN.ssl_cert_path:
|
if state.SSL_CERT_PATH:
|
||||||
kwargs['cert'] = app.CONN.ssl_cert_path
|
kwargs['cert'] = state.SSL_CERT_PATH
|
||||||
|
|
||||||
# Set the variables we were passed (fallback to request session
|
# Set the variables we were passed (fallback to request session
|
||||||
# otherwise - faster)
|
# otherwise - faster)
|
||||||
|
@ -167,68 +185,53 @@ class DownloadUtils():
|
||||||
kwargs['timeout'] = timeout
|
kwargs['timeout'] = timeout
|
||||||
|
|
||||||
# ACTUAL DOWNLOAD HAPPENING HERE
|
# ACTUAL DOWNLOAD HAPPENING HERE
|
||||||
success = False
|
|
||||||
try:
|
try:
|
||||||
r = self._doDownload(s, action_type, **kwargs)
|
r = self._doDownload(s, action_type, **kwargs)
|
||||||
|
|
||||||
# THE EXCEPTIONS
|
# THE EXCEPTIONS
|
||||||
except exceptions.SSLError as e:
|
except requests.exceptions.SSLError as e:
|
||||||
LOG.warn("Invalid SSL certificate for: %s", url)
|
LOG.warn("Invalid SSL certificate for: %s", url)
|
||||||
LOG.warn(e)
|
LOG.warn(e)
|
||||||
if reraise:
|
|
||||||
raise
|
except requests.exceptions.ConnectionError as e:
|
||||||
except exceptions.ConnectionError as e:
|
|
||||||
# Connection error
|
# Connection error
|
||||||
LOG.warn("Server unreachable at: %s", url)
|
LOG.warn("Server unreachable at: %s", url)
|
||||||
LOG.warn(e)
|
LOG.warn(e)
|
||||||
if reraise:
|
|
||||||
raise
|
except requests.exceptions.Timeout as e:
|
||||||
except exceptions.Timeout as e:
|
|
||||||
LOG.warn("Server timeout at: %s", url)
|
LOG.warn("Server timeout at: %s", url)
|
||||||
LOG.warn(e)
|
LOG.warn(e)
|
||||||
if reraise:
|
|
||||||
raise
|
except requests.exceptions.HTTPError as e:
|
||||||
except exceptions.HTTPError as e:
|
|
||||||
LOG.warn('HTTP Error at %s', url)
|
LOG.warn('HTTP Error at %s', url)
|
||||||
LOG.warn(e)
|
LOG.warn(e)
|
||||||
if reraise:
|
|
||||||
raise
|
except requests.exceptions.TooManyRedirects as e:
|
||||||
except exceptions.TooManyRedirects as e:
|
|
||||||
LOG.warn("Too many redirects connecting to: %s", url)
|
LOG.warn("Too many redirects connecting to: %s", url)
|
||||||
LOG.warn(e)
|
LOG.warn(e)
|
||||||
if reraise:
|
|
||||||
raise
|
except requests.exceptions.RequestException as e:
|
||||||
except exceptions.RequestException as e:
|
|
||||||
LOG.warn("Unknown error connecting to: %s", url)
|
LOG.warn("Unknown error connecting to: %s", url)
|
||||||
LOG.warn(e)
|
LOG.warn(e)
|
||||||
if reraise:
|
|
||||||
raise
|
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
LOG.info('SystemExit detected, aborting download')
|
LOG.info('SystemExit detected, aborting download')
|
||||||
self.stopSession()
|
self.stopSession()
|
||||||
if reraise:
|
|
||||||
raise
|
except:
|
||||||
except Exception:
|
|
||||||
LOG.warn('Unknown error while downloading. Traceback:')
|
LOG.warn('Unknown error while downloading. Traceback:')
|
||||||
import traceback
|
import traceback
|
||||||
LOG.warn(traceback.format_exc())
|
LOG.warn(traceback.format_exc())
|
||||||
if reraise:
|
|
||||||
raise
|
|
||||||
|
|
||||||
# THE RESPONSE #####
|
# THE RESPONSE #####
|
||||||
else:
|
else:
|
||||||
success = True
|
|
||||||
# We COULD contact the PMS, hence it ain't dead
|
# We COULD contact the PMS, hence it ain't dead
|
||||||
if authenticate is True:
|
if authenticate is True:
|
||||||
self.count_error = 0
|
window('countError', value='0')
|
||||||
if r.status_code != 401:
|
if r.status_code != 401:
|
||||||
self.count_unauthorized = 0
|
window('countUnauthorized', value='0')
|
||||||
|
|
||||||
if return_response is True:
|
if r.status_code == 204:
|
||||||
# return the entire response object
|
|
||||||
return r
|
|
||||||
|
|
||||||
elif r.status_code == 204:
|
|
||||||
# No body in the response
|
# No body in the response
|
||||||
# But read (empty) content to release connection back to pool
|
# But read (empty) content to release connection back to pool
|
||||||
# (see requests: keep-alive documentation)
|
# (see requests: keep-alive documentation)
|
||||||
|
@ -244,16 +247,22 @@ class DownloadUtils():
|
||||||
LOG.info(r.text)
|
LOG.info(r.text)
|
||||||
if '401 Unauthorized' in r.text:
|
if '401 Unauthorized' in r.text:
|
||||||
# Truly unauthorized
|
# Truly unauthorized
|
||||||
self.count_unauthorized += 1
|
window('countUnauthorized',
|
||||||
if self.count_unauthorized >= self.unauthorized_attempts:
|
value=str(int(window('countUnauthorized')) + 1))
|
||||||
|
if (int(window('countUnauthorized')) >=
|
||||||
|
self.unauthorizedAttempts):
|
||||||
LOG.warn('We seem to be truly unauthorized for PMS'
|
LOG.warn('We seem to be truly unauthorized for PMS'
|
||||||
' %s ', url)
|
' %s ', url)
|
||||||
# Unauthorized access, user no longer has access
|
if state.PMS_STATUS not in ('401', 'Auth'):
|
||||||
app.ACCOUNT.log_out()
|
# Tell userclient token has been revoked.
|
||||||
utils.dialog('notification',
|
LOG.debug('Setting PMS server status to '
|
||||||
utils.lang(29999),
|
'unauthorized')
|
||||||
utils.lang(30017),
|
state.PMS_STATUS = '401'
|
||||||
icon='{error}')
|
window('plex_serverStatus', value="401")
|
||||||
|
dialog('notification',
|
||||||
|
lang(29999),
|
||||||
|
lang(30017),
|
||||||
|
icon='{error}')
|
||||||
else:
|
else:
|
||||||
# there might be other 401 where e.g. PMS under strain
|
# there might be other 401 where e.g. PMS under strain
|
||||||
LOG.info('PMS might only be under strain')
|
LOG.info('PMS might only be under strain')
|
||||||
|
@ -262,11 +271,14 @@ class DownloadUtils():
|
||||||
elif r.status_code in (200, 201):
|
elif r.status_code in (200, 201):
|
||||||
# 200: OK
|
# 200: OK
|
||||||
# 201: Created
|
# 201: Created
|
||||||
|
if return_response is True:
|
||||||
|
# return the entire response object
|
||||||
|
return r
|
||||||
try:
|
try:
|
||||||
# xml response
|
# xml response
|
||||||
r = utils.defused_etree.fromstring(r.content)
|
r = etree.fromstring(r.content)
|
||||||
return r
|
return r
|
||||||
except Exception:
|
except:
|
||||||
r.encoding = 'utf-8'
|
r.encoding = 'utf-8'
|
||||||
if r.text == '':
|
if r.text == '':
|
||||||
# Answer does not contain a body
|
# Answer does not contain a body
|
||||||
|
@ -275,7 +287,7 @@ class DownloadUtils():
|
||||||
# UNICODE - JSON object
|
# UNICODE - JSON object
|
||||||
r = r.json()
|
r = r.json()
|
||||||
return r
|
return r
|
||||||
except Exception:
|
except:
|
||||||
if '200 OK' in r.text:
|
if '200 OK' in r.text:
|
||||||
# Received fucked up OK from PMS on playstate
|
# Received fucked up OK from PMS on playstate
|
||||||
# update
|
# update
|
||||||
|
@ -289,19 +301,24 @@ class DownloadUtils():
|
||||||
elif r.status_code == 403:
|
elif r.status_code == 403:
|
||||||
# E.g. deleting a PMS item
|
# E.g. deleting a PMS item
|
||||||
LOG.warn('PMS sent 403: Forbidden error for url %s', url)
|
LOG.warn('PMS sent 403: Forbidden error for url %s', url)
|
||||||
return
|
return None
|
||||||
else:
|
else:
|
||||||
r.encoding = 'utf-8'
|
r.encoding = 'utf-8'
|
||||||
LOG.warn('Unknown answer from PMS %s with status code %s: %s',
|
LOG.warn('Unknown answer from PMS %s with status code %s. ',
|
||||||
url, r.status_code, r.text)
|
url, r.status_code)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
finally:
|
# And now deal with the consequences of the exceptions
|
||||||
if not success and authenticate:
|
if authenticate is True:
|
||||||
# Deal with the consequences of the exceptions
|
# Make the addon aware of status
|
||||||
# Make the addon aware of status
|
try:
|
||||||
self.count_error += 1
|
window('countError',
|
||||||
if self.count_error >= self.connection_attempts:
|
value=str(int(window('countError')) + 1))
|
||||||
|
if int(window('countError')) >= self.connectionAttempts:
|
||||||
LOG.warn('Failed to connect to %s too many times. '
|
LOG.warn('Failed to connect to %s too many times. '
|
||||||
'Declare PMS dead', url)
|
'Declare PMS dead', url)
|
||||||
app.CONN.online = False
|
window('plex_online', value="false")
|
||||||
|
except:
|
||||||
|
# 'countError' not yet set
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,32 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistError(Exception):
|
|
||||||
"""
|
|
||||||
Exception for our playlist constructs
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class LockedDatabase(Exception):
|
|
||||||
"""
|
|
||||||
Dedicated class to make sure we're not silently catching locked DBs.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SubtitleError(Exception):
|
|
||||||
"""
|
|
||||||
Exceptions relating to subtitles
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessingNotDone(Exception):
|
|
||||||
"""
|
|
||||||
Exception to detect whether we've completed our sync and did not have to
|
|
||||||
abort or suspend.
|
|
||||||
"""
|
|
||||||
pass
|
|
|
@ -1,29 +1,137 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
###############################################################################
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
from Queue import Queue
|
||||||
|
import xml.etree.ElementTree as etree
|
||||||
|
|
||||||
from xbmc import executebuiltin
|
from xbmc import executebuiltin, translatePath
|
||||||
|
|
||||||
from . import utils
|
from utils import settings, window, language as lang, try_decode, dialog, \
|
||||||
from .utils import etree
|
XmlKodiSetting, reboot_kodi
|
||||||
from . import path_ops
|
from migration import check_migration
|
||||||
from . import migration
|
from downloadutils import DownloadUtils as DU
|
||||||
from .downloadutils import DownloadUtils as DU, exceptions
|
from userclient import UserClient
|
||||||
from . import plex_functions as PF
|
from clientinfo import getDeviceId
|
||||||
from . import plex_tv
|
import PlexFunctions as PF
|
||||||
from . import json_rpc as js
|
import plex_tv
|
||||||
from . import app
|
import json_rpc as js
|
||||||
from . import variables as v
|
import playqueue as PQ
|
||||||
|
from videonodes import VideoNodes
|
||||||
|
import state
|
||||||
|
import variables as v
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
LOG = getLogger('PLEX.initialsetup')
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
if not path_ops.exists(v.EXTERNAL_SUBTITLE_TEMP_PATH):
|
|
||||||
path_ops.makedirs(v.EXTERNAL_SUBTITLE_TEMP_PATH)
|
WINDOW_PROPERTIES = (
|
||||||
|
"plex_online", "plex_serverStatus", "plex_shouldStop", "plex_dbScan",
|
||||||
|
"plex_customplayqueue", "plex_playbackProps",
|
||||||
|
"pms_token", "plex_token", "pms_server", "plex_machineIdentifier",
|
||||||
|
"plex_servername", "plex_authenticated", "PlexUserImage", "useDirectPaths",
|
||||||
|
"countError", "countUnauthorized", "plex_restricteduser",
|
||||||
|
"plex_allows_mediaDeletion", "plex_command", "plex_result",
|
||||||
|
"plex_force_transcode_pix"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reload_pkc():
|
||||||
|
"""
|
||||||
|
Will reload state.py entirely and then initiate some values from the Kodi
|
||||||
|
settings file
|
||||||
|
"""
|
||||||
|
LOG.info('Start (re-)loading PKC settings')
|
||||||
|
# Reset state.py
|
||||||
|
reload(state)
|
||||||
|
# Reset window props
|
||||||
|
for prop in WINDOW_PROPERTIES:
|
||||||
|
window(prop, clear=True)
|
||||||
|
# Clear video nodes properties
|
||||||
|
VideoNodes().clearProperties()
|
||||||
|
|
||||||
|
# Initializing
|
||||||
|
state.VERIFY_SSL_CERT = settings('sslverify') == 'true'
|
||||||
|
state.SSL_CERT_PATH = settings('sslcert') \
|
||||||
|
if settings('sslcert') != 'None' else None
|
||||||
|
state.FULL_SYNC_INTERVALL = int(settings('fullSyncInterval')) * 60
|
||||||
|
state.SYNC_THREAD_NUMBER = int(settings('syncThreadNumber'))
|
||||||
|
state.SYNC_DIALOG = settings('dbSyncIndicator') == 'true'
|
||||||
|
state.ENABLE_MUSIC = settings('enableMusic') == 'true'
|
||||||
|
state.BACKGROUND_SYNC_DISABLED = settings(
|
||||||
|
'enableBackgroundSync') == 'false'
|
||||||
|
state.BACKGROUNDSYNC_SAFTYMARGIN = int(
|
||||||
|
settings('backgroundsync_saftyMargin'))
|
||||||
|
state.REPLACE_SMB_PATH = settings('replaceSMB') == 'true'
|
||||||
|
state.REMAP_PATH = settings('remapSMB') == 'true'
|
||||||
|
state.KODI_PLEX_TIME_OFFSET = float(settings('kodiplextimeoffset'))
|
||||||
|
state.FETCH_PMS_ITEM_NUMBER = settings('fetch_pms_item_number')
|
||||||
|
state.FORCE_RELOAD_SKIN = settings('forceReloadSkinOnPlaybackStop') == 'true'
|
||||||
|
# Init some Queues()
|
||||||
|
state.COMMAND_PIPELINE_QUEUE = Queue()
|
||||||
|
state.COMPANION_QUEUE = Queue(maxsize=100)
|
||||||
|
state.WEBSOCKET_QUEUE = Queue()
|
||||||
|
set_replace_paths()
|
||||||
|
set_webserver()
|
||||||
|
# To detect Kodi profile switches
|
||||||
|
window('plex_kodiProfile',
|
||||||
|
value=try_decode(translatePath("special://profile")))
|
||||||
|
getDeviceId()
|
||||||
|
# Initialize the PKC playqueues
|
||||||
|
PQ.init_playqueues()
|
||||||
|
LOG.info('Done (re-)loading PKC settings')
|
||||||
|
|
||||||
|
|
||||||
|
def set_replace_paths():
|
||||||
|
"""
|
||||||
|
Sets our values for direct paths correctly (including using lower-case
|
||||||
|
protocols like smb:// and NOT SMB://)
|
||||||
|
"""
|
||||||
|
for typus in v.REMAP_TYPE_FROM_PLEXTYPE.values():
|
||||||
|
for arg in ('Org', 'New'):
|
||||||
|
key = 'remapSMB%s%s' % (typus, arg)
|
||||||
|
value = settings(key)
|
||||||
|
if '://' in value:
|
||||||
|
protocol = value.split('://', 1)[0]
|
||||||
|
value = value.replace(protocol, protocol.lower())
|
||||||
|
setattr(state, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def set_webserver():
|
||||||
|
"""
|
||||||
|
Set the Kodi webserver details - used to set the texture cache
|
||||||
|
"""
|
||||||
|
if js.get_setting('services.webserver') in (None, False):
|
||||||
|
# Enable the webserver, it is disabled
|
||||||
|
js.set_setting('services.webserver', True)
|
||||||
|
# Set standard port and username
|
||||||
|
# set_setting('services.webserverport', 8080)
|
||||||
|
# set_setting('services.webserverusername', 'kodi')
|
||||||
|
# Webserver already enabled
|
||||||
|
state.WEBSERVER_PORT = js.get_setting('services.webserverport')
|
||||||
|
state.WEBSERVER_USERNAME = js.get_setting('services.webserverusername')
|
||||||
|
state.WEBSERVER_PASSWORD = js.get_setting('services.webserverpassword')
|
||||||
|
|
||||||
|
|
||||||
|
def _write_pms_settings(url, token):
|
||||||
|
"""
|
||||||
|
Sets certain settings for server by asking for the PMS' settings
|
||||||
|
Call with url: scheme://ip:port
|
||||||
|
"""
|
||||||
|
xml = PF.get_PMS_settings(url, token)
|
||||||
|
try:
|
||||||
|
xml.attrib
|
||||||
|
except AttributeError:
|
||||||
|
LOG.error('Could not get PMS settings for %s', url)
|
||||||
|
return
|
||||||
|
for entry in xml:
|
||||||
|
if entry.attrib.get('id', '') == 'allowMediaDeletion':
|
||||||
|
settings('plex_allows_mediaDeletion',
|
||||||
|
value=entry.attrib.get('value', 'true'))
|
||||||
|
window('plex_allows_mediaDeletion',
|
||||||
|
value=entry.attrib.get('value', 'true'))
|
||||||
|
|
||||||
|
|
||||||
class InitialSetup(object):
|
class InitialSetup(object):
|
||||||
|
@ -33,130 +141,30 @@ class InitialSetup(object):
|
||||||
"""
|
"""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
LOG.debug('Entering initialsetup class')
|
LOG.debug('Entering initialsetup class')
|
||||||
|
self.server = UserClient().get_server()
|
||||||
|
self.serverid = settings('plex_machineIdentifier')
|
||||||
# Get Plex credentials from settings file, if they exist
|
# Get Plex credentials from settings file, if they exist
|
||||||
plexdict = PF.GetPlexLoginFromSettings()
|
plexdict = PF.GetPlexLoginFromSettings()
|
||||||
|
self.myplexlogin = plexdict['myplexlogin'] == 'true'
|
||||||
self.plex_login = plexdict['plexLogin']
|
self.plex_login = plexdict['plexLogin']
|
||||||
self.plex_login_id = plexdict['plexid']
|
|
||||||
self.plex_token = plexdict['plexToken']
|
self.plex_token = plexdict['plexToken']
|
||||||
|
self.plexid = plexdict['plexid']
|
||||||
# Token for the PMS, not plex.tv
|
# Token for the PMS, not plex.tv
|
||||||
self.pms_token = utils.settings('accessToken')
|
self.pms_token = settings('accessToken')
|
||||||
if self.plex_token:
|
if self.plex_token:
|
||||||
LOG.debug('Found a plex.tv token in the settings')
|
LOG.debug('Found a plex.tv token in the settings')
|
||||||
|
|
||||||
def write_credentials_to_settings(self):
|
|
||||||
"""
|
|
||||||
Writes Plex username, token to plex.tv and Plex id to PKC settings
|
|
||||||
"""
|
|
||||||
utils.settings('username', value=self.plex_login or '')
|
|
||||||
utils.settings('userid', value=self.plex_login_id or '')
|
|
||||||
utils.settings('plexToken', value=self.plex_token or '')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def save_pms_settings(url, token):
|
|
||||||
"""
|
|
||||||
Sets certain settings for server by asking for the PMS' settings
|
|
||||||
Call with url: scheme://ip:port
|
|
||||||
"""
|
|
||||||
xml = PF.get_PMS_settings(url, token)
|
|
||||||
try:
|
|
||||||
xml.attrib
|
|
||||||
except AttributeError:
|
|
||||||
LOG.error('Could not get PMS settings for %s', url)
|
|
||||||
return
|
|
||||||
for entry in xml:
|
|
||||||
if entry.attrib.get('id', '') == 'allowMediaDeletion':
|
|
||||||
value = 'true' if entry.get('value', '1') == '1' else 'false'
|
|
||||||
utils.settings('plex_allows_mediaDeletion', value=value)
|
|
||||||
utils.window('plex_allows_mediaDeletion', value=value)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def enter_new_pms_address():
|
|
||||||
LOG.info('Start getting manual PMS address and port')
|
|
||||||
# "Enter your Plex Media Server's IP or URL. Examples are:"
|
|
||||||
utils.messageDialog(utils.lang(29999),
|
|
||||||
'%s\n%s\n%s' % (utils.lang(39215),
|
|
||||||
'192.168.1.2',
|
|
||||||
'plex.myServer.org'))
|
|
||||||
# "Enter PMS IP or URL"
|
|
||||||
address = utils.dialog('input', utils.lang(39083))
|
|
||||||
if not address:
|
|
||||||
return False
|
|
||||||
port = utils.dialog('input', utils.lang(39084), '32400', type='{numeric}')
|
|
||||||
if not port:
|
|
||||||
return False
|
|
||||||
url = '%s:%s' % (address, port)
|
|
||||||
# "Use HTTPS (SSL) connections? Answer should probably be yes."
|
|
||||||
https = utils.yesno_dialog(utils.lang(29999), utils.lang(39217))
|
|
||||||
if https:
|
|
||||||
url = 'https://%s' % url
|
|
||||||
else:
|
|
||||||
url = 'http://%s' % url
|
|
||||||
https = 'true' if https else 'false'
|
|
||||||
# Try to connect first
|
|
||||||
error = False
|
|
||||||
try:
|
|
||||||
machine_identifier = PF.GetMachineIdentifier(url)
|
|
||||||
except exceptions.SSLError:
|
|
||||||
LOG.error('SSL cert error contacting %s', url)
|
|
||||||
# "SSL certificate failed to validate. Please check {0}
|
|
||||||
# for solutions."
|
|
||||||
utils.messageDialog(utils.lang(29999),
|
|
||||||
utils.lang(30503).format('github.com/croneter/PlexKodiConnect/issues'))
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
error = True
|
|
||||||
if error or machine_identifier is None:
|
|
||||||
LOG.error('Could not even get a machineIdentifier for %s', url)
|
|
||||||
# "Server is unreachable"
|
|
||||||
utils.messageDialog(utils.lang(29999), utils.lang(33002))
|
|
||||||
return
|
|
||||||
# Let's use the main account's token, not managed user token
|
|
||||||
token = utils.settings('plexToken')
|
|
||||||
xml = PF.pms_root(url, token)
|
|
||||||
if xml == 401:
|
|
||||||
LOG.error('Not yet authorized for %s', url)
|
|
||||||
# "User is unauthorized for server {0}",
|
|
||||||
# "Please sign in to plex.tv."
|
|
||||||
utils.messageDialog(utils.lang(29999),
|
|
||||||
'%s. %s' % (utils.lang(33010).format(address),
|
|
||||||
utils.lang(39014)))
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
xml[0].attrib
|
|
||||||
except (IndexError, TypeError, AttributeError):
|
|
||||||
LOG.error('Could not get PMS root directory for %s', url)
|
|
||||||
# "Error contacting PMS"
|
|
||||||
utils.messageDialog(utils.lang(29999), utils.lang(39218))
|
|
||||||
return
|
|
||||||
pms = {
|
|
||||||
'baseURL': url,
|
|
||||||
'ip': address,
|
|
||||||
# Assume PMS is not local so we're not resetting verifyssl
|
|
||||||
'local': False,
|
|
||||||
'machineIdentifier': xml.get('machineIdentifier'),
|
|
||||||
'name': xml.get('friendlyName'),
|
|
||||||
# Assume that we own this PMS - no easy way to check
|
|
||||||
'owned': True,
|
|
||||||
'platform': xml.get('platform'),
|
|
||||||
'port': port,
|
|
||||||
# 'relay': True,
|
|
||||||
'scheme': 'https' if https else 'http',
|
|
||||||
'token': token,
|
|
||||||
'version': xml.get('version')
|
|
||||||
}
|
|
||||||
return pms
|
|
||||||
|
|
||||||
def plex_tv_sign_in(self):
|
def plex_tv_sign_in(self):
|
||||||
"""
|
"""
|
||||||
Signs (freshly) in to plex.tv (will be saved to file settings)
|
Signs (freshly) in to plex.tv (will be saved to file settings)
|
||||||
|
|
||||||
Returns True if successful, or False if not
|
Returns True if successful, or False if not
|
||||||
"""
|
"""
|
||||||
user = plex_tv.sign_in_with_pin()
|
result = plex_tv.sign_in_with_pin()
|
||||||
if user:
|
if result:
|
||||||
self.plex_login = user.username
|
self.plex_login = result['username']
|
||||||
self.plex_token = user.authToken
|
self.plex_token = result['token']
|
||||||
self.plex_login_id = user.id
|
self.plexid = result['plexid']
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -172,20 +180,20 @@ class InitialSetup(object):
|
||||||
# HTTP Error: unauthorized. Token is no longer valid
|
# HTTP Error: unauthorized. Token is no longer valid
|
||||||
LOG.info('plex.tv connection returned HTTP %s', str(chk))
|
LOG.info('plex.tv connection returned HTTP %s', str(chk))
|
||||||
# Delete token in the settings
|
# Delete token in the settings
|
||||||
utils.settings('plexToken', value='')
|
settings('plexToken', value='')
|
||||||
utils.settings('plexLogin', value='')
|
settings('plexLogin', value='')
|
||||||
# Could not login, please try again
|
# Could not login, please try again
|
||||||
utils.messageDialog(utils.lang(29999), utils.lang(39009))
|
dialog('ok', lang(29999), lang(39009))
|
||||||
answer = self.plex_tv_sign_in()
|
answer = self.plex_tv_sign_in()
|
||||||
elif chk is False or chk >= 400:
|
elif chk is False or chk >= 400:
|
||||||
# Problems connecting to plex.tv. Network or internet issue?
|
# Problems connecting to plex.tv. Network or internet issue?
|
||||||
LOG.info('Problems connecting to plex.tv; connection returned '
|
LOG.info('Problems connecting to plex.tv; connection returned '
|
||||||
'HTTP %s', str(chk))
|
'HTTP %s', str(chk))
|
||||||
utils.messageDialog(utils.lang(29999), utils.lang(39010))
|
dialog('ok', lang(29999), lang(39010))
|
||||||
answer = False
|
answer = False
|
||||||
else:
|
else:
|
||||||
LOG.info('plex.tv connection with token successful')
|
LOG.info('plex.tv connection with token successful')
|
||||||
utils.settings('plex_status', value=utils.lang(39227))
|
settings('plex_status', value=lang(39227))
|
||||||
# Refresh the info from Plex.tv
|
# Refresh the info from Plex.tv
|
||||||
xml = DU().downloadUrl('https://plex.tv/users/account',
|
xml = DU().downloadUrl('https://plex.tv/users/account',
|
||||||
authenticate=False,
|
authenticate=False,
|
||||||
|
@ -195,13 +203,15 @@ class InitialSetup(object):
|
||||||
except (AttributeError, KeyError):
|
except (AttributeError, KeyError):
|
||||||
LOG.error('Failed to update Plex info from plex.tv')
|
LOG.error('Failed to update Plex info from plex.tv')
|
||||||
else:
|
else:
|
||||||
utils.settings('plexLogin', value=self.plex_login)
|
settings('plexLogin', value=self.plex_login)
|
||||||
utils.settings('plexAvatar', value=xml.attrib.get('thumb'))
|
home = 'true' if xml.attrib.get('home') == '1' else 'false'
|
||||||
|
settings('plexhome', value=home)
|
||||||
|
settings('plexAvatar', value=xml.attrib.get('thumb'))
|
||||||
|
settings('plexHomeSize', value=xml.attrib.get('homeSize', '1'))
|
||||||
LOG.info('Updated Plex info from plex.tv')
|
LOG.info('Updated Plex info from plex.tv')
|
||||||
return answer
|
return answer
|
||||||
|
|
||||||
@staticmethod
|
def check_existing_pms(self):
|
||||||
def check_existing_pms():
|
|
||||||
"""
|
"""
|
||||||
Check the PMS that was set in file settings.
|
Check the PMS that was set in file settings.
|
||||||
Will return False if we need to reconnect, because:
|
Will return False if we need to reconnect, because:
|
||||||
|
@ -212,27 +222,26 @@ class InitialSetup(object):
|
||||||
not set before
|
not set before
|
||||||
"""
|
"""
|
||||||
answer = True
|
answer = True
|
||||||
chk = PF.check_connection(app.CONN.server,
|
chk = PF.check_connection(self.server, verifySSL=False)
|
||||||
verifySSL=True if v.KODIVERSION >= 18 else False)
|
|
||||||
if chk is False:
|
if chk is False:
|
||||||
LOG.warn('Could not reach PMS %s', app.CONN.server)
|
LOG.warn('Could not reach PMS %s', self.server)
|
||||||
answer = False
|
answer = False
|
||||||
if answer is True and not app.CONN.machine_identifier:
|
if answer is True and not self.serverid:
|
||||||
LOG.info('No PMS machineIdentifier found for %s. Trying to '
|
LOG.info('No PMS machineIdentifier found for %s. Trying to '
|
||||||
'get the PMS unique ID', app.CONN.server)
|
'get the PMS unique ID', self.server)
|
||||||
app.CONN.machine_identifier = PF.GetMachineIdentifier(app.CONN.server)
|
self.serverid = PF.GetMachineIdentifier(self.server)
|
||||||
if app.CONN.machine_identifier is None:
|
if self.serverid is None:
|
||||||
LOG.warn('Could not retrieve machineIdentifier')
|
LOG.warn('Could not retrieve machineIdentifier')
|
||||||
answer = False
|
answer = False
|
||||||
else:
|
else:
|
||||||
utils.settings('plex_machineIdentifier', value=app.CONN.machine_identifier)
|
settings('plex_machineIdentifier', value=self.serverid)
|
||||||
elif answer is True:
|
elif answer is True:
|
||||||
temp_server_id = PF.GetMachineIdentifier(app.CONN.server)
|
temp_server_id = PF.GetMachineIdentifier(self.server)
|
||||||
if temp_server_id != app.CONN.machine_identifier:
|
if temp_server_id != self.serverid:
|
||||||
LOG.warn('The current PMS %s was expected to have a '
|
LOG.warn('The current PMS %s was expected to have a '
|
||||||
'unique machineIdentifier of %s. But we got '
|
'unique machineIdentifier of %s. But we got '
|
||||||
'%s. Pick a new server to be sure',
|
'%s. Pick a new server to be sure',
|
||||||
app.CONN.server, app.CONN.machine_identifier, temp_server_id)
|
self.server, self.serverid, temp_server_id)
|
||||||
answer = False
|
answer = False
|
||||||
return answer
|
return answer
|
||||||
|
|
||||||
|
@ -241,20 +250,22 @@ class InitialSetup(object):
|
||||||
"""
|
"""
|
||||||
Checks for server's connectivity. Returns check_connection result
|
Checks for server's connectivity. Returns check_connection result
|
||||||
"""
|
"""
|
||||||
|
# Re-direct via plex if remote - will lead to the correct SSL
|
||||||
|
# certificate
|
||||||
if server['local']:
|
if server['local']:
|
||||||
# Deactive SSL verification if the server is local for Kodi 17
|
url = ('%s://%s:%s'
|
||||||
verifySSL = True if v.KODIVERSION >= 18 else False
|
% (server['scheme'], server['ip'], server['port']))
|
||||||
|
# Deactive SSL verification if the server is local!
|
||||||
|
verifySSL = False
|
||||||
else:
|
else:
|
||||||
|
url = server['baseURL']
|
||||||
verifySSL = True
|
verifySSL = True
|
||||||
if not server['token']:
|
chk = PF.check_connection(url,
|
||||||
# Plex GDM: we only get the token from plex.tv after
|
token=server['token'],
|
||||||
# Sign-in to plex.tv
|
verifySSL=verifySSL)
|
||||||
server['token'] = utils.settings('plexToken') or None
|
return chk
|
||||||
return PF.check_connection(server['baseURL'],
|
|
||||||
token=server['token'],
|
|
||||||
verifySSL=verifySSL)
|
|
||||||
|
|
||||||
def pick_pms(self, showDialog=False, inform_of_search=False):
|
def pick_pms(self, showDialog=False):
|
||||||
"""
|
"""
|
||||||
Searches for PMS in local Lan and optionally (if self.plex_token set)
|
Searches for PMS in local Lan and optionally (if self.plex_token set)
|
||||||
also on plex.tv
|
also on plex.tv
|
||||||
|
@ -287,16 +298,19 @@ class InitialSetup(object):
|
||||||
}
|
}
|
||||||
or None if unsuccessful
|
or None if unsuccessful
|
||||||
"""
|
"""
|
||||||
|
server = None
|
||||||
# If no server is set, let user choose one
|
# If no server is set, let user choose one
|
||||||
if not app.CONN.server or not app.CONN.machine_identifier:
|
if not self.server or not self.serverid:
|
||||||
showDialog = True
|
showDialog = True
|
||||||
if showDialog is True:
|
if showDialog is True:
|
||||||
server = self._user_pick_pms()
|
server = self._user_pick_pms()
|
||||||
else:
|
else:
|
||||||
server = self._auto_pick_pms(show_dialog=inform_of_search)
|
server = self._auto_pick_pms()
|
||||||
|
if server is not None:
|
||||||
|
_write_pms_settings(server['baseURL'], server['token'])
|
||||||
return server
|
return server
|
||||||
|
|
||||||
def _auto_pick_pms(self, show_dialog=False):
|
def _auto_pick_pms(self):
|
||||||
"""
|
"""
|
||||||
Will try to pick PMS based on machineIdentifier saved in file settings
|
Will try to pick PMS based on machineIdentifier saved in file settings
|
||||||
but only once
|
but only once
|
||||||
|
@ -305,47 +319,35 @@ class InitialSetup(object):
|
||||||
"""
|
"""
|
||||||
https_updated = False
|
https_updated = False
|
||||||
server = None
|
server = None
|
||||||
if show_dialog:
|
while True:
|
||||||
# Searching for PMS
|
if https_updated is False:
|
||||||
utils.dialog('notification',
|
serverlist = PF.discover_pms(self.plex_token)
|
||||||
heading='{plex}',
|
for item in serverlist:
|
||||||
message=utils.lang(30001),
|
if item.get('machineIdentifier') == self.serverid:
|
||||||
icon='{plex}',
|
server = item
|
||||||
time=60000)
|
if server is None:
|
||||||
try:
|
name = settings('plex_servername')
|
||||||
while True:
|
LOG.warn('The PMS you have used before with a unique '
|
||||||
if https_updated is False:
|
'machineIdentifier of %s and name %s is '
|
||||||
serverlist = PF.discover_pms(self.plex_token)
|
'offline', self.serverid, name)
|
||||||
for item in serverlist:
|
|
||||||
if item.get('machineIdentifier') == app.CONN.machine_identifier:
|
|
||||||
server = item
|
|
||||||
if server is None:
|
|
||||||
name = utils.settings('plex_servername')
|
|
||||||
LOG.warn('The PMS you have used before with a unique '
|
|
||||||
'machineIdentifier of %s and name %s is '
|
|
||||||
'offline', app.CONN.machine_identifier, name)
|
|
||||||
return
|
|
||||||
chk = self._check_pms_connectivity(server)
|
|
||||||
if chk == 504 and https_updated is False:
|
|
||||||
# switch HTTPS to HTTP or vice-versa
|
|
||||||
if server['scheme'] == 'https':
|
|
||||||
server['scheme'] = 'http'
|
|
||||||
else:
|
|
||||||
server['scheme'] = 'https'
|
|
||||||
https_updated = True
|
|
||||||
continue
|
|
||||||
# Problems connecting
|
|
||||||
elif chk >= 400 or chk is False:
|
|
||||||
LOG.warn('Problems connecting to server %s. chk is %s',
|
|
||||||
server['name'], chk)
|
|
||||||
return
|
return
|
||||||
LOG.info('We found a server to automatically connect to: %s',
|
chk = self._check_pms_connectivity(server)
|
||||||
server['name'])
|
if chk == 504 and https_updated is False:
|
||||||
return server
|
# switch HTTPS to HTTP or vice-versa
|
||||||
finally:
|
if server['scheme'] == 'https':
|
||||||
if show_dialog:
|
server['scheme'] = 'http'
|
||||||
executebuiltin("Dialog.Close(all, true)")
|
else:
|
||||||
|
server['scheme'] = 'https'
|
||||||
|
https_updated = True
|
||||||
|
continue
|
||||||
|
# Problems connecting
|
||||||
|
elif chk >= 400 or chk is False:
|
||||||
|
LOG.warn('Problems connecting to server %s. chk is %s',
|
||||||
|
server['name'], chk)
|
||||||
|
return
|
||||||
|
LOG.info('We found a server to automatically connect to: %s',
|
||||||
|
server['name'])
|
||||||
|
return server
|
||||||
|
|
||||||
def _user_pick_pms(self):
|
def _user_pick_pms(self):
|
||||||
"""
|
"""
|
||||||
|
@ -355,18 +357,18 @@ class InitialSetup(object):
|
||||||
"""
|
"""
|
||||||
https_updated = False
|
https_updated = False
|
||||||
# Searching for PMS
|
# Searching for PMS
|
||||||
utils.dialog('notification',
|
dialog('notification',
|
||||||
heading='{plex}',
|
heading='{plex}',
|
||||||
message=utils.lang(30001),
|
message=lang(30001),
|
||||||
icon='{plex}',
|
icon='{plex}',
|
||||||
time=60000)
|
time=5000)
|
||||||
while True:
|
while True:
|
||||||
if https_updated is False:
|
if https_updated is False:
|
||||||
serverlist = PF.discover_pms(self.plex_token)
|
serverlist = PF.discover_pms(self.plex_token)
|
||||||
# Exit if no servers found
|
# Exit if no servers found
|
||||||
if not serverlist:
|
if not serverlist:
|
||||||
LOG.warn('No plex media servers found!')
|
LOG.warn('No plex media servers found!')
|
||||||
utils.messageDialog(utils.lang(29999), utils.lang(39011))
|
dialog('ok', lang(29999), lang(39011))
|
||||||
return
|
return
|
||||||
# Get a nicer list
|
# Get a nicer list
|
||||||
dialoglist = []
|
dialoglist = []
|
||||||
|
@ -374,10 +376,10 @@ class InitialSetup(object):
|
||||||
if server['local']:
|
if server['local']:
|
||||||
# server is in the same network as client.
|
# server is in the same network as client.
|
||||||
# Add"local"
|
# Add"local"
|
||||||
msg = utils.lang(39022)
|
msg = lang(39022)
|
||||||
else:
|
else:
|
||||||
# Add 'remote'
|
# Add 'remote'
|
||||||
msg = utils.lang(39054)
|
msg = lang(39054)
|
||||||
if server.get('ownername'):
|
if server.get('ownername'):
|
||||||
# Display username if its not our PMS
|
# Display username if its not our PMS
|
||||||
dialoglist.append('%s (%s, %s)'
|
dialoglist.append('%s (%s, %s)'
|
||||||
|
@ -388,9 +390,7 @@ class InitialSetup(object):
|
||||||
dialoglist.append('%s (%s)'
|
dialoglist.append('%s (%s)'
|
||||||
% (server['name'], msg))
|
% (server['name'], msg))
|
||||||
# Let user pick server from a list
|
# Let user pick server from a list
|
||||||
# Close the PKC info "Searching for PMS"
|
resp = dialog('select', lang(39012), dialoglist)
|
||||||
executebuiltin("Dialog.Close(all, true)")
|
|
||||||
resp = utils.dialog('select', utils.lang(39012), dialoglist)
|
|
||||||
if resp == -1:
|
if resp == -1:
|
||||||
# User cancelled
|
# User cancelled
|
||||||
return
|
return
|
||||||
|
@ -406,20 +406,20 @@ class InitialSetup(object):
|
||||||
if chk == 401:
|
if chk == 401:
|
||||||
LOG.warn('Not yet authorized for Plex server %s',
|
LOG.warn('Not yet authorized for Plex server %s',
|
||||||
server['name'])
|
server['name'])
|
||||||
# Not yet authorized for Plex server %s
|
# Please sign in to plex.tv
|
||||||
utils.messageDialog(
|
dialog('ok',
|
||||||
utils.lang(29999),
|
lang(29999),
|
||||||
'%s %s\n%s' % (utils.lang(39013),
|
lang(39013) + server['name'],
|
||||||
server['name'].decode('utf-8'),
|
lang(39014))
|
||||||
utils.lang(39014)))
|
|
||||||
if self.plex_tv_sign_in() is False:
|
if self.plex_tv_sign_in() is False:
|
||||||
# Exit while loop if user cancels
|
# Exit while loop if user cancels
|
||||||
return
|
return
|
||||||
# Problems connecting
|
# Problems connecting
|
||||||
elif chk >= 400 or chk is False:
|
elif chk >= 400 or chk is False:
|
||||||
# Problems connecting to server. Pick another server?
|
# Problems connecting to server. Pick another server?
|
||||||
if not utils.yesno_dialog(utils.lang(29999), utils.lang(39015)):
|
answ = dialog('yesno', lang(29999), lang(39015))
|
||||||
# Exit while loop if user chooses No
|
# Exit while loop if user chooses No
|
||||||
|
if not answ:
|
||||||
return
|
return
|
||||||
# Otherwise: connection worked!
|
# Otherwise: connection worked!
|
||||||
else:
|
else:
|
||||||
|
@ -430,62 +430,36 @@ class InitialSetup(object):
|
||||||
"""
|
"""
|
||||||
Saves server to file settings
|
Saves server to file settings
|
||||||
"""
|
"""
|
||||||
utils.settings('plex_machineIdentifier', server['machineIdentifier'])
|
settings('plex_machineIdentifier', server['machineIdentifier'])
|
||||||
utils.settings('plex_servername', server['name'])
|
settings('plex_servername', server['name'])
|
||||||
utils.settings('plex_serverowned',
|
settings('plex_serverowned', 'true' if server['owned'] else 'false')
|
||||||
'true' if server['owned'] else 'false')
|
|
||||||
# Careful to distinguish local from remote PMS
|
# Careful to distinguish local from remote PMS
|
||||||
if server['local']:
|
if server['local']:
|
||||||
scheme = server['scheme']
|
scheme = server['scheme']
|
||||||
utils.settings('ipaddress', server['ip'])
|
settings('ipaddress', server['ip'])
|
||||||
utils.settings('port', server['port'])
|
settings('port', server['port'])
|
||||||
LOG.debug("Setting SSL verify to false, because server is "
|
LOG.debug("Setting SSL verify to false, because server is "
|
||||||
"local")
|
"local")
|
||||||
utils.settings('sslverify', 'false')
|
settings('sslverify', 'false')
|
||||||
else:
|
else:
|
||||||
baseURL = server['baseURL'].split(':')
|
baseURL = server['baseURL'].split(':')
|
||||||
scheme = baseURL[0]
|
scheme = baseURL[0]
|
||||||
utils.settings('ipaddress', baseURL[1].replace('//', ''))
|
settings('ipaddress', baseURL[1].replace('//', ''))
|
||||||
utils.settings('port', baseURL[2])
|
settings('port', baseURL[2])
|
||||||
LOG.debug("Setting SSL verify to true, because server is not "
|
LOG.debug("Setting SSL verify to true, because server is not "
|
||||||
"local")
|
"local")
|
||||||
utils.settings('sslverify', 'true')
|
settings('sslverify', 'true')
|
||||||
|
|
||||||
if scheme == 'https':
|
if scheme == 'https':
|
||||||
utils.settings('https', 'true')
|
settings('https', 'true')
|
||||||
else:
|
else:
|
||||||
utils.settings('https', 'false')
|
settings('https', 'false')
|
||||||
# And finally do some logging
|
# And finally do some logging
|
||||||
LOG.debug("Writing to Kodi user settings file")
|
LOG.debug("Writing to Kodi user settings file")
|
||||||
LOG.debug("PMS machineIdentifier: %s, ip: %s, port: %s, https: %s ",
|
LOG.debug("PMS machineIdentifier: %s, ip: %s, port: %s, https: %s ",
|
||||||
server['machineIdentifier'], server['ip'], server['port'],
|
server['machineIdentifier'], server['ip'], server['port'],
|
||||||
server['scheme'])
|
server['scheme'])
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _add_sources(root, extension):
|
|
||||||
changed = False
|
|
||||||
count = 2
|
|
||||||
for source in root.findall('.//path'):
|
|
||||||
if source.text == extension:
|
|
||||||
count -= 1
|
|
||||||
if count == 0:
|
|
||||||
# sources already set
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Missing smb:// occurences, re-add.
|
|
||||||
changed = True
|
|
||||||
for _ in range(0, count):
|
|
||||||
source = etree.SubElement(root, 'source')
|
|
||||||
etree.SubElement(
|
|
||||||
source,
|
|
||||||
'name').text = "PlexKodiConnect Masterlock Hack"
|
|
||||||
etree.SubElement(
|
|
||||||
source,
|
|
||||||
'path',
|
|
||||||
{'pathversion': "1"}).text = extension
|
|
||||||
etree.SubElement(source, 'allowsharing').text = "true"
|
|
||||||
return changed
|
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
"""
|
"""
|
||||||
Initial setup. Run once upon startup.
|
Initial setup. Run once upon startup.
|
||||||
|
@ -495,22 +469,18 @@ class InitialSetup(object):
|
||||||
"""
|
"""
|
||||||
LOG.info("Initial setup called.")
|
LOG.info("Initial setup called.")
|
||||||
try:
|
try:
|
||||||
with utils.XmlKodiSetting('advancedsettings.xml',
|
with XmlKodiSetting('advancedsettings.xml',
|
||||||
force_create=True,
|
force_create=True,
|
||||||
top_element='advancedsettings') as xml:
|
top_element='advancedsettings') as xml:
|
||||||
# Get current Kodi video cache setting
|
# Get current Kodi video cache setting
|
||||||
cache = xml.get_setting(['cache', 'memorysize'])
|
cache = xml.get_setting(['cache', 'memorysize'])
|
||||||
# Disable foreground "Loading media information from files"
|
# Disable foreground "Loading media information from files"
|
||||||
# (still used by Kodi, even though the Wiki says otherwise)
|
# (still used by Kodi, even though the Wiki says otherwise)
|
||||||
xml.set_setting(['musiclibrary', 'backgroundupdate'],
|
xml.set_setting(['musiclibrary', 'backgroundupdate'],
|
||||||
value='true')
|
value='true')
|
||||||
cleanonupdate = xml.get_setting(
|
# Disable cleaning of library - not compatible with PKC
|
||||||
['videolibrary', 'cleanonupdate']) == 'true'
|
xml.set_setting(['videolibrary', 'cleanonupdate'],
|
||||||
if utils.settings('useDirectPaths') != '1':
|
value='false')
|
||||||
# Disable cleaning of library - not compatible with PKC
|
|
||||||
# Only do this for add-on paths
|
|
||||||
xml.set_setting(['videolibrary', 'cleanonupdate'],
|
|
||||||
value='false')
|
|
||||||
# Set completely watched point same as plex (and not 92%)
|
# Set completely watched point same as plex (and not 92%)
|
||||||
xml.set_setting(['video', 'ignorepercentatend'], value='10')
|
xml.set_setting(['video', 'ignorepercentatend'], value='10')
|
||||||
xml.set_setting(['video', 'playcountminimumpercent'],
|
xml.set_setting(['video', 'playcountminimumpercent'],
|
||||||
|
@ -518,192 +488,161 @@ class InitialSetup(object):
|
||||||
xml.set_setting(['video', 'ignoresecondsatstart'],
|
xml.set_setting(['video', 'ignoresecondsatstart'],
|
||||||
value='60')
|
value='60')
|
||||||
reboot = xml.write_xml
|
reboot = xml.write_xml
|
||||||
except utils.ParseError:
|
except etree.ParseError:
|
||||||
cache = None
|
cache = None
|
||||||
reboot = False
|
reboot = False
|
||||||
cleanonupdate = False
|
|
||||||
# Kodi default cache if no setting is set
|
# Kodi default cache if no setting is set
|
||||||
cache = str(cache.text) if cache is not None else '20971520'
|
cache = str(cache.text) if cache is not None else '20971520'
|
||||||
LOG.info('Current Kodi video memory cache in bytes: %s', cache)
|
LOG.info('Current Kodi video memory cache in bytes: %s', cache)
|
||||||
utils.settings('kodi_video_cache', value=cache)
|
settings('kodi_video_cache', value=cache)
|
||||||
|
|
||||||
# Hack to make PKC Kodi master lock compatible
|
# Hack to make PKC Kodi master lock compatible
|
||||||
try:
|
try:
|
||||||
with utils.XmlKodiSetting('sources.xml',
|
with XmlKodiSetting('sources.xml',
|
||||||
force_create=True,
|
force_create=True,
|
||||||
top_element='sources') as xml:
|
top_element='sources') as xml:
|
||||||
changed = False
|
root = xml.set_setting(['video'])
|
||||||
for extension in ('smb://', 'nfs://'):
|
count = 2
|
||||||
root = xml.set_setting(['video'])
|
for source in root.findall('.//path'):
|
||||||
changed = self._add_sources(root, extension) or changed
|
if source.text == "smb://":
|
||||||
if changed:
|
count -= 1
|
||||||
xml.write_xml = True
|
if count == 0:
|
||||||
reboot = True
|
# sources already set
|
||||||
except utils.ParseError:
|
break
|
||||||
|
else:
|
||||||
|
# Missing smb:// occurences, re-add.
|
||||||
|
for _ in range(0, count):
|
||||||
|
source = etree.SubElement(root, 'source')
|
||||||
|
etree.SubElement(
|
||||||
|
source,
|
||||||
|
'name').text = "PlexKodiConnect Masterlock Hack"
|
||||||
|
etree.SubElement(
|
||||||
|
source,
|
||||||
|
'path',
|
||||||
|
attrib={'pathversion': "1"}).text = "smb://"
|
||||||
|
etree.SubElement(source, 'allowsharing').text = "true"
|
||||||
|
if reboot is False:
|
||||||
|
reboot = xml.write_xml
|
||||||
|
except etree.ParseError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Do we need to migrate stuff?
|
# Do we need to migrate stuff?
|
||||||
migration.check_migration()
|
check_migration()
|
||||||
# Reload the server IP cause we might've deleted it during migration
|
# Reload the server IP cause we might've deleted it during migration
|
||||||
app.CONN.load()
|
self.server = UserClient().get_server()
|
||||||
|
|
||||||
# Display a warning if Kodi puts ALL movies into the queue, basically
|
# Display a warning if Kodi puts ALL movies into the queue, basically
|
||||||
# breaking playback reporting for PKC
|
# breaking playback reporting for PKC
|
||||||
warn = False
|
if js.settings_getsettingvalue('videoplayer.autoplaynextitem'):
|
||||||
settings = js.settings_getsettingvalue('videoplayer.autoplaynextitem')
|
LOG.warn('Kodi setting videoplayer.autoplaynextitem is enabled!')
|
||||||
if v.KODIVERSION >= 18:
|
if settings('warned_setting_videoplayer.autoplaynextitem') == 'false':
|
||||||
# Answer for videoplayer.autoplaynextitem:
|
|
||||||
# [{u'label': u'Music videos', u'value': 0},
|
|
||||||
# {u'label': u'TV shows', u'value': 1},
|
|
||||||
# {u'label': u'Episodes', u'value': 2},
|
|
||||||
# {u'label': u'Movies', u'value': 3},
|
|
||||||
# {u'label': u'Uncategorized', u'value': 4}]
|
|
||||||
if 1 in settings or 2 in settings or 3 in settings:
|
|
||||||
warn = True
|
|
||||||
else:
|
|
||||||
# Kodi Krypton: answer is boolean
|
|
||||||
if settings:
|
|
||||||
warn = True
|
|
||||||
if warn:
|
|
||||||
LOG.warn('Kodi setting videoplayer.autoplaynextitem is: %s',
|
|
||||||
settings)
|
|
||||||
if utils.settings('warned_setting_videoplayer.autoplaynextitem') == 'false':
|
|
||||||
# Only warn once
|
# Only warn once
|
||||||
utils.settings('warned_setting_videoplayer.autoplaynextitem',
|
settings('warned_setting_videoplayer.autoplaynextitem',
|
||||||
value='true')
|
value='true')
|
||||||
# Warning: Kodi setting "Play next video automatically" is
|
# Warning: Kodi setting "Play next video automatically" is
|
||||||
# enabled. This could break PKC. Deactivate?
|
# enabled. This could break PKC. Deactivate?
|
||||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(30003)):
|
if dialog('yesno', lang(29999), lang(30003)):
|
||||||
if v.KODIVERSION >= 18:
|
js.settings_setsettingvalue('videoplayer.autoplaynextitem',
|
||||||
for i in (1, 2, 3):
|
False)
|
||||||
try:
|
|
||||||
settings.remove(i)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
js.settings_setsettingvalue('videoplayer.autoplaynextitem',
|
|
||||||
settings)
|
|
||||||
else:
|
|
||||||
js.settings_setsettingvalue('videoplayer.autoplaynextitem',
|
|
||||||
False)
|
|
||||||
# Set any video library updates to happen in the background in order to
|
# Set any video library updates to happen in the background in order to
|
||||||
# hide "Compressing database"
|
# hide "Compressing database"
|
||||||
js.settings_setsettingvalue('videolibrary.backgroundupdate', True)
|
js.settings_setsettingvalue('videolibrary.backgroundupdate', True)
|
||||||
|
|
||||||
# If a Plex server IP has already been set
|
# If a Plex server IP has already been set
|
||||||
# return only if the right machine identifier is found
|
# return only if the right machine identifier is found
|
||||||
if app.CONN.server:
|
if self.server:
|
||||||
LOG.info("PMS is already set: %s. Checking now...", app.CONN.server)
|
LOG.info("PMS is already set: %s. Checking now...", self.server)
|
||||||
if self.check_existing_pms():
|
if self.check_existing_pms():
|
||||||
LOG.info("Using PMS %s with machineIdentifier %s",
|
LOG.info("Using PMS %s with machineIdentifier %s",
|
||||||
app.CONN.server, app.CONN.machine_identifier)
|
self.server, self.serverid)
|
||||||
self.save_pms_settings(app.CONN.server, self.pms_token)
|
_write_pms_settings(self.server, self.pms_token)
|
||||||
if utils.settings('kodi_db_has_been_wiped_clean') == 'false':
|
|
||||||
# If the user chose to go to the PKC settings on the first run
|
|
||||||
# Will trigger a reboot
|
|
||||||
utils.wipe_database()
|
|
||||||
if reboot is True:
|
if reboot is True:
|
||||||
utils.reboot_kodi()
|
reboot_kodi()
|
||||||
return
|
return
|
||||||
else:
|
|
||||||
LOG.info('No PMS set yet')
|
|
||||||
|
|
||||||
# If not already retrieved myplex info, optionally let user sign in
|
# If not already retrieved myplex info, optionally let user sign in
|
||||||
# to plex.tv. This DOES get called on very first install run
|
# to plex.tv. This DOES get called on very first install run
|
||||||
if not self.plex_token and app.ACCOUNT.myplexlogin:
|
if not self.plex_token and self.myplexlogin:
|
||||||
self.plex_tv_sign_in()
|
self.plex_tv_sign_in()
|
||||||
|
|
||||||
server = self.pick_pms(inform_of_search=True)
|
server = self.pick_pms()
|
||||||
if server is not None:
|
if server is not None:
|
||||||
# Write our chosen server to Kodi settings file
|
# Write our chosen server to Kodi settings file
|
||||||
self.save_pms_settings(server['baseURL'], server['token'])
|
|
||||||
self.write_pms_to_settings(server)
|
self.write_pms_to_settings(server)
|
||||||
|
|
||||||
# User already answered the installation questions
|
# User already answered the installation questions
|
||||||
if utils.settings('InstallQuestionsAnswered') == 'true':
|
if settings('InstallQuestionsAnswered') == 'true':
|
||||||
LOG.info('Installation questions already answered')
|
|
||||||
if utils.settings('kodi_db_has_been_wiped_clean') == 'false':
|
|
||||||
# If the user chose to go to the PKC settings on the first run
|
|
||||||
# Will trigger a reboot
|
|
||||||
utils.wipe_database()
|
|
||||||
if reboot is True:
|
if reboot is True:
|
||||||
utils.reboot_kodi()
|
reboot_kodi()
|
||||||
# Reload relevant settings
|
|
||||||
app.CONN.load()
|
|
||||||
app.ACCOUNT.load()
|
|
||||||
app.SYNC.load()
|
|
||||||
return
|
return
|
||||||
LOG.info('Showing install questions')
|
|
||||||
# Additional settings where the user needs to choose
|
# Additional settings where the user needs to choose
|
||||||
# Direct paths (\\NAS\mymovie.mkv) or addon (http)?
|
# Direct paths (\\NAS\mymovie.mkv) or addon (http)?
|
||||||
goto_settings = False
|
goto_settings = False
|
||||||
from .windows import optionsdialog
|
if dialog('yesno',
|
||||||
# Use Add-on Paths (default, easy) or Direct Paths? PKC will not work
|
lang(29999),
|
||||||
# if your Direct Paths setup is wrong!
|
lang(39027),
|
||||||
# Buttons: Add-on Paths // Direct Paths
|
lang(39028),
|
||||||
if optionsdialog.show(utils.lang(29999), utils.lang(39080),
|
nolabel="Addon (Default)",
|
||||||
utils.lang(39081), utils.lang(39082)) == 1:
|
yeslabel="Native (Direct Paths)"):
|
||||||
LOG.debug("User opted to use direct paths.")
|
LOG.debug("User opted to use direct paths.")
|
||||||
utils.settings('useDirectPaths', value="1")
|
settings('useDirectPaths', value="1")
|
||||||
if cleanonupdate:
|
state.DIRECT_PATHS = True
|
||||||
# Re-enable cleanonupdate
|
|
||||||
with utils.XmlKodiSetting('advancedsettings.xml') as xml:
|
|
||||||
xml.set_setting(['videolibrary', 'cleanonupdate'],
|
|
||||||
value='true')
|
|
||||||
# Are you on a system where you would like to replace paths
|
# Are you on a system where you would like to replace paths
|
||||||
# \\NAS\mymovie.mkv with smb://NAS/mymovie.mkv? (e.g. Windows)
|
# \\NAS\mymovie.mkv with smb://NAS/mymovie.mkv? (e.g. Windows)
|
||||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(39033)):
|
if dialog('yesno', heading=lang(29999), line1=lang(39033)):
|
||||||
LOG.debug("User chose to replace paths with smb")
|
LOG.debug("User chose to replace paths with smb")
|
||||||
else:
|
else:
|
||||||
utils.settings('replaceSMB', value="false")
|
settings('replaceSMB', value="false")
|
||||||
|
|
||||||
# complete replace all original Plex library paths with custom SMB
|
# complete replace all original Plex library paths with custom SMB
|
||||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(39043)):
|
if dialog('yesno', heading=lang(29999), line1=lang(39043)):
|
||||||
LOG.debug("User chose custom smb paths")
|
LOG.debug("User chose custom smb paths")
|
||||||
utils.settings('remapSMB', value="true")
|
settings('remapSMB', value="true")
|
||||||
# Please enter your custom smb paths in the settings under
|
# Please enter your custom smb paths in the settings under
|
||||||
# "Sync Options" and then restart Kodi
|
# "Sync Options" and then restart Kodi
|
||||||
utils.messageDialog(utils.lang(29999), utils.lang(39044))
|
dialog('ok', heading=lang(29999), line1=lang(39044))
|
||||||
goto_settings = True
|
goto_settings = True
|
||||||
|
|
||||||
# Go to network credentials?
|
# Go to network credentials?
|
||||||
if utils.yesno_dialog(utils.lang(39029), utils.lang(39030)):
|
if dialog('yesno',
|
||||||
|
heading=lang(29999),
|
||||||
|
line1=lang(39029),
|
||||||
|
line2=lang(39030)):
|
||||||
LOG.debug("Presenting network credentials dialog.")
|
LOG.debug("Presenting network credentials dialog.")
|
||||||
from .windows import direct_path_sources
|
from utils import passwords_xml
|
||||||
direct_path_sources.start()
|
passwords_xml()
|
||||||
# Disable Plex music?
|
# Disable Plex music?
|
||||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(39016)):
|
if dialog('yesno', heading=lang(29999), line1=lang(39016)):
|
||||||
LOG.debug("User opted to disable Plex music library.")
|
LOG.debug("User opted to disable Plex music library.")
|
||||||
utils.settings('enableMusic', value="false")
|
settings('enableMusic', value="false")
|
||||||
|
|
||||||
# Download additional art from FanArtTV
|
# Download additional art from FanArtTV
|
||||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(39061)):
|
if dialog('yesno', heading=lang(29999), line1=lang(39061)):
|
||||||
LOG.debug("User opted to use FanArtTV")
|
LOG.debug("User opted to use FanArtTV")
|
||||||
utils.settings('FanartTV', value="true")
|
settings('FanartTV', value="true")
|
||||||
# Do you want to replace your custom user ratings with an indicator of
|
# Do you want to replace your custom user ratings with an indicator of
|
||||||
# how many versions of a media item you posses?
|
# how many versions of a media item you posses?
|
||||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(39718)):
|
if dialog('yesno', heading=lang(29999), line1=lang(39718)):
|
||||||
LOG.debug("User opted to replace user ratings with version number")
|
LOG.debug("User opted to replace user ratings with version number")
|
||||||
utils.settings('indicate_media_versions', value="true")
|
settings('indicate_media_versions', value="true")
|
||||||
|
|
||||||
# If you use several Plex libraries of one kind, e.g. "Kids Movies" and
|
# If you use several Plex libraries of one kind, e.g. "Kids Movies" and
|
||||||
# "Parents Movies", be sure to check https://goo.gl/JFtQV9
|
# "Parents Movies", be sure to check https://goo.gl/JFtQV9
|
||||||
# dialog.ok(heading=utils.lang(29999), line1=utils.lang(39076))
|
# dialog.ok(heading=lang(29999), line1=lang(39076))
|
||||||
|
|
||||||
# Need to tell about our image source for collections: themoviedb.org
|
# Need to tell about our image source for collections: themoviedb.org
|
||||||
# dialog.ok(heading=utils.lang(29999), line1=utils.lang(39717))
|
# dialog.ok(heading=lang(29999), line1=lang(39717))
|
||||||
# Make sure that we only ask these questions upon first installation
|
# Make sure that we only ask these questions upon first installation
|
||||||
utils.settings('InstallQuestionsAnswered', value='true')
|
settings('InstallQuestionsAnswered', value='true')
|
||||||
|
|
||||||
if goto_settings is False:
|
if goto_settings is False:
|
||||||
# Open Settings page now? You will need to restart!
|
# Open Settings page now? You will need to restart!
|
||||||
goto_settings = utils.yesno_dialog(utils.lang(29999),
|
goto_settings = dialog('yesno',
|
||||||
utils.lang(39017))
|
heading=lang(29999),
|
||||||
# New installation - make sure we start with a clean slate
|
line1=lang(39017))
|
||||||
utils.wipe_database(reboot=False)
|
|
||||||
if goto_settings:
|
if goto_settings:
|
||||||
LOG.info('User chose to go to the PKC settings - suspending PKC')
|
state.PMS_STATUS = 'Stop'
|
||||||
app.APP.stop_pkc = True
|
executebuiltin('Addon.OpenSettings(plugin.video.plexkodiconnect)')
|
||||||
executebuiltin(
|
elif reboot is True:
|
||||||
'Addon.OpenSettings(plugin.video.plexkodiconnect)')
|
reboot_kodi()
|
||||||
return
|
|
||||||
utils.reboot_kodi()
|
|
||||||
|
|
1826
resources/lib/itemtypes.py
Normal file
1826
resources/lib/itemtypes.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,30 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from .movies import Movie
|
|
||||||
from .tvshows import Show, Season, Episode
|
|
||||||
from .music import Artist, Album, Song
|
|
||||||
from .. import variables as v
|
|
||||||
|
|
||||||
# Note: always use same order of URL arguments, NOT urlencode:
|
|
||||||
# plex_id=<plex_id>&plex_type=<plex_type>&mode=play
|
|
||||||
|
|
||||||
ITEMTYPE_FROM_PLEXTYPE = {
|
|
||||||
v.PLEX_TYPE_MOVIE: Movie,
|
|
||||||
v.PLEX_TYPE_SHOW: Show,
|
|
||||||
v.PLEX_TYPE_SEASON: Season,
|
|
||||||
v.PLEX_TYPE_EPISODE: Episode,
|
|
||||||
v.PLEX_TYPE_ARTIST: Artist,
|
|
||||||
v.PLEX_TYPE_ALBUM: Album,
|
|
||||||
v.PLEX_TYPE_SONG: Song
|
|
||||||
}
|
|
||||||
|
|
||||||
ITEMTYPE_FROM_KODITYPE = {
|
|
||||||
v.KODI_TYPE_MOVIE: Movie,
|
|
||||||
v.KODI_TYPE_SHOW: Show,
|
|
||||||
v.KODI_TYPE_SEASON: Season,
|
|
||||||
v.KODI_TYPE_EPISODE: Episode,
|
|
||||||
v.KODI_TYPE_ARTIST: Artist,
|
|
||||||
v.KODI_TYPE_ALBUM: Album,
|
|
||||||
v.KODI_TYPE_SONG: Song
|
|
||||||
}
|
|
|
@ -1,171 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
from ntpath import dirname
|
|
||||||
|
|
||||||
from ..plex_db import PlexDB, PLEXDB_LOCK
|
|
||||||
from ..kodi_db import KodiVideoDB, KODIDB_LOCK
|
|
||||||
from .. import db, timing, app
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.itemtypes.common')
|
|
||||||
|
|
||||||
# Note: always use same order of URL arguments, NOT urlencode:
|
|
||||||
# plex_id=<plex_id>&plex_type=<plex_type>&mode=play
|
|
||||||
|
|
||||||
|
|
||||||
def process_path(playurl):
|
|
||||||
"""
|
|
||||||
Do NOT use os.path since we have paths that might not apply to the current
|
|
||||||
OS!
|
|
||||||
"""
|
|
||||||
if '\\' in playurl:
|
|
||||||
# Local path
|
|
||||||
path = '%s\\' % playurl
|
|
||||||
toplevelpath = '%s\\' % dirname(dirname(path))
|
|
||||||
else:
|
|
||||||
# Network path
|
|
||||||
path = '%s/' % playurl
|
|
||||||
toplevelpath = '%s/' % dirname(dirname(path))
|
|
||||||
return path, toplevelpath
|
|
||||||
|
|
||||||
|
|
||||||
class ItemBase(object):
|
|
||||||
"""
|
|
||||||
Items to be called with "with Items() as xxx:" to ensure that __enter__
|
|
||||||
method is called (opens db connections)
|
|
||||||
|
|
||||||
Input:
|
|
||||||
kodiType: optional argument; e.g. 'video' or 'music'
|
|
||||||
"""
|
|
||||||
def __init__(self, last_sync, plexdb=None, kodidb=None, lock=True):
|
|
||||||
self.last_sync = last_sync
|
|
||||||
self.lock = lock
|
|
||||||
self.plexdb = plexdb
|
|
||||||
self.kodidb = kodidb
|
|
||||||
self.plexconn = plexdb.plexconn if plexdb else None
|
|
||||||
self.plexcursor = plexdb.cursor if plexdb else None
|
|
||||||
self.kodiconn = kodidb.kodiconn if kodidb else None
|
|
||||||
self.kodicursor = kodidb.cursor if kodidb else None
|
|
||||||
self.artconn = kodidb.artconn if kodidb else None
|
|
||||||
self.artcursor = kodidb.artcursor if kodidb else None
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
"""
|
|
||||||
Open DB connections and cursors
|
|
||||||
"""
|
|
||||||
if self.lock:
|
|
||||||
PLEXDB_LOCK.acquire()
|
|
||||||
KODIDB_LOCK.acquire()
|
|
||||||
self.plexconn = db.connect('plex')
|
|
||||||
self.plexcursor = self.plexconn.cursor()
|
|
||||||
self.kodiconn = db.connect('video')
|
|
||||||
self.kodicursor = self.kodiconn.cursor()
|
|
||||||
self.artconn = db.connect('texture')
|
|
||||||
self.artcursor = self.artconn.cursor()
|
|
||||||
self.plexdb = PlexDB(plexconn=self.plexconn, lock=False)
|
|
||||||
self.kodidb = KodiVideoDB(texture_db=True,
|
|
||||||
kodiconn=self.kodiconn,
|
|
||||||
artconn=self.artconn,
|
|
||||||
lock=False)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
"""
|
|
||||||
Make sure DB changes are committed and connection to DB is closed.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if exc_type:
|
|
||||||
# re-raise any exception
|
|
||||||
return False
|
|
||||||
self.plexconn.commit()
|
|
||||||
self.kodiconn.commit()
|
|
||||||
if self.artconn:
|
|
||||||
self.artconn.commit()
|
|
||||||
return self
|
|
||||||
finally:
|
|
||||||
self.plexconn.close()
|
|
||||||
self.kodiconn.close()
|
|
||||||
if self.artconn:
|
|
||||||
self.artconn.close()
|
|
||||||
if self.lock:
|
|
||||||
PLEXDB_LOCK.release()
|
|
||||||
KODIDB_LOCK.release()
|
|
||||||
|
|
||||||
def commit(self):
|
|
||||||
self.plexconn.commit()
|
|
||||||
self.plexconn.execute('BEGIN')
|
|
||||||
self.kodiconn.commit()
|
|
||||||
self.kodiconn.execute('BEGIN')
|
|
||||||
if self.artconn:
|
|
||||||
self.artconn.commit()
|
|
||||||
self.artconn.execute('BEGIN')
|
|
||||||
|
|
||||||
def set_fanart(self, artworks, kodi_id, kodi_type):
|
|
||||||
"""
|
|
||||||
Writes artworks [dict containing only set artworks] to the Kodi art DB
|
|
||||||
"""
|
|
||||||
self.kodidb.modify_artwork(artworks,
|
|
||||||
kodi_id,
|
|
||||||
kodi_type)
|
|
||||||
|
|
||||||
def update_playstate(self, mark_played, view_count, resume, duration,
|
|
||||||
kodi_fileid, kodi_fileid_2, lastViewedAt):
|
|
||||||
"""
|
|
||||||
Use with websockets, not xml
|
|
||||||
"""
|
|
||||||
# If the playback was stopped, check whether we need to increment the
|
|
||||||
# playcount. PMS won't tell us the playcount via websockets
|
|
||||||
if mark_played:
|
|
||||||
LOG.info('Marking item as completely watched in Kodi')
|
|
||||||
try:
|
|
||||||
view_count += 1
|
|
||||||
except TypeError:
|
|
||||||
view_count = 1
|
|
||||||
resume = 0
|
|
||||||
# Do the actual update
|
|
||||||
self.kodidb.set_resume(kodi_fileid,
|
|
||||||
resume,
|
|
||||||
duration,
|
|
||||||
view_count,
|
|
||||||
timing.plex_date_to_kodi(lastViewedAt))
|
|
||||||
if kodi_fileid_2:
|
|
||||||
# Our dirty hack for episodes
|
|
||||||
self.kodidb.set_resume(kodi_fileid_2,
|
|
||||||
resume,
|
|
||||||
duration,
|
|
||||||
view_count,
|
|
||||||
timing.plex_date_to_kodi(lastViewedAt))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def sync_this_item(section_id):
|
|
||||||
"""
|
|
||||||
Returns False if we are NOT synching the corresponding Plex library
|
|
||||||
with section_id [int] to Kodi or if this sections has not yet been
|
|
||||||
encountered by PKC
|
|
||||||
"""
|
|
||||||
return section_id in app.SYNC.section_ids
|
|
||||||
|
|
||||||
def update_provider_ids(self, api, kodi_id):
|
|
||||||
"""
|
|
||||||
Updates the unique metadata provider ids (such as the IMDB id). Returns
|
|
||||||
a dict of the Kodi unique ids
|
|
||||||
"""
|
|
||||||
# We might have an old provider id stored!
|
|
||||||
self.kodidb.remove_uniqueid(kodi_id, api.kodi_type)
|
|
||||||
return self.add_provider_ids(api, kodi_id)
|
|
||||||
|
|
||||||
def add_provider_ids(self, api, kodi_id):
|
|
||||||
"""
|
|
||||||
Adds the unique ids for all metadata providers to the Kodi database,
|
|
||||||
such as IMDB or The Movie Database TMDB.
|
|
||||||
Returns a dict of the Kodi ids: {<provider>: <kodi_unique_id>}
|
|
||||||
"""
|
|
||||||
kodi_unique_ids = api.guids.copy()
|
|
||||||
for provider, provider_id in api.guids.iteritems():
|
|
||||||
kodi_unique_ids[provider] = self.kodidb.add_uniqueid(
|
|
||||||
kodi_id,
|
|
||||||
api.kodi_type,
|
|
||||||
provider_id,
|
|
||||||
provider)
|
|
||||||
return kodi_unique_ids
|
|
|
@ -1,245 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from .common import ItemBase
|
|
||||||
from ..plex_api import API
|
|
||||||
from .. import app, variables as v, plex_functions as PF
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.movies')
|
|
||||||
|
|
||||||
|
|
||||||
class Movie(ItemBase):
|
|
||||||
"""
|
|
||||||
Used for plex library-type movies
|
|
||||||
"""
|
|
||||||
def add_update(self, xml, section_name=None, section_id=None,
|
|
||||||
children=None):
|
|
||||||
"""
|
|
||||||
Process single movie
|
|
||||||
"""
|
|
||||||
api = API(xml)
|
|
||||||
if not self.sync_this_item(section_id or api.library_section_id()):
|
|
||||||
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
|
|
||||||
'Kodi', api.plex_type, api.plex_id, api.title(),
|
|
||||||
section_id or api.library_section_id())
|
|
||||||
return
|
|
||||||
plex_id = api.plex_id
|
|
||||||
movie = self.plexdb.movie(plex_id)
|
|
||||||
if movie:
|
|
||||||
update_item = True
|
|
||||||
kodi_id = movie['kodi_id']
|
|
||||||
old_kodi_fileid = movie['kodi_fileid']
|
|
||||||
kodi_pathid = movie['kodi_pathid']
|
|
||||||
else:
|
|
||||||
update_item = False
|
|
||||||
kodi_id = self.kodidb.new_movie_id()
|
|
||||||
|
|
||||||
fullpath, path, filename = api.fullpath()
|
|
||||||
if app.SYNC.direct_paths and not fullpath.startswith('http'):
|
|
||||||
kodi_pathid = self.kodidb.add_path(path,
|
|
||||||
content='movies',
|
|
||||||
scraper='metadata.local')
|
|
||||||
else:
|
|
||||||
kodi_pathid = self.kodidb.get_path(path)
|
|
||||||
|
|
||||||
if update_item:
|
|
||||||
LOG.info('UPDATE movie plex_id: %s - %s', plex_id, api.title())
|
|
||||||
file_id = self.kodidb.modify_file(filename,
|
|
||||||
kodi_pathid,
|
|
||||||
api.date_created())
|
|
||||||
if file_id != old_kodi_fileid:
|
|
||||||
self.kodidb.remove_file(old_kodi_fileid)
|
|
||||||
rating_id = self.kodidb.update_ratings(kodi_id,
|
|
||||||
v.KODI_TYPE_MOVIE,
|
|
||||||
"default",
|
|
||||||
api.rating(),
|
|
||||||
api.votecount())
|
|
||||||
unique_id = self.update_provider_ids(api, kodi_id)
|
|
||||||
self.kodidb.modify_people(kodi_id,
|
|
||||||
v.KODI_TYPE_MOVIE,
|
|
||||||
api.people())
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.kodidb.modify_artwork(api.artwork(),
|
|
||||||
kodi_id,
|
|
||||||
v.KODI_TYPE_MOVIE)
|
|
||||||
else:
|
|
||||||
LOG.info("ADD movie plex_id: %s - %s", plex_id, api.title())
|
|
||||||
file_id = self.kodidb.add_file(filename,
|
|
||||||
kodi_pathid,
|
|
||||||
api.date_created())
|
|
||||||
rating_id = self.kodidb.add_ratings(kodi_id,
|
|
||||||
v.KODI_TYPE_MOVIE,
|
|
||||||
"default",
|
|
||||||
api.rating(),
|
|
||||||
api.votecount())
|
|
||||||
unique_id = self.add_provider_ids(api, kodi_id)
|
|
||||||
self.kodidb.add_people(kodi_id,
|
|
||||||
v.KODI_TYPE_MOVIE,
|
|
||||||
api.people())
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.kodidb.add_artwork(api.artwork(),
|
|
||||||
kodi_id,
|
|
||||||
v.KODI_TYPE_MOVIE)
|
|
||||||
|
|
||||||
unique_id = self._prioritize_provider_id(unique_id)
|
|
||||||
|
|
||||||
# Update Kodi's main entry
|
|
||||||
self.kodidb.add_movie(kodi_id,
|
|
||||||
file_id,
|
|
||||||
api.title(),
|
|
||||||
api.plot(),
|
|
||||||
api.shortplot(),
|
|
||||||
api.tagline(),
|
|
||||||
api.votecount(),
|
|
||||||
rating_id,
|
|
||||||
api.list_to_string(api.writers()),
|
|
||||||
api.year(),
|
|
||||||
unique_id,
|
|
||||||
api.sorttitle(),
|
|
||||||
api.runtime(),
|
|
||||||
api.content_rating(),
|
|
||||||
api.list_to_string(api.genres()),
|
|
||||||
api.list_to_string(api.directors()),
|
|
||||||
api.title(),
|
|
||||||
api.list_to_string(api.studios()),
|
|
||||||
api.trailer(),
|
|
||||||
api.list_to_string(api.countries()),
|
|
||||||
fullpath,
|
|
||||||
kodi_pathid,
|
|
||||||
api.premiere_date(),
|
|
||||||
api.userrating())
|
|
||||||
|
|
||||||
self.kodidb.modify_countries(kodi_id,
|
|
||||||
v.KODI_TYPE_MOVIE,
|
|
||||||
api.countries())
|
|
||||||
self.kodidb.modify_genres(kodi_id, v.KODI_TYPE_MOVIE, api.genres())
|
|
||||||
|
|
||||||
self.kodidb.modify_streams(file_id, api.mediastreams(), api.runtime())
|
|
||||||
self.kodidb.modify_studios(kodi_id, v.KODI_TYPE_MOVIE, api.studios())
|
|
||||||
tags = [section_name]
|
|
||||||
self._process_collections(api, tags, kodi_id, section_id, children)
|
|
||||||
self.kodidb.modify_tags(kodi_id, v.KODI_TYPE_MOVIE, tags)
|
|
||||||
# Process playstate
|
|
||||||
self.kodidb.set_resume(file_id,
|
|
||||||
api.resume_point(),
|
|
||||||
api.runtime(),
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed())
|
|
||||||
self.plexdb.add_movie(plex_id=plex_id,
|
|
||||||
checksum=api.checksum(),
|
|
||||||
section_id=section_id,
|
|
||||||
kodi_id=kodi_id,
|
|
||||||
kodi_fileid=file_id,
|
|
||||||
kodi_pathid=kodi_pathid,
|
|
||||||
last_sync=self.last_sync)
|
|
||||||
|
|
||||||
def remove(self, plex_id, plex_type=None):
|
|
||||||
"""
|
|
||||||
Remove a movie with all references and all orphaned associated entries
|
|
||||||
from the Kodi DB
|
|
||||||
"""
|
|
||||||
movie = self.plexdb.movie(plex_id)
|
|
||||||
try:
|
|
||||||
kodi_id = movie['kodi_id']
|
|
||||||
file_id = movie['kodi_fileid']
|
|
||||||
kodi_type = v.KODI_TYPE_MOVIE
|
|
||||||
LOG.debug('Removing movie with plex_id %s, kodi_id: %s',
|
|
||||||
plex_id, kodi_id)
|
|
||||||
except TypeError:
|
|
||||||
LOG.error('Movie with plex_id %s not found - cannot delete',
|
|
||||||
plex_id)
|
|
||||||
return
|
|
||||||
# Remove the plex reference
|
|
||||||
self.plexdb.remove(plex_id, v.PLEX_TYPE_MOVIE)
|
|
||||||
# Remove artwork
|
|
||||||
self.kodidb.delete_artwork(kodi_id, kodi_type)
|
|
||||||
set_id = self.kodidb.get_set_id(kodi_id)
|
|
||||||
self.kodidb.modify_countries(kodi_id, kodi_type)
|
|
||||||
self.kodidb.modify_people(kodi_id, kodi_type)
|
|
||||||
self.kodidb.modify_genres(kodi_id, kodi_type)
|
|
||||||
self.kodidb.modify_studios(kodi_id, kodi_type)
|
|
||||||
self.kodidb.modify_tags(kodi_id, kodi_type)
|
|
||||||
# Delete kodi movie and file
|
|
||||||
self.kodidb.remove_file(file_id)
|
|
||||||
self.kodidb.remove_movie(kodi_id)
|
|
||||||
if set_id:
|
|
||||||
self.kodidb.delete_possibly_empty_set(set_id)
|
|
||||||
self.kodidb.remove_uniqueid(kodi_id, kodi_type)
|
|
||||||
self.kodidb.remove_ratings(kodi_id, kodi_type)
|
|
||||||
LOG.debug('Deleted movie %s from kodi database', plex_id)
|
|
||||||
|
|
||||||
def update_userdata(self, xml_element, plex_type):
|
|
||||||
"""
|
|
||||||
Updates the Kodi watched state of the item from PMS. Also retrieves
|
|
||||||
Plex resume points for movies in progress.
|
|
||||||
|
|
||||||
Returns True if successful, False otherwise (e.g. item missing)
|
|
||||||
"""
|
|
||||||
api = API(xml_element)
|
|
||||||
# Get key and db entry on the Kodi db side
|
|
||||||
db_item = self.plexdb.item_by_id(api.plex_id, plex_type)
|
|
||||||
if not db_item:
|
|
||||||
LOG.info('Item not yet synced: %s', xml_element.attrib)
|
|
||||||
return False
|
|
||||||
# Write to Kodi DB
|
|
||||||
self.kodidb.set_resume(db_item['kodi_fileid'],
|
|
||||||
api.resume_point(),
|
|
||||||
api.runtime(),
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed())
|
|
||||||
self.kodidb.update_userrating(db_item['kodi_id'],
|
|
||||||
db_item['kodi_type'],
|
|
||||||
api.userrating())
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _process_collections(self, api, tags, kodi_id, section_id, children):
|
|
||||||
for _, set_name in api.collections():
|
|
||||||
tags.append(set_name)
|
|
||||||
for plex_set_id, set_name in api.collections():
|
|
||||||
set_api = None
|
|
||||||
# Add any sets from Plex collection tags
|
|
||||||
kodi_set_id = self.kodidb.create_collection(set_name)
|
|
||||||
self.kodidb.assign_collection(kodi_set_id, kodi_id)
|
|
||||||
if not app.SYNC.artwork:
|
|
||||||
# Rest below is to get collection artwork
|
|
||||||
# TODO: continue instead of break (see TODO/break below)
|
|
||||||
break
|
|
||||||
if children is None:
|
|
||||||
# e.g. when added via websocket
|
|
||||||
LOG.debug('Costly looking up Plex collection %s: %s',
|
|
||||||
plex_set_id, set_name)
|
|
||||||
for index, coll_plex_id in api.collections_match(section_id):
|
|
||||||
# Get Plex artwork for collections - a pain
|
|
||||||
if index == plex_set_id:
|
|
||||||
set_xml = PF.GetPlexMetadata(coll_plex_id)
|
|
||||||
try:
|
|
||||||
set_xml.attrib
|
|
||||||
except AttributeError:
|
|
||||||
LOG.error('Could not get set metadata %s',
|
|
||||||
coll_plex_id)
|
|
||||||
continue
|
|
||||||
set_api = API(set_xml[0])
|
|
||||||
break
|
|
||||||
elif plex_set_id in children:
|
|
||||||
# Provided by get_metadata thread
|
|
||||||
set_api = API(children[plex_set_id][0])
|
|
||||||
if set_api:
|
|
||||||
self.kodidb.modify_artwork(set_api.artwork(),
|
|
||||||
kodi_set_id,
|
|
||||||
v.KODI_TYPE_SET)
|
|
||||||
# TODO: Once Kodi (19?) supports SEVERAL sets/collections per
|
|
||||||
# movie, support that. For now, we only take the very first
|
|
||||||
# collection/set that Plex returns
|
|
||||||
break
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _prioritize_provider_id(unique_ids):
|
|
||||||
"""
|
|
||||||
Prioritize which ID ends up in the SHOW table (there can only be 1)
|
|
||||||
tvdb > imdb > tmdb
|
|
||||||
"""
|
|
||||||
return unique_ids.get('imdb',
|
|
||||||
unique_ids.get('tmdb',
|
|
||||||
unique_ids.get('tvdb')))
|
|
|
@ -1,635 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from .common import ItemBase
|
|
||||||
from ..plex_api import API
|
|
||||||
from ..plex_db import PlexDB, PLEXDB_LOCK
|
|
||||||
from ..kodi_db import KodiMusicDB, KODIDB_LOCK
|
|
||||||
from .. import plex_functions as PF, db, timing, app, variables as v
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.music')
|
|
||||||
|
|
||||||
|
|
||||||
class MusicMixin(object):
|
|
||||||
def __enter__(self):
|
|
||||||
"""
|
|
||||||
Overwrite to use the Kodi music DB instead of the video DB
|
|
||||||
"""
|
|
||||||
if self.lock:
|
|
||||||
PLEXDB_LOCK.acquire()
|
|
||||||
KODIDB_LOCK.acquire()
|
|
||||||
self.plexconn = db.connect('plex')
|
|
||||||
self.plexcursor = self.plexconn.cursor()
|
|
||||||
self.kodiconn = db.connect('music')
|
|
||||||
self.kodicursor = self.kodiconn.cursor()
|
|
||||||
self.artconn = db.connect('texture')
|
|
||||||
self.artcursor = self.artconn.cursor()
|
|
||||||
self.plexdb = PlexDB(plexconn=self.plexconn, lock=False)
|
|
||||||
self.kodidb = KodiMusicDB(texture_db=True,
|
|
||||||
kodiconn=self.kodiconn,
|
|
||||||
artconn=self.artconn,
|
|
||||||
lock=False)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def update_userdata(self, xml_element, plex_type):
|
|
||||||
"""
|
|
||||||
Updates the Kodi watched state of the item from PMS. Also retrieves
|
|
||||||
Plex resume points for movies in progress.
|
|
||||||
|
|
||||||
Returns True if successful, False otherwise (e.g. item missing)
|
|
||||||
"""
|
|
||||||
api = API(xml_element)
|
|
||||||
# Get key and db entry on the Kodi db side
|
|
||||||
db_item = self.plexdb.item_by_id(api.plex_id, plex_type)
|
|
||||||
if not db_item:
|
|
||||||
LOG.info('Item not yet synced: %s', xml_element.attrib)
|
|
||||||
return False
|
|
||||||
# Grab the user's viewcount, resume points etc. from PMS' answer
|
|
||||||
self.kodidb.update_userrating(db_item['kodi_id'],
|
|
||||||
db_item['kodi_type'],
|
|
||||||
api.userrating())
|
|
||||||
if plex_type == v.PLEX_TYPE_SONG:
|
|
||||||
self.kodidb.set_playcount(api.viewcount(),
|
|
||||||
api.lastplayed(),
|
|
||||||
db_item['kodi_id'],)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def remove(self, plex_id, plex_type=None):
|
|
||||||
"""
|
|
||||||
Remove the entire music object, including all associated entries from
|
|
||||||
both Plex and Kodi DBs
|
|
||||||
"""
|
|
||||||
db_item = self.plexdb.item_by_id(plex_id, plex_type)
|
|
||||||
if not db_item:
|
|
||||||
LOG.debug('Cannot delete plex_id %s - not found in DB', plex_id)
|
|
||||||
return
|
|
||||||
LOG.debug('Removing %s %s with kodi_id: %s',
|
|
||||||
db_item['plex_type'], plex_id, db_item['kodi_id'])
|
|
||||||
|
|
||||||
# Remove the plex reference
|
|
||||||
self.plexdb.remove(plex_id, db_item['plex_type'])
|
|
||||||
|
|
||||||
# SONG #####
|
|
||||||
if db_item['plex_type'] == v.PLEX_TYPE_SONG:
|
|
||||||
# Delete episode, verify season and tvshow
|
|
||||||
self.remove_song(db_item['kodi_id'], db_item['kodi_pathid'])
|
|
||||||
# Album verification
|
|
||||||
if not self.plexdb.album_has_songs(db_item['album_id']):
|
|
||||||
# No songleft for this album - so delete the album
|
|
||||||
self.remove_album(db_item['parent_id'])
|
|
||||||
self.plexdb.remove(db_item['album_id'], v.PLEX_TYPE_ALBUM)
|
|
||||||
# Artist verification
|
|
||||||
if (not self.plexdb.artist_has_albums(db_item['artist_id']) and
|
|
||||||
not self.plexdb.artist_has_songs(db_item['artist_id'])):
|
|
||||||
self.remove_artist(db_item['grandparent_id'])
|
|
||||||
self.plexdb.remove(db_item['artist_id'], v.PLEX_TYPE_ARTIST)
|
|
||||||
# ALBUM #####
|
|
||||||
elif db_item['plex_type'] == v.PLEX_TYPE_ALBUM:
|
|
||||||
# Remove songs, album, verify artist
|
|
||||||
songs = list(self.plexdb.song_by_album(db_item['plex_id']))
|
|
||||||
for song in songs:
|
|
||||||
self.remove_song(song['kodi_id'], song['kodi_pathid'])
|
|
||||||
self.plexdb.remove(song['plex_id'], v.PLEX_TYPE_SONG)
|
|
||||||
# Remove the album
|
|
||||||
self.remove_album(db_item['kodi_id'])
|
|
||||||
# Show verification
|
|
||||||
if (not self.plexdb.artist_has_albums(db_item['kodi_id']) and
|
|
||||||
not self.plexdb.artist_has_songs(db_item['kodi_id'])):
|
|
||||||
# There's no other album or song left, delete the artist
|
|
||||||
self.remove_artist(db_item['parent_id'])
|
|
||||||
self.plexdb.remove(db_item['artist_id'], v.KODI_TYPE_ARTIST)
|
|
||||||
# ARTIST #####
|
|
||||||
elif db_item['plex_type'] == v.PLEX_TYPE_ARTIST:
|
|
||||||
# Remove songs, albums and the artist himself
|
|
||||||
songs = list(self.plexdb.song_by_artist(db_item['plex_id']))
|
|
||||||
for song in songs:
|
|
||||||
self.remove_song(song['kodi_id'], song['kodi_pathid'])
|
|
||||||
self.plexdb.remove(song['plex_id'], v.PLEX_TYPE_SONG)
|
|
||||||
albums = list(self.plexdb.album_by_artist(db_item['plex_id']))
|
|
||||||
for album in albums:
|
|
||||||
self.remove_album(album['kodi_id'])
|
|
||||||
self.plexdb.remove(album['plex_id'], v.PLEX_TYPE_ALBUM)
|
|
||||||
self.remove_artist(db_item['kodi_id'])
|
|
||||||
|
|
||||||
LOG.debug('Deleted %s %s from all databases',
|
|
||||||
db_item['plex_type'], db_item['plex_id'])
|
|
||||||
|
|
||||||
def remove_song(self, kodi_id, path_id=None):
|
|
||||||
"""
|
|
||||||
Remove song, orphaned artists and orphaned paths
|
|
||||||
"""
|
|
||||||
if not path_id:
|
|
||||||
path_id = self.kodidb.path_id_from_song(kodi_id)
|
|
||||||
self.kodidb.delete_song_from_song_artist(kodi_id)
|
|
||||||
self.kodidb.remove_song(kodi_id)
|
|
||||||
# Check whether we have orphaned path entries
|
|
||||||
if not self.kodidb.path_id_from_song(kodi_id):
|
|
||||||
self.kodidb.remove_path(path_id)
|
|
||||||
if v.KODIVERSION < 18:
|
|
||||||
self.kodidb.remove_albuminfosong(kodi_id)
|
|
||||||
self.kodidb.delete_artwork(kodi_id, v.KODI_TYPE_SONG)
|
|
||||||
|
|
||||||
def remove_album(self, kodi_id):
|
|
||||||
'''
|
|
||||||
Remove an album
|
|
||||||
'''
|
|
||||||
if v.KODIVERSION < 18:
|
|
||||||
self.kodidb.delete_album_from_album_genre(kodi_id)
|
|
||||||
self.kodidb.remove_album(kodi_id)
|
|
||||||
self.kodidb.delete_artwork(kodi_id, v.KODI_TYPE_ALBUM)
|
|
||||||
|
|
||||||
def remove_artist(self, kodi_id):
|
|
||||||
'''
|
|
||||||
Remove an artist and associated songs and albums
|
|
||||||
'''
|
|
||||||
self.kodidb.remove_artist(kodi_id)
|
|
||||||
self.kodidb.delete_artwork(kodi_id, v.KODI_TYPE_ARTIST)
|
|
||||||
|
|
||||||
|
|
||||||
class Artist(MusicMixin, ItemBase):
|
|
||||||
"""
|
|
||||||
For Plex library-type artists
|
|
||||||
"""
|
|
||||||
def add_update(self, xml, section_name=None, section_id=None,
|
|
||||||
children=None):
|
|
||||||
"""
|
|
||||||
Process a single artist
|
|
||||||
"""
|
|
||||||
api = API(xml)
|
|
||||||
if not self.sync_this_item(section_id or api.library_section_id()):
|
|
||||||
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
|
|
||||||
'Kodi', api.plex_type, api.plex_id, api.title(),
|
|
||||||
section_id or api.library_section_id())
|
|
||||||
return
|
|
||||||
plex_id = api.plex_id
|
|
||||||
artist = self.plexdb.artist(plex_id)
|
|
||||||
if not artist:
|
|
||||||
update_item = False
|
|
||||||
else:
|
|
||||||
update_item = True
|
|
||||||
kodi_id = artist['kodi_id']
|
|
||||||
|
|
||||||
# Not yet implemented by Plex
|
|
||||||
musicBrainzId = None
|
|
||||||
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
artworks = api.artwork()
|
|
||||||
if 'poster' in artworks:
|
|
||||||
thumb = "<thumb>%s</thumb>" % artworks['poster']
|
|
||||||
else:
|
|
||||||
thumb = None
|
|
||||||
if 'fanart' in artworks:
|
|
||||||
fanart = "<fanart>%s</fanart>" % artworks['fanart']
|
|
||||||
else:
|
|
||||||
fanart = None
|
|
||||||
else:
|
|
||||||
thumb, fanart = None, None
|
|
||||||
|
|
||||||
# UPDATE THE ARTIST #####
|
|
||||||
if update_item:
|
|
||||||
LOG.info("UPDATE artist plex_id: %s - Name: %s", plex_id, api.title())
|
|
||||||
# OR ADD THE ARTIST #####
|
|
||||||
else:
|
|
||||||
LOG.info("ADD artist plex_id: %s - Name: %s", plex_id, api.title())
|
|
||||||
# safety checks: It looks like plex supports the same artist
|
|
||||||
# multiple times.
|
|
||||||
# Kodi doesn't allow that. In case that happens we just merge the
|
|
||||||
# artist entries.
|
|
||||||
kodi_id = self.kodidb.add_artist(api.title(), musicBrainzId)
|
|
||||||
self.kodidb.update_artist(api.list_to_string(api.genres()),
|
|
||||||
api.plot(),
|
|
||||||
thumb,
|
|
||||||
fanart,
|
|
||||||
timing.unix_date_to_kodi(self.last_sync),
|
|
||||||
kodi_id)
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.kodidb.modify_artwork(artworks,
|
|
||||||
kodi_id,
|
|
||||||
v.KODI_TYPE_ARTIST)
|
|
||||||
self.plexdb.add_artist(plex_id,
|
|
||||||
api.checksum(),
|
|
||||||
section_id,
|
|
||||||
kodi_id,
|
|
||||||
self.last_sync)
|
|
||||||
|
|
||||||
|
|
||||||
class Album(MusicMixin, ItemBase):
|
|
||||||
def add_update(self, xml, section_name=None, section_id=None,
|
|
||||||
children=None, scan_children=True):
|
|
||||||
"""
|
|
||||||
Process a single album
|
|
||||||
scan_children: set to False if you don't want to add children, e.g. to
|
|
||||||
avoid infinite loops
|
|
||||||
"""
|
|
||||||
api = API(xml)
|
|
||||||
if not self.sync_this_item(section_id or api.library_section_id()):
|
|
||||||
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
|
|
||||||
'Kodi', api.plex_type, api.plex_id, api.title(),
|
|
||||||
section_id or api.library_section_id())
|
|
||||||
return
|
|
||||||
plex_id = api.plex_id
|
|
||||||
album = self.plexdb.album(plex_id)
|
|
||||||
if album:
|
|
||||||
update_item = True
|
|
||||||
kodi_id = album['kodi_id']
|
|
||||||
else:
|
|
||||||
update_item = False
|
|
||||||
|
|
||||||
# Parent artist - should always be present
|
|
||||||
parent_id = api.parent_id()
|
|
||||||
artist = self.plexdb.artist(parent_id)
|
|
||||||
if not artist:
|
|
||||||
LOG.info('Artist %s does not yet exist in DB', parent_id)
|
|
||||||
artist_xml = PF.GetPlexMetadata(parent_id)
|
|
||||||
try:
|
|
||||||
artist_xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error('Could not get artist %s xml for %s',
|
|
||||||
parent_id, xml.attrib)
|
|
||||||
return
|
|
||||||
Artist(self.last_sync,
|
|
||||||
plexdb=self.plexdb,
|
|
||||||
kodidb=self.kodidb).add_update(artist_xml[0],
|
|
||||||
section_name,
|
|
||||||
section_id)
|
|
||||||
artist = self.plexdb.artist(parent_id)
|
|
||||||
if not artist:
|
|
||||||
LOG.error('Adding artist %s failed for %s',
|
|
||||||
parent_id, xml.attrib)
|
|
||||||
return
|
|
||||||
artist_id = artist['kodi_id']
|
|
||||||
# See if we have a compilation - Plex does NOT feature a compilation
|
|
||||||
# flag for albums
|
|
||||||
compilation = 0
|
|
||||||
if children is None:
|
|
||||||
LOG.info('No children songs passed, getting them')
|
|
||||||
children = PF.GetAllPlexChildren(plex_id)
|
|
||||||
try:
|
|
||||||
children[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error('Could not get children for Plex id %s', plex_id)
|
|
||||||
return
|
|
||||||
for song in children:
|
|
||||||
if song.get('originalTitle') is not None:
|
|
||||||
compilation = 1
|
|
||||||
break
|
|
||||||
name = api.title()
|
|
||||||
# Not yet implemented by Plex, let's use unique last.fm or gracenote
|
|
||||||
musicBrainzId = None
|
|
||||||
genre = api.list_to_string(api.genres())
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
artworks = api.artwork()
|
|
||||||
if 'poster' in artworks:
|
|
||||||
thumb = "<thumb>%s</thumb>" % artworks['poster']
|
|
||||||
else:
|
|
||||||
thumb = None
|
|
||||||
else:
|
|
||||||
thumb = None
|
|
||||||
|
|
||||||
# UPDATE THE ALBUM #####
|
|
||||||
if update_item:
|
|
||||||
LOG.info("UPDATE album plex_id: %s - Name: %s", plex_id, name)
|
|
||||||
if v.KODIVERSION >= 18:
|
|
||||||
self.kodidb.update_album(name,
|
|
||||||
musicBrainzId,
|
|
||||||
api.artist_name(),
|
|
||||||
genre,
|
|
||||||
api.year(),
|
|
||||||
compilation,
|
|
||||||
api.plot(),
|
|
||||||
thumb,
|
|
||||||
api.list_to_string(api.studios()),
|
|
||||||
api.userrating(),
|
|
||||||
timing.unix_date_to_kodi(self.last_sync),
|
|
||||||
'album',
|
|
||||||
kodi_id)
|
|
||||||
else:
|
|
||||||
self.kodidb.update_album_17(name,
|
|
||||||
musicBrainzId,
|
|
||||||
api.artist_name(),
|
|
||||||
genre,
|
|
||||||
api.year(),
|
|
||||||
compilation,
|
|
||||||
api.plot(),
|
|
||||||
thumb,
|
|
||||||
api.list_to_string(api.studios()),
|
|
||||||
api.userrating(),
|
|
||||||
timing.unix_date_to_kodi(self.last_sync),
|
|
||||||
'album',
|
|
||||||
kodi_id)
|
|
||||||
# OR ADD THE ALBUM #####
|
|
||||||
else:
|
|
||||||
LOG.info("ADD album plex_id: %s - Name: %s", plex_id, name)
|
|
||||||
kodi_id = self.kodidb.new_album_id()
|
|
||||||
if v.KODIVERSION >= 18:
|
|
||||||
self.kodidb.add_album(kodi_id,
|
|
||||||
name,
|
|
||||||
musicBrainzId,
|
|
||||||
api.artist_name(),
|
|
||||||
genre,
|
|
||||||
api.year(),
|
|
||||||
compilation,
|
|
||||||
api.plot(),
|
|
||||||
thumb,
|
|
||||||
api.list_to_string(api.studios()),
|
|
||||||
api.userrating(),
|
|
||||||
timing.unix_date_to_kodi(self.last_sync),
|
|
||||||
'album')
|
|
||||||
else:
|
|
||||||
self.kodidb.add_album_17(kodi_id,
|
|
||||||
name,
|
|
||||||
musicBrainzId,
|
|
||||||
api.artist_name(),
|
|
||||||
genre,
|
|
||||||
api.year(),
|
|
||||||
compilation,
|
|
||||||
api.plot(),
|
|
||||||
thumb,
|
|
||||||
api.list_to_string(api.studios()),
|
|
||||||
api.userrating(),
|
|
||||||
timing.unix_date_to_kodi(self.last_sync),
|
|
||||||
'album')
|
|
||||||
self.kodidb.add_albumartist(artist_id, kodi_id, api.artist_name())
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.kodidb.modify_artwork(artworks,
|
|
||||||
kodi_id,
|
|
||||||
v.KODI_TYPE_ALBUM)
|
|
||||||
self.plexdb.add_album(plex_id,
|
|
||||||
api.checksum(),
|
|
||||||
section_id,
|
|
||||||
artist_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_id,
|
|
||||||
self.last_sync)
|
|
||||||
# Add all children - all tracks
|
|
||||||
if scan_children:
|
|
||||||
context = Song(self.last_sync,
|
|
||||||
plexdb=self.plexdb,
|
|
||||||
kodidb=self.kodidb)
|
|
||||||
for song in children:
|
|
||||||
context.add_update(song,
|
|
||||||
section_name=section_name,
|
|
||||||
section_id=section_id,
|
|
||||||
album_xml=xml,
|
|
||||||
genres=api.genres(),
|
|
||||||
genre=genre,
|
|
||||||
compilation=compilation)
|
|
||||||
|
|
||||||
|
|
||||||
class Song(MusicMixin, ItemBase):
|
|
||||||
def add_update(self, xml, section_name=None, section_id=None,
|
|
||||||
children=None, album_xml=None, genres=None, genre=None,
|
|
||||||
compilation=None):
|
|
||||||
"""
|
|
||||||
Process single song/track
|
|
||||||
"""
|
|
||||||
api = API(xml)
|
|
||||||
if not self.sync_this_item(section_id or api.library_section_id()):
|
|
||||||
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
|
|
||||||
'Kodi', api.plex_type, api.plex_id, api.title(),
|
|
||||||
section_id or api.library_section_id())
|
|
||||||
return
|
|
||||||
plex_id = api.plex_id
|
|
||||||
song = self.plexdb.song(plex_id)
|
|
||||||
if song:
|
|
||||||
update_item = True
|
|
||||||
kodi_id = song['kodi_id']
|
|
||||||
kodi_pathid = song['kodi_pathid']
|
|
||||||
else:
|
|
||||||
update_item = False
|
|
||||||
kodi_id = self.kodidb.add_song_id()
|
|
||||||
artist_id = api.grandparent_id()
|
|
||||||
album_id = api.parent_id()
|
|
||||||
|
|
||||||
# The grandparent Artist - should always be present for every song!
|
|
||||||
artist = self.plexdb.artist(artist_id)
|
|
||||||
if not artist:
|
|
||||||
LOG.warn('Grandparent artist %s not found in DB, adding it',
|
|
||||||
artist_id)
|
|
||||||
artist_xml = PF.GetPlexMetadata(artist_id)
|
|
||||||
try:
|
|
||||||
artist_xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error('Grandparent tvartist %s xml download failed for %s',
|
|
||||||
artist_id, xml.attrib)
|
|
||||||
return
|
|
||||||
Artist(self.last_sync,
|
|
||||||
plexdb=self.plexdb,
|
|
||||||
kodidb=self.kodidb).add_update(artist_xml[0],
|
|
||||||
section_name,
|
|
||||||
section_id)
|
|
||||||
artist = self.plexdb.artist(artist_id)
|
|
||||||
if not artist:
|
|
||||||
LOG.error('Still could not find grandparent artist %s for %s',
|
|
||||||
artist_id, xml.attrib)
|
|
||||||
return
|
|
||||||
grandparent_id = artist['kodi_id']
|
|
||||||
|
|
||||||
# The parent Album
|
|
||||||
if not album_id:
|
|
||||||
# No album found, create a single's album
|
|
||||||
LOG.info('Creating singles album')
|
|
||||||
parent_id = self.kodidb.new_album_id()
|
|
||||||
if v.KODIVERSION >= 18:
|
|
||||||
self.kodidb.add_album(kodi_id,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
genre,
|
|
||||||
api.year(),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
timing.unix_date_to_kodi(self.last_sync),
|
|
||||||
'single')
|
|
||||||
else:
|
|
||||||
self.kodidb.add_album_17(kodi_id,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
genre,
|
|
||||||
api.year(),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
timing.unix_date_to_kodi(self.last_sync),
|
|
||||||
'single')
|
|
||||||
else:
|
|
||||||
album = self.plexdb.album(album_id)
|
|
||||||
if not album:
|
|
||||||
LOG.warn('Parent album %s not found in DB, adding it', album_id)
|
|
||||||
album_xml = PF.GetPlexMetadata(album_id)
|
|
||||||
try:
|
|
||||||
album_xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error('Parent album %s xml download failed for %s',
|
|
||||||
album_id, xml.attrib)
|
|
||||||
return
|
|
||||||
Album(self.last_sync,
|
|
||||||
plexdb=self.plexdb,
|
|
||||||
kodidb=self.kodidb).add_update(album_xml[0],
|
|
||||||
section_name,
|
|
||||||
section_id,
|
|
||||||
children=[xml],
|
|
||||||
scan_children=False)
|
|
||||||
album = self.plexdb.album(album_id)
|
|
||||||
if not album:
|
|
||||||
LOG.error('Still could not find parent album %s for %s',
|
|
||||||
album_id, xml.attrib)
|
|
||||||
return
|
|
||||||
parent_id = album['kodi_id']
|
|
||||||
|
|
||||||
title = api.title()
|
|
||||||
# Not yet implemented by Plex
|
|
||||||
musicBrainzId = None
|
|
||||||
comment = None
|
|
||||||
# Getting artists name is complicated
|
|
||||||
if compilation is not None:
|
|
||||||
if compilation == 0:
|
|
||||||
artists = api.grandparent_title()
|
|
||||||
else:
|
|
||||||
artists = xml.get('originalTitle')
|
|
||||||
else:
|
|
||||||
# compilation not set
|
|
||||||
artists = xml.get('originalTitle', api.grandparent_title())
|
|
||||||
tracknumber = api.index() or 0
|
|
||||||
disc = api.disc_number() or 1
|
|
||||||
if disc == 1:
|
|
||||||
track = tracknumber
|
|
||||||
else:
|
|
||||||
track = disc * 2 ** 16 + tracknumber
|
|
||||||
year = api.year()
|
|
||||||
if not year and album_xml is not None:
|
|
||||||
# Plex did not pass year info - get it from the parent album
|
|
||||||
album_api = API(album_xml)
|
|
||||||
year = album_api.year()
|
|
||||||
moods = []
|
|
||||||
for entry in xml:
|
|
||||||
if entry.tag == 'Mood':
|
|
||||||
moods.append(entry.attrib['tag'])
|
|
||||||
mood = api.list_to_string(moods)
|
|
||||||
_, path, filename = api.fullpath()
|
|
||||||
# UPDATE THE SONG #####
|
|
||||||
if update_item:
|
|
||||||
LOG.info("UPDATE song plex_id: %s - %s", plex_id, title)
|
|
||||||
# Use dummy strHash '123' for Kodi
|
|
||||||
self.kodidb.update_path(path, kodi_pathid)
|
|
||||||
# Update the song entry
|
|
||||||
if v.KODIVERSION >= 18:
|
|
||||||
# Kodi Leia
|
|
||||||
self.kodidb.update_song(parent_id,
|
|
||||||
artists,
|
|
||||||
genre,
|
|
||||||
title,
|
|
||||||
track,
|
|
||||||
api.runtime(),
|
|
||||||
year,
|
|
||||||
filename,
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed(),
|
|
||||||
api.userrating(),
|
|
||||||
comment,
|
|
||||||
mood,
|
|
||||||
api.date_created(),
|
|
||||||
kodi_id)
|
|
||||||
else:
|
|
||||||
self.kodidb.update_song_17(parent_id,
|
|
||||||
artists,
|
|
||||||
genre,
|
|
||||||
title,
|
|
||||||
track,
|
|
||||||
api.runtime(),
|
|
||||||
year,
|
|
||||||
filename,
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed(),
|
|
||||||
api.userrating(),
|
|
||||||
comment,
|
|
||||||
mood,
|
|
||||||
api.date_created(),
|
|
||||||
kodi_id)
|
|
||||||
# OR ADD THE SONG #####
|
|
||||||
else:
|
|
||||||
LOG.info("ADD song plex_id: %s - %s", plex_id, title)
|
|
||||||
# Add path
|
|
||||||
kodi_pathid = self.kodidb.add_path(path)
|
|
||||||
# Create the song entry
|
|
||||||
if v.KODIVERSION >= 18:
|
|
||||||
# Kodi Leia
|
|
||||||
self.kodidb.add_song(kodi_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_pathid,
|
|
||||||
artists,
|
|
||||||
genre,
|
|
||||||
title,
|
|
||||||
track,
|
|
||||||
api.runtime(),
|
|
||||||
year,
|
|
||||||
filename,
|
|
||||||
musicBrainzId,
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed(),
|
|
||||||
api.userrating(),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
mood,
|
|
||||||
api.date_created())
|
|
||||||
else:
|
|
||||||
self.kodidb.add_song_17(kodi_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_pathid,
|
|
||||||
artists,
|
|
||||||
genre,
|
|
||||||
title,
|
|
||||||
track,
|
|
||||||
api.runtime(),
|
|
||||||
year,
|
|
||||||
filename,
|
|
||||||
musicBrainzId,
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed(),
|
|
||||||
api.userrating(),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
mood,
|
|
||||||
api.date_created())
|
|
||||||
if v.KODIVERSION < 18:
|
|
||||||
# Link song to album
|
|
||||||
self.kodidb.add_albuminfosong(kodi_id,
|
|
||||||
parent_id,
|
|
||||||
track,
|
|
||||||
title,
|
|
||||||
api.runtime())
|
|
||||||
# Link song to artists
|
|
||||||
artist_name = api.grandparent_title()
|
|
||||||
# Do the actual linking
|
|
||||||
self.kodidb.add_song_artist(grandparent_id, kodi_id, artist_name)
|
|
||||||
# Add genres
|
|
||||||
if genres:
|
|
||||||
self.kodidb.add_music_genres(kodi_id, genres, v.KODI_TYPE_SONG)
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
artworks = api.artwork()
|
|
||||||
self.kodidb.modify_artwork(artworks,
|
|
||||||
kodi_id,
|
|
||||||
v.KODI_TYPE_SONG)
|
|
||||||
if xml.get('parentKey') is None:
|
|
||||||
# Update album artwork
|
|
||||||
self.kodidb.modify_artwork(artworks,
|
|
||||||
parent_id,
|
|
||||||
v.KODI_TYPE_ALBUM)
|
|
||||||
self.plexdb.add_song(plex_id,
|
|
||||||
api.checksum(),
|
|
||||||
section_id,
|
|
||||||
artist_id,
|
|
||||||
grandparent_id,
|
|
||||||
album_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_id,
|
|
||||||
kodi_pathid,
|
|
||||||
self.last_sync)
|
|
|
@ -1,594 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from .common import ItemBase, process_path
|
|
||||||
from ..plex_api import API
|
|
||||||
from .. import plex_functions as PF, app, variables as v
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.tvshows')
|
|
||||||
|
|
||||||
|
|
||||||
class TvShowMixin(object):
|
|
||||||
def update_userdata(self, xml_element, plex_type):
|
|
||||||
"""
|
|
||||||
Updates the Kodi watched state of the item from PMS. Also retrieves
|
|
||||||
Plex resume points for movies in progress.
|
|
||||||
"""
|
|
||||||
api = API(xml_element)
|
|
||||||
# Get key and db entry on the Kodi db side
|
|
||||||
db_item = self.plexdb.item_by_id(api.plex_id, plex_type)
|
|
||||||
if not db_item:
|
|
||||||
LOG.info('Item not yet synced: %s', xml_element.attrib)
|
|
||||||
return False
|
|
||||||
# Grab the user's viewcount, resume points etc. from PMS' answer
|
|
||||||
self.kodidb.update_userrating(db_item['kodi_id'],
|
|
||||||
db_item['kodi_type'],
|
|
||||||
api.userrating())
|
|
||||||
if plex_type == v.PLEX_TYPE_EPISODE:
|
|
||||||
self.kodidb.set_resume(db_item['kodi_fileid'],
|
|
||||||
api.resume_point(),
|
|
||||||
api.runtime(),
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed())
|
|
||||||
if db_item['kodi_fileid_2']:
|
|
||||||
self.kodidb.set_resume(db_item['kodi_fileid_2'],
|
|
||||||
api.resume_point(),
|
|
||||||
api.runtime(),
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed())
|
|
||||||
return True
|
|
||||||
|
|
||||||
def remove(self, plex_id, plex_type=None):
|
|
||||||
"""
|
|
||||||
Remove the entire TV shows object (show, season or episode) including
|
|
||||||
all associated entries from the Kodi DB.
|
|
||||||
"""
|
|
||||||
db_item = self.plexdb.item_by_id(plex_id, plex_type)
|
|
||||||
if not db_item:
|
|
||||||
LOG.debug('Cannot delete plex_id %s - not found in DB', plex_id)
|
|
||||||
return
|
|
||||||
LOG.debug('Removing %s %s with kodi_id: %s',
|
|
||||||
db_item['plex_type'], plex_id, db_item['kodi_id'])
|
|
||||||
|
|
||||||
# Remove the plex reference
|
|
||||||
self.plexdb.remove(plex_id, db_item['plex_type'])
|
|
||||||
|
|
||||||
# EPISODE #####
|
|
||||||
if db_item['plex_type'] == v.PLEX_TYPE_EPISODE:
|
|
||||||
# Delete episode, verify season and tvshow
|
|
||||||
self.remove_episode(db_item)
|
|
||||||
# Season verification
|
|
||||||
if (db_item['season_id'] and
|
|
||||||
not self.plexdb.season_has_episodes(db_item['season_id'])):
|
|
||||||
# No episode left for this season - so delete the season
|
|
||||||
self.remove_season(db_item['parent_id'])
|
|
||||||
self.plexdb.remove(db_item['season_id'], v.PLEX_TYPE_SEASON)
|
|
||||||
# Show verification
|
|
||||||
if (not self.plexdb.show_has_seasons(db_item['show_id']) and
|
|
||||||
not self.plexdb.show_has_episodes(db_item['show_id'])):
|
|
||||||
# No seasons for show left - so delete entire show
|
|
||||||
self.remove_show(db_item['grandparent_id'])
|
|
||||||
self.plexdb.remove(db_item['show_id'], v.PLEX_TYPE_SHOW)
|
|
||||||
# SEASON #####
|
|
||||||
elif db_item['plex_type'] == v.PLEX_TYPE_SEASON:
|
|
||||||
# Remove episodes, season, verify tvshow
|
|
||||||
episodes = list(self.plexdb.episode_by_season(db_item['plex_id']))
|
|
||||||
for episode in episodes:
|
|
||||||
self.remove_episode(episode)
|
|
||||||
self.plexdb.remove(episode['plex_id'], v.PLEX_TYPE_EPISODE)
|
|
||||||
# Remove season
|
|
||||||
self.remove_season(db_item['kodi_id'])
|
|
||||||
# Show verification
|
|
||||||
if (not self.plexdb.show_has_seasons(db_item['show_id']) and
|
|
||||||
not self.plexdb.show_has_episodes(db_item['show_id'])):
|
|
||||||
# There's no other season or episode left, delete the show
|
|
||||||
self.remove_show(db_item['parent_id'])
|
|
||||||
self.plexdb.remove(db_item['show_id'], v.PLEX_TYPE_SHOW)
|
|
||||||
# TVSHOW #####
|
|
||||||
elif db_item['plex_type'] == v.PLEX_TYPE_SHOW:
|
|
||||||
# Remove episodes, seasons and the tvshow itself
|
|
||||||
seasons = list(self.plexdb.season_by_show(db_item['plex_id']))
|
|
||||||
for season in seasons:
|
|
||||||
self.remove_season(season['kodi_id'])
|
|
||||||
self.plexdb.remove(season['plex_id'], v.PLEX_TYPE_SEASON)
|
|
||||||
episodes = list(self.plexdb.episode_by_show(db_item['plex_id']))
|
|
||||||
for episode in episodes:
|
|
||||||
self.remove_episode(episode)
|
|
||||||
self.plexdb.remove(episode['plex_id'], v.PLEX_TYPE_EPISODE)
|
|
||||||
self.remove_show(db_item['kodi_id'])
|
|
||||||
|
|
||||||
LOG.debug('Deleted %s %s from all databases',
|
|
||||||
db_item['plex_type'], db_item['plex_id'])
|
|
||||||
|
|
||||||
def remove_show(self, kodi_id):
|
|
||||||
"""
|
|
||||||
Remove a TV show, and only the show, no seasons or episodes
|
|
||||||
"""
|
|
||||||
self.kodidb.modify_genres(kodi_id, v.KODI_TYPE_SHOW)
|
|
||||||
self.kodidb.modify_studios(kodi_id, v.KODI_TYPE_SHOW)
|
|
||||||
self.kodidb.modify_tags(kodi_id, v.KODI_TYPE_SHOW)
|
|
||||||
self.kodidb.delete_artwork(kodi_id, v.KODI_TYPE_SHOW)
|
|
||||||
self.kodidb.remove_show(kodi_id)
|
|
||||||
self.kodidb.remove_uniqueid(kodi_id, v.KODI_TYPE_SHOW)
|
|
||||||
self.kodidb.remove_ratings(kodi_id, v.KODI_TYPE_SHOW)
|
|
||||||
LOG.debug("Removed tvshow: %s", kodi_id)
|
|
||||||
|
|
||||||
def remove_season(self, kodi_id):
|
|
||||||
"""
|
|
||||||
Remove a season, and only a season, not the show or episodes
|
|
||||||
"""
|
|
||||||
self.kodidb.delete_artwork(kodi_id, v.KODI_TYPE_SEASON)
|
|
||||||
self.kodidb.remove_season(kodi_id)
|
|
||||||
LOG.debug("Removed season: %s", kodi_id)
|
|
||||||
|
|
||||||
def remove_episode(self, db_item):
|
|
||||||
"""
|
|
||||||
Remove an episode, and episode only from the Kodi DB (not Plex DB)
|
|
||||||
"""
|
|
||||||
self.kodidb.modify_people(db_item['kodi_id'], v.KODI_TYPE_EPISODE)
|
|
||||||
self.kodidb.remove_file(db_item['kodi_fileid'])
|
|
||||||
if db_item['kodi_fileid_2']:
|
|
||||||
self.kodidb.remove_file(db_item['kodi_fileid_2'])
|
|
||||||
self.kodidb.delete_artwork(db_item['kodi_id'], v.KODI_TYPE_EPISODE)
|
|
||||||
self.kodidb.remove_episode(db_item['kodi_id'])
|
|
||||||
self.kodidb.remove_uniqueid(db_item['kodi_id'], v.KODI_TYPE_EPISODE)
|
|
||||||
self.kodidb.remove_ratings(db_item['kodi_id'], v.KODI_TYPE_EPISODE)
|
|
||||||
LOG.debug("Removed episode: %s", db_item['kodi_id'])
|
|
||||||
|
|
||||||
|
|
||||||
class Show(TvShowMixin, ItemBase):
|
|
||||||
"""
|
|
||||||
For Plex library-type TV shows
|
|
||||||
"""
|
|
||||||
def add_update(self, xml, section_name=None, section_id=None,
|
|
||||||
children=None):
|
|
||||||
"""
|
|
||||||
Process a single show
|
|
||||||
"""
|
|
||||||
api = API(xml)
|
|
||||||
if not self.sync_this_item(section_id or api.library_section_id()):
|
|
||||||
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
|
|
||||||
'Kodi', api.plex_type, api.plex_id, api.title(),
|
|
||||||
section_id or api.library_section_id())
|
|
||||||
return
|
|
||||||
plex_id = api.plex_id
|
|
||||||
show = self.plexdb.show(plex_id)
|
|
||||||
if not show:
|
|
||||||
update_item = False
|
|
||||||
kodi_id = self.kodidb.new_show_id()
|
|
||||||
else:
|
|
||||||
update_item = True
|
|
||||||
kodi_id = show['kodi_id']
|
|
||||||
kodi_pathid = show['kodi_pathid']
|
|
||||||
|
|
||||||
# GET THE FILE AND PATH #####
|
|
||||||
if app.SYNC.direct_paths:
|
|
||||||
# Direct paths is set the Kodi way
|
|
||||||
playurl = api.validate_playurl(api.tv_show_path(),
|
|
||||||
api.plex_type,
|
|
||||||
folder=True)
|
|
||||||
if playurl is None:
|
|
||||||
return
|
|
||||||
path, toplevelpath = process_path(playurl)
|
|
||||||
toppathid = self.kodidb.add_path(toplevelpath,
|
|
||||||
content='tvshows',
|
|
||||||
scraper='metadata.local')
|
|
||||||
else:
|
|
||||||
# Set plugin path
|
|
||||||
toplevelpath = "plugin://%s.tvshows/" % v.ADDON_ID
|
|
||||||
path = "%s%s/" % (toplevelpath, plex_id)
|
|
||||||
# Do NOT set a parent id because addon-path cannot be "stacked"
|
|
||||||
toppathid = None
|
|
||||||
|
|
||||||
kodi_pathid = self.kodidb.add_path(path,
|
|
||||||
date_added=api.date_created(),
|
|
||||||
id_parent_path=toppathid)
|
|
||||||
# UPDATE THE TVSHOW #####
|
|
||||||
if update_item:
|
|
||||||
LOG.info("UPDATE tvshow plex_id: %s - %s", plex_id, api.title())
|
|
||||||
# update new ratings Kodi 17
|
|
||||||
rating_id = self.kodidb.update_ratings(kodi_id,
|
|
||||||
v.KODI_TYPE_SHOW,
|
|
||||||
"default",
|
|
||||||
api.rating(),
|
|
||||||
api.votecount())
|
|
||||||
unique_id = self._prioritize_provider_id(
|
|
||||||
self.update_provider_ids(api, kodi_id))
|
|
||||||
self.kodidb.modify_people(kodi_id,
|
|
||||||
v.KODI_TYPE_SHOW,
|
|
||||||
api.people())
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.kodidb.modify_artwork(api.artwork(),
|
|
||||||
kodi_id,
|
|
||||||
v.KODI_TYPE_SHOW)
|
|
||||||
# Update the tvshow entry
|
|
||||||
self.kodidb.update_show(api.title(),
|
|
||||||
api.plot(),
|
|
||||||
rating_id,
|
|
||||||
api.premiere_date(),
|
|
||||||
api.list_to_string(api.genres()),
|
|
||||||
api.title(),
|
|
||||||
unique_id,
|
|
||||||
api.content_rating(),
|
|
||||||
api.list_to_string(api.studios()),
|
|
||||||
api.sorttitle(),
|
|
||||||
kodi_id)
|
|
||||||
# OR ADD THE TVSHOW #####
|
|
||||||
else:
|
|
||||||
LOG.info("ADD tvshow plex_id: %s - %s", plex_id, api.title())
|
|
||||||
# Link the path
|
|
||||||
self.kodidb.add_showlinkpath(kodi_id, kodi_pathid)
|
|
||||||
rating_id = self.kodidb.add_ratings(kodi_id,
|
|
||||||
v.KODI_TYPE_SHOW,
|
|
||||||
"default",
|
|
||||||
api.rating(),
|
|
||||||
api.votecount())
|
|
||||||
unique_id = self._prioritize_provider_id(
|
|
||||||
self.add_provider_ids(api, kodi_id))
|
|
||||||
self.kodidb.add_people(kodi_id,
|
|
||||||
v.KODI_TYPE_SHOW,
|
|
||||||
api.people())
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.kodidb.add_artwork(api.artwork(),
|
|
||||||
kodi_id,
|
|
||||||
v.KODI_TYPE_SHOW)
|
|
||||||
# Create the tvshow entry
|
|
||||||
self.kodidb.add_show(kodi_id,
|
|
||||||
api.title(),
|
|
||||||
api.plot(),
|
|
||||||
rating_id,
|
|
||||||
api.premiere_date(),
|
|
||||||
api.list_to_string(api.genres()),
|
|
||||||
api.title(),
|
|
||||||
unique_id,
|
|
||||||
api.content_rating(),
|
|
||||||
api.list_to_string(api.studios()),
|
|
||||||
api.sorttitle())
|
|
||||||
self.kodidb.modify_genres(kodi_id, v.KODI_TYPE_SHOW, api.genres())
|
|
||||||
# Process studios
|
|
||||||
self.kodidb.modify_studios(kodi_id, v.KODI_TYPE_SHOW, api.studios())
|
|
||||||
# Process tags: view, PMS collection tags
|
|
||||||
tags = [section_name]
|
|
||||||
tags.extend([i for _, i in api.collections()])
|
|
||||||
self.kodidb.modify_tags(kodi_id, v.KODI_TYPE_SHOW, tags)
|
|
||||||
self.plexdb.add_show(plex_id=plex_id,
|
|
||||||
checksum=api.checksum(),
|
|
||||||
section_id=section_id,
|
|
||||||
kodi_id=kodi_id,
|
|
||||||
kodi_pathid=kodi_pathid,
|
|
||||||
last_sync=self.last_sync)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _prioritize_provider_id(unique_ids):
|
|
||||||
"""
|
|
||||||
Prioritize which ID ends up in the SHOW table (there can only be 1)
|
|
||||||
tvdb > imdb > tmdb
|
|
||||||
"""
|
|
||||||
return unique_ids.get('tvdb',
|
|
||||||
unique_ids.get('imdb',
|
|
||||||
unique_ids.get('tmdb')))
|
|
||||||
|
|
||||||
|
|
||||||
class Season(TvShowMixin, ItemBase):
|
|
||||||
def add_update(self, xml, section_name=None, section_id=None,
|
|
||||||
children=None):
|
|
||||||
"""
|
|
||||||
Process a single season of a certain tv show
|
|
||||||
"""
|
|
||||||
api = API(xml)
|
|
||||||
if not self.sync_this_item(section_id or api.library_section_id()):
|
|
||||||
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
|
|
||||||
'Kodi', api.plex_type, api.plex_id, api.season_name(),
|
|
||||||
section_id or api.library_section_id())
|
|
||||||
return
|
|
||||||
plex_id = api.plex_id
|
|
||||||
season = self.plexdb.season(plex_id)
|
|
||||||
if not season:
|
|
||||||
update_item = False
|
|
||||||
else:
|
|
||||||
update_item = True
|
|
||||||
show_id = api.parent_id()
|
|
||||||
show = self.plexdb.show(show_id)
|
|
||||||
if not show:
|
|
||||||
LOG.warn('Parent TV show %s not found in DB, adding it', show_id)
|
|
||||||
show_xml = PF.GetPlexMetadata(show_id)
|
|
||||||
try:
|
|
||||||
show_xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error("Parent tvshow %s xml download failed", show_id)
|
|
||||||
return False
|
|
||||||
Show(self.last_sync,
|
|
||||||
plexdb=self.plexdb,
|
|
||||||
kodidb=self.kodidb).add_update(show_xml[0],
|
|
||||||
section_name,
|
|
||||||
section_id)
|
|
||||||
show = self.plexdb.show(show_id)
|
|
||||||
if not show:
|
|
||||||
LOG.error('Still could not find parent tv show %s', show_id)
|
|
||||||
return
|
|
||||||
parent_id = show['kodi_id']
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
parent_artwork = api.artwork(kodi_id=parent_id,
|
|
||||||
kodi_type=v.KODI_TYPE_SHOW)
|
|
||||||
artwork = api.artwork()
|
|
||||||
# Remove all artwork that is identical for the season's show
|
|
||||||
for key in parent_artwork:
|
|
||||||
if key in artwork and artwork[key] == parent_artwork[key]:
|
|
||||||
del artwork[key]
|
|
||||||
if update_item:
|
|
||||||
LOG.info('UPDATE season plex_id %s - %s',
|
|
||||||
plex_id, api.season_name())
|
|
||||||
kodi_id = season['kodi_id']
|
|
||||||
self.kodidb.update_season(kodi_id,
|
|
||||||
parent_id,
|
|
||||||
api.index(),
|
|
||||||
api.season_name(),
|
|
||||||
api.userrating() or None)
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.kodidb.modify_artwork(artwork,
|
|
||||||
kodi_id,
|
|
||||||
v.KODI_TYPE_SEASON)
|
|
||||||
else:
|
|
||||||
LOG.info('ADD season plex_id %s - %s', plex_id, api.season_name())
|
|
||||||
kodi_id = self.kodidb.add_season(parent_id,
|
|
||||||
api.index(),
|
|
||||||
api.season_name(),
|
|
||||||
api.userrating() or None)
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.kodidb.add_artwork(artwork,
|
|
||||||
kodi_id,
|
|
||||||
v.KODI_TYPE_SEASON)
|
|
||||||
self.plexdb.add_season(plex_id=plex_id,
|
|
||||||
checksum=api.checksum(),
|
|
||||||
section_id=section_id,
|
|
||||||
show_id=show_id,
|
|
||||||
parent_id=parent_id,
|
|
||||||
kodi_id=kodi_id,
|
|
||||||
last_sync=self.last_sync)
|
|
||||||
|
|
||||||
|
|
||||||
class Episode(TvShowMixin, ItemBase):
|
|
||||||
def add_update(self, xml, section_name=None, section_id=None,
|
|
||||||
children=None):
|
|
||||||
"""
|
|
||||||
Process single episode
|
|
||||||
"""
|
|
||||||
api = API(xml)
|
|
||||||
if not self.sync_this_item(section_id or api.library_section_id()):
|
|
||||||
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
|
|
||||||
'Kodi', api.plex_type, api.plex_id, api.title(),
|
|
||||||
section_id or api.library_section_id())
|
|
||||||
return
|
|
||||||
plex_id = api.plex_id
|
|
||||||
episode = self.plexdb.episode(plex_id)
|
|
||||||
if not episode:
|
|
||||||
update_item = False
|
|
||||||
kodi_id = self.kodidb.new_episode_id()
|
|
||||||
else:
|
|
||||||
update_item = True
|
|
||||||
kodi_id = episode['kodi_id']
|
|
||||||
old_kodi_fileid = episode['kodi_fileid']
|
|
||||||
old_kodi_fileid_2 = episode['kodi_fileid_2']
|
|
||||||
kodi_pathid = episode['kodi_pathid']
|
|
||||||
|
|
||||||
airs_before_season = "-1"
|
|
||||||
airs_before_episode = "-1"
|
|
||||||
|
|
||||||
# The grandparent TV show
|
|
||||||
show = self.plexdb.show(api.show_id())
|
|
||||||
if not show:
|
|
||||||
LOG.warn('Grandparent TV show %s not found in DB, adding it', api.show_id())
|
|
||||||
show_xml = PF.GetPlexMetadata(api.show_id())
|
|
||||||
try:
|
|
||||||
show_xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error("Grandparent tvshow %s xml download failed", api.show_id())
|
|
||||||
return False
|
|
||||||
Show(self.last_sync,
|
|
||||||
plexdb=self.plexdb,
|
|
||||||
kodidb=self.kodidb).add_update(show_xml[0],
|
|
||||||
section_name,
|
|
||||||
section_id)
|
|
||||||
show = self.plexdb.show(api.show_id())
|
|
||||||
if not show:
|
|
||||||
LOG.error('Still could not find grandparent tv show %s', api.show_id())
|
|
||||||
return
|
|
||||||
grandparent_id = show['kodi_id']
|
|
||||||
|
|
||||||
# The parent Season
|
|
||||||
season = self.plexdb.season(api.season_id())
|
|
||||||
if not season and api.season_id():
|
|
||||||
LOG.warn('Parent season %s not found in DB, adding it', api.season_id())
|
|
||||||
season_xml = PF.GetPlexMetadata(api.season_id())
|
|
||||||
try:
|
|
||||||
season_xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error("Parent season %s xml download failed", api.season_id())
|
|
||||||
return False
|
|
||||||
Season(self.last_sync,
|
|
||||||
plexdb=self.plexdb,
|
|
||||||
kodidb=self.kodidb).add_update(season_xml[0],
|
|
||||||
section_name,
|
|
||||||
section_id)
|
|
||||||
season = self.plexdb.season(api.season_id())
|
|
||||||
if not season:
|
|
||||||
LOG.error('Still could not find parent season %s', api.season_id())
|
|
||||||
return
|
|
||||||
parent_id = season['kodi_id'] if season else None
|
|
||||||
|
|
||||||
fullpath, path, filename = api.fullpath()
|
|
||||||
if app.SYNC.direct_paths and not fullpath.startswith('http'):
|
|
||||||
parent_path_id = self.kodidb.parent_path_id(path)
|
|
||||||
kodi_pathid = self.kodidb.add_path(path,
|
|
||||||
id_parent_path=parent_path_id)
|
|
||||||
else:
|
|
||||||
# Root path tvshows/ already saved in Kodi DB
|
|
||||||
kodi_pathid = self.kodidb.add_path(path)
|
|
||||||
# need to set a 2nd file entry for a path without plex show id
|
|
||||||
# This fixes e.g. context menu and widgets working as they
|
|
||||||
# should
|
|
||||||
# A dirty hack, really
|
|
||||||
path_2 = 'plugin://%s.tvshows/' % v.ADDON_ID
|
|
||||||
# filename_2 is exactly the same as filename
|
|
||||||
# so WITH plex show id!
|
|
||||||
kodi_pathid_2 = self.kodidb.add_path(path_2)
|
|
||||||
|
|
||||||
# UPDATE THE EPISODE #####
|
|
||||||
if update_item:
|
|
||||||
LOG.info("UPDATE episode plex_id: %s - %s", plex_id, api.title())
|
|
||||||
kodi_fileid = self.kodidb.modify_file(filename,
|
|
||||||
kodi_pathid,
|
|
||||||
api.date_created())
|
|
||||||
if not app.SYNC.direct_paths:
|
|
||||||
kodi_fileid_2 = self.kodidb.modify_file(filename,
|
|
||||||
kodi_pathid_2,
|
|
||||||
api.date_created())
|
|
||||||
else:
|
|
||||||
kodi_fileid_2 = None
|
|
||||||
|
|
||||||
if kodi_fileid != old_kodi_fileid:
|
|
||||||
self.kodidb.remove_file(old_kodi_fileid)
|
|
||||||
if not app.SYNC.direct_paths:
|
|
||||||
self.kodidb.remove_file(old_kodi_fileid_2)
|
|
||||||
ratingid = self.kodidb.update_ratings(kodi_id,
|
|
||||||
v.KODI_TYPE_EPISODE,
|
|
||||||
"default",
|
|
||||||
api.rating(),
|
|
||||||
api.votecount())
|
|
||||||
unique_id = self._prioritize_provider_id(
|
|
||||||
self.update_provider_ids(api, kodi_id))
|
|
||||||
self.kodidb.modify_people(kodi_id,
|
|
||||||
v.KODI_TYPE_EPISODE,
|
|
||||||
api.people())
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.kodidb.modify_artwork(api.artwork(),
|
|
||||||
kodi_id,
|
|
||||||
v.KODI_TYPE_EPISODE)
|
|
||||||
self.kodidb.update_episode(api.title(),
|
|
||||||
api.plot(),
|
|
||||||
ratingid,
|
|
||||||
api.list_to_string(api.writers()),
|
|
||||||
api.premiere_date(),
|
|
||||||
api.runtime(),
|
|
||||||
api.list_to_string(api.directors()),
|
|
||||||
api.season_number(),
|
|
||||||
api.index(),
|
|
||||||
api.title(),
|
|
||||||
airs_before_season,
|
|
||||||
airs_before_episode,
|
|
||||||
fullpath,
|
|
||||||
kodi_pathid,
|
|
||||||
unique_id,
|
|
||||||
kodi_fileid, # and NOT kodi_fileid_2
|
|
||||||
parent_id,
|
|
||||||
api.userrating(),
|
|
||||||
kodi_id)
|
|
||||||
self.kodidb.set_resume(kodi_fileid,
|
|
||||||
api.resume_point(),
|
|
||||||
api.runtime(),
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed())
|
|
||||||
if not app.SYNC.direct_paths:
|
|
||||||
self.kodidb.set_resume(kodi_fileid_2,
|
|
||||||
api.resume_point(),
|
|
||||||
api.runtime(),
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed())
|
|
||||||
self.plexdb.add_episode(plex_id=plex_id,
|
|
||||||
checksum=api.checksum(),
|
|
||||||
section_id=section_id,
|
|
||||||
show_id=api.show_id(),
|
|
||||||
grandparent_id=grandparent_id,
|
|
||||||
season_id=api.season_id(),
|
|
||||||
parent_id=parent_id,
|
|
||||||
kodi_id=kodi_id,
|
|
||||||
kodi_fileid=kodi_fileid,
|
|
||||||
kodi_fileid_2=kodi_fileid_2,
|
|
||||||
kodi_pathid=kodi_pathid,
|
|
||||||
last_sync=self.last_sync)
|
|
||||||
# OR ADD THE EPISODE #####
|
|
||||||
else:
|
|
||||||
LOG.info("ADD episode plex_id: %s - %s", plex_id, api.title())
|
|
||||||
kodi_fileid = self.kodidb.add_file(filename,
|
|
||||||
kodi_pathid,
|
|
||||||
api.date_created())
|
|
||||||
if not app.SYNC.direct_paths:
|
|
||||||
kodi_fileid_2 = self.kodidb.add_file(filename,
|
|
||||||
kodi_pathid_2,
|
|
||||||
api.date_created())
|
|
||||||
else:
|
|
||||||
kodi_fileid_2 = None
|
|
||||||
|
|
||||||
rating_id = self.kodidb.add_ratings(kodi_id,
|
|
||||||
v.KODI_TYPE_EPISODE,
|
|
||||||
"default",
|
|
||||||
api.rating(),
|
|
||||||
api.votecount())
|
|
||||||
unique_id = self._prioritize_provider_id(
|
|
||||||
self.add_provider_ids(api, kodi_id))
|
|
||||||
self.kodidb.add_people(kodi_id,
|
|
||||||
v.KODI_TYPE_EPISODE,
|
|
||||||
api.people())
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.kodidb.add_artwork(api.artwork(),
|
|
||||||
kodi_id,
|
|
||||||
v.KODI_TYPE_EPISODE)
|
|
||||||
self.kodidb.add_episode(kodi_id,
|
|
||||||
kodi_fileid, # and NOT kodi_fileid_2
|
|
||||||
api.title(),
|
|
||||||
api.plot(),
|
|
||||||
rating_id,
|
|
||||||
api.list_to_string(api.writers()),
|
|
||||||
api.premiere_date(),
|
|
||||||
api.runtime(),
|
|
||||||
api.list_to_string(api.directors()),
|
|
||||||
api.season_number(),
|
|
||||||
api.index(),
|
|
||||||
api.title(),
|
|
||||||
grandparent_id,
|
|
||||||
airs_before_season,
|
|
||||||
airs_before_episode,
|
|
||||||
fullpath,
|
|
||||||
kodi_pathid,
|
|
||||||
unique_id,
|
|
||||||
parent_id,
|
|
||||||
api.userrating())
|
|
||||||
self.kodidb.set_resume(kodi_fileid,
|
|
||||||
api.resume_point(),
|
|
||||||
api.runtime(),
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed())
|
|
||||||
if not app.SYNC.direct_paths:
|
|
||||||
self.kodidb.set_resume(kodi_fileid_2,
|
|
||||||
api.resume_point(),
|
|
||||||
api.runtime(),
|
|
||||||
api.viewcount(),
|
|
||||||
api.lastplayed())
|
|
||||||
self.plexdb.add_episode(plex_id=plex_id,
|
|
||||||
checksum=api.checksum(),
|
|
||||||
section_id=section_id,
|
|
||||||
show_id=api.show_id(),
|
|
||||||
grandparent_id=grandparent_id,
|
|
||||||
season_id=api.season_id(),
|
|
||||||
parent_id=parent_id,
|
|
||||||
kodi_id=kodi_id,
|
|
||||||
kodi_fileid=kodi_fileid,
|
|
||||||
kodi_fileid_2=kodi_fileid_2,
|
|
||||||
kodi_pathid=kodi_pathid,
|
|
||||||
last_sync=self.last_sync)
|
|
||||||
|
|
||||||
self.kodidb.modify_streams(kodi_fileid, # and NOT kodi_fileid_2
|
|
||||||
api.mediastreams(),
|
|
||||||
api.runtime())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _prioritize_provider_id(unique_ids):
|
|
||||||
"""
|
|
||||||
Prioritize which ID ends up in the SHOW table (there can only be 1)
|
|
||||||
tvdb > imdb > tmdb
|
|
||||||
"""
|
|
||||||
return unique_ids.get('tvdb',
|
|
||||||
unique_ids.get('imdb',
|
|
||||||
unique_ids.get('tmdb')))
|
|
|
@ -1,34 +1,11 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
"""
|
||||||
Collection of functions using the Kodi JSON RPC interface.
|
Collection of functions using the Kodi JSON RPC interface.
|
||||||
See http://kodi.wiki/view/JSON-RPC_API
|
See http://kodi.wiki/view/JSON-RPC_API
|
||||||
"""
|
"""
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from json import loads, dumps
|
from json import loads, dumps
|
||||||
|
from utils import millis_to_kodi_time
|
||||||
from xbmc import executeJSONRPC
|
from xbmc import executeJSONRPC
|
||||||
|
|
||||||
from . import kodi_constants, timing, variables as v
|
|
||||||
|
|
||||||
JSON_FROM_KODITYPE = {
|
|
||||||
v.KODI_TYPE_MOVIE: ('VideoLibrary.GetMovieDetails',
|
|
||||||
kodi_constants.FIELDS_MOVIES),
|
|
||||||
v.KODI_TYPE_SHOW: ('VideoLibrary.GetTVShowDetails',
|
|
||||||
kodi_constants.FIELDS_TVSHOWS),
|
|
||||||
v.KODI_TYPE_SEASON: ('VideoLibrary.GetSeasonDetails',
|
|
||||||
kodi_constants.FIELDS_SEASON),
|
|
||||||
v.KODI_TYPE_EPISODE: ('VideoLibrary.GetEpisodeDetails',
|
|
||||||
kodi_constants.FIELDS_EPISODES),
|
|
||||||
v.KODI_TYPE_ARTIST: ('AudioLibrary.GetArtistDetails',
|
|
||||||
kodi_constants.FIELDS_ARTISTS),
|
|
||||||
v.KODI_TYPE_ALBUM: ('AudioLibrary.GetAlbumDetails',
|
|
||||||
kodi_constants.FIELDS_ALBUMS),
|
|
||||||
v.KODI_TYPE_SONG: ('AudioLibrary.GetSongDetails',
|
|
||||||
kodi_constants.FIELDS_SONGS),
|
|
||||||
v.KODI_TYPE_SET: ('VideoLibrary.GetMovieSetDetails',
|
|
||||||
[]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class JsonRPC(object):
|
class JsonRPC(object):
|
||||||
"""
|
"""
|
||||||
|
@ -170,12 +147,12 @@ def stop():
|
||||||
|
|
||||||
def seek_to(offset):
|
def seek_to(offset):
|
||||||
"""
|
"""
|
||||||
Seeks all Kodi players to offset [int] in milliseconds
|
Seeks all Kodi players to offset [int]
|
||||||
"""
|
"""
|
||||||
for playerid in get_player_ids():
|
for playerid in get_player_ids():
|
||||||
return JsonRPC("Player.Seek").execute(
|
JsonRPC("Player.Seek").execute(
|
||||||
{"playerid": playerid,
|
{"playerid": playerid,
|
||||||
"value": timing.millis_to_kodi_time(offset)})
|
"value": millis_to_kodi_time(offset)})
|
||||||
|
|
||||||
|
|
||||||
def smallforward():
|
def smallforward():
|
||||||
|
@ -421,41 +398,6 @@ def get_item(playerid):
|
||||||
'properties': ['title', 'file']})['result']['item']
|
'properties': ['title', 'file']})['result']['item']
|
||||||
|
|
||||||
|
|
||||||
def get_current_audio_stream_index(playerid):
|
|
||||||
"""
|
|
||||||
Returns the currently active audio stream index [int]
|
|
||||||
"""
|
|
||||||
return JsonRPC('Player.GetProperties').execute({
|
|
||||||
'playerid': playerid,
|
|
||||||
'properties': ['currentaudiostream']})['result']['currentaudiostream']['index']
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_subtitle_stream_index(playerid):
|
|
||||||
"""
|
|
||||||
Returns the currently active subtitle stream index [int] or None if there
|
|
||||||
are no subs
|
|
||||||
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE! The
|
|
||||||
JSON reply won't change even though subtitles are changed :-(
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return JsonRPC('Player.GetProperties').execute({
|
|
||||||
'playerid': playerid,
|
|
||||||
'properties': ['currentsubtitle', ]})['result']['currentsubtitle']['index']
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def get_subtitle_enabled(playerid):
|
|
||||||
"""
|
|
||||||
Returns True if a subtitle is currently enabled, False otherwise.
|
|
||||||
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE! The
|
|
||||||
JSON reply won't change even though subtitles are changed :-(
|
|
||||||
"""
|
|
||||||
return JsonRPC('Player.GetProperties').execute({
|
|
||||||
'playerid': playerid,
|
|
||||||
'properties': ['subtitleenabled', ]})['result']['subtitleenabled']
|
|
||||||
|
|
||||||
|
|
||||||
def get_player_props(playerid):
|
def get_player_props(playerid):
|
||||||
"""
|
"""
|
||||||
Returns a dict for the active Kodi player with the following values:
|
Returns a dict for the active Kodi player with the following values:
|
||||||
|
@ -611,16 +553,3 @@ def settings_setsettingvalue(setting, value):
|
||||||
'setting': setting,
|
'setting': setting,
|
||||||
'value': value
|
'value': value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def item_details(kodi_id, kodi_type):
|
|
||||||
'''
|
|
||||||
Returns the Kodi item dict for this item
|
|
||||||
'''
|
|
||||||
json, fields = JSON_FROM_KODITYPE[kodi_type]
|
|
||||||
ret = JsonRPC(json).execute({'%sid' % kodi_type: kodi_id,
|
|
||||||
'properties': fields})
|
|
||||||
try:
|
|
||||||
return ret['result']['%sdetails' % kodi_type]
|
|
||||||
except (KeyError, TypeError):
|
|
||||||
return {}
|
|
||||||
|
|
|
@ -1,92 +0,0 @@
|
||||||
#!/usr/bin/python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
'''
|
|
||||||
script.module.metadatautils
|
|
||||||
kodi_constants.py
|
|
||||||
Several common constants for use with Kodi json api
|
|
||||||
'''
|
|
||||||
FIELDS_BASE = ['dateadded', 'file', 'lastplayed', 'plot', 'title', 'art',
|
|
||||||
'playcount']
|
|
||||||
FIELDS_FILE = FIELDS_BASE + ['streamdetails', 'director', 'resume', 'runtime']
|
|
||||||
FIELDS_MOVIES = FIELDS_FILE + ['plotoutline', 'sorttitle', 'cast', 'votes',
|
|
||||||
'showlink', 'top250', 'trailer', 'year', 'country', 'studio', 'set',
|
|
||||||
'genre', 'mpaa', 'setid', 'rating', 'tag', 'tagline', 'writer',
|
|
||||||
'originaltitle', 'imdbnumber', 'uniqueid']
|
|
||||||
FIELDS_TVSHOWS = FIELDS_BASE + ['sorttitle', 'mpaa', 'premiered', 'year',
|
|
||||||
'episode', 'watchedepisodes', 'votes', 'rating', 'studio', 'season',
|
|
||||||
'genre', 'cast', 'episodeguide', 'tag', 'originaltitle', 'imdbnumber']
|
|
||||||
FIELDS_SEASON = ['art', 'playcount', 'season', 'showtitle', 'episode',
|
|
||||||
'tvshowid', 'watchedepisodes', 'userrating', 'fanart', 'thumbnail']
|
|
||||||
FIELDS_EPISODES = FIELDS_FILE + ['cast', 'productioncode', 'rating', 'votes',
|
|
||||||
'episode', 'showtitle', 'tvshowid', 'season', 'firstaired', 'writer',
|
|
||||||
'originaltitle']
|
|
||||||
FIELDS_MUSICVIDEOS = FIELDS_FILE + ['genre', 'artist', 'tag', 'album', 'track',
|
|
||||||
'studio', 'year']
|
|
||||||
FIELDS_FILES = FIELDS_FILE + ['plotoutline', 'sorttitle', 'cast', 'votes',
|
|
||||||
'trailer', 'year', 'country', 'studio', 'genre', 'mpaa', 'rating',
|
|
||||||
'tagline', 'writer', 'originaltitle', 'imdbnumber', 'premiered', 'episode',
|
|
||||||
'showtitle', 'firstaired', 'watchedepisodes', 'duration', 'season']
|
|
||||||
FIELDS_SONGS = ['artist', 'displayartist', 'title', 'rating', 'fanart',
|
|
||||||
'thumbnail', 'duration', 'disc', 'playcount', 'comment', 'file', 'album',
|
|
||||||
'lastplayed', 'genre', 'musicbrainzartistid', 'track', 'dateadded']
|
|
||||||
FIELDS_ALBUMS = ['title', 'fanart', 'thumbnail', 'genre', 'displayartist',
|
|
||||||
'artist', 'musicbrainzalbumartistid', 'year', 'rating', 'artistid',
|
|
||||||
'musicbrainzalbumid', 'theme', 'description', 'type', 'style', 'playcount',
|
|
||||||
'albumlabel', 'mood', 'dateadded']
|
|
||||||
FIELDS_ARTISTS = ['born', 'formed', 'died', 'style', 'yearsactive', 'mood',
|
|
||||||
'fanart', 'thumbnail', 'musicbrainzartistid', 'disbanded', 'description',
|
|
||||||
'instrument']
|
|
||||||
FIELDS_RECORDINGS = ['art', 'channel', 'directory', 'endtime', 'file', 'genre',
|
|
||||||
'icon', 'playcount', 'plot', 'plotoutline', 'resume', 'runtime',
|
|
||||||
'starttime', 'streamurl', 'title']
|
|
||||||
FIELDS_CHANNELS = ['broadcastnow', 'channeltype', 'hidden', 'locked',
|
|
||||||
'lastplayed', 'thumbnail', 'channel']
|
|
||||||
|
|
||||||
FILTER_UNWATCHED = {
|
|
||||||
'operator': 'lessthan',
|
|
||||||
'field': 'playcount',
|
|
||||||
'value': '1'
|
|
||||||
}
|
|
||||||
FILTER_WATCHED = {
|
|
||||||
'operator': 'isnot',
|
|
||||||
'field': 'playcount',
|
|
||||||
'value': '0'
|
|
||||||
}
|
|
||||||
FILTER_RATING = {
|
|
||||||
'operator': 'greaterthan',
|
|
||||||
'field': 'rating',
|
|
||||||
'value': '7'
|
|
||||||
}
|
|
||||||
FILTER_RATING_MUSIC = {
|
|
||||||
'operator': 'greaterthan',
|
|
||||||
'field': 'rating',
|
|
||||||
'value': '3'
|
|
||||||
}
|
|
||||||
FILTER_INPROGRESS = {
|
|
||||||
'operator': 'true',
|
|
||||||
'field': 'inprogress',
|
|
||||||
'value': ''
|
|
||||||
}
|
|
||||||
SORT_RATING = {
|
|
||||||
'method': 'rating',
|
|
||||||
'order': 'descending'
|
|
||||||
}
|
|
||||||
SORT_RANDOM = {
|
|
||||||
'method': 'random',
|
|
||||||
'order': 'descending'
|
|
||||||
}
|
|
||||||
SORT_TITLE = {
|
|
||||||
'method': 'title',
|
|
||||||
'order': 'ascending'
|
|
||||||
}
|
|
||||||
SORT_DATEADDED = {
|
|
||||||
'method': 'dateadded',
|
|
||||||
'order': 'descending'
|
|
||||||
}
|
|
||||||
SORT_LASTPLAYED = {
|
|
||||||
'method': 'lastplayed',
|
|
||||||
'order': 'descending'
|
|
||||||
}
|
|
||||||
SORT_EPISODE = {
|
|
||||||
'method': 'episode'
|
|
||||||
}
|
|
|
@ -1,126 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from .common import KODIDB_LOCK
|
|
||||||
from .video import KodiVideoDB
|
|
||||||
from .music import KodiMusicDB
|
|
||||||
from .texture import KodiTextureDB
|
|
||||||
|
|
||||||
from .. import path_ops, utils, variables as v
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.kodi_db')
|
|
||||||
|
|
||||||
|
|
||||||
def kodiid_from_filename(path, kodi_type=None, db_type=None):
|
|
||||||
"""
|
|
||||||
Returns kodi_id if we have an item in the Kodi video or audio database with
|
|
||||||
said path. Feed with either koditype, e.v. 'movie', 'song' or the DB
|
|
||||||
you want to poll ('video' or 'music')
|
|
||||||
Returns None, <kodi_type> if not possible
|
|
||||||
"""
|
|
||||||
kodi_id = None
|
|
||||||
path = utils.try_decode(path)
|
|
||||||
# Make sure path ends in either '/' or '\'
|
|
||||||
# We CANNOT use path_ops.path.join as this can result in \ where we need /
|
|
||||||
try:
|
|
||||||
filename = path.rsplit('/', 1)[1]
|
|
||||||
path = path.rsplit('/', 1)[0] + '/'
|
|
||||||
except IndexError:
|
|
||||||
filename = path.rsplit('\\', 1)[1]
|
|
||||||
path = path.rsplit('\\', 1)[0] + '\\'
|
|
||||||
if kodi_type == v.KODI_TYPE_SONG or db_type == 'music':
|
|
||||||
with KodiMusicDB(lock=False) as kodidb:
|
|
||||||
try:
|
|
||||||
kodi_id = kodidb.song_id_from_filename(filename, path)
|
|
||||||
except TypeError:
|
|
||||||
LOG.debug('No Kodi audio db element found for path %s', path)
|
|
||||||
else:
|
|
||||||
kodi_type = v.KODI_TYPE_SONG
|
|
||||||
else:
|
|
||||||
with KodiVideoDB(lock=False) as kodidb:
|
|
||||||
try:
|
|
||||||
kodi_id, kodi_type = kodidb.video_id_from_filename(filename,
|
|
||||||
path)
|
|
||||||
except TypeError:
|
|
||||||
LOG.debug('No kodi video db element found for path %s file %s',
|
|
||||||
path, filename)
|
|
||||||
return kodi_id, kodi_type
|
|
||||||
|
|
||||||
|
|
||||||
def setup_kodi_default_entries():
|
|
||||||
"""
|
|
||||||
Makes sure that we retain the Kodi standard databases. E.g. that there
|
|
||||||
is a dummy artist with ID 1
|
|
||||||
"""
|
|
||||||
if utils.settings('enableMusic') == 'true':
|
|
||||||
with KodiMusicDB() as kodidb:
|
|
||||||
kodidb.setup_kodi_default_entries()
|
|
||||||
|
|
||||||
|
|
||||||
def reset_cached_images():
|
|
||||||
LOG.info('Resetting cached artwork')
|
|
||||||
LOG.debug('Resetting the Kodi texture DB')
|
|
||||||
with KodiTextureDB() as kodidb:
|
|
||||||
kodidb.wipe()
|
|
||||||
LOG.debug('Deleting all cached image files')
|
|
||||||
path = path_ops.translate_path('special://thumbnails/')
|
|
||||||
if path_ops.exists(path):
|
|
||||||
path_ops.rmtree(path, ignore_errors=True)
|
|
||||||
paths = ('', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
|
||||||
'a', 'b', 'c', 'd', 'e', 'f',
|
|
||||||
'Video', 'plex')
|
|
||||||
for path in paths:
|
|
||||||
new_path = path_ops.translate_path('special://thumbnails/%s' % path)
|
|
||||||
try:
|
|
||||||
path_ops.makedirs(path_ops.encode_path(new_path))
|
|
||||||
except OSError as err:
|
|
||||||
LOG.warn('Could not create thumbnail directory %s: %s',
|
|
||||||
new_path, err)
|
|
||||||
LOG.info('Done resetting cached artwork')
|
|
||||||
|
|
||||||
|
|
||||||
def wipe_dbs(music=True):
|
|
||||||
"""
|
|
||||||
Completely resets the Kodi databases 'video', 'texture' and 'music' (if
|
|
||||||
music sync is enabled)
|
|
||||||
|
|
||||||
We need to connect without sqlite WAL mode as Kodi might still be accessing
|
|
||||||
the dbs and we need to prevent that
|
|
||||||
"""
|
|
||||||
LOG.warn('Wiping Kodi databases!')
|
|
||||||
LOG.info('Wiping Kodi video database')
|
|
||||||
with KodiVideoDB() as kodidb:
|
|
||||||
kodidb.wipe()
|
|
||||||
if music:
|
|
||||||
LOG.info('Wiping Kodi music database')
|
|
||||||
with KodiMusicDB() as kodidb:
|
|
||||||
kodidb.wipe()
|
|
||||||
reset_cached_images()
|
|
||||||
setup_kodi_default_entries()
|
|
||||||
# Delete SQLITE wal files
|
|
||||||
import xbmc
|
|
||||||
# Make sure Kodi knows we wiped the databases
|
|
||||||
xbmc.executebuiltin('UpdateLibrary(video)')
|
|
||||||
if utils.settings('enableMusic') == 'true':
|
|
||||||
xbmc.executebuiltin('UpdateLibrary(music)')
|
|
||||||
|
|
||||||
|
|
||||||
def create_kodi_db_indicees():
|
|
||||||
"""
|
|
||||||
Index the "actors" because we got a TON - speed up SELECT and WHEN
|
|
||||||
"""
|
|
||||||
with KodiVideoDB() as kodidb:
|
|
||||||
kodidb.create_kodi_db_indicees()
|
|
||||||
|
|
||||||
|
|
||||||
KODIDB_FROM_PLEXTYPE = {
|
|
||||||
v.PLEX_TYPE_MOVIE: KodiVideoDB,
|
|
||||||
v.PLEX_TYPE_SHOW: KodiVideoDB,
|
|
||||||
v.PLEX_TYPE_SEASON: KodiVideoDB,
|
|
||||||
v.PLEX_TYPE_EPISODE: KodiVideoDB,
|
|
||||||
v.PLEX_TYPE_ARTIST: KodiMusicDB,
|
|
||||||
v.PLEX_TYPE_ALBUM: KodiMusicDB,
|
|
||||||
v.PLEX_TYPE_SONG: KodiMusicDB
|
|
||||||
}
|
|
|
@ -1,155 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from threading import Lock
|
|
||||||
|
|
||||||
from .. import db, path_ops
|
|
||||||
|
|
||||||
KODIDB_LOCK = Lock()
|
|
||||||
# Names of tables we generally leave untouched and e.g. don't wipe
|
|
||||||
UNTOUCHED_TABLES = ('version', 'versiontagscan')
|
|
||||||
|
|
||||||
|
|
||||||
class KodiDBBase(object):
|
|
||||||
"""
|
|
||||||
Kodi database methods used for all types of items
|
|
||||||
"""
|
|
||||||
def __init__(self, texture_db=False, kodiconn=None, artconn=None,
|
|
||||||
lock=True):
|
|
||||||
"""
|
|
||||||
Allows direct use with a cursor instead of context mgr
|
|
||||||
"""
|
|
||||||
self._texture_db = texture_db
|
|
||||||
self.lock = lock
|
|
||||||
self.kodiconn = kodiconn
|
|
||||||
self.cursor = self.kodiconn.cursor() if self.kodiconn else None
|
|
||||||
self.artconn = artconn
|
|
||||||
self.artcursor = self.artconn.cursor() if self.artconn else None
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
if self.lock:
|
|
||||||
KODIDB_LOCK.acquire()
|
|
||||||
self.kodiconn = db.connect(self.db_kind)
|
|
||||||
self.cursor = self.kodiconn.cursor()
|
|
||||||
self.artconn = db.connect('texture') if self._texture_db \
|
|
||||||
else None
|
|
||||||
self.artcursor = self.artconn.cursor() if self._texture_db else None
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, e_typ, e_val, trcbak):
|
|
||||||
try:
|
|
||||||
if e_typ:
|
|
||||||
# re-raise any exception
|
|
||||||
return False
|
|
||||||
self.kodiconn.commit()
|
|
||||||
if self.artconn:
|
|
||||||
self.artconn.commit()
|
|
||||||
finally:
|
|
||||||
self.kodiconn.close()
|
|
||||||
if self.artconn:
|
|
||||||
self.artconn.close()
|
|
||||||
if self.lock:
|
|
||||||
KODIDB_LOCK.release()
|
|
||||||
|
|
||||||
def art_urls(self, kodi_id, kodi_type):
|
|
||||||
return (x[0] for x in
|
|
||||||
self.cursor.execute('SELECT url FROM art WHERE media_id = ? AND media_type = ?',
|
|
||||||
(kodi_id, kodi_type)))
|
|
||||||
|
|
||||||
def artwork_generator(self, kodi_type, limit, offset):
|
|
||||||
query = 'SELECT url FROM art WHERE type == ? LIMIT ? OFFSET ?'
|
|
||||||
return (x[0] for x in
|
|
||||||
self.cursor.execute(query, (kodi_type, limit, offset)))
|
|
||||||
|
|
||||||
def add_artwork(self, artworks, kodi_id, kodi_type):
|
|
||||||
"""
|
|
||||||
Pass in an artworks dict (see PlexAPI) to set an items artwork.
|
|
||||||
"""
|
|
||||||
for kodi_art, url in artworks.iteritems():
|
|
||||||
self.add_art(url, kodi_id, kodi_type, kodi_art)
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def add_art(self, url, kodi_id, kodi_type, kodi_art):
|
|
||||||
"""
|
|
||||||
Adds or modifies the artwork of kind kodi_art (e.g. 'poster') in the
|
|
||||||
Kodi art table for item kodi_id/kodi_type. Will also cache everything
|
|
||||||
except actor portraits.
|
|
||||||
"""
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT INTO art(media_id, media_type, type, url)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
''', (kodi_id, kodi_type, kodi_art, url))
|
|
||||||
|
|
||||||
def modify_artwork(self, artworks, kodi_id, kodi_type):
|
|
||||||
"""
|
|
||||||
Pass in an artworks dict (see PlexAPI) to set an items artwork.
|
|
||||||
"""
|
|
||||||
for kodi_art, url in artworks.iteritems():
|
|
||||||
self.modify_art(url, kodi_id, kodi_type, kodi_art)
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def modify_art(self, url, kodi_id, kodi_type, kodi_art):
|
|
||||||
"""
|
|
||||||
Adds or modifies the artwork of kind kodi_art (e.g. 'poster') in the
|
|
||||||
Kodi art table for item kodi_id/kodi_type. Will also cache everything
|
|
||||||
except actor portraits.
|
|
||||||
"""
|
|
||||||
self.cursor.execute('''
|
|
||||||
SELECT url FROM art
|
|
||||||
WHERE media_id = ? AND media_type = ? AND type = ?
|
|
||||||
LIMIT 1
|
|
||||||
''', (kodi_id, kodi_type, kodi_art,))
|
|
||||||
try:
|
|
||||||
# Update the artwork
|
|
||||||
old_url = self.cursor.fetchone()[0]
|
|
||||||
except TypeError:
|
|
||||||
# Add the artwork
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT INTO art(media_id, media_type, type, url)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
''', (kodi_id, kodi_type, kodi_art, url))
|
|
||||||
else:
|
|
||||||
if url == old_url:
|
|
||||||
# Only cache artwork if it changed
|
|
||||||
return
|
|
||||||
self.delete_cached_artwork(old_url)
|
|
||||||
self.cursor.execute('''
|
|
||||||
UPDATE art SET url = ?
|
|
||||||
WHERE media_id = ? AND media_type = ? AND type = ?
|
|
||||||
''', (url, kodi_id, kodi_type, kodi_art))
|
|
||||||
|
|
||||||
def delete_artwork(self, kodi_id, kodi_type):
|
|
||||||
self.cursor.execute('SELECT url FROM art WHERE media_id = ? AND media_type = ?',
|
|
||||||
(kodi_id, kodi_type, ))
|
|
||||||
for row in self.cursor.fetchall():
|
|
||||||
self.delete_cached_artwork(row[0])
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def delete_cached_artwork(self, url):
|
|
||||||
try:
|
|
||||||
self.artcursor.execute("SELECT cachedurl FROM texture WHERE url = ? LIMIT 1",
|
|
||||||
(url, ))
|
|
||||||
cachedurl = self.artcursor.fetchone()[0]
|
|
||||||
except TypeError:
|
|
||||||
# Could not find cached url
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# Delete thumbnail as well as the entry
|
|
||||||
path = path_ops.translate_path("special://thumbnails/%s"
|
|
||||||
% cachedurl)
|
|
||||||
if path_ops.exists(path):
|
|
||||||
path_ops.rmtree(path, ignore_errors=True)
|
|
||||||
self.artcursor.execute("DELETE FROM texture WHERE url = ?", (url, ))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def wipe(self):
|
|
||||||
"""
|
|
||||||
Completely wipes the corresponding Kodi database
|
|
||||||
"""
|
|
||||||
self.cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
||||||
tables = [i[0] for i in self.cursor.fetchall()]
|
|
||||||
for table in UNTOUCHED_TABLES:
|
|
||||||
if table in tables:
|
|
||||||
tables.remove(table)
|
|
||||||
for table in tables:
|
|
||||||
self.cursor.execute('DELETE FROM %s' % table)
|
|
|
@ -1,628 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from . import common
|
|
||||||
from .. import db, variables as v, app, timing
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.kodi_db.music')
|
|
||||||
|
|
||||||
|
|
||||||
class KodiMusicDB(common.KodiDBBase):
|
|
||||||
db_kind = 'music'
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def add_path(self, path):
|
|
||||||
"""
|
|
||||||
Add the path (unicode) to the music DB, if it does not exist already.
|
|
||||||
Returns the path id
|
|
||||||
"""
|
|
||||||
# SQL won't return existing paths otherwise
|
|
||||||
path = '' if path is None else path
|
|
||||||
self.cursor.execute('SELECT idPath FROM path WHERE strPath = ?',
|
|
||||||
(path,))
|
|
||||||
try:
|
|
||||||
pathid = self.cursor.fetchone()[0]
|
|
||||||
except TypeError:
|
|
||||||
self.cursor.execute('INSERT INTO path(strPath, strHash) VALUES (?, ?)',
|
|
||||||
(path, '123'))
|
|
||||||
pathid = self.cursor.lastrowid
|
|
||||||
return pathid
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def setup_kodi_default_entries(self):
|
|
||||||
"""
|
|
||||||
Makes sure that we retain the Kodi standard databases. E.g. that there
|
|
||||||
is a dummy artist with ID 1
|
|
||||||
"""
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT OR REPLACE INTO artist(
|
|
||||||
idArtist,
|
|
||||||
strArtist,
|
|
||||||
strMusicBrainzArtistID)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
''', (1, '[Missing Tag]', 'Artist Tag Missing'))
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT OR REPLACE INTO role(
|
|
||||||
idRole,
|
|
||||||
strRole)
|
|
||||||
VALUES (?, ?)
|
|
||||||
''', (1, 'Artist'))
|
|
||||||
if v.KODIVERSION >= 18:
|
|
||||||
self.cursor.execute('DELETE FROM versiontagscan')
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT INTO versiontagscan(
|
|
||||||
idVersion,
|
|
||||||
iNeedsScan,
|
|
||||||
lastscanned)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
''', (v.DB_MUSIC_VERSION,
|
|
||||||
0,
|
|
||||||
timing.kodi_now()))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def update_path(self, path, kodi_pathid):
|
|
||||||
self.cursor.execute('''
|
|
||||||
UPDATE path
|
|
||||||
SET strPath = ?, strHash = ?
|
|
||||||
WHERE idPath = ?
|
|
||||||
''', (path, '123', kodi_pathid))
|
|
||||||
|
|
||||||
def song_id_from_filename(self, filename, path):
|
|
||||||
"""
|
|
||||||
Returns the Kodi song_id from the Kodi music database or None if not
|
|
||||||
found OR something went wrong.
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT idPath FROM path WHERE strPath = ?',
|
|
||||||
(path, ))
|
|
||||||
path_ids = self.cursor.fetchall()
|
|
||||||
if len(path_ids) != 1:
|
|
||||||
LOG.debug('Found wrong number of path ids: %s for path %s, abort',
|
|
||||||
path_ids, path)
|
|
||||||
return
|
|
||||||
self.cursor.execute('SELECT idSong FROM song WHERE strFileName = ? AND idPath = ?',
|
|
||||||
(filename, path_ids[0][0]))
|
|
||||||
song_ids = self.cursor.fetchall()
|
|
||||||
if len(song_ids) != 1:
|
|
||||||
LOG.info('Found wrong number of songs %s, abort', song_ids)
|
|
||||||
return
|
|
||||||
return song_ids[0][0]
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def delete_song_from_song_artist(self, song_id):
|
|
||||||
"""
|
|
||||||
Deletes son from song_artist table and possibly orphaned roles
|
|
||||||
"""
|
|
||||||
self.cursor.execute('''
|
|
||||||
SELECT idArtist, idRole FROM song_artist
|
|
||||||
WHERE idSong = ? LIMIT 1
|
|
||||||
''', (song_id, ))
|
|
||||||
artist = self.cursor.fetchone()
|
|
||||||
if not artist:
|
|
||||||
# No entry to begin with
|
|
||||||
return
|
|
||||||
# Delete the entry
|
|
||||||
self.cursor.execute('DELETE FROM song_artist WHERE idSong = ?',
|
|
||||||
(song_id, ))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def delete_song_from_song_genre(self, song_id):
|
|
||||||
"""
|
|
||||||
Deletes the one entry with id song_id from the song_genre table.
|
|
||||||
Will also delete orphaned genres from genre table
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT idGenre FROM song_genre WHERE idSong = ?',
|
|
||||||
(song_id, ))
|
|
||||||
genres = self.cursor.fetchall()
|
|
||||||
self.cursor.execute('DELETE FROM song_genre WHERE idSong = ?',
|
|
||||||
(song_id, ))
|
|
||||||
# Check for orphaned genres in both song_genre and album_genre tables
|
|
||||||
for genre in genres:
|
|
||||||
self.cursor.execute('SELECT idGenre FROM song_genre WHERE idGenre = ? LIMIT 1',
|
|
||||||
(genre[0], ))
|
|
||||||
if not self.cursor.fetchone():
|
|
||||||
self.cursor.execute('SELECT idGenre FROM album_genre WHERE idGenre = ? LIMIT 1',
|
|
||||||
(genre[0], ))
|
|
||||||
if not self.cursor.fetchone():
|
|
||||||
self.delete_genre(genre[0])
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def delete_genre(self, genre_id):
|
|
||||||
"""
|
|
||||||
Dedicated method in order to catch OperationalErrors correctly
|
|
||||||
"""
|
|
||||||
self.cursor.execute('DELETE FROM genre WHERE idGenre = ?',
|
|
||||||
(genre_id, ))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def delete_album_from_album_genre(self, album_id):
|
|
||||||
"""
|
|
||||||
Deletes the one entry with id album_id from the album_genre table.
|
|
||||||
Will also delete orphaned genres from genre table
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT idGenre FROM album_genre WHERE idAlbum = ?',
|
|
||||||
(album_id, ))
|
|
||||||
genres = self.cursor.fetchall()
|
|
||||||
self.cursor.execute('DELETE FROM album_genre WHERE idAlbum = ?',
|
|
||||||
(album_id, ))
|
|
||||||
# Check for orphaned genres in both album_genre and song_genre tables
|
|
||||||
for genre in genres:
|
|
||||||
self.cursor.execute('SELECT idGenre FROM album_genre WHERE idGenre = ? LIMIT 1',
|
|
||||||
(genre[0], ))
|
|
||||||
if not self.cursor.fetchone():
|
|
||||||
self.cursor.execute('SELECT idGenre FROM song_genre WHERE idGenre = ? LIMIT 1',
|
|
||||||
(genre[0], ))
|
|
||||||
if not self.cursor.fetchone():
|
|
||||||
self.delete_genre(genre[0])
|
|
||||||
|
|
||||||
def new_album_id(self):
|
|
||||||
self.cursor.execute('SELECT COALESCE(MAX(idAlbum), 0) FROM album')
|
|
||||||
return self.cursor.fetchone()[0] + 1
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def add_album_17(self, *args):
|
|
||||||
"""
|
|
||||||
strReleaseType: 'album' or 'single'
|
|
||||||
"""
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT INTO album(
|
|
||||||
idAlbum,
|
|
||||||
strAlbum,
|
|
||||||
strMusicBrainzAlbumID,
|
|
||||||
strArtists,
|
|
||||||
strGenres,
|
|
||||||
iYear,
|
|
||||||
bCompilation,
|
|
||||||
strReview,
|
|
||||||
strImage,
|
|
||||||
strLabel,
|
|
||||||
iUserrating,
|
|
||||||
lastScraped,
|
|
||||||
strReleaseType)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''', (args))
|
|
||||||
else:
|
|
||||||
args = list(args)
|
|
||||||
del args[8]
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT INTO album(
|
|
||||||
idAlbum,
|
|
||||||
strAlbum,
|
|
||||||
strMusicBrainzAlbumID,
|
|
||||||
strArtists,
|
|
||||||
strGenres,
|
|
||||||
iYear,
|
|
||||||
bCompilation,
|
|
||||||
strReview,
|
|
||||||
strLabel,
|
|
||||||
iUserrating,
|
|
||||||
lastScraped,
|
|
||||||
strReleaseType)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''', (args))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def update_album_17(self, *args):
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.cursor.execute('''
|
|
||||||
UPDATE album
|
|
||||||
SET strAlbum = ?,
|
|
||||||
strMusicBrainzAlbumID = ?,
|
|
||||||
strArtists = ?,
|
|
||||||
strGenres = ?,
|
|
||||||
iYear = ?,
|
|
||||||
bCompilation = ?,
|
|
||||||
strReview = ?,
|
|
||||||
strImage = ?,
|
|
||||||
strLabel = ?,
|
|
||||||
iUserrating = ?,
|
|
||||||
lastScraped = ?,
|
|
||||||
strReleaseType = ?
|
|
||||||
WHERE idAlbum = ?
|
|
||||||
''', (args))
|
|
||||||
else:
|
|
||||||
args = list(args)
|
|
||||||
del args[7]
|
|
||||||
self.cursor.execute('''
|
|
||||||
UPDATE album
|
|
||||||
SET strAlbum = ?,
|
|
||||||
strMusicBrainzAlbumID = ?,
|
|
||||||
strArtists = ?,
|
|
||||||
strGenres = ?,
|
|
||||||
iYear = ?,
|
|
||||||
bCompilation = ?,
|
|
||||||
strReview = ?,
|
|
||||||
strLabel = ?,
|
|
||||||
iUserrating = ?,
|
|
||||||
lastScraped = ?,
|
|
||||||
strReleaseType = ?
|
|
||||||
WHERE idAlbum = ?
|
|
||||||
''', (args))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def add_album(self, *args):
|
|
||||||
"""
|
|
||||||
strReleaseType: 'album' or 'single'
|
|
||||||
"""
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT INTO album(
|
|
||||||
idAlbum,
|
|
||||||
strAlbum,
|
|
||||||
strMusicBrainzAlbumID,
|
|
||||||
strArtistDisp,
|
|
||||||
strGenres,
|
|
||||||
iYear,
|
|
||||||
bCompilation,
|
|
||||||
strReview,
|
|
||||||
strImage,
|
|
||||||
strLabel,
|
|
||||||
iUserrating,
|
|
||||||
lastScraped,
|
|
||||||
strReleaseType)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''', (args))
|
|
||||||
else:
|
|
||||||
args = list(args)
|
|
||||||
del args[8]
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT INTO album(
|
|
||||||
idAlbum,
|
|
||||||
strAlbum,
|
|
||||||
strMusicBrainzAlbumID,
|
|
||||||
strArtistDisp,
|
|
||||||
strGenres,
|
|
||||||
iYear,
|
|
||||||
bCompilation,
|
|
||||||
strReview,
|
|
||||||
strLabel,
|
|
||||||
iUserrating,
|
|
||||||
lastScraped,
|
|
||||||
strReleaseType)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''', (args))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def update_album(self, *args):
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.cursor.execute('''
|
|
||||||
UPDATE album
|
|
||||||
SET strAlbum = ?,
|
|
||||||
strMusicBrainzAlbumID = ?,
|
|
||||||
strArtistDisp = ?,
|
|
||||||
strGenres = ?,
|
|
||||||
iYear = ?,
|
|
||||||
bCompilation = ?,
|
|
||||||
strReview = ?,
|
|
||||||
strImage = ?,
|
|
||||||
strLabel = ?,
|
|
||||||
iUserrating = ?,
|
|
||||||
lastScraped = ?,
|
|
||||||
strReleaseType = ?
|
|
||||||
WHERE idAlbum = ?
|
|
||||||
''', (args))
|
|
||||||
else:
|
|
||||||
args = list(args)
|
|
||||||
del args[7]
|
|
||||||
self.cursor.execute('''
|
|
||||||
UPDATE album
|
|
||||||
SET strAlbum = ?,
|
|
||||||
strMusicBrainzAlbumID = ?,
|
|
||||||
strArtistDisp = ?,
|
|
||||||
strGenres = ?,
|
|
||||||
iYear = ?,
|
|
||||||
bCompilation = ?,
|
|
||||||
strReview = ?,
|
|
||||||
strLabel = ?,
|
|
||||||
iUserrating = ?,
|
|
||||||
lastScraped = ?,
|
|
||||||
strReleaseType = ?
|
|
||||||
WHERE idAlbum = ?
|
|
||||||
''', (args))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def add_albumartist(self, artist_id, kodi_id, artistname):
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT OR REPLACE INTO album_artist(
|
|
||||||
idArtist,
|
|
||||||
idAlbum,
|
|
||||||
strArtist)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
''', (artist_id, kodi_id, artistname))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def add_music_genres(self, kodiid, genres, mediatype):
|
|
||||||
"""
|
|
||||||
Adds a list of genres (list of unicode) for a certain Kodi item
|
|
||||||
"""
|
|
||||||
if mediatype == "album":
|
|
||||||
# Delete current genres for clean slate
|
|
||||||
self.cursor.execute('DELETE FROM album_genre WHERE idAlbum = ?',
|
|
||||||
(kodiid, ))
|
|
||||||
for genre in genres:
|
|
||||||
self.cursor.execute('SELECT idGenre FROM genre WHERE strGenre = ?',
|
|
||||||
(genre, ))
|
|
||||||
try:
|
|
||||||
genreid = self.cursor.fetchone()[0]
|
|
||||||
except TypeError:
|
|
||||||
# Create the genre
|
|
||||||
self.cursor.execute('INSERT INTO genre(strGenre) VALUES(?)',
|
|
||||||
(genre, ))
|
|
||||||
genreid = self.cursor.lastrowid
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT OR REPLACE INTO album_genre(
|
|
||||||
idGenre,
|
|
||||||
idAlbum)
|
|
||||||
VALUES (?, ?)
|
|
||||||
''', (genreid, kodiid))
|
|
||||||
elif mediatype == "song":
|
|
||||||
# Delete current genres for clean slate
|
|
||||||
self.cursor.execute('DELETE FROM song_genre WHERE idSong = ?',
|
|
||||||
(kodiid, ))
|
|
||||||
for genre in genres:
|
|
||||||
self.cursor.execute('SELECT idGenre FROM genre WHERE strGenre = ?',
|
|
||||||
(genre, ))
|
|
||||||
try:
|
|
||||||
genreid = self.cursor.fetchone()[0]
|
|
||||||
except TypeError:
|
|
||||||
# Create the genre
|
|
||||||
self.cursor.execute('INSERT INTO genre(strGenre) VALUES (?)',
|
|
||||||
(genre, ))
|
|
||||||
genreid = self.cursor.lastrowid
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT OR REPLACE INTO song_genre(
|
|
||||||
idGenre,
|
|
||||||
idSong,
|
|
||||||
iOrder)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
''', (genreid, kodiid, 0))
|
|
||||||
|
|
||||||
def add_song_id(self):
|
|
||||||
self.cursor.execute('SELECT COALESCE(MAX(idSong),0) FROM song')
|
|
||||||
return self.cursor.fetchone()[0] + 1
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def add_song(self, *args):
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT INTO song(
|
|
||||||
idSong,
|
|
||||||
idAlbum,
|
|
||||||
idPath,
|
|
||||||
strArtistDisp,
|
|
||||||
strGenres,
|
|
||||||
strTitle,
|
|
||||||
iTrack,
|
|
||||||
iDuration,
|
|
||||||
iYear,
|
|
||||||
strFileName,
|
|
||||||
strMusicBrainzTrackID,
|
|
||||||
iTimesPlayed,
|
|
||||||
lastplayed,
|
|
||||||
rating,
|
|
||||||
iStartOffset,
|
|
||||||
iEndOffset,
|
|
||||||
mood,
|
|
||||||
dateAdded)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''', (args))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def add_song_17(self, *args):
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT INTO song(
|
|
||||||
idSong,
|
|
||||||
idAlbum,
|
|
||||||
idPath,
|
|
||||||
strArtists,
|
|
||||||
strGenres,
|
|
||||||
strTitle,
|
|
||||||
iTrack,
|
|
||||||
iDuration,
|
|
||||||
iYear,
|
|
||||||
strFileName,
|
|
||||||
strMusicBrainzTrackID,
|
|
||||||
iTimesPlayed,
|
|
||||||
lastplayed,
|
|
||||||
rating,
|
|
||||||
iStartOffset,
|
|
||||||
iEndOffset,
|
|
||||||
mood,
|
|
||||||
dateAdded)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''', (args))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def update_song(self, *args):
|
|
||||||
self.cursor.execute('''
|
|
||||||
UPDATE song
|
|
||||||
SET idAlbum = ?,
|
|
||||||
strArtistDisp = ?,
|
|
||||||
strGenres = ?,
|
|
||||||
strTitle = ?,
|
|
||||||
iTrack = ?,
|
|
||||||
iDuration = ?,
|
|
||||||
iYear = ?,
|
|
||||||
strFilename = ?,
|
|
||||||
iTimesPlayed = ?,
|
|
||||||
lastplayed = ?,
|
|
||||||
rating = ?,
|
|
||||||
comment = ?,
|
|
||||||
mood = ?,
|
|
||||||
dateAdded = ?
|
|
||||||
WHERE idSong = ?
|
|
||||||
''', (args))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def set_playcount(self, *args):
|
|
||||||
self.cursor.execute('''
|
|
||||||
UPDATE song
|
|
||||||
SET iTimesPlayed = ?,
|
|
||||||
lastplayed = ?
|
|
||||||
WHERE idSong = ?
|
|
||||||
''', (args))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def update_song_17(self, *args):
|
|
||||||
self.cursor.execute('''
|
|
||||||
UPDATE song
|
|
||||||
SET idAlbum = ?,
|
|
||||||
strArtists = ?,
|
|
||||||
strGenres = ?,
|
|
||||||
strTitle = ?,
|
|
||||||
iTrack = ?,
|
|
||||||
iDuration = ?,
|
|
||||||
iYear = ?,
|
|
||||||
strFilename = ?,
|
|
||||||
iTimesPlayed = ?,
|
|
||||||
lastplayed = ?,
|
|
||||||
rating = ?,
|
|
||||||
comment = ?,
|
|
||||||
mood = ?,
|
|
||||||
dateAdded = ?
|
|
||||||
WHERE idSong = ?
|
|
||||||
''', (args))
|
|
||||||
|
|
||||||
def path_id_from_song(self, kodi_id):
|
|
||||||
self.cursor.execute('SELECT idPath FROM song WHERE idSong = ? LIMIT 1',
|
|
||||||
(kodi_id, ))
|
|
||||||
try:
|
|
||||||
return self.cursor.fetchone()[0]
|
|
||||||
except TypeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def add_artist(self, name, musicbrainz):
|
|
||||||
"""
|
|
||||||
Adds a single artist's name to the db
|
|
||||||
"""
|
|
||||||
self.cursor.execute('''
|
|
||||||
SELECT idArtist, strArtist
|
|
||||||
FROM artist
|
|
||||||
WHERE strMusicBrainzArtistID = ?
|
|
||||||
''', (musicbrainz, ))
|
|
||||||
try:
|
|
||||||
result = self.cursor.fetchone()
|
|
||||||
artistid = result[0]
|
|
||||||
artistname = result[1]
|
|
||||||
except TypeError:
|
|
||||||
self.cursor.execute('SELECT idArtist FROM artist WHERE strArtist = ? COLLATE NOCASE',
|
|
||||||
(name, ))
|
|
||||||
try:
|
|
||||||
artistid = self.cursor.fetchone()[0]
|
|
||||||
except TypeError:
|
|
||||||
# Krypton has a dummy first entry idArtist: 1 strArtist:
|
|
||||||
# [Missing Tag] strMusicBrainzArtistID: Artist Tag Missing
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT INTO artist(strArtist, strMusicBrainzArtistID)
|
|
||||||
VALUES (?, ?)
|
|
||||||
''', (name, musicbrainz))
|
|
||||||
artistid = self.cursor.lastrowid
|
|
||||||
else:
|
|
||||||
if artistname != name:
|
|
||||||
self.cursor.execute('UPDATE artist SET strArtist = ? WHERE idArtist = ?',
|
|
||||||
(name, artistid,))
|
|
||||||
return artistid
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def update_artist(self, *args):
|
|
||||||
if app.SYNC.artwork:
|
|
||||||
self.cursor.execute('''
|
|
||||||
UPDATE artist
|
|
||||||
SET strGenres = ?,
|
|
||||||
strBiography = ?,
|
|
||||||
strImage = ?,
|
|
||||||
strFanart = ?,
|
|
||||||
lastScraped = ?
|
|
||||||
WHERE idArtist = ?
|
|
||||||
''', (args))
|
|
||||||
else:
|
|
||||||
args = list(args)
|
|
||||||
del args[3], args[2]
|
|
||||||
self.cursor.execute('''
|
|
||||||
UPDATE artist
|
|
||||||
SET strGenres = ?,
|
|
||||||
strBiography = ?,
|
|
||||||
lastScraped = ?
|
|
||||||
WHERE idArtist = ?
|
|
||||||
''', (args))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def remove_song(self, kodi_id):
|
|
||||||
self.cursor.execute('DELETE FROM song WHERE idSong = ?', (kodi_id, ))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def remove_path(self, path_id):
|
|
||||||
self.cursor.execute('DELETE FROM path WHERE idPath = ?', (path_id, ))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def add_song_artist(self, artist_id, song_id, artist_name):
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT OR REPLACE INTO song_artist(
|
|
||||||
idArtist,
|
|
||||||
idSong,
|
|
||||||
idRole,
|
|
||||||
iOrder,
|
|
||||||
strArtist)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
''', (artist_id, song_id, 1, 0, artist_name))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def add_albuminfosong(self, song_id, album_id, track_no, track_title,
|
|
||||||
runtime):
|
|
||||||
"""
|
|
||||||
Kodi 17 only
|
|
||||||
"""
|
|
||||||
self.cursor.execute('''
|
|
||||||
INSERT OR REPLACE INTO albuminfosong(
|
|
||||||
idAlbumInfoSong,
|
|
||||||
idAlbumInfo,
|
|
||||||
iTrack,
|
|
||||||
strTitle,
|
|
||||||
iDuration)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
''', (song_id, album_id, track_no, track_title, runtime))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def update_userrating(self, kodi_id, kodi_type, userrating):
|
|
||||||
"""
|
|
||||||
Updates userrating for songs and albums
|
|
||||||
"""
|
|
||||||
if kodi_type == v.KODI_TYPE_SONG:
|
|
||||||
column = 'userrating'
|
|
||||||
identifier = 'idSong'
|
|
||||||
elif kodi_type == v.KODI_TYPE_ALBUM:
|
|
||||||
column = 'iUserrating'
|
|
||||||
identifier = 'idAlbum'
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
self.cursor.execute('''UPDATE %s SET %s = ? WHERE ? = ?'''
|
|
||||||
% (kodi_type, column),
|
|
||||||
(userrating, identifier, kodi_id))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def remove_albuminfosong(self, kodi_id):
|
|
||||||
"""
|
|
||||||
Kodi 17 only
|
|
||||||
"""
|
|
||||||
self.cursor.execute('DELETE FROM albuminfosong WHERE idAlbumInfoSong = ?',
|
|
||||||
(kodi_id, ))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def remove_album(self, kodi_id):
|
|
||||||
if v.KODIVERSION < 18:
|
|
||||||
self.cursor.execute('DELETE FROM albuminfosong WHERE idAlbumInfo = ?',
|
|
||||||
(kodi_id, ))
|
|
||||||
self.cursor.execute('DELETE FROM album_artist WHERE idAlbum = ?',
|
|
||||||
(kodi_id, ))
|
|
||||||
self.cursor.execute('DELETE FROM album WHERE idAlbum = ?', (kodi_id, ))
|
|
||||||
|
|
||||||
@db.catch_operationalerrors
|
|
||||||
def remove_artist(self, kodi_id):
|
|
||||||
self.cursor.execute('DELETE FROM album_artist WHERE idArtist = ?',
|
|
||||||
(kodi_id, ))
|
|
||||||
self.cursor.execute('DELETE FROM artist WHERE idArtist = ?',
|
|
||||||
(kodi_id, ))
|
|
||||||
self.cursor.execute('DELETE FROM song_artist WHERE idArtist = ?',
|
|
||||||
(kodi_id, ))
|
|
|
@ -1,17 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
from . import common
|
|
||||||
|
|
||||||
|
|
||||||
class KodiTextureDB(common.KodiDBBase):
|
|
||||||
db_kind = 'texture'
|
|
||||||
|
|
||||||
def url_not_yet_cached(self, url):
|
|
||||||
"""
|
|
||||||
Returns True if url has not yet been cached to the Kodi texture cache
|
|
||||||
"""
|
|
||||||
self.artcursor.execute('SELECT url FROM texture WHERE url = ? LIMIT 1',
|
|
||||||
(url, ))
|
|
||||||
return self.artcursor.fetchone() is None
|
|
File diff suppressed because it is too large
Load diff
1215
resources/lib/kodidb_functions.py
Normal file
1215
resources/lib/kodidb_functions.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,42 +1,73 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
"""
|
||||||
PKC Kodi Monitoring implementation
|
PKC Kodi Monitoring implementation
|
||||||
"""
|
"""
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from json import loads
|
from json import loads
|
||||||
|
from threading import Thread
|
||||||
import copy
|
import copy
|
||||||
import json
|
|
||||||
import binascii
|
|
||||||
|
|
||||||
import xbmc
|
import xbmc
|
||||||
|
from xbmcgui import Window
|
||||||
|
|
||||||
from .plex_api import API
|
import plexdb_functions as plexdb
|
||||||
from .plex_db import PlexDB
|
import kodidb_functions as kodidb
|
||||||
from .kodi_db import KodiVideoDB
|
from utils import window, settings, plex_command, thread_methods, try_encode, \
|
||||||
from . import kodi_db
|
kodi_time_to_millis, unix_date_to_kodi, unix_timestamp
|
||||||
from .downloadutils import DownloadUtils as DU
|
from PlexFunctions import scrobble
|
||||||
from . import utils, timing, plex_functions as PF
|
from downloadutils import DownloadUtils as DU
|
||||||
from . import json_rpc as js, playqueue as PQ, playlist_func as PL
|
from kodidb_functions import kodiid_from_filename
|
||||||
from . import backgroundthread, app, variables as v
|
from plexbmchelper.subscribers import LOCKER
|
||||||
from . import exceptions
|
from playback import playback_triage
|
||||||
|
from initialsetup import set_replace_paths
|
||||||
|
import playqueue as PQ
|
||||||
|
import json_rpc as js
|
||||||
|
import playlist_func as PL
|
||||||
|
import state
|
||||||
|
import variables as v
|
||||||
|
|
||||||
LOG = getLogger('PLEX.kodimonitor')
|
###############################################################################
|
||||||
|
|
||||||
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
|
# settings: window-variable
|
||||||
|
WINDOW_SETTINGS = {
|
||||||
|
'plex_restricteduser': 'plex_restricteduser',
|
||||||
|
'force_transcode_pix': 'plex_force_transcode_pix'
|
||||||
|
}
|
||||||
|
|
||||||
|
# settings: state-variable (state.py)
|
||||||
|
# Need to use getattr and setattr!
|
||||||
|
STATE_SETTINGS = {
|
||||||
|
'dbSyncIndicator': 'SYNC_DIALOG',
|
||||||
|
'remapSMB': 'REMAP_PATH',
|
||||||
|
'remapSMBmovieOrg': 'remapSMBmovieOrg',
|
||||||
|
'remapSMBmovieNew': 'remapSMBmovieNew',
|
||||||
|
'remapSMBtvOrg': 'remapSMBtvOrg',
|
||||||
|
'remapSMBtvNew': 'remapSMBtvNew',
|
||||||
|
'remapSMBmusicOrg': 'remapSMBmusicOrg',
|
||||||
|
'remapSMBmusicNew': 'remapSMBmusicNew',
|
||||||
|
'remapSMBphotoOrg': 'remapSMBphotoOrg',
|
||||||
|
'remapSMBphotoNew': 'remapSMBphotoNew',
|
||||||
|
'enableMusic': 'ENABLE_MUSIC',
|
||||||
|
'forceReloadSkinOnPlaybackStop': 'FORCE_RELOAD_SKIN',
|
||||||
|
'fetch_pms_item_number': 'FETCH_PMS_ITEM_NUMBER',
|
||||||
|
'imageSyncNotifications': 'IMAGE_SYNC_NOTIFICATIONS'
|
||||||
|
}
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
class KodiMonitor(xbmc.Monitor):
|
class KodiMonitor(xbmc.Monitor):
|
||||||
"""
|
"""
|
||||||
PKC implementation of the Kodi Monitor class. Invoke only once.
|
PKC implementation of the Kodi Monitor class. Invoke only once.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self.xbmcplayer = xbmc.Player()
|
||||||
self._already_slept = False
|
self._already_slept = False
|
||||||
self._switched_to_plex_streams = True
|
|
||||||
xbmc.Monitor.__init__(self)
|
xbmc.Monitor.__init__(self)
|
||||||
for playerid in app.PLAYSTATE.player_states:
|
for playerid in state.PLAYER_STATES:
|
||||||
app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
|
state.PLAYER_STATES[playerid] = copy.deepcopy(state.PLAYSTATE)
|
||||||
app.PLAYSTATE.old_player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
|
state.OLD_PLAYER_STATES[playerid] = copy.deepcopy(state.PLAYSTATE)
|
||||||
LOG.info("Kodi monitor started.")
|
LOG.info("Kodi monitor started.")
|
||||||
|
|
||||||
def onScanStarted(self, library):
|
def onScanStarted(self, library):
|
||||||
|
@ -56,6 +87,46 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
Monitor the PKC settings for changes made by the user
|
Monitor the PKC settings for changes made by the user
|
||||||
"""
|
"""
|
||||||
LOG.debug('PKC settings change detected')
|
LOG.debug('PKC settings change detected')
|
||||||
|
changed = False
|
||||||
|
# Reset the window variables from the settings variables
|
||||||
|
for settings_value, window_value in WINDOW_SETTINGS.iteritems():
|
||||||
|
if window(window_value) != settings(settings_value):
|
||||||
|
changed = True
|
||||||
|
LOG.debug('PKC window settings changed: %s is now %s',
|
||||||
|
settings_value, settings(settings_value))
|
||||||
|
window(window_value, value=settings(settings_value))
|
||||||
|
# Reset the state variables in state.py
|
||||||
|
for settings_value, state_name in STATE_SETTINGS.iteritems():
|
||||||
|
new = settings(settings_value)
|
||||||
|
if new == 'true':
|
||||||
|
new = True
|
||||||
|
elif new == 'false':
|
||||||
|
new = False
|
||||||
|
if getattr(state, state_name) != new:
|
||||||
|
changed = True
|
||||||
|
LOG.debug('PKC state settings %s changed from %s to %s',
|
||||||
|
settings_value, getattr(state, state_name), new)
|
||||||
|
setattr(state, state_name, new)
|
||||||
|
if state_name == 'FETCH_PMS_ITEM_NUMBER':
|
||||||
|
LOG.info('Requesting playlist/nodes refresh')
|
||||||
|
plex_command('RUN_LIB_SCAN', 'views')
|
||||||
|
# Special cases, overwrite all internal settings
|
||||||
|
set_replace_paths()
|
||||||
|
state.BACKGROUND_SYNC_DISABLED = settings(
|
||||||
|
'enableBackgroundSync') == 'false'
|
||||||
|
state.FULL_SYNC_INTERVALL = int(settings('fullSyncInterval')) * 60
|
||||||
|
state.BACKGROUNDSYNC_SAFTYMARGIN = int(
|
||||||
|
settings('backgroundsync_saftyMargin'))
|
||||||
|
state.SYNC_THREAD_NUMBER = int(settings('syncThreadNumber'))
|
||||||
|
state.SSL_CERT_PATH = settings('sslcert') \
|
||||||
|
if settings('sslcert') != 'None' else None
|
||||||
|
# Never set through the user
|
||||||
|
# state.KODI_PLEX_TIME_OFFSET = float(settings('kodiplextimeoffset'))
|
||||||
|
if changed is True:
|
||||||
|
# Assume that the user changed the settings so that we can now find
|
||||||
|
# the path to all media files
|
||||||
|
state.STOP_SYNC = False
|
||||||
|
state.PATH_VERIFIED = False
|
||||||
|
|
||||||
def onNotification(self, sender, method, data):
|
def onNotification(self, sender, method, data):
|
||||||
"""
|
"""
|
||||||
|
@ -66,50 +137,72 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
LOG.debug("Method: %s Data: %s", method, data)
|
LOG.debug("Method: %s Data: %s", method, data)
|
||||||
|
|
||||||
if method == "Player.OnPlay":
|
if method == "Player.OnPlay":
|
||||||
with app.APP.lock_playqueues:
|
state.SUSPEND_SYNC = True
|
||||||
self.PlayBackStart(data)
|
self.PlayBackStart(data)
|
||||||
elif method == 'Player.OnAVChange':
|
|
||||||
with app.APP.lock_playqueues:
|
|
||||||
self._on_av_change(data)
|
|
||||||
elif method == "Player.OnStop":
|
elif method == "Player.OnStop":
|
||||||
with app.APP.lock_playqueues:
|
# Should refresh our video nodes, e.g. on deck
|
||||||
_playback_cleanup(ended=data.get('end'))
|
# xbmc.executebuiltin('ReloadSkin()')
|
||||||
|
if data.get('end'):
|
||||||
|
if state.PKC_CAUSED_STOP is True:
|
||||||
|
state.PKC_CAUSED_STOP = False
|
||||||
|
LOG.debug('PKC caused this playback stop - ignoring')
|
||||||
|
else:
|
||||||
|
_playback_cleanup(ended=True)
|
||||||
|
else:
|
||||||
|
_playback_cleanup()
|
||||||
|
state.PKC_CAUSED_STOP_DONE = True
|
||||||
|
state.SUSPEND_SYNC = False
|
||||||
elif method == 'Playlist.OnAdd':
|
elif method == 'Playlist.OnAdd':
|
||||||
if 'item' in data and data['item'].get('type') == v.KODI_TYPE_SHOW:
|
self._playlist_onadd(data)
|
||||||
# Hitting the "browse" button on tv show info dialog
|
|
||||||
# Hence show the tv show directly
|
|
||||||
xbmc.executebuiltin("Dialog.Close(all, true)")
|
|
||||||
js.activate_window('videos',
|
|
||||||
'videodb://tvshows/titles/%s/' % data['item']['id'])
|
|
||||||
with app.APP.lock_playqueues:
|
|
||||||
self._playlist_onadd(data)
|
|
||||||
elif method == 'Playlist.OnRemove':
|
elif method == 'Playlist.OnRemove':
|
||||||
self._playlist_onremove(data)
|
self._playlist_onremove(data)
|
||||||
elif method == 'Playlist.OnClear':
|
elif method == 'Playlist.OnClear':
|
||||||
with app.APP.lock_playqueues:
|
self._playlist_onclear(data)
|
||||||
self._playlist_onclear(data)
|
|
||||||
elif method == "VideoLibrary.OnUpdate":
|
elif method == "VideoLibrary.OnUpdate":
|
||||||
with app.APP.lock_playqueues:
|
# Manually marking as watched/unwatched
|
||||||
_videolibrary_onupdate(data)
|
playcount = data.get('playcount')
|
||||||
|
item = data.get('item')
|
||||||
|
if playcount is None or item is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
kodiid = item['id']
|
||||||
|
item_type = item['type']
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
LOG.info("Item is invalid for playstate update.")
|
||||||
|
return
|
||||||
|
# Send notification to the server.
|
||||||
|
with plexdb.Get_Plex_DB() as plexcur:
|
||||||
|
plex_dbitem = plexcur.getItem_byKodiId(kodiid, item_type)
|
||||||
|
try:
|
||||||
|
itemid = plex_dbitem[0]
|
||||||
|
except TypeError:
|
||||||
|
LOG.error("Could not find itemid in plex database for a "
|
||||||
|
"video library update")
|
||||||
|
else:
|
||||||
|
# notify the server
|
||||||
|
if playcount > 0:
|
||||||
|
scrobble(itemid, 'watched')
|
||||||
|
else:
|
||||||
|
scrobble(itemid, 'unwatched')
|
||||||
elif method == "VideoLibrary.OnRemove":
|
elif method == "VideoLibrary.OnRemove":
|
||||||
pass
|
pass
|
||||||
elif method == "System.OnSleep":
|
elif method == "System.OnSleep":
|
||||||
# Connection is going to sleep
|
# Connection is going to sleep
|
||||||
LOG.info("Marking the server as offline. SystemOnSleep activated.")
|
LOG.info("Marking the server as offline. SystemOnSleep activated.")
|
||||||
|
window('plex_online', value="sleep")
|
||||||
elif method == "System.OnWake":
|
elif method == "System.OnWake":
|
||||||
# Allow network to wake up
|
# Allow network to wake up
|
||||||
self.waitForAbort(10)
|
xbmc.sleep(10000)
|
||||||
app.CONN.online = False
|
window('plex_online', value="false")
|
||||||
elif method == "GUI.OnScreensaverDeactivated":
|
elif method == "GUI.OnScreensaverDeactivated":
|
||||||
if utils.settings('dbSyncScreensaver') == "true":
|
if settings('dbSyncScreensaver') == "true":
|
||||||
self.waitForAbort(5)
|
xbmc.sleep(5000)
|
||||||
app.SYNC.run_lib_scan = 'full'
|
plex_command('RUN_LIB_SCAN', 'full')
|
||||||
elif method == "System.OnQuit":
|
elif method == "System.OnQuit":
|
||||||
LOG.info('Kodi OnQuit detected - shutting down')
|
LOG.info('Kodi OnQuit detected - shutting down')
|
||||||
app.APP.stop_pkc = True
|
state.STOP_PKC = True
|
||||||
elif method == 'Other.plugin.video.plexkodiconnect_play_action':
|
|
||||||
self._start_next_episode(data)
|
|
||||||
|
|
||||||
|
@LOCKER.lockthis
|
||||||
def _playlist_onadd(self, data):
|
def _playlist_onadd(self, data):
|
||||||
"""
|
"""
|
||||||
Called if an item is added to a Kodi playlist. Example data dict:
|
Called if an item is added to a Kodi playlist. Example data dict:
|
||||||
|
@ -122,7 +215,26 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
}
|
}
|
||||||
Will NOT be called if playback initiated by Kodi widgets
|
Will NOT be called if playback initiated by Kodi widgets
|
||||||
"""
|
"""
|
||||||
pass
|
if 'id' not in data['item']:
|
||||||
|
return
|
||||||
|
old = state.OLD_PLAYER_STATES[data['playlistid']]
|
||||||
|
if (not state.DIRECT_PATHS and data['position'] == 0 and
|
||||||
|
not PQ.PLAYQUEUES[data['playlistid']].items and
|
||||||
|
data['item']['type'] == old['kodi_type'] and
|
||||||
|
data['item']['id'] == old['kodi_id']):
|
||||||
|
# Hack we need for RESUMABLE items because Kodi lost the path of the
|
||||||
|
# last played item that is now being replayed (see playback.py's
|
||||||
|
# Player().play()) Also see playqueue.py _compare_playqueues()
|
||||||
|
LOG.info('Detected re-start of playback of last item')
|
||||||
|
kwargs = {
|
||||||
|
'plex_id': old['plex_id'],
|
||||||
|
'plex_type': old['plex_type'],
|
||||||
|
'path': old['file'],
|
||||||
|
'resolve': False
|
||||||
|
}
|
||||||
|
thread = Thread(target=playback_triage, kwargs=kwargs)
|
||||||
|
thread.start()
|
||||||
|
return
|
||||||
|
|
||||||
def _playlist_onremove(self, data):
|
def _playlist_onremove(self, data):
|
||||||
"""
|
"""
|
||||||
|
@ -134,8 +246,8 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@LOCKER.lockthis
|
||||||
def _playlist_onclear(data):
|
def _playlist_onclear(self, data):
|
||||||
"""
|
"""
|
||||||
Called if a Kodi playlist is cleared. Example data dict:
|
Called if a Kodi playlist is cleared. Example data dict:
|
||||||
{
|
{
|
||||||
|
@ -149,8 +261,7 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
else:
|
else:
|
||||||
LOG.debug('Detected PKC clear - ignoring')
|
LOG.debug('Detected PKC clear - ignoring')
|
||||||
|
|
||||||
@staticmethod
|
def _get_ids(self, kodi_id, kodi_type, path):
|
||||||
def _get_ids(kodi_id, kodi_type, path):
|
|
||||||
"""
|
"""
|
||||||
Returns the tuple (plex_id, plex_type) or (None, None)
|
Returns the tuple (plex_id, plex_type) or (None, None)
|
||||||
"""
|
"""
|
||||||
|
@ -159,13 +270,16 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
plex_type = None
|
plex_type = None
|
||||||
# If using direct paths and starting playback from a widget
|
# If using direct paths and starting playback from a widget
|
||||||
if not kodi_id and kodi_type and path:
|
if not kodi_id and kodi_type and path:
|
||||||
kodi_id, _ = kodi_db.kodiid_from_filename(path, kodi_type)
|
kodi_id = kodiid_from_filename(path, kodi_type)
|
||||||
if kodi_id:
|
if kodi_id:
|
||||||
with PlexDB() as plexdb:
|
with plexdb.Get_Plex_DB() as plex_db:
|
||||||
db_item = plexdb.item_by_kodi_id(kodi_id, kodi_type)
|
plex_dbitem = plex_db.getItem_byKodiId(kodi_id, kodi_type)
|
||||||
if db_item:
|
try:
|
||||||
plex_id = db_item['plex_id']
|
plex_id = plex_dbitem[0]
|
||||||
plex_type = db_item['plex_type']
|
plex_type = plex_dbitem[2]
|
||||||
|
except TypeError:
|
||||||
|
# No plex id, hence item not in the library. E.g. clips
|
||||||
|
pass
|
||||||
return plex_id, plex_type
|
return plex_id, plex_type
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -182,8 +296,8 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
items.pop(0)
|
items.pop(0)
|
||||||
try:
|
try:
|
||||||
for i, item in enumerate(items):
|
for i, item in enumerate(items):
|
||||||
PL.add_item_to_plex_playqueue(playqueue, i + 1, kodi_item=item)
|
PL.add_item_to_PMS_playlist(playqueue, i + 1, kodi_item=item)
|
||||||
except exceptions.PlaylistError:
|
except PL.PlaylistError:
|
||||||
LOG.info('Could not build Plex playlist for: %s', items)
|
LOG.info('Could not build Plex playlist for: %s', items)
|
||||||
|
|
||||||
def _json_item(self, playerid):
|
def _json_item(self, playerid):
|
||||||
|
@ -197,29 +311,14 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
# start as Kodi updates this info very late!! Might get previous
|
# start as Kodi updates this info very late!! Might get previous
|
||||||
# element otherwise
|
# element otherwise
|
||||||
self._already_slept = True
|
self._already_slept = True
|
||||||
self.waitForAbort(1)
|
xbmc.sleep(1000)
|
||||||
try:
|
json_item = js.get_item(playerid)
|
||||||
json_item = js.get_item(playerid)
|
|
||||||
except KeyError:
|
|
||||||
LOG.debug('No playing item returned by Kodi')
|
|
||||||
return None, None, None
|
|
||||||
LOG.debug('Kodi playing item properties: %s', json_item)
|
LOG.debug('Kodi playing item properties: %s', json_item)
|
||||||
return (json_item.get('id'),
|
return (json_item.get('id'),
|
||||||
json_item.get('type'),
|
json_item.get('type'),
|
||||||
json_item.get('file'))
|
json_item.get('file'))
|
||||||
|
|
||||||
@staticmethod
|
@LOCKER.lockthis
|
||||||
def _start_next_episode(data):
|
|
||||||
"""
|
|
||||||
Used for the add-on Upnext to start playback of the next episode
|
|
||||||
"""
|
|
||||||
LOG.info('Upnext: Start playback of the next episode')
|
|
||||||
play_info = binascii.unhexlify(data[0])
|
|
||||||
play_info = json.loads(play_info)
|
|
||||||
app.APP.player.stop()
|
|
||||||
handle = 'RunPlugin(%s)' % play_info.get('handle')
|
|
||||||
xbmc.executebuiltin(handle.encode('utf-8'))
|
|
||||||
|
|
||||||
def PlayBackStart(self, data):
|
def PlayBackStart(self, data):
|
||||||
"""
|
"""
|
||||||
Called whenever playback is started. Example data:
|
Called whenever playback is started. Example data:
|
||||||
|
@ -237,41 +336,23 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
LOG.info('Aborting playback report - item invalid for updates %s',
|
LOG.info('Aborting playback report - item invalid for updates %s',
|
||||||
data)
|
data)
|
||||||
return
|
return
|
||||||
kodi_id = data['item'].get('id') if 'item' in data else None
|
|
||||||
kodi_type = data['item'].get('type') if 'item' in data else None
|
|
||||||
path = data['item'].get('file') if 'item' in data else None
|
|
||||||
if playerid == -1:
|
if playerid == -1:
|
||||||
# Kodi might return -1 for "last player"
|
# Kodi might return -1 for "last player"
|
||||||
# Getting the playerid is really a PITA
|
|
||||||
try:
|
try:
|
||||||
playerid = js.get_player_ids()[0]
|
playerid = js.get_player_ids()[0]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
# E.g. Kodi 18 doesn't tell us anything useful
|
LOG.error('Could not retreive active player - aborting')
|
||||||
if kodi_type in v.KODI_VIDEOTYPES:
|
return
|
||||||
playlist_type = v.KODI_TYPE_VIDEO_PLAYLIST
|
|
||||||
elif kodi_type in v.KODI_AUDIOTYPES:
|
|
||||||
playlist_type = v.KODI_TYPE_AUDIO_PLAYLIST
|
|
||||||
else:
|
|
||||||
LOG.error('Unexpected type %s, data %s', kodi_type, data)
|
|
||||||
return
|
|
||||||
playerid = js.get_playlist_id(playlist_type)
|
|
||||||
if not playerid:
|
|
||||||
LOG.error('Coud not get playerid for data %s', data)
|
|
||||||
return
|
|
||||||
playqueue = PQ.PLAYQUEUES[playerid]
|
playqueue = PQ.PLAYQUEUES[playerid]
|
||||||
info = js.get_player_props(playerid)
|
info = js.get_player_props(playerid)
|
||||||
if playqueue.kodi_playlist_playback:
|
pos = info['position'] if info['position'] != -1 else 0
|
||||||
# Kodi will tell us the wrong position - of the playlist, not the
|
LOG.debug('Detected position %s for %s', pos, playqueue)
|
||||||
# playqueue, when user starts playing from a playlist :-(
|
status = state.PLAYER_STATES[playerid]
|
||||||
pos = 0
|
kodi_id = data.get('id')
|
||||||
LOG.debug('Detected playback from a Kodi playlist')
|
kodi_type = data.get('type')
|
||||||
else:
|
path = data.get('file')
|
||||||
pos = info['position'] if info['position'] != -1 else 0
|
|
||||||
LOG.debug('Detected position %s for %s', pos, playqueue)
|
|
||||||
status = app.PLAYSTATE.player_states[playerid]
|
|
||||||
try:
|
try:
|
||||||
item = playqueue.items[pos]
|
item = playqueue.items[pos]
|
||||||
LOG.debug('PKC playqueue item is: %s', item)
|
|
||||||
except IndexError:
|
except IndexError:
|
||||||
# PKC playqueue not yet initialized
|
# PKC playqueue not yet initialized
|
||||||
LOG.debug('Position %s not in PKC playqueue yet', pos)
|
LOG.debug('Position %s not in PKC playqueue yet', pos)
|
||||||
|
@ -289,24 +370,9 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
# E.g. clips set-up previously with no Kodi DB entry
|
# E.g. clips set-up previously with no Kodi DB entry
|
||||||
if not path:
|
if not path:
|
||||||
kodi_id, kodi_type, path = self._json_item(playerid)
|
kodi_id, kodi_type, path = self._json_item(playerid)
|
||||||
if path == '':
|
|
||||||
LOG.debug('Detected empty path: aborting playback report')
|
|
||||||
return
|
|
||||||
if item.file != path:
|
if item.file != path:
|
||||||
# Clips will get a new path
|
|
||||||
LOG.debug('Detected different path')
|
LOG.debug('Detected different path')
|
||||||
try:
|
initialize = True
|
||||||
tmp_plex_id = int(utils.REGEX_PLEX_ID.findall(path)[0])
|
|
||||||
except (IndexError, TypeError):
|
|
||||||
LOG.debug('No Plex id in path, need to init playqueue')
|
|
||||||
initialize = True
|
|
||||||
else:
|
|
||||||
if tmp_plex_id == item.plex_id:
|
|
||||||
LOG.debug('Detected different path for the same id')
|
|
||||||
initialize = False
|
|
||||||
else:
|
|
||||||
LOG.debug('Different Plex id, need to init playqueue')
|
|
||||||
initialize = True
|
|
||||||
else:
|
else:
|
||||||
initialize = False
|
initialize = False
|
||||||
if initialize:
|
if initialize:
|
||||||
|
@ -316,14 +382,9 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
plex_id, plex_type = self._get_ids(kodi_id, kodi_type, path)
|
plex_id, plex_type = self._get_ids(kodi_id, kodi_type, path)
|
||||||
if not plex_id:
|
if not plex_id:
|
||||||
LOG.debug('No Plex id obtained - aborting playback report')
|
LOG.debug('No Plex id obtained - aborting playback report')
|
||||||
app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
|
state.PLAYER_STATES[playerid] = copy.deepcopy(state.PLAYSTATE)
|
||||||
return
|
return
|
||||||
try:
|
item = PL.init_Plex_playlist(playqueue, plex_id=plex_id)
|
||||||
item = PL.init_plex_playqueue(playqueue, plex_id=plex_id)
|
|
||||||
except exceptions.PlaylistError:
|
|
||||||
LOG.info('Could not initialize the Plex playlist')
|
|
||||||
return
|
|
||||||
item.file = path
|
|
||||||
# Set the Plex container key (e.g. using the Plex playqueue)
|
# Set the Plex container key (e.g. using the Plex playqueue)
|
||||||
container_key = None
|
container_key = None
|
||||||
if info['playlistid'] != -1:
|
if info['playlistid'] != -1:
|
||||||
|
@ -343,13 +404,8 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
container_key = '/playQueues/%s' % playqueue.id
|
container_key = '/playQueues/%s' % playqueue.id
|
||||||
else:
|
else:
|
||||||
container_key = '/library/metadata/%s' % plex_id
|
container_key = '/library/metadata/%s' % plex_id
|
||||||
# Mechanik for Plex skip intro feature
|
|
||||||
if utils.settings('enableSkipIntro') == 'true':
|
|
||||||
status['intro_markers'] = item.api.intro_markers()
|
|
||||||
# Remember the currently playing item
|
|
||||||
app.PLAYSTATE.item = item
|
|
||||||
# Remember that this player has been active
|
# Remember that this player has been active
|
||||||
app.PLAYSTATE.active_players.add(playerid)
|
state.ACTIVE_PLAYERS.append(playerid)
|
||||||
status.update(info)
|
status.update(info)
|
||||||
LOG.debug('Set the Plex container_key to: %s', container_key)
|
LOG.debug('Set the Plex container_key to: %s', container_key)
|
||||||
status['container_key'] = container_key
|
status['container_key'] = container_key
|
||||||
|
@ -360,46 +416,34 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
status['plex_type'] = plex_type
|
status['plex_type'] = plex_type
|
||||||
status['playmethod'] = item.playmethod
|
status['playmethod'] = item.playmethod
|
||||||
status['playcount'] = item.playcount
|
status['playcount'] = item.playcount
|
||||||
status['external_player'] = app.APP.player.isExternalPlayer() == 1
|
|
||||||
LOG.debug('Set the player state: %s', status)
|
LOG.debug('Set the player state: %s', status)
|
||||||
|
|
||||||
# Workaround for the Kodi add-on Up Next
|
|
||||||
if not app.SYNC.direct_paths:
|
|
||||||
_notify_upnext(item)
|
|
||||||
self._switched_to_plex_streams = False
|
|
||||||
|
|
||||||
def _on_av_change(self, data):
|
@thread_methods
|
||||||
"""
|
class SpecialMonitor(Thread):
|
||||||
Will be called when Kodi has a video, audio or subtitle stream. Also
|
"""
|
||||||
happens when the stream changes.
|
Detect the resume dialog for widgets.
|
||||||
|
Could also be used to detect external players (see Emby implementation)
|
||||||
Example data as returned by Kodi:
|
"""
|
||||||
{'item': {'id': 5, 'type': 'movie'},
|
def run(self):
|
||||||
'player': {'playerid': 1, 'speed': 1}}
|
LOG.info("----====# Starting Special Monitor #====----")
|
||||||
|
# "Start from beginning", "Play from beginning"
|
||||||
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE!
|
strings = (try_encode(xbmc.getLocalizedString(12021)),
|
||||||
Kodi subs will never change. Also see json_rpc.py
|
try_encode(xbmc.getLocalizedString(12023)))
|
||||||
"""
|
while not self.stopped():
|
||||||
playerid = data['player']['playerid']
|
if xbmc.getCondVisibility('Window.IsVisible(DialogContextMenu.xml)'):
|
||||||
if not playerid == v.KODI_VIDEO_PLAYER_ID:
|
if xbmc.getInfoLabel('Control.GetLabel(1002)') in strings:
|
||||||
# We're just messing with Kodi's videoplayer
|
# Remember that the item IS indeed resumable
|
||||||
return
|
control = int(Window(10106).getFocusId())
|
||||||
item = app.PLAYSTATE.item
|
state.RESUME_PLAYBACK = True if control == 1001 else False
|
||||||
if item is None:
|
else:
|
||||||
# Player might've quit
|
# Different context menu is displayed
|
||||||
return
|
state.RESUME_PLAYBACK = False
|
||||||
if not self._switched_to_plex_streams:
|
xbmc.sleep(200)
|
||||||
# We need to switch to the Plex streams ONCE upon playback start
|
LOG.info("#====---- Special Monitor Stopped ----====#")
|
||||||
# after onavchange has been fired
|
|
||||||
if utils.settings('audioStreamPick') == '0':
|
|
||||||
item.switch_to_plex_stream('audio')
|
|
||||||
if utils.settings('subtitleStreamPick') == '0':
|
|
||||||
item.switch_to_plex_stream('subtitle')
|
|
||||||
self._switched_to_plex_streams = True
|
|
||||||
else:
|
|
||||||
item.on_av_change(playerid)
|
|
||||||
|
|
||||||
|
|
||||||
|
@LOCKER.lockthis
|
||||||
def _playback_cleanup(ended=False):
|
def _playback_cleanup(ended=False):
|
||||||
"""
|
"""
|
||||||
PKC cleanup after playback ends/is stopped. Pass ended=True if Kodi
|
PKC cleanup after playback ends/is stopped. Pass ended=True if Kodi
|
||||||
|
@ -407,19 +451,16 @@ def _playback_cleanup(ended=False):
|
||||||
timing data otherwise)
|
timing data otherwise)
|
||||||
"""
|
"""
|
||||||
LOG.debug('playback_cleanup called. Active players: %s',
|
LOG.debug('playback_cleanup called. Active players: %s',
|
||||||
app.PLAYSTATE.active_players)
|
state.ACTIVE_PLAYERS)
|
||||||
if app.APP.skip_intro_dialog:
|
|
||||||
app.APP.skip_intro_dialog.close()
|
|
||||||
app.APP.skip_intro_dialog = None
|
|
||||||
# We might have saved a transient token from a user flinging media via
|
# We might have saved a transient token from a user flinging media via
|
||||||
# Companion (if we could not use the playqueue to store the token)
|
# Companion (if we could not use the playqueue to store the token)
|
||||||
app.CONN.plex_transient_token = None
|
state.PLEX_TRANSIENT_TOKEN = None
|
||||||
for playerid in app.PLAYSTATE.active_players:
|
for playerid in state.ACTIVE_PLAYERS:
|
||||||
status = app.PLAYSTATE.player_states[playerid]
|
status = state.PLAYER_STATES[playerid]
|
||||||
# Remember the last played item later
|
# Remember the last played item later
|
||||||
app.PLAYSTATE.old_player_states[playerid] = copy.deepcopy(status)
|
state.OLD_PLAYER_STATES[playerid] = copy.deepcopy(status)
|
||||||
# Stop transcoding
|
# Stop transcoding
|
||||||
if status['playmethod'] == v.PLAYBACK_METHOD_TRANSCODE:
|
if status['playmethod'] == 'Transcode':
|
||||||
LOG.debug('Tell the PMS to stop transcoding')
|
LOG.debug('Tell the PMS to stop transcoding')
|
||||||
DU().downloadUrl(
|
DU().downloadUrl(
|
||||||
'{server}/video/:/transcode/universal/stop',
|
'{server}/video/:/transcode/universal/stop',
|
||||||
|
@ -430,11 +471,9 @@ def _playback_cleanup(ended=False):
|
||||||
# started playback via PMS
|
# started playback via PMS
|
||||||
_record_playstate(status, ended)
|
_record_playstate(status, ended)
|
||||||
# Reset the player's status
|
# Reset the player's status
|
||||||
app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
|
state.PLAYER_STATES[playerid] = copy.deepcopy(state.PLAYSTATE)
|
||||||
# As all playback has halted, reset the players that have been active
|
# As all playback has halted, reset the players that have been active
|
||||||
app.PLAYSTATE.active_players = set()
|
state.ACTIVE_PLAYERS = []
|
||||||
app.PLAYSTATE.item = None
|
|
||||||
utils.delete_temporary_subtitles()
|
|
||||||
LOG.debug('Finished PKC playback cleanup')
|
LOG.debug('Finished PKC playback cleanup')
|
||||||
|
|
||||||
|
|
||||||
|
@ -442,44 +481,30 @@ def _record_playstate(status, ended):
|
||||||
if not status['plex_id']:
|
if not status['plex_id']:
|
||||||
LOG.debug('No Plex id found to record playstate for status %s', status)
|
LOG.debug('No Plex id found to record playstate for status %s', status)
|
||||||
return
|
return
|
||||||
if status['plex_type'] not in v.PLEX_VIDEOTYPES:
|
with plexdb.Get_Plex_DB() as plex_db:
|
||||||
LOG.debug('Not messing with non-video entries')
|
kodi_db_item = plex_db.getItem_byId(status['plex_id'])
|
||||||
return
|
if kodi_db_item is None:
|
||||||
with PlexDB() as plexdb:
|
|
||||||
db_item = plexdb.item_by_id(status['plex_id'], status['plex_type'])
|
|
||||||
if not db_item:
|
|
||||||
# Item not (yet) in Kodi library
|
# Item not (yet) in Kodi library
|
||||||
LOG.debug('No playstate update due to Plex id not found: %s', status)
|
LOG.debug('No playstate update due to Plex id not found: %s', status)
|
||||||
return
|
return
|
||||||
totaltime = float(timing.kodi_time_to_millis(status['totaltime'])) / 1000
|
totaltime = float(kodi_time_to_millis(status['totaltime'])) / 1000
|
||||||
if status['external_player']:
|
if ended:
|
||||||
# video has either been entirely watched - or not.
|
progress = 0.99
|
||||||
# "ended" won't work, need a workaround
|
time = v.IGNORE_SECONDS_AT_START + 1
|
||||||
ended = _external_player_correct_plex_watch_count(db_item)
|
|
||||||
if ended:
|
|
||||||
progress = 0.99
|
|
||||||
time = v.IGNORE_SECONDS_AT_START + 1
|
|
||||||
else:
|
|
||||||
progress = 0.0
|
|
||||||
time = 0.0
|
|
||||||
else:
|
else:
|
||||||
if ended:
|
time = float(kodi_time_to_millis(status['time'])) / 1000
|
||||||
progress = 0.99
|
try:
|
||||||
time = v.IGNORE_SECONDS_AT_START + 1
|
progress = time / totaltime
|
||||||
else:
|
except ZeroDivisionError:
|
||||||
time = float(timing.kodi_time_to_millis(status['time'])) / 1000
|
progress = 0.0
|
||||||
try:
|
LOG.debug('Playback progress %s (%s of %s seconds)',
|
||||||
progress = time / totaltime
|
progress, time, totaltime)
|
||||||
except ZeroDivisionError:
|
|
||||||
progress = 0.0
|
|
||||||
LOG.debug('Playback progress %s (%s of %s seconds)',
|
|
||||||
progress, time, totaltime)
|
|
||||||
playcount = status['playcount']
|
playcount = status['playcount']
|
||||||
last_played = timing.kodi_now()
|
last_played = unix_date_to_kodi(unix_timestamp())
|
||||||
if playcount is None:
|
if playcount is None:
|
||||||
LOG.debug('playcount not found, looking it up in the Kodi DB')
|
LOG.debug('playcount not found, looking it up in the Kodi DB')
|
||||||
with kodi_db.KodiVideoDB() as kodidb:
|
with kodidb.GetKodiDB('video') as kodi_db:
|
||||||
playcount = kodidb.get_playcount(db_item['kodi_fileid'])
|
playcount = kodi_db.get_playcount(kodi_db_item[1])
|
||||||
playcount = 0 if playcount is None else playcount
|
playcount = 0 if playcount is None else playcount
|
||||||
if time < v.IGNORE_SECONDS_AT_START:
|
if time < v.IGNORE_SECONDS_AT_START:
|
||||||
LOG.debug('Ignoring playback less than %s seconds',
|
LOG.debug('Ignoring playback less than %s seconds',
|
||||||
|
@ -493,43 +518,20 @@ def _record_playstate(status, ended):
|
||||||
v.MARK_PLAYED_AT)
|
v.MARK_PLAYED_AT)
|
||||||
playcount += 1
|
playcount += 1
|
||||||
time = 0
|
time = 0
|
||||||
with kodi_db.KodiVideoDB() as kodidb:
|
with kodidb.GetKodiDB('video') as kodi_db:
|
||||||
kodidb.set_resume(db_item['kodi_fileid'],
|
kodi_db.addPlaystate(kodi_db_item[1],
|
||||||
time,
|
time,
|
||||||
totaltime,
|
totaltime,
|
||||||
playcount,
|
playcount,
|
||||||
last_played)
|
last_played)
|
||||||
if 'kodi_fileid_2' in db_item and db_item['kodi_fileid_2']:
|
|
||||||
# Dirty hack for our episodes
|
|
||||||
kodidb.set_resume(db_item['kodi_fileid_2'],
|
|
||||||
time,
|
|
||||||
totaltime,
|
|
||||||
playcount,
|
|
||||||
last_played)
|
|
||||||
# Hack to force "in progress" widget to appear if it wasn't visible before
|
# Hack to force "in progress" widget to appear if it wasn't visible before
|
||||||
if (app.APP.force_reload_skin and
|
if (state.FORCE_RELOAD_SKIN and
|
||||||
xbmc.getCondVisibility('Window.IsVisible(Home.xml)')):
|
xbmc.getCondVisibility('Window.IsVisible(Home.xml)')):
|
||||||
LOG.debug('Refreshing skin to update widgets')
|
LOG.debug('Refreshing skin to update widgets')
|
||||||
xbmc.executebuiltin('ReloadSkin()')
|
xbmc.executebuiltin('ReloadSkin()')
|
||||||
task = backgroundthread.FunctionAsTask(_clean_file_table, None)
|
thread = Thread(target=_clean_file_table)
|
||||||
backgroundthread.BGThreader.addTasksToFront([task])
|
thread.setDaemon(True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
def _external_player_correct_plex_watch_count(db_item):
|
|
||||||
"""
|
|
||||||
Kodi won't safe playstate at all for external players
|
|
||||||
|
|
||||||
There's currently no way to get a resumpoint if an external player is
|
|
||||||
in use We are just checking whether we should mark video as
|
|
||||||
completely watched or completely unwatched (according to
|
|
||||||
playcountminimumtime set in playercorefactory.xml)
|
|
||||||
See https://kodi.wiki/view/External_players
|
|
||||||
"""
|
|
||||||
with kodi_db.KodiVideoDB() as kodidb:
|
|
||||||
playcount = kodidb.get_playcount(db_item['kodi_fileid'])
|
|
||||||
LOG.debug('External player detected. Playcount: %s', playcount)
|
|
||||||
PF.scrobble(db_item['plex_id'], 'watched' if playcount else 'unwatched')
|
|
||||||
return True if playcount else False
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_file_table():
|
def _clean_file_table():
|
||||||
|
@ -540,146 +542,16 @@ def _clean_file_table():
|
||||||
This function tries for at most 5 seconds to clean the file table.
|
This function tries for at most 5 seconds to clean the file table.
|
||||||
"""
|
"""
|
||||||
LOG.debug('Start cleaning Kodi files table')
|
LOG.debug('Start cleaning Kodi files table')
|
||||||
if app.APP.monitor.waitForAbort(2):
|
i = 0
|
||||||
# PKC should exit
|
while i < 100 and not state.STOP_PKC:
|
||||||
return
|
with kodidb.GetKodiDB('video') as kodi_db:
|
||||||
try:
|
files = kodi_db.obsolete_file_ids()
|
||||||
with kodi_db.KodiVideoDB() as kodidb:
|
if files:
|
||||||
obsolete_file_ids = list(kodidb.obsolete_file_ids())
|
|
||||||
for file_id in obsolete_file_ids:
|
|
||||||
LOG.debug('Removing obsolete Kodi file_id %s', file_id)
|
|
||||||
kodidb.remove_file(file_id, remove_orphans=False)
|
|
||||||
except utils.OperationalError:
|
|
||||||
LOG.debug('Database was locked, unable to clean file table')
|
|
||||||
else:
|
|
||||||
LOG.debug('Done cleaning up Kodi file table')
|
|
||||||
|
|
||||||
|
|
||||||
def _next_episode(current_api):
|
|
||||||
"""
|
|
||||||
Returns the xml for the next episode after the current one
|
|
||||||
Returns None if something went wrong or there is no next episode
|
|
||||||
"""
|
|
||||||
xml = PF.show_episodes(current_api.grandparent_id())
|
|
||||||
if xml is None:
|
|
||||||
return
|
|
||||||
for counter, episode in enumerate(xml):
|
|
||||||
api = API(episode)
|
|
||||||
if api.plex_id == current_api.plex_id:
|
|
||||||
break
|
break
|
||||||
else:
|
i += 1
|
||||||
LOG.error('Did not find the episode with Plex id %s for show %s: %s',
|
xbmc.sleep(50)
|
||||||
current_api.plex_id, current_api.grandparent_id(),
|
with kodidb.GetKodiDB('video') as kodi_db:
|
||||||
current_api.grandparent_title())
|
for file_id in files:
|
||||||
return
|
LOG.debug('Removing obsolete Kodi file_id %s', file_id)
|
||||||
try:
|
kodi_db.remove_file(file_id[0], remove_orphans=False)
|
||||||
return API(xml[counter + 1])
|
LOG.debug('Done cleaning up Kodi file table')
|
||||||
except IndexError:
|
|
||||||
# Was the last episode
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _complete_artwork_keys(info):
|
|
||||||
"""
|
|
||||||
Make sure that the minimum set of keys is present in the info dict
|
|
||||||
"""
|
|
||||||
for key in ('tvshow.poster',
|
|
||||||
'tvshow.fanart',
|
|
||||||
'tvshow.landscape',
|
|
||||||
'tvshow.clearart',
|
|
||||||
'tvshow.clearlogo',
|
|
||||||
'thumb'):
|
|
||||||
if key not in info['art']:
|
|
||||||
info['art'][key] = ''
|
|
||||||
|
|
||||||
|
|
||||||
def _notify_upnext(item):
|
|
||||||
"""
|
|
||||||
Signals to the Kodi add-on Upnext that there is another episode after this
|
|
||||||
one.
|
|
||||||
Needed for add-on paths in order to prevent crashes when Upnext does this
|
|
||||||
by itself
|
|
||||||
"""
|
|
||||||
if not item.plex_type == v.PLEX_TYPE_EPISODE:
|
|
||||||
return
|
|
||||||
this_api = item.api
|
|
||||||
next_api = _next_episode(this_api)
|
|
||||||
if next_api is None:
|
|
||||||
return
|
|
||||||
info = {}
|
|
||||||
for key, api in (('current_episode', this_api),
|
|
||||||
('next_episode', next_api)):
|
|
||||||
info[key] = {
|
|
||||||
'episodeid': api.plex_id,
|
|
||||||
'tvshowid': api.grandparent_id(),
|
|
||||||
'title': api.title(),
|
|
||||||
'showtitle': api.grandparent_title(),
|
|
||||||
'plot': api.plot(),
|
|
||||||
'playcount': api.viewcount(),
|
|
||||||
'season': api.season_number(),
|
|
||||||
'episode': api.index(),
|
|
||||||
'firstaired': api.year(),
|
|
||||||
'rating': api.rating(),
|
|
||||||
'art': api.artwork(kodi_id=api.kodi_id,
|
|
||||||
kodi_type=api.kodi_type,
|
|
||||||
full_artwork=True)
|
|
||||||
}
|
|
||||||
_complete_artwork_keys(info[key])
|
|
||||||
info['play_info'] = {'handle': next_api.fullpath(force_addon=True)[0]}
|
|
||||||
sender = v.ADDON_ID.encode('utf-8')
|
|
||||||
method = 'upnext_data'.encode('utf-8')
|
|
||||||
data = binascii.hexlify(json.dumps(info))
|
|
||||||
data = '\\"[\\"{0}\\"]\\"'.format(data)
|
|
||||||
xbmc.executebuiltin('NotifyAll(%s, %s, %s)' % (sender, method, data))
|
|
||||||
|
|
||||||
|
|
||||||
def _videolibrary_onupdate(data):
|
|
||||||
"""
|
|
||||||
A specific Kodi library item has been updated. This seems to happen if the
|
|
||||||
user marks an item as watched/unwatched or if playback of the item just
|
|
||||||
stopped
|
|
||||||
|
|
||||||
2 kinds of messages possible, e.g.
|
|
||||||
Method: VideoLibrary.OnUpdate Data: ("Reset resume position" and also
|
|
||||||
fired just after stopping playback - BEFORE OnStop fires)
|
|
||||||
{'id': 1, 'type': 'movie'}
|
|
||||||
Method: VideoLibrary.OnUpdate Data: ("Mark as watched")
|
|
||||||
{'item': {'id': 1, 'type': 'movie'}, 'playcount': 1}
|
|
||||||
"""
|
|
||||||
item = data.get('item') if 'item' in data else data
|
|
||||||
try:
|
|
||||||
kodi_id = item['id']
|
|
||||||
kodi_type = item['type']
|
|
||||||
except (KeyError, TypeError):
|
|
||||||
LOG.debug("Item is invalid for a Plex playstate update")
|
|
||||||
return
|
|
||||||
playcount = data.get('playcount')
|
|
||||||
if playcount is None:
|
|
||||||
# "Reset resume position"
|
|
||||||
# Kodi might set as watched or unwatched!
|
|
||||||
with KodiVideoDB(lock=False) as kodidb:
|
|
||||||
file_id = kodidb.file_id_from_id(kodi_id, kodi_type)
|
|
||||||
if file_id is None:
|
|
||||||
return
|
|
||||||
if kodidb.get_resume(file_id):
|
|
||||||
# We do have an existing bookmark entry - not toggling to
|
|
||||||
# either watched or unwatched on the Plex side
|
|
||||||
return
|
|
||||||
playcount = kodidb.get_playcount(file_id) or 0
|
|
||||||
if app.PLAYSTATE.item and kodi_id == app.PLAYSTATE.item.kodi_id and \
|
|
||||||
kodi_type == app.PLAYSTATE.item.kodi_type:
|
|
||||||
# Kodi updates an item immediately after playback. Hence we do NOT
|
|
||||||
# increase or decrease the viewcount
|
|
||||||
return
|
|
||||||
# Send notification to the server.
|
|
||||||
with PlexDB(lock=False) as plexdb:
|
|
||||||
db_item = plexdb.item_by_kodi_id(kodi_id, kodi_type)
|
|
||||||
if not db_item:
|
|
||||||
LOG.error("Could not find plex_id in plex database for a "
|
|
||||||
"video library update")
|
|
||||||
return
|
|
||||||
# notify the server
|
|
||||||
if playcount > 0:
|
|
||||||
PF.scrobble(db_item['plex_id'], 'watched')
|
|
||||||
else:
|
|
||||||
PF.scrobble(db_item['plex_id'], 'unwatched')
|
|
||||||
|
|
|
@ -1,9 +1 @@
|
||||||
# -*- coding: utf-8 -*-
|
# Dummy file to make this directory a package.
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
from .full_sync import start
|
|
||||||
from .websocket import store_websocket_message, process_websocket_messages, \
|
|
||||||
WEBSOCKET_MESSAGES, PLAYSTATE_SESSIONS
|
|
||||||
from .common import update_kodi_library, PLAYLIST_SYNC_ENABLED
|
|
||||||
from .fanart import FanartThread, FanartTask
|
|
||||||
from .sections import force_full_sync, delete_files, clear_window_vars
|
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
import xbmc
|
|
||||||
|
|
||||||
from .. import utils, app, variables as v
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.sync')
|
|
||||||
|
|
||||||
PLAYLIST_SYNC_ENABLED = (v.DEVICE != 'Microsoft UWP' and
|
|
||||||
utils.settings('enablePlaylistSync') == 'true')
|
|
||||||
|
|
||||||
|
|
||||||
class LibrarySyncMixin(object):
|
|
||||||
def suspend(self, block=False, timeout=None):
|
|
||||||
"""
|
|
||||||
Let's NOT suspend sync threads but immediately terminate them
|
|
||||||
"""
|
|
||||||
self.cancel()
|
|
||||||
|
|
||||||
def wait_while_suspended(self):
|
|
||||||
"""
|
|
||||||
Return immediately
|
|
||||||
"""
|
|
||||||
return self.should_cancel()
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
app.APP.register_thread(self)
|
|
||||||
LOG.debug('##===--- Starting %s ---===##', self.__class__.__name__)
|
|
||||||
try:
|
|
||||||
self._run()
|
|
||||||
except Exception as err:
|
|
||||||
LOG.error('Exception encountered: %s', err)
|
|
||||||
utils.ERROR(notify=True)
|
|
||||||
finally:
|
|
||||||
app.APP.deregister_thread(self)
|
|
||||||
LOG.debug('##===--- %s Stopped ---===##', self.__class__.__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def update_kodi_library(video=True, music=True):
|
|
||||||
"""
|
|
||||||
Updates the Kodi library and thus refreshes the Kodi views and widgets
|
|
||||||
"""
|
|
||||||
if video:
|
|
||||||
if not xbmc.getCondVisibility('Window.IsMedia'):
|
|
||||||
xbmc.executebuiltin('UpdateLibrary(video)')
|
|
||||||
else:
|
|
||||||
# Prevent cursor from moving - refresh later
|
|
||||||
xbmc.executebuiltin('Container.Refresh')
|
|
||||||
app.APP.update_widgets = True
|
|
||||||
if music:
|
|
||||||
xbmc.executebuiltin('UpdateLibrary(music)')
|
|
||||||
|
|
||||||
|
|
||||||
def tag_last(iterable):
|
|
||||||
"""
|
|
||||||
Given some iterable, returns (last, item), where last is only True if you
|
|
||||||
are on the final iteration.
|
|
||||||
"""
|
|
||||||
iterator = iter(iterable)
|
|
||||||
gotone = False
|
|
||||||
try:
|
|
||||||
lookback = next(iterator)
|
|
||||||
gotone = True
|
|
||||||
while True:
|
|
||||||
cur = next(iterator)
|
|
||||||
yield False, lookback
|
|
||||||
lookback = cur
|
|
||||||
except StopIteration:
|
|
||||||
if gotone:
|
|
||||||
yield True, lookback
|
|
||||||
raise StopIteration()
|
|
|
@ -1,154 +1,99 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
from threading import Thread
|
||||||
|
from Queue import Empty
|
||||||
|
|
||||||
from ..plex_api import API
|
from xbmc import sleep
|
||||||
from ..plex_db import PlexDB
|
|
||||||
from ..kodi_db import KodiVideoDB
|
from utils import thread_methods, settings, language as lang, dialog
|
||||||
from .. import backgroundthread, utils
|
import plexdb_functions as plexdb
|
||||||
from .. import itemtypes, plex_functions as PF, variables as v, app
|
import itemtypes
|
||||||
|
from artwork import ArtworkSyncMessage
|
||||||
|
import variables as v
|
||||||
|
import state
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.sync.fanart')
|
@thread_methods(add_suspends=['SUSPEND_LIBRARY_THREAD',
|
||||||
|
'DB_SCAN',
|
||||||
SUPPORTED_TYPES = (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)
|
'STOP_SYNC',
|
||||||
SYNC_FANART = (utils.settings('FanartTV') == 'true' and
|
'SUSPEND_SYNC'])
|
||||||
utils.settings('usePlexArtwork') == 'true')
|
class ThreadedProcessFanart(Thread):
|
||||||
PREFER_KODI_COLLECTION_ART = utils.settings('PreferKodiCollectionArt') == 'false'
|
|
||||||
BATCH_SIZE = 500
|
|
||||||
|
|
||||||
|
|
||||||
class FanartThread(backgroundthread.KillableThread):
|
|
||||||
"""
|
"""
|
||||||
This will potentially take hours!
|
Threaded download of additional fanart in the background
|
||||||
"""
|
|
||||||
def __init__(self, callback, refresh=False):
|
|
||||||
self.callback = callback
|
|
||||||
self.refresh = refresh
|
|
||||||
super(FanartThread, self).__init__()
|
|
||||||
|
|
||||||
def should_suspend(self):
|
Input:
|
||||||
return self._suspended or app.APP.is_playing_video
|
queue Queue.Queue() object that you will need to fill with
|
||||||
|
dicts of the following form:
|
||||||
|
{
|
||||||
|
'plex_id': the Plex id as a string
|
||||||
|
'plex_type': the Plex media type, e.g. 'movie'
|
||||||
|
'refresh': True/False if True, will overwrite any 3rd party
|
||||||
|
fanart. If False, will only get missing
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
def __init__(self, queue):
|
||||||
|
self.queue = queue
|
||||||
|
Thread.__init__(self)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
LOG.info('Starting FanartThread')
|
"""
|
||||||
app.APP.register_fanart_thread(self)
|
Do the work
|
||||||
try:
|
"""
|
||||||
self._run()
|
LOG.debug("---===### Starting FanartSync ###===---")
|
||||||
except Exception:
|
stopped = self.stopped
|
||||||
utils.ERROR(notify=True)
|
suspended = self.suspended
|
||||||
finally:
|
queue = self.queue
|
||||||
app.APP.deregister_fanart_thread(self)
|
counter = 0
|
||||||
|
set_zero = False
|
||||||
|
while not stopped():
|
||||||
|
# In the event the server goes offline
|
||||||
|
while suspended():
|
||||||
|
# Set in service.py
|
||||||
|
if stopped():
|
||||||
|
# Abort was requested while waiting. We should exit
|
||||||
|
LOG.info("---===### Stopped FanartSync ###===---")
|
||||||
|
return
|
||||||
|
sleep(1000)
|
||||||
|
# grabs Plex item from queue
|
||||||
|
try:
|
||||||
|
item = queue.get(block=False)
|
||||||
|
except Empty:
|
||||||
|
if not set_zero:
|
||||||
|
# Avoid saving '0' all the time
|
||||||
|
set_zero = True
|
||||||
|
settings('fanarttv_lookups', value='0')
|
||||||
|
sleep(200)
|
||||||
|
continue
|
||||||
|
set_zero = False
|
||||||
|
if isinstance(item, ArtworkSyncMessage):
|
||||||
|
if state.IMAGE_SYNC_NOTIFICATIONS:
|
||||||
|
dialog('notification',
|
||||||
|
heading=lang(29999),
|
||||||
|
message=item.message,
|
||||||
|
icon='{plex}',
|
||||||
|
sound=False)
|
||||||
|
queue.task_done()
|
||||||
|
continue
|
||||||
|
|
||||||
def _loop(self):
|
LOG.debug('Get additional fanart for Plex id %s', item['plex_id'])
|
||||||
for typus in SUPPORTED_TYPES:
|
with getattr(itemtypes,
|
||||||
offset = 0
|
v.ITEMTYPE_FROM_PLEXTYPE[item['plex_type']])() as item_type:
|
||||||
while True:
|
result = item_type.getfanart(item['plex_id'],
|
||||||
with PlexDB() as plexdb:
|
refresh=item['refresh'])
|
||||||
# Keep DB connection open only for a short period of time!
|
if result is True:
|
||||||
if self.refresh:
|
LOG.debug('Done getting fanart for Plex id %s', item['plex_id'])
|
||||||
batch = list(plexdb.every_plex_id(typus,
|
with plexdb.Get_Plex_DB() as plex_db:
|
||||||
offset,
|
plex_db.set_fanart_synched(item['plex_id'])
|
||||||
BATCH_SIZE))
|
# Update the caching state in the PKC settings. Avoid saving '0'
|
||||||
else:
|
counter += 1
|
||||||
batch = list(plexdb.missing_fanart(typus,
|
if counter > 10:
|
||||||
offset,
|
counter = 0
|
||||||
BATCH_SIZE))
|
settings('fanarttv_lookups', value=str(queue.qsize()))
|
||||||
for plex_id in batch:
|
queue.task_done()
|
||||||
# Do the actual, time-consuming processing
|
LOG.debug("---===### Stopped FanartSync ###===---")
|
||||||
if self.should_suspend() or self.should_cancel():
|
|
||||||
return False
|
|
||||||
process_fanart(plex_id, typus, self.refresh)
|
|
||||||
if len(batch) < BATCH_SIZE:
|
|
||||||
break
|
|
||||||
offset += BATCH_SIZE
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
finished = False
|
|
||||||
while not finished:
|
|
||||||
finished = self._loop()
|
|
||||||
if self.wait_while_suspended():
|
|
||||||
break
|
|
||||||
LOG.info('FanartThread finished: %s', finished)
|
|
||||||
self.callback(finished)
|
|
||||||
|
|
||||||
|
|
||||||
class FanartTask(backgroundthread.Task):
|
|
||||||
"""
|
|
||||||
This task will also be executed while library sync is suspended!
|
|
||||||
"""
|
|
||||||
def setup(self, plex_id, plex_type, refresh=False):
|
|
||||||
self.plex_id = plex_id
|
|
||||||
self.plex_type = plex_type
|
|
||||||
self.refresh = refresh
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
process_fanart(self.plex_id, self.plex_type, self.refresh)
|
|
||||||
|
|
||||||
|
|
||||||
def process_fanart(plex_id, plex_type, refresh=False):
|
|
||||||
"""
|
|
||||||
Will look for additional fanart for the plex_type item with plex_id.
|
|
||||||
Will check if we already got all artwork and only look if some are indeed
|
|
||||||
missing.
|
|
||||||
Will set the fanart_synced flag in the Plex DB if successful.
|
|
||||||
"""
|
|
||||||
done = False
|
|
||||||
try:
|
|
||||||
artworks = None
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
db_item = plexdb.item_by_id(plex_id,
|
|
||||||
plex_type)
|
|
||||||
if not db_item:
|
|
||||||
LOG.error('Could not get Kodi id for plex id %s', plex_id)
|
|
||||||
return
|
|
||||||
if not refresh:
|
|
||||||
with KodiVideoDB() as kodidb:
|
|
||||||
artworks = kodidb.get_art(db_item['kodi_id'],
|
|
||||||
db_item['kodi_type'])
|
|
||||||
# Check if we even need to get additional art
|
|
||||||
for key in v.ALL_KODI_ARTWORK:
|
|
||||||
if key not in artworks:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
done = True
|
|
||||||
return
|
|
||||||
xml = PF.GetPlexMetadata(plex_id)
|
|
||||||
try:
|
|
||||||
xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.warn('Could not get metadata for %s. Skipping that item '
|
|
||||||
'for now', plex_id)
|
|
||||||
return
|
|
||||||
api = API(xml[0])
|
|
||||||
if artworks is None:
|
|
||||||
artworks = api.artwork()
|
|
||||||
# Get additional missing artwork from fanart artwork sites
|
|
||||||
artworks = api.fanart_artwork(artworks)
|
|
||||||
with itemtypes.ITEMTYPE_FROM_PLEXTYPE[plex_type](None) as context:
|
|
||||||
context.set_fanart(artworks,
|
|
||||||
db_item['kodi_id'],
|
|
||||||
db_item['kodi_type'])
|
|
||||||
# Additional fanart for sets/collections
|
|
||||||
if plex_type == v.PLEX_TYPE_MOVIE:
|
|
||||||
for _, setname in api.collections():
|
|
||||||
LOG.debug('Getting artwork for movie set %s', setname)
|
|
||||||
with KodiVideoDB() as kodidb:
|
|
||||||
setid = kodidb.create_collection(setname)
|
|
||||||
external_set_artwork = api.set_artwork()
|
|
||||||
if external_set_artwork and PREFER_KODI_COLLECTION_ART:
|
|
||||||
kodi_artwork = api.artwork(kodi_id=setid,
|
|
||||||
kodi_type=v.KODI_TYPE_SET)
|
|
||||||
for art in kodi_artwork:
|
|
||||||
if art in external_set_artwork:
|
|
||||||
del external_set_artwork[art]
|
|
||||||
with itemtypes.Movie(None) as movie:
|
|
||||||
movie.kodidb.modify_artwork(external_set_artwork,
|
|
||||||
setid,
|
|
||||||
v.KODI_TYPE_SET)
|
|
||||||
done = True
|
|
||||||
finally:
|
|
||||||
if done is True:
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
plexdb.set_fanart_synced(plex_id, plex_type)
|
|
||||||
|
|
|
@ -1,80 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
from Queue import Full
|
|
||||||
|
|
||||||
from . import common, sections
|
|
||||||
from ..plex_db import PlexDB
|
|
||||||
from .. import backgroundthread
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.sync.fill_metadata_queue')
|
|
||||||
|
|
||||||
QUEUE_TIMEOUT = 60 # seconds
|
|
||||||
|
|
||||||
|
|
||||||
class FillMetadataQueue(common.LibrarySyncMixin,
|
|
||||||
backgroundthread.KillableThread):
|
|
||||||
"""
|
|
||||||
Determines which plex_ids we need to sync and puts these ids in a separate
|
|
||||||
queue. Will use a COPIED plex.db file (plex-copy.db) in order to read much
|
|
||||||
faster without the writing thread stalling
|
|
||||||
"""
|
|
||||||
def __init__(self, repair, section_queue, get_metadata_queue,
|
|
||||||
processing_queue):
|
|
||||||
self.repair = repair
|
|
||||||
self.section_queue = section_queue
|
|
||||||
self.get_metadata_queue = get_metadata_queue
|
|
||||||
self.processing_queue = processing_queue
|
|
||||||
super(FillMetadataQueue, self).__init__()
|
|
||||||
|
|
||||||
def _process_section(self, section):
|
|
||||||
# Initialize only once to avoid loosing the last value before we're
|
|
||||||
# breaking the for loop
|
|
||||||
LOG.debug('Process section %s with %s items',
|
|
||||||
section, section.number_of_items)
|
|
||||||
count = 0
|
|
||||||
do_process_section = False
|
|
||||||
with PlexDB(lock=False, copy=True) as plexdb:
|
|
||||||
for xml in section.iterator:
|
|
||||||
if self.should_cancel():
|
|
||||||
break
|
|
||||||
plex_id = int(xml.get('ratingKey'))
|
|
||||||
checksum = int('{}{}'.format(
|
|
||||||
plex_id,
|
|
||||||
abs(int(xml.get('updatedAt',
|
|
||||||
xml.get('addedAt', '1541572987'))))))
|
|
||||||
if (not self.repair and
|
|
||||||
plexdb.checksum(plex_id, section.plex_type) == checksum):
|
|
||||||
continue
|
|
||||||
if not do_process_section:
|
|
||||||
do_process_section = True
|
|
||||||
self.processing_queue.add_section(section)
|
|
||||||
LOG.debug('Put section in processing queue: %s', section)
|
|
||||||
try:
|
|
||||||
self.get_metadata_queue.put((count, plex_id, section),
|
|
||||||
timeout=QUEUE_TIMEOUT)
|
|
||||||
except Full:
|
|
||||||
LOG.error('Putting %s in get_metadata_queue timed out - '
|
|
||||||
'aborting sync now', plex_id)
|
|
||||||
section.sync_successful = False
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
count += 1
|
|
||||||
# We might have received LESS items from the PMS than anticipated.
|
|
||||||
# Ensures that our queues finish
|
|
||||||
self.processing_queue.change_section_number_of_items(section,
|
|
||||||
count)
|
|
||||||
LOG.debug('%s items to process for section %s',
|
|
||||||
section.number_of_items, section)
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
while not self.should_cancel():
|
|
||||||
section = self.section_queue.get()
|
|
||||||
self.section_queue.task_done()
|
|
||||||
if section is None:
|
|
||||||
break
|
|
||||||
self._process_section(section)
|
|
||||||
# Signal the download metadata threads to stop with a sentinel
|
|
||||||
self.get_metadata_queue.put(None)
|
|
||||||
# Sentinel for the process_thread once we added everything else
|
|
||||||
self.processing_queue.add_sentinel(sections.Section())
|
|
|
@ -1,325 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
import Queue
|
|
||||||
|
|
||||||
import xbmcgui
|
|
||||||
|
|
||||||
from .get_metadata import GetMetadataThread
|
|
||||||
from .fill_metadata_queue import FillMetadataQueue
|
|
||||||
from .process_metadata import ProcessMetadataThread
|
|
||||||
from . import common, sections
|
|
||||||
from .. import utils, timing, backgroundthread as bg, variables as v, app
|
|
||||||
from .. import plex_functions as PF, itemtypes, path_ops
|
|
||||||
|
|
||||||
if common.PLAYLIST_SYNC_ENABLED:
|
|
||||||
from .. import playlists
|
|
||||||
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.sync.full_sync')
|
|
||||||
DELETION_BATCH_SIZE = 250
|
|
||||||
PLAYSTATE_BATCH_SIZE = 5000
|
|
||||||
|
|
||||||
# Max. number of plex_ids held in memory for later processing
|
|
||||||
BACKLOG_QUEUE_SIZE = 10000
|
|
||||||
# Max number of xmls held in memory
|
|
||||||
XML_QUEUE_SIZE = 500
|
|
||||||
# Safety margin to filter PMS items - how many seconds to look into the past?
|
|
||||||
UPDATED_AT_SAFETY = 60 * 5
|
|
||||||
LAST_VIEWED_AT_SAFETY = 60 * 5
|
|
||||||
|
|
||||||
|
|
||||||
class FullSync(common.LibrarySyncMixin, bg.KillableThread):
|
|
||||||
def __init__(self, repair, callback, show_dialog):
|
|
||||||
"""
|
|
||||||
repair=True: force sync EVERY item
|
|
||||||
"""
|
|
||||||
self.successful = True
|
|
||||||
self.repair = repair
|
|
||||||
self.callback = callback
|
|
||||||
# For progress dialog
|
|
||||||
self.show_dialog = show_dialog
|
|
||||||
self.show_dialog_userdata = utils.settings('playstate_sync_indicator') == 'true'
|
|
||||||
if self.show_dialog:
|
|
||||||
self.dialog = xbmcgui.DialogProgressBG()
|
|
||||||
self.dialog.create(utils.lang(39714))
|
|
||||||
else:
|
|
||||||
self.dialog = None
|
|
||||||
self.current_time = timing.plex_now()
|
|
||||||
self.last_section = sections.Section()
|
|
||||||
self.install_sync_done = utils.settings('SyncInstallRunDone') == 'true'
|
|
||||||
super(FullSync, self).__init__()
|
|
||||||
|
|
||||||
def update_progressbar(self, section, title, current):
|
|
||||||
if not self.dialog:
|
|
||||||
return
|
|
||||||
current += 1
|
|
||||||
try:
|
|
||||||
progress = int(float(current) / float(section.number_of_items) * 100.0)
|
|
||||||
except ZeroDivisionError:
|
|
||||||
progress = 0
|
|
||||||
self.dialog.update(progress,
|
|
||||||
'%s (%s)' % (section.name, section.section_type_text),
|
|
||||||
'%s %s/%s'
|
|
||||||
% (title, current, section.number_of_items))
|
|
||||||
if app.APP.is_playing_video:
|
|
||||||
self.dialog.close()
|
|
||||||
self.dialog = None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def copy_plex_db():
|
|
||||||
"""
|
|
||||||
Takes the current plex.db file and copies it to plex-copy.db
|
|
||||||
This will allow us to have "concurrent" connections during adding/
|
|
||||||
updating items, increasing sync speed tremendously.
|
|
||||||
Using the same DB with e.g. WAL mode did not really work out...
|
|
||||||
"""
|
|
||||||
path_ops.copyfile(v.DB_PLEX_PATH, v.DB_PLEX_COPY_PATH)
|
|
||||||
|
|
||||||
@utils.log_time
|
|
||||||
def process_new_and_changed_items(self, section_queue, processing_queue):
|
|
||||||
LOG.debug('Start working')
|
|
||||||
get_metadata_queue = Queue.Queue(maxsize=BACKLOG_QUEUE_SIZE)
|
|
||||||
scanner_thread = FillMetadataQueue(self.repair,
|
|
||||||
section_queue,
|
|
||||||
get_metadata_queue,
|
|
||||||
processing_queue)
|
|
||||||
scanner_thread.start()
|
|
||||||
metadata_threads = [
|
|
||||||
GetMetadataThread(get_metadata_queue, processing_queue)
|
|
||||||
for _ in range(int(utils.settings('syncThreadNumber')))
|
|
||||||
]
|
|
||||||
for t in metadata_threads:
|
|
||||||
t.start()
|
|
||||||
process_thread = ProcessMetadataThread(self.current_time,
|
|
||||||
processing_queue,
|
|
||||||
self.update_progressbar)
|
|
||||||
process_thread.start()
|
|
||||||
LOG.debug('Waiting for scanner thread to finish up')
|
|
||||||
scanner_thread.join()
|
|
||||||
LOG.debug('Waiting for metadata download threads to finish up')
|
|
||||||
for t in metadata_threads:
|
|
||||||
t.join()
|
|
||||||
LOG.debug('Download metadata threads finished')
|
|
||||||
process_thread.join()
|
|
||||||
self.successful = process_thread.successful
|
|
||||||
LOG.debug('threads finished work. successful: %s', self.successful)
|
|
||||||
|
|
||||||
@utils.log_time
|
|
||||||
def processing_loop_playstates(self, section_queue):
|
|
||||||
while not self.should_cancel():
|
|
||||||
section = section_queue.get()
|
|
||||||
section_queue.task_done()
|
|
||||||
if section is None:
|
|
||||||
break
|
|
||||||
self.playstate_per_section(section)
|
|
||||||
|
|
||||||
def playstate_per_section(self, section):
|
|
||||||
LOG.debug('Processing %s playstates for library section %s',
|
|
||||||
section.number_of_items, section)
|
|
||||||
try:
|
|
||||||
with section.context(self.current_time) as context:
|
|
||||||
for xml in section.iterator:
|
|
||||||
section.count += 1
|
|
||||||
if not context.update_userdata(xml, section.plex_type):
|
|
||||||
# Somehow did not sync this item yet
|
|
||||||
context.add_update(xml,
|
|
||||||
section_name=section.name,
|
|
||||||
section_id=section.section_id)
|
|
||||||
context.plexdb.update_last_sync(int(xml.attrib['ratingKey']),
|
|
||||||
section.plex_type,
|
|
||||||
self.current_time)
|
|
||||||
self.update_progressbar(section, '', section.count - 1)
|
|
||||||
if section.count % PLAYSTATE_BATCH_SIZE == 0:
|
|
||||||
context.commit()
|
|
||||||
except RuntimeError:
|
|
||||||
LOG.error('Could not entirely process section %s', section)
|
|
||||||
self.successful = False
|
|
||||||
|
|
||||||
def threaded_get_generators(self, kinds, section_queue, items):
|
|
||||||
"""
|
|
||||||
Getting iterators is costly, so let's do it in a dedicated thread
|
|
||||||
"""
|
|
||||||
LOG.debug('Start threaded_get_generators')
|
|
||||||
try:
|
|
||||||
for kind in kinds:
|
|
||||||
for section in (x for x in app.SYNC.sections
|
|
||||||
if x.section_type == kind[1]):
|
|
||||||
if self.should_cancel():
|
|
||||||
LOG.debug('Need to exit now')
|
|
||||||
return
|
|
||||||
if not section.sync_to_kodi:
|
|
||||||
LOG.info('User chose to not sync section %s', section)
|
|
||||||
continue
|
|
||||||
section = sections.get_sync_section(section,
|
|
||||||
plex_type=kind[0])
|
|
||||||
timestamp = section.last_sync - UPDATED_AT_SAFETY \
|
|
||||||
if section.last_sync else None
|
|
||||||
if items == 'all':
|
|
||||||
updated_at = None
|
|
||||||
last_viewed_at = None
|
|
||||||
elif items == 'watched':
|
|
||||||
if not timestamp:
|
|
||||||
# No need to sync playstate updates since section
|
|
||||||
# has not yet been synched
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
updated_at = None
|
|
||||||
last_viewed_at = timestamp
|
|
||||||
elif items == 'updated':
|
|
||||||
updated_at = timestamp
|
|
||||||
last_viewed_at = None
|
|
||||||
try:
|
|
||||||
section.iterator = PF.get_section_iterator(
|
|
||||||
section.section_id,
|
|
||||||
plex_type=section.plex_type,
|
|
||||||
updated_at=updated_at,
|
|
||||||
last_viewed_at=last_viewed_at)
|
|
||||||
except RuntimeError:
|
|
||||||
LOG.error('Sync at least partially unsuccessful!')
|
|
||||||
LOG.error('Error getting section iterator %s', section)
|
|
||||||
else:
|
|
||||||
section.number_of_items = section.iterator.total
|
|
||||||
if section.number_of_items > 0:
|
|
||||||
section_queue.put(section)
|
|
||||||
LOG.debug('Put section in queue with %s items: %s',
|
|
||||||
section.number_of_items, section)
|
|
||||||
except Exception:
|
|
||||||
utils.ERROR(notify=True)
|
|
||||||
finally:
|
|
||||||
# Sentinel for the section queue
|
|
||||||
section_queue.put(None)
|
|
||||||
LOG.debug('Exiting threaded_get_generators')
|
|
||||||
|
|
||||||
def full_library_sync(self):
|
|
||||||
section_queue = Queue.Queue()
|
|
||||||
processing_queue = bg.ProcessingQueue(maxsize=XML_QUEUE_SIZE)
|
|
||||||
kinds = [
|
|
||||||
(v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_MOVIE),
|
|
||||||
(v.PLEX_TYPE_SHOW, v.PLEX_TYPE_SHOW),
|
|
||||||
(v.PLEX_TYPE_SEASON, v.PLEX_TYPE_SHOW),
|
|
||||||
(v.PLEX_TYPE_EPISODE, v.PLEX_TYPE_SHOW)
|
|
||||||
]
|
|
||||||
if app.SYNC.enable_music:
|
|
||||||
kinds.extend([
|
|
||||||
(v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_ARTIST),
|
|
||||||
(v.PLEX_TYPE_ALBUM, v.PLEX_TYPE_ARTIST),
|
|
||||||
])
|
|
||||||
|
|
||||||
# ADD NEW ITEMS
|
|
||||||
# We need to enforce syncing e.g. show before season before episode
|
|
||||||
bg.FunctionAsTask(self.threaded_get_generators,
|
|
||||||
None,
|
|
||||||
kinds,
|
|
||||||
section_queue,
|
|
||||||
items='all' if self.repair else 'updated').start()
|
|
||||||
# Do the heavy lifting
|
|
||||||
self.process_new_and_changed_items(section_queue, processing_queue)
|
|
||||||
common.update_kodi_library(video=True, music=True)
|
|
||||||
if self.should_cancel() or not self.successful:
|
|
||||||
return
|
|
||||||
|
|
||||||
# In order to not delete all your songs again for playstate synch
|
|
||||||
if app.SYNC.enable_music:
|
|
||||||
kinds.extend([
|
|
||||||
(v.PLEX_TYPE_SONG, v.PLEX_TYPE_ARTIST),
|
|
||||||
])
|
|
||||||
|
|
||||||
# Update playstate progress since last sync - especially useful for
|
|
||||||
# users of very large libraries since this step is very fast
|
|
||||||
# These playstates will be synched twice
|
|
||||||
LOG.debug('Start synching playstate for last watched items')
|
|
||||||
bg.FunctionAsTask(self.threaded_get_generators,
|
|
||||||
None,
|
|
||||||
kinds,
|
|
||||||
section_queue,
|
|
||||||
items='watched').start()
|
|
||||||
self.processing_loop_playstates(section_queue)
|
|
||||||
if self.should_cancel() or not self.successful:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Sync Plex playlists to Kodi and vice-versa
|
|
||||||
if common.PLAYLIST_SYNC_ENABLED:
|
|
||||||
LOG.debug('Start playlist sync')
|
|
||||||
if self.show_dialog:
|
|
||||||
if self.dialog:
|
|
||||||
self.dialog.close()
|
|
||||||
self.dialog = xbmcgui.DialogProgressBG()
|
|
||||||
# "Synching playlists"
|
|
||||||
self.dialog.create(utils.lang(39715))
|
|
||||||
if not playlists.full_sync() or self.should_cancel():
|
|
||||||
return
|
|
||||||
|
|
||||||
# SYNC PLAYSTATE of ALL items (otherwise we won't pick up on items that
|
|
||||||
# were set to unwatched or changed user ratings). Also mark all items on
|
|
||||||
# the PMS to be able to delete the ones still in Kodi
|
|
||||||
LOG.debug('Start synching playstate and userdata for every item')
|
|
||||||
# Make sure we're not showing an item's title in the sync dialog
|
|
||||||
if not self.show_dialog_userdata and self.dialog:
|
|
||||||
# Close the progress indicator dialog
|
|
||||||
self.dialog.close()
|
|
||||||
self.dialog = None
|
|
||||||
bg.FunctionAsTask(self.threaded_get_generators,
|
|
||||||
None,
|
|
||||||
kinds,
|
|
||||||
section_queue,
|
|
||||||
items='all').start()
|
|
||||||
self.processing_loop_playstates(section_queue)
|
|
||||||
if self.should_cancel() or not self.successful:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Delete movies that are not on Plex anymore
|
|
||||||
LOG.debug('Looking for items to delete')
|
|
||||||
kinds = [
|
|
||||||
(v.PLEX_TYPE_MOVIE, itemtypes.Movie),
|
|
||||||
(v.PLEX_TYPE_SHOW, itemtypes.Show),
|
|
||||||
(v.PLEX_TYPE_SEASON, itemtypes.Season),
|
|
||||||
(v.PLEX_TYPE_EPISODE, itemtypes.Episode)
|
|
||||||
]
|
|
||||||
if app.SYNC.enable_music:
|
|
||||||
kinds.extend([
|
|
||||||
(v.PLEX_TYPE_ARTIST, itemtypes.Artist),
|
|
||||||
(v.PLEX_TYPE_ALBUM, itemtypes.Album),
|
|
||||||
(v.PLEX_TYPE_SONG, itemtypes.Song)
|
|
||||||
])
|
|
||||||
for plex_type, context in kinds:
|
|
||||||
# Delete movies that are not on Plex anymore
|
|
||||||
while True:
|
|
||||||
with context(self.current_time) as ctx:
|
|
||||||
plex_ids = list(
|
|
||||||
ctx.plexdb.plex_id_by_last_sync(plex_type,
|
|
||||||
self.current_time,
|
|
||||||
DELETION_BATCH_SIZE))
|
|
||||||
for plex_id in plex_ids:
|
|
||||||
if self.should_cancel():
|
|
||||||
return
|
|
||||||
ctx.remove(plex_id, plex_type)
|
|
||||||
if len(plex_ids) < DELETION_BATCH_SIZE:
|
|
||||||
break
|
|
||||||
LOG.debug('Done looking for items to delete')
|
|
||||||
|
|
||||||
@utils.log_time
|
|
||||||
def _run(self):
|
|
||||||
try:
|
|
||||||
# Get latest Plex libraries and build playlist and video node files
|
|
||||||
if self.should_cancel() or not sections.sync_from_pms(self):
|
|
||||||
return
|
|
||||||
self.copy_plex_db()
|
|
||||||
self.full_library_sync()
|
|
||||||
finally:
|
|
||||||
common.update_kodi_library(video=True, music=True)
|
|
||||||
if self.dialog:
|
|
||||||
self.dialog.close()
|
|
||||||
if not self.successful and not self.should_cancel():
|
|
||||||
# "ERROR in library sync"
|
|
||||||
utils.dialog('notification',
|
|
||||||
heading='{plex}',
|
|
||||||
message=utils.lang(39410),
|
|
||||||
icon='{error}')
|
|
||||||
self.callback(self.successful)
|
|
||||||
|
|
||||||
|
|
||||||
def start(show_dialog, repair=False, callback=None):
|
|
||||||
# Call run() and NOT start in order to not spawn another thread
|
|
||||||
FullSync(repair, callback, show_dialog).run()
|
|
|
@ -1,123 +1,122 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
from threading import Thread
|
||||||
|
from Queue import Empty
|
||||||
|
|
||||||
from . import common
|
from xbmc import sleep
|
||||||
from ..plex_api import API
|
|
||||||
from .. import backgroundthread, plex_functions as PF, utils, variables as v
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.sync.get_metadata')
|
from utils import thread_methods, window
|
||||||
LOCK = backgroundthread.threading.Lock()
|
from PlexFunctions import GetPlexMetadata, GetAllPlexChildren
|
||||||
|
import sync_info
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
class GetMetadataThread(common.LibrarySyncMixin,
|
@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD',
|
||||||
backgroundthread.KillableThread):
|
'STOP_SYNC',
|
||||||
|
'SUSPEND_SYNC'])
|
||||||
|
class ThreadedGetMetadata(Thread):
|
||||||
"""
|
"""
|
||||||
Threaded download of Plex XML metadata for a certain library item.
|
Threaded download of Plex XML metadata for a certain library item.
|
||||||
Fills the queue with the downloaded etree XML objects
|
Fills the out_queue with the downloaded etree XML objects
|
||||||
|
|
||||||
|
Input:
|
||||||
|
queue Queue.Queue() object that you'll need to fill up
|
||||||
|
with plex_ids
|
||||||
|
out_queue Queue() object where this thread will store
|
||||||
|
the downloaded metadata XMLs as etree objects
|
||||||
"""
|
"""
|
||||||
def __init__(self, get_metadata_queue, processing_queue):
|
def __init__(self, queue, out_queue):
|
||||||
self.get_metadata_queue = get_metadata_queue
|
self.queue = queue
|
||||||
self.processing_queue = processing_queue
|
self.out_queue = out_queue
|
||||||
super(GetMetadataThread, self).__init__()
|
Thread.__init__(self)
|
||||||
|
|
||||||
def _collections(self, item):
|
def terminate_now(self):
|
||||||
api = API(item['xml'][0])
|
"""
|
||||||
collection_match = item['section'].collection_match
|
Needed to terminate this thread, because there might be items left in
|
||||||
collection_xmls = item['section'].collection_xmls
|
the queue which could cause other threads to hang
|
||||||
if collection_match is None:
|
"""
|
||||||
collection_match = PF.collections(api.library_section_id())
|
while not self.queue.empty():
|
||||||
if collection_match is None:
|
# Still try because remaining item might have been taken
|
||||||
LOG.error('Could not download collections')
|
|
||||||
return
|
|
||||||
# Extract what we need to know
|
|
||||||
collection_match = \
|
|
||||||
[(utils.cast(int, x.get('index')),
|
|
||||||
utils.cast(int, x.get('ratingKey'))) for x in collection_match]
|
|
||||||
item['children'] = {}
|
|
||||||
for plex_set_id, set_name in api.collections():
|
|
||||||
if self.should_cancel():
|
|
||||||
return
|
|
||||||
if plex_set_id not in collection_xmls:
|
|
||||||
# Get Plex metadata for collections - a pain
|
|
||||||
for index, collection_plex_id in collection_match:
|
|
||||||
if index == plex_set_id:
|
|
||||||
collection_xml = PF.GetPlexMetadata(collection_plex_id)
|
|
||||||
try:
|
|
||||||
collection_xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error('Could not get collection %s %s',
|
|
||||||
collection_plex_id, set_name)
|
|
||||||
continue
|
|
||||||
collection_xmls[plex_set_id] = collection_xml
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
LOG.error('Did not find Plex collection %s %s',
|
|
||||||
plex_set_id, set_name)
|
|
||||||
continue
|
|
||||||
item['children'][plex_set_id] = collection_xmls[plex_set_id]
|
|
||||||
|
|
||||||
def _process_abort(self, count, section):
|
|
||||||
# Make sure other threads will also receive sentinel
|
|
||||||
self.get_metadata_queue.put(None)
|
|
||||||
if count is not None:
|
|
||||||
self._process_skipped_item(count, section)
|
|
||||||
|
|
||||||
def _process_skipped_item(self, count, section):
|
|
||||||
section.sync_successful = False
|
|
||||||
# Add a "dummy" item so we're not skipping a beat
|
|
||||||
self.processing_queue.put((count, {'section': section, 'xml': None}))
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
while True:
|
|
||||||
item = self.get_metadata_queue.get()
|
|
||||||
try:
|
try:
|
||||||
if item is None or self.should_cancel():
|
self.queue.get(block=False)
|
||||||
self._process_abort(item[0] if item else None,
|
except Empty:
|
||||||
item[2] if item else None)
|
sleep(10)
|
||||||
break
|
continue
|
||||||
count, plex_id, section = item
|
else:
|
||||||
item = {
|
self.queue.task_done()
|
||||||
'xml': PF.GetPlexMetadata(plex_id), # This will block
|
if self.stopped():
|
||||||
'children': None,
|
# Shutdown from outside requested; purge out_queue as well
|
||||||
'section': section
|
while not self.out_queue.empty():
|
||||||
}
|
# Still try because remaining item might have been taken
|
||||||
if item['xml'] is None:
|
try:
|
||||||
# Did not receive a valid XML - skip that item for now
|
self.out_queue.get(block=False)
|
||||||
LOG.error("Could not get metadata for %s. Skipping item "
|
except Empty:
|
||||||
"for now", plex_id)
|
sleep(10)
|
||||||
self._process_skipped_item(count, section)
|
|
||||||
continue
|
continue
|
||||||
elif item['xml'] == 401:
|
else:
|
||||||
LOG.error('HTTP 401 returned by PMS. Too much strain? '
|
self.out_queue.task_done()
|
||||||
'Cancelling sync for now')
|
|
||||||
utils.window('plex_scancrashed', value='401')
|
def run(self):
|
||||||
self._process_abort(count, section)
|
"""
|
||||||
break
|
Do the work
|
||||||
if section.plex_type == v.PLEX_TYPE_MOVIE:
|
"""
|
||||||
# Check for collections/sets
|
LOG.debug('Starting get metadata thread')
|
||||||
collections = False
|
# cache local variables because it's faster
|
||||||
for child in item['xml'][0]:
|
queue = self.queue
|
||||||
if child.tag == 'Collection':
|
out_queue = self.out_queue
|
||||||
collections = True
|
stopped = self.stopped
|
||||||
break
|
while stopped() is False:
|
||||||
if collections:
|
# grabs Plex item from queue
|
||||||
with LOCK:
|
try:
|
||||||
self._collections(item)
|
item = queue.get(block=False)
|
||||||
if section.get_children:
|
# Empty queue
|
||||||
if self.should_cancel():
|
except Empty:
|
||||||
self._process_abort(count, section)
|
sleep(20)
|
||||||
break
|
continue
|
||||||
children_xml = PF.GetAllPlexChildren(plex_id) # Will block
|
# Download Metadata
|
||||||
try:
|
xml = GetPlexMetadata(item['plex_id'])
|
||||||
children_xml[0].attrib
|
if xml is None:
|
||||||
except (TypeError, IndexError, AttributeError):
|
# Did not receive a valid XML - skip that item for now
|
||||||
LOG.error('Could not get children for Plex id %s',
|
LOG.error("Could not get metadata for %s. Skipping that item "
|
||||||
plex_id)
|
"for now", item['plex_id'])
|
||||||
self._process_skipped_item(count, section)
|
# Increase BOTH counters - since metadata won't be processed
|
||||||
continue
|
with sync_info.LOCK:
|
||||||
else:
|
sync_info.GET_METADATA_COUNT += 1
|
||||||
item['children'] = children_xml
|
sync_info.PROCESS_METADATA_COUNT += 1
|
||||||
self.processing_queue.put((count, item))
|
queue.task_done()
|
||||||
finally:
|
continue
|
||||||
self.get_metadata_queue.task_done()
|
elif xml == 401:
|
||||||
|
LOG.error('HTTP 401 returned by PMS. Too much strain? '
|
||||||
|
'Cancelling sync for now')
|
||||||
|
window('plex_scancrashed', value='401')
|
||||||
|
# Kill remaining items in queue (for main thread to cont.)
|
||||||
|
queue.task_done()
|
||||||
|
break
|
||||||
|
|
||||||
|
item['xml'] = xml
|
||||||
|
if item.get('get_children') is True:
|
||||||
|
children_xml = GetAllPlexChildren(item['plex_id'])
|
||||||
|
try:
|
||||||
|
children_xml[0].attrib
|
||||||
|
except (TypeError, IndexError, AttributeError):
|
||||||
|
LOG.error('Could not get children for Plex id %s',
|
||||||
|
item['plex_id'])
|
||||||
|
item['children'] = []
|
||||||
|
else:
|
||||||
|
item['children'] = children_xml
|
||||||
|
|
||||||
|
# place item into out queue
|
||||||
|
out_queue.put(item)
|
||||||
|
# Keep track of where we are at
|
||||||
|
with sync_info.LOCK:
|
||||||
|
sync_info.GET_METADATA_COUNT += 1
|
||||||
|
# signals to queue job is done
|
||||||
|
queue.task_done()
|
||||||
|
# Empty queue in case PKC was shut down (main thread hangs otherwise)
|
||||||
|
self.terminate_now()
|
||||||
|
LOG.debug('Get metadata thread terminated')
|
||||||
|
|
|
@ -1,412 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
import urllib
|
|
||||||
import copy
|
|
||||||
|
|
||||||
from ..utils import etree
|
|
||||||
from .. import variables as v, utils
|
|
||||||
|
|
||||||
ICON_PATH = 'special://home/addons/plugin.video.plexkodiconnect/icon.png'
|
|
||||||
RECOMMENDED_SCORE_LOWER_BOUND = 7
|
|
||||||
|
|
||||||
# Logic of the following nodes:
|
|
||||||
# (node_type,
|
|
||||||
# label/node name,
|
|
||||||
# args for PKC add-on callback,
|
|
||||||
# Kodi "content",
|
|
||||||
# Bool: does this node's xml even point back to PKC add-on callback?
|
|
||||||
# )
|
|
||||||
NODE_TYPES = {
|
|
||||||
v.PLEX_TYPE_MOVIE: (
|
|
||||||
('plex_ondeck',
|
|
||||||
utils.lang(39500), # "On Deck"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/onDeck',
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_MOVIE,
|
|
||||||
True),
|
|
||||||
('ondeck',
|
|
||||||
utils.lang(39502), # "PKC On Deck (faster)"
|
|
||||||
{},
|
|
||||||
v.CONTENT_TYPE_MOVIE,
|
|
||||||
False),
|
|
||||||
('recent',
|
|
||||||
utils.lang(30174), # "Recently Added"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/recentlyAdded',
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_MOVIE,
|
|
||||||
False),
|
|
||||||
('all',
|
|
||||||
'{self.name}', # We're using this section's name
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/all',
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_MOVIE,
|
|
||||||
False),
|
|
||||||
('recommended',
|
|
||||||
utils.lang(30230), # "Recommended"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': ('/library/sections/{self.section_id}&%s'
|
|
||||||
% urllib.urlencode({'sort': 'rating:desc'})),
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_MOVIE,
|
|
||||||
False),
|
|
||||||
('genres',
|
|
||||||
utils.lang(135), # "Genres"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/genre',
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_MOVIE,
|
|
||||||
False),
|
|
||||||
('sets',
|
|
||||||
utils.lang(39501), # "Collections"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/collection',
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_MOVIE,
|
|
||||||
False),
|
|
||||||
('random',
|
|
||||||
utils.lang(30227), # "Random"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': ('/library/sections/{self.section_id}&%s'
|
|
||||||
% urllib.urlencode({'sort': 'random'})),
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_MOVIE,
|
|
||||||
False),
|
|
||||||
('lastplayed',
|
|
||||||
utils.lang(568), # "Last played"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/recentlyViewed',
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_MOVIE,
|
|
||||||
False),
|
|
||||||
('browse',
|
|
||||||
utils.lang(39702), # "Browse by folder"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/folder',
|
|
||||||
'plex_type': '{self.section_type}',
|
|
||||||
'section_id': '{self.section_id}',
|
|
||||||
'folder': True
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_MOVIE,
|
|
||||||
True),
|
|
||||||
('more',
|
|
||||||
utils.lang(22082), # "More..."
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}',
|
|
||||||
'section_id': '{self.section_id}',
|
|
||||||
'folder': True
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_FILE,
|
|
||||||
True),
|
|
||||||
),
|
|
||||||
###########################################################
|
|
||||||
v.PLEX_TYPE_SHOW: (
|
|
||||||
('ondeck',
|
|
||||||
utils.lang(39500), # "On Deck"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/onDeck',
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_EPISODE,
|
|
||||||
True),
|
|
||||||
('recent',
|
|
||||||
utils.lang(30174), # "Recently Added"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/recentlyAdded',
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_EPISODE,
|
|
||||||
False),
|
|
||||||
('all',
|
|
||||||
'{self.name}', # We're using this section's name
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/all',
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_SHOW,
|
|
||||||
False),
|
|
||||||
('recommended',
|
|
||||||
utils.lang(30230), # "Recommended"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': ('/library/sections/{self.section_id}&%s'
|
|
||||||
% urllib.urlencode({'sort': 'rating:desc'})),
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_SHOW,
|
|
||||||
False),
|
|
||||||
('genres',
|
|
||||||
utils.lang(135), # "Genres"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/genre',
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_SHOW,
|
|
||||||
False),
|
|
||||||
('sets',
|
|
||||||
utils.lang(39501), # "Collections"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/collection',
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_SHOW,
|
|
||||||
True), # There are no sets/collections for shows with Kodi
|
|
||||||
('random',
|
|
||||||
utils.lang(30227), # "Random"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': ('/library/sections/{self.section_id}&%s'
|
|
||||||
% urllib.urlencode({'sort': 'random'})),
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_SHOW,
|
|
||||||
False),
|
|
||||||
('lastplayed',
|
|
||||||
utils.lang(568), # "Last played"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': ('/library/sections/{self.section_id}/recentlyViewed&%s'
|
|
||||||
% urllib.urlencode({'type': v.PLEX_TYPE_NUMBER_FROM_PLEX_TYPE[v.PLEX_TYPE_EPISODE]})),
|
|
||||||
'section_id': '{self.section_id}'
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_EPISODE,
|
|
||||||
False),
|
|
||||||
('browse',
|
|
||||||
utils.lang(39702), # "Browse by folder"
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}/folder',
|
|
||||||
'section_id': '{self.section_id}',
|
|
||||||
'folder': True
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_EPISODE,
|
|
||||||
True),
|
|
||||||
('more',
|
|
||||||
utils.lang(22082), # "More..."
|
|
||||||
{
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/{self.section_id}',
|
|
||||||
'section_id': '{self.section_id}',
|
|
||||||
'folder': True
|
|
||||||
},
|
|
||||||
v.CONTENT_TYPE_FILE,
|
|
||||||
True),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def node_pms(section, node_name, args):
|
|
||||||
"""
|
|
||||||
Nodes where the logic resides with the PMS - we're NOT building an
|
|
||||||
xml that filters and sorts, but point to PKC add-on path
|
|
||||||
|
|
||||||
Be sure to set args['folder'] = True if the listing is a folder and does
|
|
||||||
not contain playable elements like movies, episodes or tracks
|
|
||||||
"""
|
|
||||||
if 'folder' in args:
|
|
||||||
args = copy.deepcopy(args)
|
|
||||||
args.pop('folder')
|
|
||||||
folder = True
|
|
||||||
else:
|
|
||||||
folder = False
|
|
||||||
xml = etree.Element('node',
|
|
||||||
attrib={'order': unicode(section.order),
|
|
||||||
'type': 'folder' if folder else 'filter'})
|
|
||||||
etree.SubElement(xml, 'label').text = node_name
|
|
||||||
etree.SubElement(xml, 'icon').text = ICON_PATH
|
|
||||||
etree.SubElement(xml, 'content').text = section.content
|
|
||||||
etree.SubElement(xml, 'path').text = section.addon_path(args)
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def node_ondeck(section, node_name):
|
|
||||||
"""
|
|
||||||
For movies only - returns in-progress movies sorted by last played
|
|
||||||
"""
|
|
||||||
xml = etree.Element('node', attrib={'order': unicode(section.order),
|
|
||||||
'type': 'filter'})
|
|
||||||
etree.SubElement(xml, 'match').text = 'all'
|
|
||||||
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
|
|
||||||
'operator': 'is'})
|
|
||||||
etree.SubElement(rule, 'value').text = section.name
|
|
||||||
etree.SubElement(xml, 'rule', attrib={'field': 'inprogress',
|
|
||||||
'operator': 'true'})
|
|
||||||
etree.SubElement(xml, 'label').text = node_name
|
|
||||||
etree.SubElement(xml, 'icon').text = ICON_PATH
|
|
||||||
etree.SubElement(xml, 'content').text = section.content
|
|
||||||
etree.SubElement(xml, 'limit').text = utils.settings('widgetLimit')
|
|
||||||
etree.SubElement(xml,
|
|
||||||
'order',
|
|
||||||
attrib={'direction':
|
|
||||||
'descending'}).text = 'lastplayed'
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def node_recent(section, node_name):
|
|
||||||
xml = etree.Element('node',
|
|
||||||
attrib={'order': unicode(section.order),
|
|
||||||
'type': 'filter'})
|
|
||||||
etree.SubElement(xml, 'match').text = 'all'
|
|
||||||
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
|
|
||||||
'operator': 'is'})
|
|
||||||
etree.SubElement(rule, 'value').text = section.name
|
|
||||||
if ((section.section_type == v.PLEX_TYPE_SHOW and
|
|
||||||
utils.settings('TVShowWatched') == 'false') or
|
|
||||||
(section.section_type == v.PLEX_TYPE_MOVIE and
|
|
||||||
utils.settings('MovieShowWatched') == 'false')):
|
|
||||||
# Adds an additional rule if user deactivated the PKC setting
|
|
||||||
# "Recently Added: Also show already watched episodes"
|
|
||||||
# or
|
|
||||||
# "Recently Added: Also show already watched episodes"
|
|
||||||
rule = etree.SubElement(xml, 'rule', attrib={'field': 'playcount',
|
|
||||||
'operator': 'is'})
|
|
||||||
etree.SubElement(rule, 'value').text = '0'
|
|
||||||
etree.SubElement(xml, 'label').text = node_name
|
|
||||||
etree.SubElement(xml, 'icon').text = ICON_PATH
|
|
||||||
etree.SubElement(xml, 'content').text = section.content
|
|
||||||
etree.SubElement(xml, 'limit').text = utils.settings('widgetLimit')
|
|
||||||
etree.SubElement(xml,
|
|
||||||
'order',
|
|
||||||
attrib={'direction':
|
|
||||||
'descending'}).text = 'dateadded'
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def node_all(section, node_name):
|
|
||||||
xml = etree.Element('node', attrib={'order': unicode(section.order),
|
|
||||||
'type': 'filter'})
|
|
||||||
etree.SubElement(xml, 'match').text = 'all'
|
|
||||||
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
|
|
||||||
'operator': 'is'})
|
|
||||||
etree.SubElement(rule, 'value').text = section.name
|
|
||||||
etree.SubElement(xml, 'label').text = node_name
|
|
||||||
etree.SubElement(xml, 'icon').text = ICON_PATH
|
|
||||||
etree.SubElement(xml, 'content').text = section.content
|
|
||||||
etree.SubElement(xml,
|
|
||||||
'order',
|
|
||||||
attrib={'direction':
|
|
||||||
'ascending'}).text = 'sorttitle'
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def node_recommended(section, node_name):
|
|
||||||
xml = etree.Element('node', attrib={'order': unicode(section.order),
|
|
||||||
'type': 'filter'})
|
|
||||||
etree.SubElement(xml, 'match').text = 'all'
|
|
||||||
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
|
|
||||||
'operator': 'is'})
|
|
||||||
etree.SubElement(rule, 'value').text = section.name
|
|
||||||
# rule = etree.SubElement(xml, 'rule', attrib={'field': 'rating',
|
|
||||||
# 'operator': 'greaterthan'})
|
|
||||||
# etree.SubElement(rule, 'value').text = unicode(RECOMMENDED_SCORE_LOWER_BOUND)
|
|
||||||
etree.SubElement(xml, 'label').text = node_name
|
|
||||||
etree.SubElement(xml, 'icon').text = ICON_PATH
|
|
||||||
etree.SubElement(xml, 'content').text = section.content
|
|
||||||
etree.SubElement(xml, 'limit').text = utils.settings('widgetLimit')
|
|
||||||
etree.SubElement(xml,
|
|
||||||
'order',
|
|
||||||
attrib={'direction':
|
|
||||||
'descending'}).text = 'rating'
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def node_genres(section, node_name):
|
|
||||||
xml = etree.Element('node', attrib={'order': unicode(section.order),
|
|
||||||
'type': 'filter'})
|
|
||||||
etree.SubElement(xml, 'match').text = 'all'
|
|
||||||
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
|
|
||||||
'operator': 'is'})
|
|
||||||
etree.SubElement(rule, 'value').text = section.name
|
|
||||||
etree.SubElement(xml, 'label').text = node_name
|
|
||||||
etree.SubElement(xml, 'icon').text = ICON_PATH
|
|
||||||
etree.SubElement(xml, 'content').text = section.content
|
|
||||||
etree.SubElement(xml,
|
|
||||||
'order',
|
|
||||||
attrib={'direction':
|
|
||||||
'ascending'}).text = 'sorttitle'
|
|
||||||
etree.SubElement(xml, 'group').text = 'genres'
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def node_sets(section, node_name):
|
|
||||||
xml = etree.Element('node', attrib={'order': unicode(section.order),
|
|
||||||
'type': 'filter'})
|
|
||||||
etree.SubElement(xml, 'match').text = 'all'
|
|
||||||
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
|
|
||||||
'operator': 'is'})
|
|
||||||
etree.SubElement(rule, 'value').text = section.name
|
|
||||||
# "Collections"
|
|
||||||
etree.SubElement(xml, 'label').text = node_name
|
|
||||||
etree.SubElement(xml, 'icon').text = ICON_PATH
|
|
||||||
etree.SubElement(xml, 'content').text = section.content
|
|
||||||
etree.SubElement(xml,
|
|
||||||
'order',
|
|
||||||
attrib={'direction':
|
|
||||||
'ascending'}).text = 'sorttitle'
|
|
||||||
etree.SubElement(xml, 'group').text = 'sets'
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def node_random(section, node_name):
|
|
||||||
xml = etree.Element('node', attrib={'order': unicode(section.order),
|
|
||||||
'type': 'filter'})
|
|
||||||
etree.SubElement(xml, 'match').text = 'all'
|
|
||||||
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
|
|
||||||
'operator': 'is'})
|
|
||||||
etree.SubElement(rule, 'value').text = section.name
|
|
||||||
etree.SubElement(xml, 'label').text = node_name
|
|
||||||
etree.SubElement(xml, 'icon').text = ICON_PATH
|
|
||||||
etree.SubElement(xml, 'content').text = section.content
|
|
||||||
etree.SubElement(xml, 'limit').text = utils.settings('widgetLimit')
|
|
||||||
etree.SubElement(xml,
|
|
||||||
'order',
|
|
||||||
attrib={'direction':
|
|
||||||
'ascending'}).text = 'random'
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def node_lastplayed(section, node_name):
|
|
||||||
xml = etree.Element('node', attrib={'order': unicode(section.order),
|
|
||||||
'type': 'filter'})
|
|
||||||
etree.SubElement(xml, 'match').text = 'all'
|
|
||||||
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
|
|
||||||
'operator': 'is'})
|
|
||||||
etree.SubElement(rule, 'value').text = section.name
|
|
||||||
rule = etree.SubElement(xml, 'rule', attrib={'field': 'playcount',
|
|
||||||
'operator': 'greaterthan'})
|
|
||||||
etree.SubElement(rule, 'value').text = '0'
|
|
||||||
etree.SubElement(xml, 'label').text = node_name
|
|
||||||
etree.SubElement(xml, 'icon').text = ICON_PATH
|
|
||||||
etree.SubElement(xml, 'content').text = section.content
|
|
||||||
etree.SubElement(xml, 'limit').text = utils.settings('widgetLimit')
|
|
||||||
etree.SubElement(xml,
|
|
||||||
'order',
|
|
||||||
attrib={'direction':
|
|
||||||
'descending'}).text = 'lastplayed'
|
|
||||||
return xml
|
|
|
@ -1,92 +1,88 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
from threading import Thread
|
||||||
|
from Queue import Empty
|
||||||
|
|
||||||
from . import common, sections
|
from xbmc import sleep
|
||||||
from ..plex_db import PlexDB
|
|
||||||
from .. import backgroundthread, app
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.sync.process_metadata')
|
from utils import thread_methods
|
||||||
|
import itemtypes
|
||||||
|
import sync_info
|
||||||
|
|
||||||
COMMIT_TO_DB_EVERY_X_ITEMS = 500
|
###############################################################################
|
||||||
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
class ProcessMetadataThread(common.LibrarySyncMixin,
|
@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD',
|
||||||
backgroundthread.KillableThread):
|
'STOP_SYNC',
|
||||||
|
'SUSPEND_SYNC'])
|
||||||
|
class ThreadedProcessMetadata(Thread):
|
||||||
"""
|
"""
|
||||||
Invoke once in order to process the received PMS metadata xmls
|
Not yet implemented for more than 1 thread - if ever. Only to be called by
|
||||||
|
ONE thread!
|
||||||
|
Processes the XML metadata in the queue
|
||||||
|
|
||||||
|
Input:
|
||||||
|
queue: Queue.Queue() object that you'll need to fill up with
|
||||||
|
the downloaded XML eTree objects
|
||||||
|
item_class: as used to call functions in itemtypes.py e.g. 'Movies' =>
|
||||||
|
itemtypes.Movies()
|
||||||
"""
|
"""
|
||||||
def __init__(self, current_time, processing_queue, update_progressbar):
|
def __init__(self, queue, item_class):
|
||||||
self.current_time = current_time
|
self.queue = queue
|
||||||
self.processing_queue = processing_queue
|
self.item_class = item_class
|
||||||
self.update_progressbar = update_progressbar
|
Thread.__init__(self)
|
||||||
self.last_section = sections.Section()
|
|
||||||
self.successful = True
|
|
||||||
super(ProcessMetadataThread, self).__init__()
|
|
||||||
|
|
||||||
def start_section(self, section):
|
def terminate_now(self):
|
||||||
if section != self.last_section:
|
"""
|
||||||
if self.last_section:
|
Needed to terminate this thread, because there might be items left in
|
||||||
self.finish_last_section()
|
the queue which could cause other threads to hang
|
||||||
LOG.debug('Start or continue processing section %s', section)
|
"""
|
||||||
self.last_section = section
|
while not self.queue.empty():
|
||||||
# Warn the user for this new section if we cannot access a file
|
# Still try because remaining item might have been taken
|
||||||
app.SYNC.path_verified = False
|
try:
|
||||||
else:
|
self.queue.get(block=False)
|
||||||
LOG.debug('Resume processing section %s', section)
|
except Empty:
|
||||||
|
sleep(10)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
self.queue.task_done()
|
||||||
|
|
||||||
def finish_last_section(self):
|
def run(self):
|
||||||
if (not self.should_cancel() and self.last_section and
|
"""
|
||||||
self.last_section.sync_successful):
|
Do the work
|
||||||
# Check for should_cancel() because we cannot be sure that we
|
"""
|
||||||
# processed every item of the section
|
LOG.debug('Processing thread started')
|
||||||
with PlexDB() as plexdb:
|
# Constructs the method name, e.g. itemtypes.Movies
|
||||||
# Set the new time mark for the next delta sync
|
item_fct = getattr(itemtypes, self.item_class)
|
||||||
plexdb.update_section_last_sync(self.last_section.section_id,
|
# cache local variables because it's faster
|
||||||
self.current_time)
|
queue = self.queue
|
||||||
LOG.info('Finished processing section successfully: %s',
|
stopped = self.stopped
|
||||||
self.last_section)
|
with item_fct() as item_class:
|
||||||
elif self.last_section and not self.last_section.sync_successful:
|
while stopped() is False:
|
||||||
LOG.warn('Sync not successful for section %s', self.last_section)
|
# grabs item from queue
|
||||||
self.successful = False
|
try:
|
||||||
|
item = queue.get(block=False)
|
||||||
def _get(self):
|
except Empty:
|
||||||
item = {'xml': None}
|
sleep(20)
|
||||||
while item and item['xml'] is None:
|
continue
|
||||||
item = self.processing_queue.get()
|
# Do the work
|
||||||
self.processing_queue.task_done()
|
item_method = getattr(item_class, item['method'])
|
||||||
return item
|
if item.get('children'):
|
||||||
|
item_method(item['xml'][0],
|
||||||
def _run(self):
|
viewtag=item['view_name'],
|
||||||
# There are 2 sentinels: None for aborting/ending this thread, the dict
|
viewid=item['view_id'],
|
||||||
# {'section': section, 'xml': None} for skipped/invalid items
|
children=item['children'])
|
||||||
item = self._get()
|
else:
|
||||||
if item:
|
item_method(item['xml'][0],
|
||||||
section = item['section']
|
viewtag=item['view_name'],
|
||||||
processed = 0
|
viewid=item['view_id'])
|
||||||
self.start_section(section)
|
# Keep track of where we are at
|
||||||
while not self.should_cancel():
|
with sync_info.LOCK:
|
||||||
if item is None:
|
sync_info.PROCESS_METADATA_COUNT += 1
|
||||||
break
|
sync_info.PROCESSING_VIEW_NAME = item['title']
|
||||||
elif item['section'] != section:
|
queue.task_done()
|
||||||
# We received an entirely new section
|
self.terminate_now()
|
||||||
self.start_section(item['section'])
|
LOG.debug('Processing thread terminated')
|
||||||
section = item['section']
|
|
||||||
with section.context(self.current_time) as context:
|
|
||||||
while not self.should_cancel():
|
|
||||||
if item is None or item['section'] != section:
|
|
||||||
break
|
|
||||||
self.update_progressbar(section,
|
|
||||||
item['xml'][0].get('title'),
|
|
||||||
section.count)
|
|
||||||
context.add_update(item['xml'][0],
|
|
||||||
section_name=section.name,
|
|
||||||
section_id=section.section_id,
|
|
||||||
children=item['children'])
|
|
||||||
processed += 1
|
|
||||||
section.count += 1
|
|
||||||
if processed == COMMIT_TO_DB_EVERY_X_ITEMS:
|
|
||||||
processed = 0
|
|
||||||
context.commit()
|
|
||||||
item = self._get()
|
|
||||||
self.finish_last_section()
|
|
||||||
|
|
|
@ -1,745 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
import copy
|
|
||||||
|
|
||||||
from . import nodes
|
|
||||||
from ..plex_db import PlexDB
|
|
||||||
from ..plex_api import API
|
|
||||||
from .. import kodi_db
|
|
||||||
from .. import itemtypes, path_ops
|
|
||||||
from .. import plex_functions as PF, music, utils, variables as v, app
|
|
||||||
from ..utils import etree
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.sync.sections')
|
|
||||||
|
|
||||||
BATCH_SIZE = 500
|
|
||||||
# Need a way to interrupt our synching process
|
|
||||||
SHOULD_CANCEL = None
|
|
||||||
|
|
||||||
LIBRARY_PATH = path_ops.translate_path('special://profile/library/video/')
|
|
||||||
# The video library might not yet exist for this user - create it
|
|
||||||
if not path_ops.exists(LIBRARY_PATH):
|
|
||||||
path_ops.copy_tree(
|
|
||||||
src=path_ops.translate_path('special://xbmc/system/library/video'),
|
|
||||||
dst=LIBRARY_PATH,
|
|
||||||
preserve_mode=0) # dont copy permission bits so we have write access!
|
|
||||||
PLAYLISTS_PATH = path_ops.translate_path("special://profile/playlists/video/")
|
|
||||||
if not path_ops.exists(PLAYLISTS_PATH):
|
|
||||||
path_ops.makedirs(PLAYLISTS_PATH)
|
|
||||||
|
|
||||||
# Windows variables we set for each node
|
|
||||||
WINDOW_ARGS = ('index', 'title', 'id', 'path', 'type', 'content', 'artwork')
|
|
||||||
|
|
||||||
|
|
||||||
class Section(object):
|
|
||||||
"""
|
|
||||||
Setting the attribute section_type will automatically set content and
|
|
||||||
sync_to_kodi
|
|
||||||
"""
|
|
||||||
def __init__(self, index=None, xml_element=None, section_db_element=None):
|
|
||||||
# Unique Plex id of this Plex library section
|
|
||||||
self._section_id = None # int
|
|
||||||
# Building block for window variable
|
|
||||||
self._node = None # unicode
|
|
||||||
# Index of this section (as section_id might not be subsequent)
|
|
||||||
# This follows 1:1 the sequence in with the PMS returns the sections
|
|
||||||
self._index = None # Codacy-bug
|
|
||||||
self.index = index # int
|
|
||||||
# This section's name for the user to display
|
|
||||||
self.name = None # unicode
|
|
||||||
# Library type section (NOT the same as the KODI_TYPE_...)
|
|
||||||
# E.g. 'movies', 'tvshows', 'episodes'
|
|
||||||
self.content = None # unicode
|
|
||||||
# Setting the section_type WILL re_set sync_to_kodi!
|
|
||||||
self._section_type = None # unicode
|
|
||||||
# E.g. "season" or "movie" (translated)
|
|
||||||
self.section_type_text = None
|
|
||||||
# Do we sync all items of this section to the Kodi DB?
|
|
||||||
# This will be set with section_type!!
|
|
||||||
self.sync_to_kodi = None # bool
|
|
||||||
# For sections to be synched, the section name will be recorded as a
|
|
||||||
# tag. This is the corresponding id for this tag
|
|
||||||
self.kodi_tagid = None # int
|
|
||||||
# When was this section last successfully/completely synched to the
|
|
||||||
# Kodi database?
|
|
||||||
self.last_sync = None # int
|
|
||||||
# Path to the Kodi userdata library FOLDER for this section
|
|
||||||
self._path = None # unicode
|
|
||||||
# Path to the smart playlist for this section
|
|
||||||
self._playlist_path = None
|
|
||||||
# "Poster" for this section
|
|
||||||
self.icon = None # unicode
|
|
||||||
# Background image for this section
|
|
||||||
self.artwork = None
|
|
||||||
# Thumbnail for this section, similar for each section type
|
|
||||||
self.thumb = None
|
|
||||||
# Order number in which xmls will be listed inside Kodei
|
|
||||||
self.order = None
|
|
||||||
# Original PMS xml for this section, including children
|
|
||||||
self.xml = None
|
|
||||||
# A section_type encompasses possible several plex_types! E.g. shows
|
|
||||||
# contain shows, seasons, episodes
|
|
||||||
self._plex_type = None
|
|
||||||
if xml_element is not None:
|
|
||||||
self.from_xml(xml_element)
|
|
||||||
elif section_db_element:
|
|
||||||
self.from_db_element(section_db_element)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return ("{{"
|
|
||||||
"'index': {self.index}, "
|
|
||||||
"'name': '{self.name}', "
|
|
||||||
"'section_id': {self.section_id}, "
|
|
||||||
"'section_type': '{self.section_type}', "
|
|
||||||
"'plex_type': '{self.plex_type}', "
|
|
||||||
"'sync_to_kodi': {self.sync_to_kodi}, "
|
|
||||||
"'last_sync': {self.last_sync}"
|
|
||||||
"}}").format(self=self).encode('utf-8')
|
|
||||||
__str__ = __repr__
|
|
||||||
|
|
||||||
def __nonzero__(self):
|
|
||||||
return (self.section_id is not None and
|
|
||||||
self.name is not None and
|
|
||||||
self.section_type is not None)
|
|
||||||
|
|
||||||
def __eq__(self, section):
|
|
||||||
"""
|
|
||||||
Sections compare equal if their section_id, name and plex_type (first
|
|
||||||
prio) OR section_type (if there is no plex_type is set) compare equal
|
|
||||||
"""
|
|
||||||
if not isinstance(section, Section):
|
|
||||||
return False
|
|
||||||
return (self.section_id == section.section_id and
|
|
||||||
self.name == section.name and
|
|
||||||
(self.plex_type == section.plex_type if self.plex_type else
|
|
||||||
self.section_type == section.section_type))
|
|
||||||
|
|
||||||
def __ne__(self, section):
|
|
||||||
return not self == section
|
|
||||||
|
|
||||||
@property
|
|
||||||
def section_id(self):
|
|
||||||
return self._section_id
|
|
||||||
|
|
||||||
@section_id.setter
|
|
||||||
def section_id(self, value):
|
|
||||||
self._section_id = value
|
|
||||||
self._path = path_ops.path.join(LIBRARY_PATH, 'Plex-%s' % value, '')
|
|
||||||
self._playlist_path = path_ops.path.join(PLAYLISTS_PATH,
|
|
||||||
'Plex %s.xsp' % value)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def section_type(self):
|
|
||||||
return self._section_type
|
|
||||||
|
|
||||||
@section_type.setter
|
|
||||||
def section_type(self, value):
|
|
||||||
self._section_type = value
|
|
||||||
self.content = v.CONTENT_FROM_PLEX_TYPE[value]
|
|
||||||
# Default values whether we sync or not based on the Plex type
|
|
||||||
if value == v.PLEX_TYPE_PHOTO:
|
|
||||||
self.sync_to_kodi = False
|
|
||||||
elif not app.SYNC.enable_music and value == v.PLEX_TYPE_ARTIST:
|
|
||||||
self.sync_to_kodi = False
|
|
||||||
else:
|
|
||||||
self.sync_to_kodi = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def plex_type(self):
|
|
||||||
return self._plex_type
|
|
||||||
|
|
||||||
@plex_type.setter
|
|
||||||
def plex_type(self, value):
|
|
||||||
self._plex_type = value
|
|
||||||
self.section_type_text = utils.lang(v.TRANSLATION_FROM_PLEXTYPE[value])
|
|
||||||
|
|
||||||
@property
|
|
||||||
def index(self):
|
|
||||||
return self._index
|
|
||||||
|
|
||||||
@index.setter
|
|
||||||
def index(self, value):
|
|
||||||
self._index = value
|
|
||||||
self._node = 'Plex.nodes.%s' % value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def node(self):
|
|
||||||
return self._node
|
|
||||||
|
|
||||||
@property
|
|
||||||
def path(self):
|
|
||||||
return self._path
|
|
||||||
|
|
||||||
@property
|
|
||||||
def playlist_path(self):
|
|
||||||
return self._playlist_path
|
|
||||||
|
|
||||||
def from_db_element(self, section_db_element):
|
|
||||||
self.section_id = section_db_element['section_id']
|
|
||||||
self.name = section_db_element['section_name']
|
|
||||||
self.section_type = section_db_element['plex_type']
|
|
||||||
self.kodi_tagid = section_db_element['kodi_tagid']
|
|
||||||
self.sync_to_kodi = section_db_element['sync_to_kodi']
|
|
||||||
self.last_sync = section_db_element['last_sync']
|
|
||||||
|
|
||||||
def from_xml(self, xml_element):
|
|
||||||
"""
|
|
||||||
Reads section from a PMS xml (Plex id, name, Plex type)
|
|
||||||
"""
|
|
||||||
api = API(xml_element)
|
|
||||||
self.section_id = utils.cast(int, xml_element.get('key'))
|
|
||||||
self.name = api.title()
|
|
||||||
self.section_type = api.plex_type
|
|
||||||
self.icon = api.one_artwork('composite')
|
|
||||||
self.artwork = api.one_artwork('art')
|
|
||||||
self.thumb = api.one_artwork('thumb')
|
|
||||||
self.xml = xml_element
|
|
||||||
|
|
||||||
def from_plex_db(self, section_id, plexdb=None):
|
|
||||||
"""
|
|
||||||
Reads section with id section_id from the plex.db
|
|
||||||
"""
|
|
||||||
if plexdb:
|
|
||||||
section = plexdb.section(section_id)
|
|
||||||
else:
|
|
||||||
with PlexDB(lock=False) as plexdb:
|
|
||||||
section = plexdb.section(section_id)
|
|
||||||
if section:
|
|
||||||
self.from_db_element(section)
|
|
||||||
|
|
||||||
def to_plex_db(self, plexdb=None):
|
|
||||||
"""
|
|
||||||
Writes this Section to the plex.db, potentially overwriting
|
|
||||||
(INSERT OR REPLACE)
|
|
||||||
"""
|
|
||||||
if not self:
|
|
||||||
raise RuntimeError('Section not clearly defined: %s' % self)
|
|
||||||
if plexdb:
|
|
||||||
plexdb.add_section(self.section_id,
|
|
||||||
self.name,
|
|
||||||
self.section_type,
|
|
||||||
self.kodi_tagid,
|
|
||||||
self.sync_to_kodi,
|
|
||||||
self.last_sync)
|
|
||||||
else:
|
|
||||||
with PlexDB(lock=False) as plexdb:
|
|
||||||
plexdb.add_section(self.section_id,
|
|
||||||
self.name,
|
|
||||||
self.section_type,
|
|
||||||
self.kodi_tagid,
|
|
||||||
self.sync_to_kodi,
|
|
||||||
self.last_sync)
|
|
||||||
|
|
||||||
def addon_path(self, args):
|
|
||||||
"""
|
|
||||||
Returns the plugin path pointing back to PKC for key in order to browse
|
|
||||||
args is a dict. Its values may contain string info of the form
|
|
||||||
{key: '{self.<Section attribute>}'}
|
|
||||||
"""
|
|
||||||
args = copy.deepcopy(args)
|
|
||||||
for key, value in args.iteritems():
|
|
||||||
args[key] = value.format(self=self)
|
|
||||||
return utils.extend_url('plugin://%s' % v.ADDON_ID, args)
|
|
||||||
|
|
||||||
def to_kodi(self):
|
|
||||||
"""
|
|
||||||
Writes this section's nodes to the library folder in the Kodi userdata
|
|
||||||
directory
|
|
||||||
Won't do anything if self.sync_to_kodi is not True
|
|
||||||
"""
|
|
||||||
if self.index is None:
|
|
||||||
raise RuntimeError('Index not initialized')
|
|
||||||
# Main list entry for this section - which will show the different
|
|
||||||
# nodes as "submenus" once the user navigates into this section
|
|
||||||
if self.sync_to_kodi and self.section_type in v.PLEX_VIDEOTYPES:
|
|
||||||
# Node showing a menu for this section
|
|
||||||
args = {
|
|
||||||
'mode': 'show_section',
|
|
||||||
'section_index': self.index
|
|
||||||
}
|
|
||||||
index = utils.extend_url('plugin://%s' % v.ADDON_ID, args)
|
|
||||||
# Node directly displaying all content
|
|
||||||
path = 'library://video/Plex-{0}/{0}_all.xml'
|
|
||||||
path = path.format(self.section_id)
|
|
||||||
else:
|
|
||||||
# Node showing a menu for this section
|
|
||||||
args = {
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/%s' % self.section_id,
|
|
||||||
'section_id': unicode(self.section_id)
|
|
||||||
}
|
|
||||||
if not self.sync_to_kodi:
|
|
||||||
args['synched'] = 'false'
|
|
||||||
# No library xmls to speed things up
|
|
||||||
# Immediately show the PMS options for this section
|
|
||||||
index = self.addon_path(args)
|
|
||||||
# Node directly displaying all content
|
|
||||||
args = {
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': '/library/sections/%s/all' % self.section_id,
|
|
||||||
'section_id': unicode(self.section_id)
|
|
||||||
}
|
|
||||||
if not self.sync_to_kodi:
|
|
||||||
args['synched'] = 'false'
|
|
||||||
path = self.addon_path(args)
|
|
||||||
utils.window('%s.index' % self.node, value=index)
|
|
||||||
utils.window('%s.title' % self.node, value=self.name)
|
|
||||||
utils.window('%s.type' % self.node, value=self.content)
|
|
||||||
utils.window('%s.content' % self.node, value=index)
|
|
||||||
# .path leads to all elements of this library
|
|
||||||
if self.section_type in v.PLEX_VIDEOTYPES:
|
|
||||||
utils.window('%s.path' % self.node,
|
|
||||||
value='ActivateWindow(videos,%s,return)' % path)
|
|
||||||
elif self.section_type == v.PLEX_TYPE_ARTIST:
|
|
||||||
utils.window('%s.path' % self.node,
|
|
||||||
value='ActivateWindow(music,%s,return)' % path)
|
|
||||||
else:
|
|
||||||
# Pictures
|
|
||||||
utils.window('%s.path' % self.node,
|
|
||||||
value='ActivateWindow(pictures,%s,return)' % path)
|
|
||||||
utils.window('%s.id' % self.node, value=str(self.section_id))
|
|
||||||
if not self.sync_to_kodi:
|
|
||||||
self.remove_files_from_kodi()
|
|
||||||
return
|
|
||||||
if self.section_type == v.PLEX_TYPE_ARTIST:
|
|
||||||
# Todo: Write window variables for music
|
|
||||||
return
|
|
||||||
if self.section_type == v.PLEX_TYPE_PHOTO:
|
|
||||||
# Todo: Write window variables for photos
|
|
||||||
return
|
|
||||||
|
|
||||||
# Create a dedicated directory for this section
|
|
||||||
if not path_ops.exists(self.path):
|
|
||||||
path_ops.makedirs(self.path)
|
|
||||||
# Create a tag just like the section name in the Kodi DB
|
|
||||||
with kodi_db.KodiVideoDB(lock=False) as kodidb:
|
|
||||||
self.kodi_tagid = kodidb.create_tag(self.name)
|
|
||||||
# The xmls are numbered in order of appearance
|
|
||||||
self.order = 0
|
|
||||||
if not path_ops.exists(path_ops.path.join(self.path, 'index.xml')):
|
|
||||||
LOG.debug('Creating index.xml for section %s', self.name)
|
|
||||||
xml = etree.Element('node',
|
|
||||||
attrib={'order': unicode(self.order)})
|
|
||||||
etree.SubElement(xml, 'label').text = self.name
|
|
||||||
etree.SubElement(xml, 'icon').text = self.icon or nodes.ICON_PATH
|
|
||||||
self._write_xml(xml, 'index.xml')
|
|
||||||
self.order += 1
|
|
||||||
# Create the one smart playlist for this section
|
|
||||||
if not path_ops.exists(self.playlist_path):
|
|
||||||
self._write_playlist()
|
|
||||||
# Now build all nodes for this section - potentially creating xmls
|
|
||||||
for node in nodes.NODE_TYPES[self.section_type]:
|
|
||||||
self._build_node(*node)
|
|
||||||
|
|
||||||
def _build_node(self, node_type, node_name, args, content, pms_node):
|
|
||||||
self.content = content
|
|
||||||
node_name = node_name.format(self=self)
|
|
||||||
if pms_node:
|
|
||||||
# Do NOT write a Kodi video library xml - can't use type="filter"
|
|
||||||
# to point back to plugin://plugin.video.plexkodiconnect
|
|
||||||
xml = nodes.node_pms(self, node_name, args)
|
|
||||||
args.pop('folder', None)
|
|
||||||
path = self.addon_path(args)
|
|
||||||
else:
|
|
||||||
# Write a Kodi video library xml
|
|
||||||
xml_name = '%s_%s.xml' % (self.section_id, node_type)
|
|
||||||
path = path_ops.path.join(self.path, xml_name)
|
|
||||||
if not path_ops.exists(path):
|
|
||||||
# Let's use Kodi's logic to sort/filter the Kodi library
|
|
||||||
xml = getattr(nodes, 'node_%s' % node_type)(self, node_name)
|
|
||||||
self._write_xml(xml, xml_name)
|
|
||||||
path = 'library://video/Plex-%s/%s' % (self.section_id, xml_name)
|
|
||||||
self.order += 1
|
|
||||||
self._window_node(path, node_name, node_type, pms_node)
|
|
||||||
|
|
||||||
def _write_xml(self, xml, xml_name):
|
|
||||||
LOG.debug('Creating xml for section %s: %s', self.name, xml_name)
|
|
||||||
utils.indent(xml)
|
|
||||||
etree.ElementTree(xml).write(path_ops.path.join(self.path, xml_name),
|
|
||||||
encoding='utf-8',
|
|
||||||
xml_declaration=True)
|
|
||||||
|
|
||||||
def _write_playlist(self):
|
|
||||||
LOG.debug('Creating smart playlist for section %s: %s',
|
|
||||||
self.name, self.playlist_path)
|
|
||||||
xml = etree.Element('smartplaylist',
|
|
||||||
attrib={'type': v.CONTENT_FROM_PLEX_TYPE[self.section_type]})
|
|
||||||
etree.SubElement(xml, 'name').text = self.name
|
|
||||||
etree.SubElement(xml, 'match').text = 'all'
|
|
||||||
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
|
|
||||||
'operator': 'is'})
|
|
||||||
etree.SubElement(rule, 'value').text = self.name
|
|
||||||
utils.indent(xml)
|
|
||||||
etree.ElementTree(xml).write(self.playlist_path, encoding='utf-8')
|
|
||||||
|
|
||||||
def _window_node(self, path, node_name, node_type, pms_node):
|
|
||||||
"""
|
|
||||||
Will save this section's node to the Kodi window variables
|
|
||||||
|
|
||||||
Uses the same conventions/logic as Emby for Kodi does
|
|
||||||
"""
|
|
||||||
if pms_node or not self.sync_to_kodi:
|
|
||||||
# Check: elif node_type in ('browse', 'homevideos', 'photos'):
|
|
||||||
window_path = path
|
|
||||||
elif self.section_type == v.PLEX_TYPE_ARTIST:
|
|
||||||
window_path = 'ActivateWindow(Music,%s,return)' % path
|
|
||||||
else:
|
|
||||||
window_path = 'ActivateWindow(Videos,%s,return)' % path
|
|
||||||
# if node_type == 'all':
|
|
||||||
# var = self.node
|
|
||||||
# utils.window('%s.index' % var,
|
|
||||||
# value=path.replace('%s_all.xml' % self.section_id, ''))
|
|
||||||
# utils.window('%s.title' % var, value=self.name)
|
|
||||||
# else:
|
|
||||||
var = '%s.%s' % (self.node, node_type)
|
|
||||||
utils.window('%s.index' % var, value=path)
|
|
||||||
utils.window('%s.title' % var, value=node_name)
|
|
||||||
utils.window('%s.id' % var, value=str(self.section_id))
|
|
||||||
utils.window('%s.path' % var, value=window_path)
|
|
||||||
utils.window('%s.type' % var, value=self.content)
|
|
||||||
utils.window('%s.content' % var, value=path)
|
|
||||||
utils.window('%s.artwork' % var, value=self.artwork)
|
|
||||||
|
|
||||||
def remove_files_from_kodi(self):
|
|
||||||
"""
|
|
||||||
Removes this sections from the Kodi userdata library folder (if appl.)
|
|
||||||
Also removes the smart playlist
|
|
||||||
"""
|
|
||||||
if self.section_type in (v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_PHOTO):
|
|
||||||
# No files created for these types
|
|
||||||
return
|
|
||||||
if path_ops.exists(self.path):
|
|
||||||
path_ops.rmtree(self.path, ignore_errors=True)
|
|
||||||
if path_ops.exists(self.playlist_path):
|
|
||||||
try:
|
|
||||||
path_ops.remove(self.playlist_path)
|
|
||||||
except (OSError, IOError):
|
|
||||||
LOG.warn('Could not delete smart playlist for section %s: %s',
|
|
||||||
self.name, self.playlist_path)
|
|
||||||
|
|
||||||
def remove_window_vars(self):
|
|
||||||
"""
|
|
||||||
Removes all windows variables 'Plex.nodes.<section_id>.xxx'
|
|
||||||
"""
|
|
||||||
if self.index is not None:
|
|
||||||
_clear_window_vars(self.index)
|
|
||||||
|
|
||||||
def remove_from_plex(self, plexdb=None):
|
|
||||||
"""
|
|
||||||
Removes this sections completely from the Plex DB
|
|
||||||
"""
|
|
||||||
if plexdb:
|
|
||||||
plexdb.remove_section(self.section_id)
|
|
||||||
else:
|
|
||||||
with PlexDB(lock=False) as plexdb:
|
|
||||||
plexdb.remove_section(self.section_id)
|
|
||||||
|
|
||||||
def remove(self):
|
|
||||||
"""
|
|
||||||
Completely and utterly removes this section from Kodi and Plex DB
|
|
||||||
as well as from the window variables
|
|
||||||
"""
|
|
||||||
self.remove_files_from_kodi()
|
|
||||||
self.remove_window_vars()
|
|
||||||
self.remove_from_plex()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_children(plex_type):
|
|
||||||
if plex_type == v.PLEX_TYPE_ALBUM:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_sync_section(section, plex_type):
|
|
||||||
"""
|
|
||||||
Deep-copies section and adds certain arguments in order to prep section
|
|
||||||
for the library sync
|
|
||||||
"""
|
|
||||||
section = copy.deepcopy(section)
|
|
||||||
section.plex_type = plex_type
|
|
||||||
section.context = itemtypes.ITEMTYPE_FROM_PLEXTYPE[plex_type]
|
|
||||||
section.get_children = _get_children(plex_type)
|
|
||||||
# Some more init stuff
|
|
||||||
# Has sync for this section been successful?
|
|
||||||
section.sync_successful = True
|
|
||||||
# List of tuples: (collection index [as in an item's metadata with
|
|
||||||
# "Collection id"], collection plex id)
|
|
||||||
section.collection_match = None
|
|
||||||
# Dict with entries of the form <collection index>: <collection xml>
|
|
||||||
section.collection_xmls = {}
|
|
||||||
# Keep count during sync
|
|
||||||
section.count = 0
|
|
||||||
# Total number of items that we need to sync
|
|
||||||
section.number_of_items = 0
|
|
||||||
# Iterator to get one sync item after the other
|
|
||||||
section.iterator = None
|
|
||||||
return section
|
|
||||||
|
|
||||||
|
|
||||||
def force_full_sync():
|
|
||||||
"""
|
|
||||||
Resets the sync timestamp for all sections to 0, thus forcing a subsequent
|
|
||||||
full sync (not delta)
|
|
||||||
"""
|
|
||||||
LOG.info('Telling PKC to do a full sync instead of a delta sync')
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
plexdb.force_full_sync()
|
|
||||||
|
|
||||||
|
|
||||||
def _save_sections_to_plex_db(sections):
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
for section in sections:
|
|
||||||
section.to_plex_db(plexdb=plexdb)
|
|
||||||
|
|
||||||
|
|
||||||
def _retrieve_old_settings(sections, old_sections):
|
|
||||||
"""
|
|
||||||
Overwrites the PKC settings for sections, grabing them from old_sections
|
|
||||||
if a particular section is in both sections and old_sections
|
|
||||||
|
|
||||||
Thus sets to the old values:
|
|
||||||
section.last_sync
|
|
||||||
section.kodi_tagid
|
|
||||||
section.sync_to_kodi
|
|
||||||
section.last_sync
|
|
||||||
"""
|
|
||||||
for section in sections:
|
|
||||||
for old_section in old_sections:
|
|
||||||
if section == old_section:
|
|
||||||
section.last_sync = old_section.last_sync
|
|
||||||
section.kodi_tagid = old_section.kodi_tagid
|
|
||||||
section.sync_to_kodi = old_section.sync_to_kodi
|
|
||||||
section.last_sync = old_section.last_sync
|
|
||||||
|
|
||||||
|
|
||||||
def _delete_kodi_db_items(section):
|
|
||||||
if section.section_type == v.PLEX_TYPE_MOVIE:
|
|
||||||
kodi_context = kodi_db.KodiVideoDB
|
|
||||||
types = ((v.PLEX_TYPE_MOVIE, itemtypes.Movie), )
|
|
||||||
elif section.section_type == v.PLEX_TYPE_SHOW:
|
|
||||||
kodi_context = kodi_db.KodiVideoDB
|
|
||||||
types = ((v.PLEX_TYPE_SHOW, itemtypes.Show),
|
|
||||||
(v.PLEX_TYPE_SEASON, itemtypes.Season),
|
|
||||||
(v.PLEX_TYPE_EPISODE, itemtypes.Episode))
|
|
||||||
elif section.section_type == v.PLEX_TYPE_ARTIST:
|
|
||||||
kodi_context = kodi_db.KodiMusicDB
|
|
||||||
types = ((v.PLEX_TYPE_ARTIST, itemtypes.Artist),
|
|
||||||
(v.PLEX_TYPE_ALBUM, itemtypes.Album),
|
|
||||||
(v.PLEX_TYPE_SONG, itemtypes.Song))
|
|
||||||
else:
|
|
||||||
types = ()
|
|
||||||
LOG.debug('Skipping deletion of DB elements for section %s', section)
|
|
||||||
for plex_type, context in types:
|
|
||||||
while True:
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
plex_ids = list(plexdb.plexid_by_sectionid(section.section_id,
|
|
||||||
plex_type,
|
|
||||||
BATCH_SIZE))
|
|
||||||
with kodi_context(texture_db=True) as kodidb:
|
|
||||||
typus = context(None, plexdb=plexdb, kodidb=kodidb)
|
|
||||||
for plex_id in plex_ids:
|
|
||||||
if SHOULD_CANCEL():
|
|
||||||
return False
|
|
||||||
typus.remove(plex_id)
|
|
||||||
if len(plex_ids) < BATCH_SIZE:
|
|
||||||
break
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _choose_libraries(sections):
|
|
||||||
"""
|
|
||||||
Displays a dialog for the user to select the libraries he wants synched
|
|
||||||
|
|
||||||
Returns True if the user chose new sections, False if he aborted
|
|
||||||
"""
|
|
||||||
import xbmcgui
|
|
||||||
selectable_sections = []
|
|
||||||
preselected = []
|
|
||||||
index = 0
|
|
||||||
for section in sections:
|
|
||||||
if not app.SYNC.enable_music and section.section_type == v.PLEX_TYPE_ARTIST:
|
|
||||||
LOG.info('Ignoring music section: %s', section)
|
|
||||||
continue
|
|
||||||
elif section.section_type == v.PLEX_TYPE_PHOTO:
|
|
||||||
# We won't ever show Photo sections
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# Offer user the new section
|
|
||||||
selectable_sections.append(section.name)
|
|
||||||
# Sections have been either preselected by the user or they are new
|
|
||||||
if section.sync_to_kodi:
|
|
||||||
preselected.append(index)
|
|
||||||
index += 1
|
|
||||||
# Don't ask the user again for this PMS even if user cancel the sync dialog
|
|
||||||
utils.settings('sections_asked_for_machine_identifier',
|
|
||||||
value=app.CONN.machine_identifier)
|
|
||||||
# "Select Plex libraries to sync"
|
|
||||||
selected_sections = xbmcgui.Dialog().multiselect(utils.lang(30524),
|
|
||||||
selectable_sections,
|
|
||||||
preselect=preselected,
|
|
||||||
useDetails=False)
|
|
||||||
if selected_sections is None:
|
|
||||||
LOG.info('User chose not to select which libraries to sync')
|
|
||||||
return False
|
|
||||||
index = 0
|
|
||||||
for section in sections:
|
|
||||||
if not app.SYNC.enable_music and section.section_type == v.PLEX_TYPE_ARTIST:
|
|
||||||
continue
|
|
||||||
elif section.section_type == v.PLEX_TYPE_PHOTO:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
section.sync_to_kodi = index in selected_sections
|
|
||||||
index += 1
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def delete_playlists():
|
|
||||||
"""
|
|
||||||
Clean up the playlists
|
|
||||||
"""
|
|
||||||
path = path_ops.translate_path('special://profile/playlists/video/')
|
|
||||||
for root, _, files in path_ops.walk(path):
|
|
||||||
for file in files:
|
|
||||||
if file.startswith('Plex'):
|
|
||||||
path_ops.remove(path_ops.path.join(root, file))
|
|
||||||
|
|
||||||
|
|
||||||
def delete_nodes():
|
|
||||||
"""
|
|
||||||
Clean up video nodes
|
|
||||||
"""
|
|
||||||
path = path_ops.translate_path("special://profile/library/video/")
|
|
||||||
for root, dirs, _ in path_ops.walk(path):
|
|
||||||
for directory in dirs:
|
|
||||||
if directory.startswith('Plex-'):
|
|
||||||
path_ops.rmtree(path_ops.path.join(root, directory))
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def delete_files():
|
|
||||||
"""
|
|
||||||
Deletes both all the Plex-xxx video node xmls as well as smart playlists
|
|
||||||
"""
|
|
||||||
delete_nodes()
|
|
||||||
delete_playlists()
|
|
||||||
|
|
||||||
|
|
||||||
def sync_from_pms(parent_self, pick_libraries=False):
|
|
||||||
"""
|
|
||||||
Sync the Plex library sections.
|
|
||||||
pick_libraries=True will prompt the user the select the libraries he
|
|
||||||
wants to sync
|
|
||||||
"""
|
|
||||||
global SHOULD_CANCEL
|
|
||||||
LOG.info('Starting synching sections from the PMS')
|
|
||||||
SHOULD_CANCEL = parent_self.should_cancel
|
|
||||||
try:
|
|
||||||
return _sync_from_pms(pick_libraries)
|
|
||||||
finally:
|
|
||||||
SHOULD_CANCEL = None
|
|
||||||
LOG.info('Done synching sections from the PMS: %s', app.SYNC.sections)
|
|
||||||
|
|
||||||
|
|
||||||
def _sync_from_pms(pick_libraries):
|
|
||||||
# Re-set value in order to make sure we got the lastest user input
|
|
||||||
app.SYNC.enable_music = utils.settings('enableMusic') == 'true'
|
|
||||||
xml = PF.get_plex_sections()
|
|
||||||
if xml is None:
|
|
||||||
LOG.error("Error download PMS sections, abort")
|
|
||||||
return False
|
|
||||||
sections = []
|
|
||||||
old_sections = []
|
|
||||||
for i, xml_element in enumerate(xml.findall('Directory')):
|
|
||||||
api = API(xml_element)
|
|
||||||
if api.plex_type in v.UNSUPPORTED_PLEX_TYPES:
|
|
||||||
continue
|
|
||||||
sections.append(Section(index=i, xml_element=xml_element))
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
for section_db in plexdb.all_sections():
|
|
||||||
old_sections.append(Section(section_db_element=section_db))
|
|
||||||
# Update our latest PMS sections with info saved in the PMS DB
|
|
||||||
_retrieve_old_settings(sections, old_sections)
|
|
||||||
if (app.CONN.machine_identifier != utils.settings('sections_asked_for_machine_identifier') or
|
|
||||||
pick_libraries):
|
|
||||||
if not pick_libraries:
|
|
||||||
LOG.info('First time connecting to this PMS, choosing libraries')
|
|
||||||
_choose_libraries(sections)
|
|
||||||
|
|
||||||
# We got everything - save to Plex db in case Kodi restarts before we're
|
|
||||||
# done here
|
|
||||||
_save_sections_to_plex_db(sections)
|
|
||||||
# Tweak some settings so Kodi does NOT scan the music folders
|
|
||||||
if app.SYNC.direct_paths is True:
|
|
||||||
# Will reboot Kodi is new library detected
|
|
||||||
music.excludefromscan_music_folders(sections)
|
|
||||||
|
|
||||||
# Delete all old sections that are obsolete
|
|
||||||
# This will also delete sections whose name (or type) have changed
|
|
||||||
for old_section in old_sections:
|
|
||||||
for section in sections:
|
|
||||||
if old_section == section:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
if not old_section.sync_to_kodi:
|
|
||||||
continue
|
|
||||||
LOG.info('Deleting entire section: %s', old_section)
|
|
||||||
# Remove all linked items
|
|
||||||
if not _delete_kodi_db_items(old_section):
|
|
||||||
return False
|
|
||||||
# Remove the section itself
|
|
||||||
old_section.remove()
|
|
||||||
|
|
||||||
# Clear all existing window vars because we did NOT remove them with the
|
|
||||||
# command section.remove()
|
|
||||||
clear_window_vars()
|
|
||||||
# Time to write the sections to Kodi
|
|
||||||
for section in sections:
|
|
||||||
section.to_kodi()
|
|
||||||
# Counter that tells us how many sections we have - e.g. for skins and
|
|
||||||
# listings
|
|
||||||
utils.window('Plex.nodes.total', str(len(sections)))
|
|
||||||
app.SYNC.sections = sections
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_window_vars(index):
|
|
||||||
node = 'Plex.nodes.%s' % index
|
|
||||||
utils.window('%s.index' % node, clear=True)
|
|
||||||
utils.window('%s.title' % node, clear=True)
|
|
||||||
utils.window('%s.type' % node, clear=True)
|
|
||||||
utils.window('%s.content' % node, clear=True)
|
|
||||||
utils.window('%s.path' % node, clear=True)
|
|
||||||
utils.window('%s.id' % node, clear=True)
|
|
||||||
# Just clear everything here, ignore the plex_type
|
|
||||||
for typus in (x[0] for y in nodes.NODE_TYPES.values() for x in y):
|
|
||||||
for kind in WINDOW_ARGS:
|
|
||||||
node = 'Plex.nodes.%s.%s.%s' % (index, typus, kind)
|
|
||||||
utils.window(node, clear=True)
|
|
||||||
|
|
||||||
|
|
||||||
def clear_window_vars():
|
|
||||||
"""
|
|
||||||
Removes all references to sections stored in window vars 'Plex.nodes...'
|
|
||||||
"""
|
|
||||||
LOG.debug('Clearing all the Plex video node variables')
|
|
||||||
number_of_nodes = int(utils.window('Plex.nodes.total') or 0)
|
|
||||||
utils.window('Plex.nodes.total', clear=True)
|
|
||||||
for index in range(number_of_nodes):
|
|
||||||
_clear_window_vars(index)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_videonode_files():
|
|
||||||
"""
|
|
||||||
Removes all the PKC video node files under userdata/library/video that
|
|
||||||
start with 'Plex-'
|
|
||||||
"""
|
|
||||||
for root, dirs, _ in path_ops.walk(LIBRARY_PATH):
|
|
||||||
for directory in dirs:
|
|
||||||
if directory.startswith('Plex-'):
|
|
||||||
abs_path = path_ops.path.join(root, directory)
|
|
||||||
LOG.info('Removing video node directory %s', abs_path)
|
|
||||||
path_ops.rmtree(abs_path, ignore_errors=True)
|
|
||||||
break
|
|
71
resources/lib/library_sync/sync_info.py
Normal file
71
resources/lib/library_sync/sync_info.py
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from logging import getLogger
|
||||||
|
from threading import Thread, Lock
|
||||||
|
|
||||||
|
from xbmc import sleep
|
||||||
|
from xbmcgui import DialogProgressBG
|
||||||
|
|
||||||
|
from utils import thread_methods, language as lang
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
|
GET_METADATA_COUNT = 0
|
||||||
|
PROCESS_METADATA_COUNT = 0
|
||||||
|
PROCESSING_VIEW_NAME = ''
|
||||||
|
LOCK = Lock()
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD',
|
||||||
|
'STOP_SYNC',
|
||||||
|
'SUSPEND_SYNC'])
|
||||||
|
class ThreadedShowSyncInfo(Thread):
|
||||||
|
"""
|
||||||
|
Threaded class to show the Kodi statusbar of the metadata download.
|
||||||
|
|
||||||
|
Input:
|
||||||
|
total: Total number of items to get
|
||||||
|
item_type:
|
||||||
|
"""
|
||||||
|
def __init__(self, total, item_type):
|
||||||
|
self.total = total
|
||||||
|
self.item_type = item_type
|
||||||
|
Thread.__init__(self)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""
|
||||||
|
Do the work
|
||||||
|
"""
|
||||||
|
LOG.debug('Show sync info thread started')
|
||||||
|
# cache local variables because it's faster
|
||||||
|
total = self.total
|
||||||
|
dialog = DialogProgressBG('dialoglogProgressBG')
|
||||||
|
dialog.create("%s %s: %s %s"
|
||||||
|
% (lang(39714), self.item_type, str(total), lang(39715)))
|
||||||
|
|
||||||
|
total = 2 * total
|
||||||
|
total_progress = 0
|
||||||
|
while not self.stopped():
|
||||||
|
with LOCK:
|
||||||
|
get_progress = GET_METADATA_COUNT
|
||||||
|
process_progress = PROCESS_METADATA_COUNT
|
||||||
|
view_name = PROCESSING_VIEW_NAME
|
||||||
|
total_progress = get_progress + process_progress
|
||||||
|
try:
|
||||||
|
percentage = int(float(total_progress) / float(total)*100.0)
|
||||||
|
except ZeroDivisionError:
|
||||||
|
percentage = 0
|
||||||
|
dialog.update(percentage,
|
||||||
|
message="%s %s. %s %s: %s"
|
||||||
|
% (get_progress,
|
||||||
|
lang(39712),
|
||||||
|
process_progress,
|
||||||
|
lang(39713),
|
||||||
|
view_name))
|
||||||
|
# Sleep for x milliseconds
|
||||||
|
sleep(200)
|
||||||
|
dialog.close()
|
||||||
|
LOG.debug('Show sync info thread terminated')
|
|
@ -1,372 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from .common import update_kodi_library, PLAYLIST_SYNC_ENABLED
|
|
||||||
from .fanart import SYNC_FANART, FanartTask
|
|
||||||
from ..plex_api import API
|
|
||||||
from ..plex_db import PlexDB
|
|
||||||
from .. import kodi_db
|
|
||||||
from .. import backgroundthread, plex_functions as PF, itemtypes
|
|
||||||
from .. import artwork, utils, timing, variables as v, app
|
|
||||||
|
|
||||||
if PLAYLIST_SYNC_ENABLED:
|
|
||||||
from .. import playlists
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.sync.websocket')
|
|
||||||
|
|
||||||
CACHING_ENALBED = utils.settings('enableTextureCache') == "true"
|
|
||||||
|
|
||||||
WEBSOCKET_MESSAGES = []
|
|
||||||
# Dict to save info for Plex items currently being played somewhere
|
|
||||||
PLAYSTATE_SESSIONS = {}
|
|
||||||
|
|
||||||
|
|
||||||
def multi_delete(input_list, delete_list):
|
|
||||||
"""
|
|
||||||
Deletes the list items of input_list at the positions in delete_list
|
|
||||||
(which can be in any arbitrary order)
|
|
||||||
"""
|
|
||||||
for index in sorted(delete_list, reverse=True):
|
|
||||||
del input_list[index]
|
|
||||||
return input_list
|
|
||||||
|
|
||||||
|
|
||||||
def store_websocket_message(message):
|
|
||||||
"""
|
|
||||||
processes json.loads() messages from websocket. Triage what we need to
|
|
||||||
do with "process_" methods
|
|
||||||
"""
|
|
||||||
if message['type'] == 'playing':
|
|
||||||
process_playing(message['PlaySessionStateNotification'])
|
|
||||||
elif message['type'] == 'timeline':
|
|
||||||
store_timeline_message(message['TimelineEntry'])
|
|
||||||
elif message['type'] == 'activity':
|
|
||||||
store_activity_message(message['ActivityNotification'])
|
|
||||||
|
|
||||||
|
|
||||||
def process_websocket_messages():
|
|
||||||
"""
|
|
||||||
Periodically called to process new/updated PMS items
|
|
||||||
|
|
||||||
PMS needs a while to download info from internet AFTER it
|
|
||||||
showed up under 'timeline' websocket messages
|
|
||||||
|
|
||||||
data['type']:
|
|
||||||
1: movie
|
|
||||||
2: tv show??
|
|
||||||
3: season??
|
|
||||||
4: episode
|
|
||||||
8: artist (band)
|
|
||||||
9: album
|
|
||||||
10: track (song)
|
|
||||||
12: trailer, extras?
|
|
||||||
|
|
||||||
data['state']:
|
|
||||||
0: 'created',
|
|
||||||
2: 'matching',
|
|
||||||
3: 'downloading',
|
|
||||||
4: 'loading',
|
|
||||||
5: 'finished',
|
|
||||||
6: 'analyzing',
|
|
||||||
9: 'deleted'
|
|
||||||
"""
|
|
||||||
global WEBSOCKET_MESSAGES
|
|
||||||
now = timing.unix_timestamp()
|
|
||||||
update_kodi_video_library, update_kodi_music_library = False, False
|
|
||||||
delete_list = []
|
|
||||||
for i, message in enumerate(WEBSOCKET_MESSAGES):
|
|
||||||
if message['state'] == 9:
|
|
||||||
successful, video, music = process_delete_message(message)
|
|
||||||
elif now - message['timestamp'] < app.SYNC.backgroundsync_saftymargin:
|
|
||||||
# We haven't waited long enough for the PMS to finish processing the
|
|
||||||
# item. Do it later (excepting deletions)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
successful, video, music = process_new_item_message(message)
|
|
||||||
if (successful and SYNC_FANART and
|
|
||||||
message['plex_type'] in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)):
|
|
||||||
task = FanartTask()
|
|
||||||
task.setup(message['plex_id'],
|
|
||||||
message['plex_type'],
|
|
||||||
refresh=False)
|
|
||||||
backgroundthread.BGThreader.addTask(task)
|
|
||||||
if successful is True:
|
|
||||||
delete_list.append(i)
|
|
||||||
update_kodi_video_library = True if video else update_kodi_video_library
|
|
||||||
update_kodi_music_library = True if music else update_kodi_music_library
|
|
||||||
else:
|
|
||||||
# Safety net if we can't process an item
|
|
||||||
message['attempt'] += 1
|
|
||||||
if message['attempt'] > 3:
|
|
||||||
LOG.error('Repeatedly could not process message %s, abort',
|
|
||||||
message)
|
|
||||||
delete_list.append(i)
|
|
||||||
|
|
||||||
# Get rid of the items we just processed
|
|
||||||
if delete_list:
|
|
||||||
WEBSOCKET_MESSAGES = multi_delete(WEBSOCKET_MESSAGES, delete_list)
|
|
||||||
# Let Kodi know of the change
|
|
||||||
if update_kodi_video_library or update_kodi_music_library:
|
|
||||||
update_kodi_library(video=update_kodi_video_library,
|
|
||||||
music=update_kodi_music_library)
|
|
||||||
|
|
||||||
|
|
||||||
def process_new_item_message(message):
|
|
||||||
LOG.debug('Message: %s', message)
|
|
||||||
xml = PF.GetPlexMetadata(message['plex_id'])
|
|
||||||
try:
|
|
||||||
plex_type = xml[0].attrib['type']
|
|
||||||
except (IndexError, KeyError, TypeError):
|
|
||||||
LOG.error('Could not download metadata for %s', message['plex_id'])
|
|
||||||
return False, False, False
|
|
||||||
LOG.debug("Processing new/updated PMS item: %s", message['plex_id'])
|
|
||||||
with itemtypes.ITEMTYPE_FROM_PLEXTYPE[plex_type](timing.unix_timestamp()) as typus:
|
|
||||||
typus.add_update(xml[0],
|
|
||||||
section_name=xml.get('librarySectionTitle'),
|
|
||||||
section_id=utils.cast(int, xml.get('librarySectionID')))
|
|
||||||
cache_artwork(message['plex_id'], plex_type)
|
|
||||||
return True, plex_type in v.PLEX_VIDEOTYPES, plex_type in v.PLEX_AUDIOTYPES
|
|
||||||
|
|
||||||
|
|
||||||
def process_delete_message(message):
|
|
||||||
plex_type = message['plex_type']
|
|
||||||
with itemtypes.ITEMTYPE_FROM_PLEXTYPE[plex_type](None) as typus:
|
|
||||||
typus.remove(message['plex_id'], plex_type=plex_type)
|
|
||||||
return True, plex_type in v.PLEX_VIDEOTYPES, plex_type in v.PLEX_AUDIOTYPES
|
|
||||||
|
|
||||||
|
|
||||||
def store_timeline_message(data):
|
|
||||||
"""
|
|
||||||
PMS is messing with the library items, e.g. new or changed. Put in our
|
|
||||||
"processing queue" for later
|
|
||||||
"""
|
|
||||||
global WEBSOCKET_MESSAGES
|
|
||||||
for message in data:
|
|
||||||
if 'tv.plex' in message.get('identifier', ''):
|
|
||||||
# Ommit Plex DVR messages - the Plex IDs are not corresponding
|
|
||||||
# (DVR ratingKeys are not unique and might correspond to a
|
|
||||||
# movie or episode)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
typus = v.PLEX_TYPE_FROM_WEBSOCKET[int(message['type'])]
|
|
||||||
except KeyError:
|
|
||||||
# E.g. -1 - thanks Plex!
|
|
||||||
LOG.info('Ignoring invalid message %s', data)
|
|
||||||
continue
|
|
||||||
if typus in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_SET):
|
|
||||||
# No need to process extras or trailers
|
|
||||||
continue
|
|
||||||
status = int(message['state'])
|
|
||||||
if typus == 'playlist' and PLAYLIST_SYNC_ENABLED:
|
|
||||||
playlists.websocket(plex_id=unicode(message['itemID']),
|
|
||||||
status=status)
|
|
||||||
elif status == 9:
|
|
||||||
# Immediately and always process deletions (as the PMS will
|
|
||||||
# send additional message with other codes)
|
|
||||||
WEBSOCKET_MESSAGES.append({
|
|
||||||
'state': status,
|
|
||||||
'plex_type': typus,
|
|
||||||
'plex_id': utils.cast(int, message['itemID']),
|
|
||||||
'timestamp': timing.unix_timestamp(),
|
|
||||||
'attempt': 0
|
|
||||||
})
|
|
||||||
elif typus in (v.PLEX_TYPE_MOVIE,
|
|
||||||
v.PLEX_TYPE_EPISODE,
|
|
||||||
v.PLEX_TYPE_SONG) and status == 5:
|
|
||||||
plex_id = int(message['itemID'])
|
|
||||||
# Have we already added this element for processing?
|
|
||||||
for existing_message in WEBSOCKET_MESSAGES:
|
|
||||||
if existing_message['plex_id'] == plex_id:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Haven't added this element to the queue yet
|
|
||||||
WEBSOCKET_MESSAGES.append({
|
|
||||||
'state': status,
|
|
||||||
'plex_type': typus,
|
|
||||||
'plex_id': plex_id,
|
|
||||||
'timestamp': timing.unix_timestamp(),
|
|
||||||
'attempt': 0
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def store_activity_message(data):
|
|
||||||
"""
|
|
||||||
PMS is re-scanning an item, e.g. after having changed a movie poster.
|
|
||||||
WATCH OUT for this if it's triggered by our PKC library scan!
|
|
||||||
"""
|
|
||||||
global WEBSOCKET_MESSAGES
|
|
||||||
for message in data:
|
|
||||||
if message['event'] != 'ended':
|
|
||||||
# Scan still going on, so skip for now
|
|
||||||
continue
|
|
||||||
elif message['Activity'].get('Context') is None:
|
|
||||||
# Not related to any Plex element, but entire library
|
|
||||||
continue
|
|
||||||
elif message['Activity']['type'] != 'library.refresh.items':
|
|
||||||
# Not the type of message relevant for us
|
|
||||||
continue
|
|
||||||
plex_id = PF.GetPlexKeyNumber(message['Activity']['Context']['key'])[1]
|
|
||||||
if not plex_id:
|
|
||||||
# Likely a Plex id like /library/metadata/3/children
|
|
||||||
continue
|
|
||||||
# We're only looking at existing elements - have we synced yet?
|
|
||||||
with PlexDB(lock=False) as plexdb:
|
|
||||||
typus = plexdb.item_by_id(plex_id, plex_type=None)
|
|
||||||
if not typus:
|
|
||||||
LOG.debug('plex_id %s not synced yet - skipping', plex_id)
|
|
||||||
continue
|
|
||||||
# Have we already added this element?
|
|
||||||
for existing_message in WEBSOCKET_MESSAGES:
|
|
||||||
if existing_message['plex_id'] == plex_id:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Haven't added this element to the queue yet
|
|
||||||
WEBSOCKET_MESSAGES.append({
|
|
||||||
'state': None, # Don't need a state here
|
|
||||||
'plex_type': typus['plex_type'],
|
|
||||||
'plex_id': plex_id,
|
|
||||||
'timestamp': timing.unix_timestamp(),
|
|
||||||
'attempt': 0
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def process_playing(data):
|
|
||||||
"""
|
|
||||||
Someone (not necessarily the user signed in) is playing something some-
|
|
||||||
where
|
|
||||||
"""
|
|
||||||
global PLAYSTATE_SESSIONS
|
|
||||||
for message in data:
|
|
||||||
status = message['state']
|
|
||||||
if status == 'buffering' or status == 'stopped':
|
|
||||||
# Drop buffering and stop messages immediately - no value
|
|
||||||
continue
|
|
||||||
plex_id = utils.cast(int, message['ratingKey'])
|
|
||||||
skip = False
|
|
||||||
for pid in (0, 1, 2):
|
|
||||||
if plex_id == app.PLAYSTATE.player_states[pid]['plex_id']:
|
|
||||||
# Kodi is playing this message - no need to set the playstate
|
|
||||||
skip = True
|
|
||||||
if skip:
|
|
||||||
continue
|
|
||||||
if 'sessionKey' not in message:
|
|
||||||
LOG.warn('Received malformed message from the PMS: %s', message)
|
|
||||||
continue
|
|
||||||
session_key = message['sessionKey']
|
|
||||||
# Do we already have a sessionKey stored?
|
|
||||||
if session_key not in PLAYSTATE_SESSIONS:
|
|
||||||
with PlexDB(lock=False) as plexdb:
|
|
||||||
typus = plexdb.item_by_id(plex_id, plex_type=None)
|
|
||||||
if not typus or 'kodi_fileid' not in typus:
|
|
||||||
# Item not (yet) in Kodi library or not affiliated with a file
|
|
||||||
continue
|
|
||||||
if utils.settings('plex_serverowned') == 'false':
|
|
||||||
# Not our PMS, we are not authorized to get the sessions
|
|
||||||
# On the bright side, it must be us playing :-)
|
|
||||||
PLAYSTATE_SESSIONS[session_key] = {}
|
|
||||||
else:
|
|
||||||
# PMS is ours - get all current sessions
|
|
||||||
pms_sessions = PF.GetPMSStatus(app.ACCOUNT.plex_token)
|
|
||||||
if session_key not in pms_sessions:
|
|
||||||
LOG.info('Session key %s still unknown! Skip '
|
|
||||||
'playstate update', session_key)
|
|
||||||
continue
|
|
||||||
PLAYSTATE_SESSIONS[session_key] = pms_sessions[session_key]
|
|
||||||
LOG.debug('Updated current sessions. They are: %s',
|
|
||||||
PLAYSTATE_SESSIONS)
|
|
||||||
# Attach Kodi info to the session
|
|
||||||
PLAYSTATE_SESSIONS[session_key]['kodi_fileid'] = typus['kodi_fileid']
|
|
||||||
if typus['plex_type'] == v.PLEX_TYPE_EPISODE:
|
|
||||||
PLAYSTATE_SESSIONS[session_key]['kodi_fileid_2'] = typus['kodi_fileid_2']
|
|
||||||
else:
|
|
||||||
PLAYSTATE_SESSIONS[session_key]['kodi_fileid_2'] = None
|
|
||||||
PLAYSTATE_SESSIONS[session_key]['kodi_id'] = typus['kodi_id']
|
|
||||||
PLAYSTATE_SESSIONS[session_key]['kodi_type'] = typus['kodi_type']
|
|
||||||
session = PLAYSTATE_SESSIONS[session_key]
|
|
||||||
if utils.settings('plex_serverowned') != 'false':
|
|
||||||
# Identify the user - same one as signed on with PKC? Skip
|
|
||||||
# update if neither session's username nor userid match
|
|
||||||
# (Owner sometime's returns id '1', not always)
|
|
||||||
if not app.ACCOUNT.plex_token and session['userId'] == '1':
|
|
||||||
# PKC not signed in to plex.tv. Plus owner of PMS is
|
|
||||||
# playing (the '1').
|
|
||||||
# Hence must be us (since several users require plex.tv
|
|
||||||
# token for PKC)
|
|
||||||
pass
|
|
||||||
elif not (session['userId'] == app.ACCOUNT.plex_user_id or
|
|
||||||
session['username'] == app.ACCOUNT.plex_username):
|
|
||||||
LOG.debug('Our username %s, userid %s did not match '
|
|
||||||
'the session username %s with userid %s',
|
|
||||||
app.ACCOUNT.plex_username,
|
|
||||||
app.ACCOUNT.plex_user_id,
|
|
||||||
session['username'],
|
|
||||||
session['userId'])
|
|
||||||
continue
|
|
||||||
# Get an up-to-date XML from the PMS because PMS will NOT directly
|
|
||||||
# tell us: duration of item viewCount
|
|
||||||
if not session.get('duration'):
|
|
||||||
xml = PF.GetPlexMetadata(plex_id)
|
|
||||||
if xml in (None, 401):
|
|
||||||
LOG.error('Could not get up-to-date xml for item %s',
|
|
||||||
plex_id)
|
|
||||||
continue
|
|
||||||
api = API(xml[0])
|
|
||||||
session['duration'] = api.runtime()
|
|
||||||
session['viewCount'] = api.viewcount()
|
|
||||||
# Sometimes, Plex tells us resume points in milliseconds and
|
|
||||||
# not in seconds - thank you very much!
|
|
||||||
if message['viewOffset'] > session['duration']:
|
|
||||||
resume = message['viewOffset'] / 1000
|
|
||||||
else:
|
|
||||||
resume = message['viewOffset']
|
|
||||||
if resume < v.IGNORE_SECONDS_AT_START:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
completed = float(resume) / float(session['duration'])
|
|
||||||
except (ZeroDivisionError, TypeError):
|
|
||||||
LOG.error('Could not mark playstate for %s and session %s',
|
|
||||||
data, session)
|
|
||||||
continue
|
|
||||||
if completed >= v.MARK_PLAYED_AT:
|
|
||||||
# Only mark completely watched ONCE
|
|
||||||
if session.get('marked_played') is None:
|
|
||||||
session['marked_played'] = True
|
|
||||||
mark_played = True
|
|
||||||
else:
|
|
||||||
# Don't mark it as completely watched again
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
mark_played = False
|
|
||||||
LOG.debug('Update playstate for user %s for %s with plex id %s to '
|
|
||||||
'viewCount %s, resume %s, mark_played %s for item %s',
|
|
||||||
app.ACCOUNT.plex_username, session['kodi_type'], plex_id,
|
|
||||||
session['viewCount'], resume, mark_played, PLAYSTATE_SESSIONS[session_key])
|
|
||||||
func = itemtypes.ITEMTYPE_FROM_KODITYPE[session['kodi_type']]
|
|
||||||
with func(None) as fkt:
|
|
||||||
fkt.update_playstate(mark_played,
|
|
||||||
session['viewCount'],
|
|
||||||
resume,
|
|
||||||
session['duration'],
|
|
||||||
session['kodi_fileid'],
|
|
||||||
session['kodi_fileid_2'],
|
|
||||||
timing.unix_timestamp())
|
|
||||||
|
|
||||||
|
|
||||||
def cache_artwork(plex_id, plex_type, kodi_id=None, kodi_type=None):
|
|
||||||
"""
|
|
||||||
Triggers caching of artwork (if so enabled in the PKC settings)
|
|
||||||
"""
|
|
||||||
if not CACHING_ENALBED:
|
|
||||||
return
|
|
||||||
if not kodi_id:
|
|
||||||
with PlexDB(lock=False) as plexdb:
|
|
||||||
item = plexdb.item_by_id(plex_id, plex_type)
|
|
||||||
if not item:
|
|
||||||
LOG.error('Could not retrieve Plex db info for %s', plex_id)
|
|
||||||
return
|
|
||||||
kodi_id, kodi_type = item['kodi_id'], item['kodi_type']
|
|
||||||
with kodi_db.KODIDB_FROM_PLEXTYPE[plex_type]() as kodidb:
|
|
||||||
for url in kodidb.art_urls(kodi_id, kodi_type):
|
|
||||||
artwork.cache_url(url)
|
|
1662
resources/lib/librarysync.py
Normal file
1662
resources/lib/librarysync.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,5 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
###############################################################################
|
||||||
import logging
|
import logging
|
||||||
import xbmc
|
import xbmc
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
@ -38,11 +37,9 @@ def config():
|
||||||
class LogHandler(logging.StreamHandler):
|
class LogHandler(logging.StreamHandler):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
logging.StreamHandler.__init__(self)
|
logging.StreamHandler.__init__(self)
|
||||||
self.setFormatter(logging.Formatter(fmt=b"%(name)s: %(message)s"))
|
self.setFormatter(logging.Formatter(fmt="%(name)s: %(message)s"))
|
||||||
|
|
||||||
def emit(self, record):
|
def emit(self, record):
|
||||||
if isinstance(record.msg, unicode):
|
|
||||||
record.msg = record.msg.encode('utf-8')
|
|
||||||
try:
|
try:
|
||||||
xbmc.log(self.format(record), level=LEVELS[record.levelno])
|
xbmc.log(self.format(record), level=LEVELS[record.levelno])
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
|
|
|
@ -1,102 +1,29 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
import variables as v
|
||||||
from . import variables as v
|
from utils import compare_version, settings
|
||||||
from . import utils
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
LOG = getLogger('PLEX.migration')
|
log = getLogger("PLEX."+__name__)
|
||||||
|
|
||||||
|
|
||||||
def check_migration():
|
def check_migration():
|
||||||
LOG.info('Checking whether we need to migrate something')
|
log.info('Checking whether we need to migrate something')
|
||||||
last_migration = utils.settings('last_migrated_PKC_version')
|
last_migration = settings('last_migrated_PKC_version')
|
||||||
# Ensure later migration if user downgraded PKC!
|
if last_migration == v.ADDON_VERSION:
|
||||||
utils.settings('last_migrated_PKC_version', value=v.ADDON_VERSION)
|
log.info('Already migrated to PKC version %s' % v.ADDON_VERSION)
|
||||||
|
# Ensure later migration if user downgraded PKC!
|
||||||
if last_migration == '':
|
settings('last_migrated_PKC_version', value=v.ADDON_VERSION)
|
||||||
LOG.info('New, clean PKC installation - no migration necessary')
|
|
||||||
return
|
|
||||||
elif last_migration == v.ADDON_VERSION:
|
|
||||||
LOG.info('Already migrated to PKC version %s' % v.ADDON_VERSION)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not utils.compare_version(last_migration, '1.8.2'):
|
if not compare_version(v.ADDON_VERSION, '1.8.2'):
|
||||||
LOG.info('Migrating to version 1.8.1')
|
log.info('Migrating to version 1.8.1')
|
||||||
# Set the new PKC theMovieDB key
|
# Set the new PKC theMovieDB key
|
||||||
utils.settings('themoviedbAPIKey',
|
settings('themoviedbAPIKey', value='19c90103adb9e98f2172c6a6a3d85dc4')
|
||||||
value='19c90103adb9e98f2172c6a6a3d85dc4')
|
|
||||||
|
|
||||||
if not utils.compare_version(last_migration, '2.0.25'):
|
if not compare_version(v.ADDON_VERSION, '2.0.25'):
|
||||||
LOG.info('Migrating to version 2.0.24')
|
log.info('Migrating to version 2.0.24')
|
||||||
# Need to re-connect with PMS to pick up on plex.direct URIs
|
# Need to re-connect with PMS to pick up on plex.direct URIs
|
||||||
utils.settings('ipaddress', value='')
|
settings('ipaddress', value='')
|
||||||
utils.settings('port', value='')
|
settings('port', value='')
|
||||||
|
|
||||||
if not utils.compare_version(last_migration, '2.7.6'):
|
settings('last_migrated_PKC_version', value=v.ADDON_VERSION)
|
||||||
LOG.info('Migrating to version 2.7.5')
|
|
||||||
from .library_sync.sections import delete_files
|
|
||||||
delete_files()
|
|
||||||
|
|
||||||
if not utils.compare_version(last_migration, '2.8.3'):
|
|
||||||
LOG.info('Migrating to version 2.8.2')
|
|
||||||
from .library_sync import sections
|
|
||||||
sections.clear_window_vars()
|
|
||||||
sections.delete_videonode_files()
|
|
||||||
|
|
||||||
if not utils.compare_version(last_migration, '2.8.7'):
|
|
||||||
LOG.info('Migrating to version 2.8.6')
|
|
||||||
# Need to delete the UNIQUE index that prevents creating several
|
|
||||||
# playlist entries with the same kodi_hash
|
|
||||||
from .plex_db import PlexDB
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
plexdb.cursor.execute('DROP INDEX IF EXISTS ix_playlists_3')
|
|
||||||
# Index will be automatically recreated on next PKC startup
|
|
||||||
|
|
||||||
if not utils.compare_version(last_migration, '2.8.9'):
|
|
||||||
LOG.info('Migrating to version 2.8.8')
|
|
||||||
from .library_sync import sections
|
|
||||||
sections.clear_window_vars()
|
|
||||||
sections.delete_videonode_files()
|
|
||||||
|
|
||||||
if not utils.compare_version(last_migration, '2.9.3'):
|
|
||||||
LOG.info('Migrating to version 2.9.2')
|
|
||||||
# Re-sync all playlists to Kodi
|
|
||||||
from .playlists import remove_synced_playlists
|
|
||||||
remove_synced_playlists()
|
|
||||||
|
|
||||||
if not utils.compare_version(last_migration, '2.9.7'):
|
|
||||||
LOG.info('Migrating to version 2.9.6')
|
|
||||||
# Allow for a new "Direct Stream" setting (number 2), so shift the
|
|
||||||
# last setting for "force transcoding"
|
|
||||||
current_playback_type = utils.cast(int, utils.settings('playType')) or 0
|
|
||||||
if current_playback_type == 2:
|
|
||||||
current_playback_type = 3
|
|
||||||
utils.settings('playType', value=str(current_playback_type))
|
|
||||||
|
|
||||||
if not utils.compare_version(last_migration, '2.9.8'):
|
|
||||||
LOG.info('Migrating to version 2.9.7')
|
|
||||||
# Force-scan every single item in the library - seems like we could
|
|
||||||
# loose some recently added items otherwise
|
|
||||||
# Caused by 65a921c3cc2068c4a34990d07289e2958f515156
|
|
||||||
from . import library_sync
|
|
||||||
library_sync.force_full_sync()
|
|
||||||
|
|
||||||
if not utils.compare_version(last_migration, '2.11.3'):
|
|
||||||
LOG.info('Migrating to version 2.11.2')
|
|
||||||
# Re-sync all playlists to Kodi
|
|
||||||
from .playlists import remove_synced_playlists
|
|
||||||
remove_synced_playlists()
|
|
||||||
|
|
||||||
if not utils.compare_version(last_migration, '2.12.2'):
|
|
||||||
LOG.info('Migrating to version 2.12.1')
|
|
||||||
# Sign user out to make sure he needs to sign in again
|
|
||||||
utils.settings('username', value='')
|
|
||||||
utils.settings('userid', value='')
|
|
||||||
utils.settings('plex_restricteduser', value='')
|
|
||||||
utils.settings('accessToken', value='')
|
|
||||||
utils.settings('plexAvatar', value='')
|
|
||||||
|
|
||||||
utils.settings('last_migrated_PKC_version', value=v.ADDON_VERSION)
|
|
||||||
|
|
|
@ -1,47 +1,52 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
import re
|
from re import compile as re_compile
|
||||||
|
from xml.etree.ElementTree import ParseError
|
||||||
|
|
||||||
from .plex_api.media import Media
|
from utils import XmlKodiSetting, reboot_kodi, language as lang
|
||||||
from . import utils
|
from PlexFunctions import get_plex_sections
|
||||||
from . import variables as v
|
from PlexAPI import API
|
||||||
|
import variables as v
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
LOG = getLogger('PLEX.music.py')
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
|
REGEX_MUSICPATH = re_compile(r'''^\^(.+)\$$''')
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
def excludefromscan_music_folders(sections):
|
def excludefromscan_music_folders():
|
||||||
"""
|
"""
|
||||||
Gets a complete list of paths for music libraries from the PMS. Sets them
|
Gets a complete list of paths for music libraries from the PMS. Sets them
|
||||||
to be excluded in the advancedsettings.xml from being scanned by Kodi.
|
to be excluded in the advancedsettings.xml from being scanned by Kodi.
|
||||||
Existing keys will be replaced
|
Existing keys will be replaced
|
||||||
xml: etree XML PMS answer containing all library sections
|
|
||||||
|
|
||||||
Reboots Kodi if new library detected
|
Reboots Kodi if new library detected
|
||||||
"""
|
"""
|
||||||
|
xml = get_plex_sections()
|
||||||
|
try:
|
||||||
|
xml[0].attrib
|
||||||
|
except (TypeError, IndexError, AttributeError):
|
||||||
|
LOG.error('Could not get Plex sections')
|
||||||
|
return
|
||||||
|
# Build paths
|
||||||
paths = []
|
paths = []
|
||||||
reboot = False
|
api = API(item=None)
|
||||||
api = Media()
|
for library in xml:
|
||||||
for section in sections:
|
if library.attrib['type'] != v.PLEX_TYPE_ARTIST:
|
||||||
if section.section_type != v.PLEX_TYPE_ARTIST:
|
|
||||||
# Only look at music libraries
|
# Only look at music libraries
|
||||||
continue
|
continue
|
||||||
if not section.sync_to_kodi:
|
for location in library:
|
||||||
continue
|
if location.tag == 'Location':
|
||||||
for location in section.xml.findall('Location'):
|
path = api.validate_playurl(location.attrib['path'],
|
||||||
path = api.validate_playurl(location.attrib['path'],
|
typus=v.PLEX_TYPE_ARTIST,
|
||||||
typus=v.PLEX_TYPE_ARTIST,
|
omit_check=True)
|
||||||
omit_check=True)
|
paths.append(__turn_to_regex(path))
|
||||||
paths.append(_turn_to_regex(path))
|
|
||||||
try:
|
try:
|
||||||
with utils.XmlKodiSetting(
|
with XmlKodiSetting('advancedsettings.xml',
|
||||||
'advancedsettings.xml',
|
force_create=True,
|
||||||
force_create=True,
|
top_element='advancedsettings') as xml:
|
||||||
top_element='advancedsettings') as xml_file:
|
parent = xml.set_setting(['audio', 'excludefromscan'])
|
||||||
parent = xml_file.set_setting(['audio', 'excludefromscan'])
|
|
||||||
for path in paths:
|
for path in paths:
|
||||||
for element in parent:
|
for element in parent:
|
||||||
if element.text == path:
|
if element.text == path:
|
||||||
|
@ -49,15 +54,11 @@ def excludefromscan_music_folders(sections):
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
LOG.info('New Plex music library detected: %s', path)
|
LOG.info('New Plex music library detected: %s', path)
|
||||||
xml_file.set_setting(['audio', 'excludefromscan', 'regexp'],
|
xml.set_setting(['audio', 'excludefromscan', 'regexp'],
|
||||||
value=path,
|
value=path, append=True)
|
||||||
append=True)
|
# We only need to reboot if we ADD new paths!
|
||||||
if paths:
|
reboot = xml.write_xml
|
||||||
# We only need to reboot if we ADD new paths!
|
|
||||||
reboot = xml_file.write_xml
|
|
||||||
# Delete obsolete entries
|
# Delete obsolete entries
|
||||||
# Make sure we're not saving an empty audio-excludefromscan
|
|
||||||
xml_file.write_xml = reboot
|
|
||||||
for element in parent:
|
for element in parent:
|
||||||
for path in paths:
|
for path in paths:
|
||||||
if element.text == path:
|
if element.text == path:
|
||||||
|
@ -66,16 +67,16 @@ def excludefromscan_music_folders(sections):
|
||||||
LOG.info('Deleting music library from advancedsettings: %s',
|
LOG.info('Deleting music library from advancedsettings: %s',
|
||||||
element.text)
|
element.text)
|
||||||
parent.remove(element)
|
parent.remove(element)
|
||||||
xml_file.write_xml = True
|
except (ParseError, IOError):
|
||||||
except (utils.ParseError, IOError):
|
|
||||||
LOG.error('Could not adjust advancedsettings.xml')
|
LOG.error('Could not adjust advancedsettings.xml')
|
||||||
|
reboot = False
|
||||||
if reboot is True:
|
if reboot is True:
|
||||||
# 'New Plex music library detected. Sorry, but we need to
|
# 'New Plex music library detected. Sorry, but we need to
|
||||||
# restart Kodi now due to the changes made.'
|
# restart Kodi now due to the changes made.'
|
||||||
utils.reboot_kodi(utils.lang(39711))
|
reboot_kodi(lang(39711))
|
||||||
|
|
||||||
|
|
||||||
def _turn_to_regex(path):
|
def __turn_to_regex(path):
|
||||||
"""
|
"""
|
||||||
Turns a path into regex expression to be fed to Kodi's advancedsettings.xml
|
Turns a path into regex expression to be fed to Kodi's advancedsettings.xml
|
||||||
"""
|
"""
|
||||||
|
@ -86,7 +87,7 @@ def _turn_to_regex(path):
|
||||||
else:
|
else:
|
||||||
if not path.endswith('\\'):
|
if not path.endswith('\\'):
|
||||||
path = '%s\\' % path
|
path = '%s\\' % path
|
||||||
# Escape all characters that could cause problems
|
# Need to escape backslashes
|
||||||
path = re.escape(path)
|
path = path.replace('\\', '\\\\')
|
||||||
# Beginning of path only needs to be similar
|
# Beginning of path only needs to be similar
|
||||||
return '^%s' % path
|
return '^%s' % path
|
||||||
|
|
|
@ -1,243 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
File and Path operations
|
|
||||||
|
|
||||||
Kodi xbmc*.*() functions usually take utf-8 encoded commands, thus try_encode
|
|
||||||
works.
|
|
||||||
Unfortunatly, working with filenames and paths seems to require an encoding in
|
|
||||||
the OS' getfilesystemencoding - it will NOT always work with unicode paths.
|
|
||||||
However, sys.getfilesystemencoding might return None.
|
|
||||||
Feed unicode to all the functions below and you're fine.
|
|
||||||
|
|
||||||
WARNING: os.path won't really work with smb paths (possibly others). For
|
|
||||||
xbmcvfs functions to work with smb paths, they need to be both in passwords.xml
|
|
||||||
as well as sources.xml
|
|
||||||
"""
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
import shutil
|
|
||||||
import os
|
|
||||||
from os import path # allows to use path_ops.path.join, for example
|
|
||||||
from distutils import dir_util
|
|
||||||
import re
|
|
||||||
|
|
||||||
import xbmc
|
|
||||||
import xbmcvfs
|
|
||||||
|
|
||||||
from .tools import unicode_paths
|
|
||||||
|
|
||||||
# Kodi seems to encode in utf-8 in ALL cases (unlike e.g. the OS filesystem)
|
|
||||||
KODI_ENCODING = 'utf-8'
|
|
||||||
REGEX_FILE_NUMBERING = re.compile(r'''_(\d\d)\.\w+$''')
|
|
||||||
|
|
||||||
|
|
||||||
def encode_path(path):
|
|
||||||
"""
|
|
||||||
Filenames and paths are not necessarily utf-8 encoded. Use this function
|
|
||||||
instead of try_encode/trydecode if working with filenames and paths!
|
|
||||||
(os.walk only feeds on encoded paths. sys.getfilesystemencoding returns None
|
|
||||||
for Raspberry Pi)
|
|
||||||
"""
|
|
||||||
return unicode_paths.encode(path)
|
|
||||||
|
|
||||||
|
|
||||||
def decode_path(path):
|
|
||||||
"""
|
|
||||||
Filenames and paths are not necessarily utf-8 encoded. Use this function
|
|
||||||
instead of try_encode/trydecode if working with filenames and paths!
|
|
||||||
(os.walk only feeds on encoded paths. sys.getfilesystemencoding returns None
|
|
||||||
for Raspberry Pi)
|
|
||||||
"""
|
|
||||||
return unicode_paths.decode(path)
|
|
||||||
|
|
||||||
|
|
||||||
def translate_path(path):
|
|
||||||
"""
|
|
||||||
Returns the XBMC translated path [unicode]
|
|
||||||
e.g. Converts 'special://masterprofile/script_data'
|
|
||||||
-> '/home/user/XBMC/UserData/script_data' on Linux.
|
|
||||||
"""
|
|
||||||
translated = xbmc.translatePath(path.encode(KODI_ENCODING, 'strict'))
|
|
||||||
return translated.decode(KODI_ENCODING, 'strict')
|
|
||||||
|
|
||||||
|
|
||||||
def exists(path):
|
|
||||||
"""
|
|
||||||
Returns True if the path [unicode] exists. Folders NEED a trailing slash or
|
|
||||||
backslash!!
|
|
||||||
"""
|
|
||||||
return xbmcvfs.exists(path.encode(KODI_ENCODING, 'strict')) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def rmtree(path, *args, **kwargs):
|
|
||||||
"""Recursively delete a directory tree.
|
|
||||||
|
|
||||||
If ignore_errors is set, errors are ignored; otherwise, if onerror
|
|
||||||
is set, it is called to handle the error with arguments (func,
|
|
||||||
path, exc_info) where func is os.listdir, os.remove, or os.rmdir;
|
|
||||||
path is the argument to that function that caused it to fail; and
|
|
||||||
exc_info is a tuple returned by sys.exc_info(). If ignore_errors
|
|
||||||
is false and onerror is None, an exception is raised.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return shutil.rmtree(encode_path(path), *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def copyfile(src, dst):
|
|
||||||
"""Copy data from src to dst"""
|
|
||||||
return shutil.copyfile(encode_path(src), encode_path(dst))
|
|
||||||
|
|
||||||
|
|
||||||
def makedirs(path, *args, **kwargs):
|
|
||||||
"""makedirs(path [, mode=0777])
|
|
||||||
|
|
||||||
Super-mkdir; create a leaf directory and all intermediate ones. Works like
|
|
||||||
mkdir, except that any intermediate path segment (not just the rightmost)
|
|
||||||
will be created if it does not exist. This is recursive.
|
|
||||||
"""
|
|
||||||
return os.makedirs(encode_path(path), *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def remove(path):
|
|
||||||
"""
|
|
||||||
Remove (delete) the file path. If path is a directory, OSError is raised;
|
|
||||||
see rmdir() below to remove a directory. This is identical to the unlink()
|
|
||||||
function documented below. On Windows, attempting to remove a file that is
|
|
||||||
in use causes an exception to be raised; on Unix, the directory entry is
|
|
||||||
removed but the storage allocated to the file is not made available until
|
|
||||||
the original file is no longer in use.
|
|
||||||
"""
|
|
||||||
return os.remove(encode_path(path))
|
|
||||||
|
|
||||||
|
|
||||||
def walk(top, topdown=True, onerror=None, followlinks=False):
|
|
||||||
"""
|
|
||||||
Directory tree generator.
|
|
||||||
|
|
||||||
For each directory in the directory tree rooted at top (including top
|
|
||||||
itself, but excluding '.' and '..'), yields a 3-tuple
|
|
||||||
|
|
||||||
dirpath, dirnames, filenames
|
|
||||||
|
|
||||||
dirpath is a string, the path to the directory. dirnames is a list of
|
|
||||||
the names of the subdirectories in dirpath (excluding '.' and '..').
|
|
||||||
filenames is a list of the names of the non-directory files in dirpath.
|
|
||||||
Note that the names in the lists are just names, with no path components.
|
|
||||||
To get a full path (which begins with top) to a file or directory in
|
|
||||||
dirpath, do os.path.join(dirpath, name).
|
|
||||||
|
|
||||||
If optional arg 'topdown' is true or not specified, the triple for a
|
|
||||||
directory is generated before the triples for any of its subdirectories
|
|
||||||
(directories are generated top down). If topdown is false, the triple
|
|
||||||
for a directory is generated after the triples for all of its
|
|
||||||
subdirectories (directories are generated bottom up).
|
|
||||||
|
|
||||||
When topdown is true, the caller can modify the dirnames list in-place
|
|
||||||
(e.g., via del or slice assignment), and walk will only recurse into the
|
|
||||||
subdirectories whose names remain in dirnames; this can be used to prune the
|
|
||||||
search, or to impose a specific order of visiting. Modifying dirnames when
|
|
||||||
topdown is false is ineffective, since the directories in dirnames have
|
|
||||||
already been generated by the time dirnames itself is generated. No matter
|
|
||||||
the value of topdown, the list of subdirectories is retrieved before the
|
|
||||||
tuples for the directory and its subdirectories are generated.
|
|
||||||
|
|
||||||
By default errors from the os.listdir() call are ignored. If
|
|
||||||
optional arg 'onerror' is specified, it should be a function; it
|
|
||||||
will be called with one argument, an os.error instance. It can
|
|
||||||
report the error to continue with the walk, or raise the exception
|
|
||||||
to abort the walk. Note that the filename is available as the
|
|
||||||
filename attribute of the exception object.
|
|
||||||
|
|
||||||
By default, os.walk does not follow symbolic links to subdirectories on
|
|
||||||
systems that support them. In order to get this functionality, set the
|
|
||||||
optional argument 'followlinks' to true.
|
|
||||||
|
|
||||||
Caution: if you pass a relative pathname for top, don't change the
|
|
||||||
current working directory between resumptions of walk. walk never
|
|
||||||
changes the current directory, and assumes that the client doesn't
|
|
||||||
either.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
import os
|
|
||||||
from os.path import join, getsize
|
|
||||||
for root, dirs, files in os.walk('python/Lib/email'):
|
|
||||||
print root, "consumes",
|
|
||||||
print sum([getsize(join(root, name)) for name in files]),
|
|
||||||
print "bytes in", len(files), "non-directory files"
|
|
||||||
if 'CVS' in dirs:
|
|
||||||
dirs.remove('CVS') # don't visit CVS directories
|
|
||||||
|
|
||||||
"""
|
|
||||||
# Get all the results from os.walk and store them in a list
|
|
||||||
walker = list(os.walk(encode_path(top),
|
|
||||||
topdown,
|
|
||||||
onerror,
|
|
||||||
followlinks))
|
|
||||||
for top, dirs, nondirs in walker:
|
|
||||||
yield (decode_path(top),
|
|
||||||
[decode_path(x) for x in dirs],
|
|
||||||
[decode_path(x) for x in nondirs])
|
|
||||||
|
|
||||||
|
|
||||||
def copy_tree(src, dst, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Copy an entire directory tree 'src' to a new location 'dst'.
|
|
||||||
|
|
||||||
Both 'src' and 'dst' must be directory names. If 'src' is not a
|
|
||||||
directory, raise DistutilsFileError. If 'dst' does not exist, it is
|
|
||||||
created with 'mkpath()'. The end result of the copy is that every
|
|
||||||
file in 'src' is copied to 'dst', and directories under 'src' are
|
|
||||||
recursively copied to 'dst'. Return the list of files that were
|
|
||||||
copied or might have been copied, using their output name. The
|
|
||||||
return value is unaffected by 'update' or 'dry_run': it is simply
|
|
||||||
the list of all files under 'src', with the names changed to be
|
|
||||||
under 'dst'.
|
|
||||||
|
|
||||||
'preserve_mode' and 'preserve_times' are the same as for
|
|
||||||
'copy_file'; note that they only apply to regular files, not to
|
|
||||||
directories. If 'preserve_symlinks' is true, symlinks will be
|
|
||||||
copied as symlinks (on platforms that support them!); otherwise
|
|
||||||
(the default), the destination of the symlink will be copied.
|
|
||||||
'update' and 'verbose' are the same as for 'copy_file'.
|
|
||||||
"""
|
|
||||||
src = encode_path(src)
|
|
||||||
dst = encode_path(dst)
|
|
||||||
return dir_util.copy_tree(src, dst, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def basename(path):
|
|
||||||
"""
|
|
||||||
Returns the filename for path [unicode] or an empty string if not possible.
|
|
||||||
Safer than using os.path.basename, as we could be expecting \\ for / or
|
|
||||||
vice versa
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return path.rsplit('/', 1)[1]
|
|
||||||
except IndexError:
|
|
||||||
try:
|
|
||||||
return path.rsplit('\\', 1)[1]
|
|
||||||
except IndexError:
|
|
||||||
return ''
|
|
||||||
|
|
||||||
|
|
||||||
def create_unique_path(directory, filename, extension):
|
|
||||||
"""
|
|
||||||
Checks whether 'directory/filename.extension' exists. If so, will start
|
|
||||||
numbering the filename until the file does not exist yet (up to 99)
|
|
||||||
"""
|
|
||||||
res = path.join(directory, '.'.join((filename, extension)))
|
|
||||||
while exists(res):
|
|
||||||
occurance = REGEX_FILE_NUMBERING.search(res)
|
|
||||||
if not occurance:
|
|
||||||
filename = '{}_00'.format(filename[:min(len(filename),
|
|
||||||
251 - len(extension))])
|
|
||||||
res = path.join(directory, '.'.join((filename, extension)))
|
|
||||||
else:
|
|
||||||
number = int(occurance.group(1)) + 1
|
|
||||||
if number > 99:
|
|
||||||
raise RuntimeError('Could not create unique file: {} {} {}'.format(
|
|
||||||
directory, filename, extension))
|
|
||||||
basename = re.sub(REGEX_FILE_NUMBERING, '', res)
|
|
||||||
res = '{}_{:02d}.{}'.format(basename, number, extension)
|
|
||||||
return res
|
|
|
@ -1,21 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# pathtools: File system path tools.
|
|
||||||
# Copyright (C) 2010 Yesudeep Mangalapilly <yesudeep@gmail.com>
|
|
||||||
#
|
|
||||||
# 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.
|
|
|
@ -1,206 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# path.py: Path functions.
|
|
||||||
#
|
|
||||||
# Copyright (C) 2010 Yesudeep Mangalapilly <yesudeep@gmail.com>
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
"""
|
|
||||||
:module: pathtools.path
|
|
||||||
:synopsis: Directory walking, listing, and path sanitizing functions.
|
|
||||||
:author: Yesudeep Mangalapilly <yesudeep@gmail.com>
|
|
||||||
|
|
||||||
Functions
|
|
||||||
---------
|
|
||||||
.. autofunction:: get_dir_walker
|
|
||||||
.. autofunction:: walk
|
|
||||||
.. autofunction:: listdir
|
|
||||||
.. autofunction:: list_directories
|
|
||||||
.. autofunction:: list_files
|
|
||||||
.. autofunction:: absolute_path
|
|
||||||
.. autofunction:: real_absolute_path
|
|
||||||
.. autofunction:: parent_dir_path
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os.path
|
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'get_dir_walker',
|
|
||||||
'walk',
|
|
||||||
'listdir',
|
|
||||||
'list_directories',
|
|
||||||
'list_files',
|
|
||||||
'absolute_path',
|
|
||||||
'real_absolute_path',
|
|
||||||
'parent_dir_path',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_dir_walker(recursive, topdown=True, followlinks=False):
|
|
||||||
"""
|
|
||||||
Returns a recursive or a non-recursive directory walker.
|
|
||||||
|
|
||||||
:param recursive:
|
|
||||||
``True`` produces a recursive walker; ``False`` produces a non-recursive
|
|
||||||
walker.
|
|
||||||
:returns:
|
|
||||||
A walker function.
|
|
||||||
"""
|
|
||||||
if recursive:
|
|
||||||
walk = partial(os.walk, topdown=topdown, followlinks=followlinks)
|
|
||||||
else:
|
|
||||||
def walk(path, topdown=topdown, followlinks=followlinks):
|
|
||||||
try:
|
|
||||||
yield next(os.walk(path, topdown=topdown, followlinks=followlinks))
|
|
||||||
except NameError:
|
|
||||||
yield os.walk(path, topdown=topdown, followlinks=followlinks).next() #IGNORE:E1101
|
|
||||||
return walk
|
|
||||||
|
|
||||||
|
|
||||||
def walk(dir_pathname, recursive=True, topdown=True, followlinks=False):
|
|
||||||
"""
|
|
||||||
Walks a directory tree optionally recursively. Works exactly like
|
|
||||||
:func:`os.walk` only adding the `recursive` argument.
|
|
||||||
|
|
||||||
:param dir_pathname:
|
|
||||||
The directory to traverse.
|
|
||||||
:param recursive:
|
|
||||||
``True`` for walking recursively through the directory tree;
|
|
||||||
``False`` otherwise.
|
|
||||||
:param topdown:
|
|
||||||
Please see the documentation for :func:`os.walk`
|
|
||||||
:param followlinks:
|
|
||||||
Please see the documentation for :func:`os.walk`
|
|
||||||
"""
|
|
||||||
walk_func = get_dir_walker(recursive, topdown, followlinks)
|
|
||||||
for root, dirnames, filenames in walk_func(dir_pathname):
|
|
||||||
yield (root, dirnames, filenames)
|
|
||||||
|
|
||||||
|
|
||||||
def listdir(dir_pathname,
|
|
||||||
recursive=True,
|
|
||||||
topdown=True,
|
|
||||||
followlinks=False):
|
|
||||||
"""
|
|
||||||
Enlists all items using their absolute paths in a directory, optionally
|
|
||||||
recursively.
|
|
||||||
|
|
||||||
:param dir_pathname:
|
|
||||||
The directory to traverse.
|
|
||||||
:param recursive:
|
|
||||||
``True`` for walking recursively through the directory tree;
|
|
||||||
``False`` otherwise.
|
|
||||||
:param topdown:
|
|
||||||
Please see the documentation for :func:`os.walk`
|
|
||||||
:param followlinks:
|
|
||||||
Please see the documentation for :func:`os.walk`
|
|
||||||
"""
|
|
||||||
for root, dirnames, filenames\
|
|
||||||
in walk(dir_pathname, recursive, topdown, followlinks):
|
|
||||||
for dirname in dirnames:
|
|
||||||
yield absolute_path(os.path.join(root, dirname))
|
|
||||||
for filename in filenames:
|
|
||||||
yield absolute_path(os.path.join(root, filename))
|
|
||||||
|
|
||||||
|
|
||||||
def list_directories(dir_pathname,
|
|
||||||
recursive=True,
|
|
||||||
topdown=True,
|
|
||||||
followlinks=False):
|
|
||||||
"""
|
|
||||||
Enlists all the directories using their absolute paths within the specified
|
|
||||||
directory, optionally recursively.
|
|
||||||
|
|
||||||
:param dir_pathname:
|
|
||||||
The directory to traverse.
|
|
||||||
:param recursive:
|
|
||||||
``True`` for walking recursively through the directory tree;
|
|
||||||
``False`` otherwise.
|
|
||||||
:param topdown:
|
|
||||||
Please see the documentation for :func:`os.walk`
|
|
||||||
:param followlinks:
|
|
||||||
Please see the documentation for :func:`os.walk`
|
|
||||||
"""
|
|
||||||
for root, dirnames, filenames\
|
|
||||||
in walk(dir_pathname, recursive, topdown, followlinks):
|
|
||||||
for dirname in dirnames:
|
|
||||||
yield absolute_path(os.path.join(root, dirname))
|
|
||||||
|
|
||||||
|
|
||||||
def list_files(dir_pathname,
|
|
||||||
recursive=True,
|
|
||||||
topdown=True,
|
|
||||||
followlinks=False):
|
|
||||||
"""
|
|
||||||
Enlists all the files using their absolute paths within the specified
|
|
||||||
directory, optionally recursively.
|
|
||||||
|
|
||||||
:param dir_pathname:
|
|
||||||
The directory to traverse.
|
|
||||||
:param recursive:
|
|
||||||
``True`` for walking recursively through the directory tree;
|
|
||||||
``False`` otherwise.
|
|
||||||
:param topdown:
|
|
||||||
Please see the documentation for :func:`os.walk`
|
|
||||||
:param followlinks:
|
|
||||||
Please see the documentation for :func:`os.walk`
|
|
||||||
"""
|
|
||||||
for root, dirnames, filenames\
|
|
||||||
in walk(dir_pathname, recursive, topdown, followlinks):
|
|
||||||
for filename in filenames:
|
|
||||||
yield absolute_path(os.path.join(root, filename))
|
|
||||||
|
|
||||||
|
|
||||||
def absolute_path(path):
|
|
||||||
"""
|
|
||||||
Returns the absolute path for the given path and normalizes the path.
|
|
||||||
|
|
||||||
:param path:
|
|
||||||
Path for which the absolute normalized path will be found.
|
|
||||||
:returns:
|
|
||||||
Absolute normalized path.
|
|
||||||
"""
|
|
||||||
return os.path.abspath(os.path.normpath(path))
|
|
||||||
|
|
||||||
|
|
||||||
def real_absolute_path(path):
|
|
||||||
"""
|
|
||||||
Returns the real absolute normalized path for the given path.
|
|
||||||
|
|
||||||
:param path:
|
|
||||||
Path for which the real absolute normalized path will be found.
|
|
||||||
:returns:
|
|
||||||
Real absolute normalized path.
|
|
||||||
"""
|
|
||||||
return os.path.realpath(absolute_path(path))
|
|
||||||
|
|
||||||
|
|
||||||
def parent_dir_path(path):
|
|
||||||
"""
|
|
||||||
Returns the parent directory path.
|
|
||||||
|
|
||||||
:param path:
|
|
||||||
Path for which the parent directory will be obtained.
|
|
||||||
:returns:
|
|
||||||
Parent directory path.
|
|
||||||
"""
|
|
||||||
return absolute_path(os.path.dirname(path))
|
|
|
@ -1,265 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# patterns.py: Common wildcard searching/filtering functionality for files.
|
|
||||||
#
|
|
||||||
# Copyright (C) 2010 Yesudeep Mangalapilly <yesudeep@gmail.com>
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
"""
|
|
||||||
:module: pathtools.patterns
|
|
||||||
:synopsis: Wildcard pattern matching and filtering functions for paths.
|
|
||||||
:author: Yesudeep Mangalapilly <yesudeep@gmail.com>
|
|
||||||
|
|
||||||
Functions
|
|
||||||
---------
|
|
||||||
.. autofunction:: match_path
|
|
||||||
.. autofunction:: match_path_against
|
|
||||||
.. autofunction:: filter_paths
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fnmatch import fnmatch, fnmatchcase
|
|
||||||
|
|
||||||
__all__ = ['match_path',
|
|
||||||
'match_path_against',
|
|
||||||
'match_any_paths',
|
|
||||||
'filter_paths']
|
|
||||||
|
|
||||||
|
|
||||||
def _string_lower(s):
|
|
||||||
"""
|
|
||||||
Convenience function to lowercase a string (the :mod:`string` module is
|
|
||||||
deprecated/removed in Python 3.0).
|
|
||||||
|
|
||||||
:param s:
|
|
||||||
The string which will be lowercased.
|
|
||||||
:returns:
|
|
||||||
Lowercased copy of string s.
|
|
||||||
"""
|
|
||||||
return s.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def match_path_against(pathname, patterns, case_sensitive=True):
|
|
||||||
"""
|
|
||||||
Determines whether the pathname matches any of the given wildcard patterns,
|
|
||||||
optionally ignoring the case of the pathname and patterns.
|
|
||||||
|
|
||||||
:param pathname:
|
|
||||||
A path name that will be matched against a wildcard pattern.
|
|
||||||
:param patterns:
|
|
||||||
A list of wildcard patterns to match_path the filename against.
|
|
||||||
:param case_sensitive:
|
|
||||||
``True`` if the matching should be case-sensitive; ``False`` otherwise.
|
|
||||||
:returns:
|
|
||||||
``True`` if the pattern matches; ``False`` otherwise.
|
|
||||||
|
|
||||||
Doctests::
|
|
||||||
>>> match_path_against("/home/username/foobar/blah.py", ["*.py", "*.txt"], False)
|
|
||||||
True
|
|
||||||
>>> match_path_against("/home/username/foobar/blah.py", ["*.PY", "*.txt"], True)
|
|
||||||
False
|
|
||||||
>>> match_path_against("/home/username/foobar/blah.py", ["*.PY", "*.txt"], False)
|
|
||||||
True
|
|
||||||
>>> match_path_against("C:\\windows\\blah\\BLAH.PY", ["*.py", "*.txt"], True)
|
|
||||||
False
|
|
||||||
>>> match_path_against("C:\\windows\\blah\\BLAH.PY", ["*.py", "*.txt"], False)
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
if case_sensitive:
|
|
||||||
match_func = fnmatchcase
|
|
||||||
pattern_transform_func = (lambda w: w)
|
|
||||||
else:
|
|
||||||
match_func = fnmatch
|
|
||||||
pathname = pathname.lower()
|
|
||||||
pattern_transform_func = _string_lower
|
|
||||||
for pattern in set(patterns):
|
|
||||||
pattern = pattern_transform_func(pattern)
|
|
||||||
if match_func(pathname, pattern):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _match_path(pathname,
|
|
||||||
included_patterns,
|
|
||||||
excluded_patterns,
|
|
||||||
case_sensitive=True):
|
|
||||||
"""Internal function same as :func:`match_path` but does not check arguments.
|
|
||||||
|
|
||||||
Doctests::
|
|
||||||
>>> _match_path("/users/gorakhargosh/foobar.py", ["*.py"], ["*.PY"], True)
|
|
||||||
True
|
|
||||||
>>> _match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], True)
|
|
||||||
False
|
|
||||||
>>> _match_path("/users/gorakhargosh/foobar/", ["*.py"], ["*.txt"], False)
|
|
||||||
False
|
|
||||||
>>> _match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], False)
|
|
||||||
Traceback (most recent call last):
|
|
||||||
...
|
|
||||||
ValueError: conflicting patterns `set(['*.py'])` included and excluded
|
|
||||||
"""
|
|
||||||
if not case_sensitive:
|
|
||||||
included_patterns = set(map(_string_lower, included_patterns))
|
|
||||||
excluded_patterns = set(map(_string_lower, excluded_patterns))
|
|
||||||
else:
|
|
||||||
included_patterns = set(included_patterns)
|
|
||||||
excluded_patterns = set(excluded_patterns)
|
|
||||||
common_patterns = included_patterns & excluded_patterns
|
|
||||||
if common_patterns:
|
|
||||||
raise ValueError('conflicting patterns `%s` included and excluded'\
|
|
||||||
% common_patterns)
|
|
||||||
return (match_path_against(pathname, included_patterns, case_sensitive)\
|
|
||||||
and not match_path_against(pathname, excluded_patterns,
|
|
||||||
case_sensitive))
|
|
||||||
|
|
||||||
|
|
||||||
def match_path(pathname,
|
|
||||||
included_patterns=None,
|
|
||||||
excluded_patterns=None,
|
|
||||||
case_sensitive=True):
|
|
||||||
"""
|
|
||||||
Matches a pathname against a set of acceptable and ignored patterns.
|
|
||||||
|
|
||||||
:param pathname:
|
|
||||||
A pathname which will be matched against a pattern.
|
|
||||||
:param included_patterns:
|
|
||||||
Allow filenames matching wildcard patterns specified in this list.
|
|
||||||
If no pattern is specified, the function treats the pathname as
|
|
||||||
a match_path.
|
|
||||||
:param excluded_patterns:
|
|
||||||
Ignores filenames matching wildcard patterns specified in this list.
|
|
||||||
If no pattern is specified, the function treats the pathname as
|
|
||||||
a match_path.
|
|
||||||
:param case_sensitive:
|
|
||||||
``True`` if matching should be case-sensitive; ``False`` otherwise.
|
|
||||||
:returns:
|
|
||||||
``True`` if the pathname matches; ``False`` otherwise.
|
|
||||||
:raises:
|
|
||||||
ValueError if included patterns and excluded patterns contain the
|
|
||||||
same pattern.
|
|
||||||
|
|
||||||
Doctests::
|
|
||||||
>>> match_path("/Users/gorakhargosh/foobar.py")
|
|
||||||
True
|
|
||||||
>>> match_path("/Users/gorakhargosh/foobar.py", case_sensitive=False)
|
|
||||||
True
|
|
||||||
>>> match_path("/users/gorakhargosh/foobar.py", ["*.py"], ["*.PY"], True)
|
|
||||||
True
|
|
||||||
>>> match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], True)
|
|
||||||
False
|
|
||||||
>>> match_path("/users/gorakhargosh/foobar/", ["*.py"], ["*.txt"], False)
|
|
||||||
False
|
|
||||||
>>> match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], False)
|
|
||||||
Traceback (most recent call last):
|
|
||||||
...
|
|
||||||
ValueError: conflicting patterns `set(['*.py'])` included and excluded
|
|
||||||
"""
|
|
||||||
included = ["*"] if included_patterns is None else included_patterns
|
|
||||||
excluded = [] if excluded_patterns is None else excluded_patterns
|
|
||||||
return _match_path(pathname, included, excluded, case_sensitive)
|
|
||||||
|
|
||||||
|
|
||||||
def filter_paths(pathnames,
|
|
||||||
included_patterns=None,
|
|
||||||
excluded_patterns=None,
|
|
||||||
case_sensitive=True):
|
|
||||||
"""
|
|
||||||
Filters from a set of paths based on acceptable patterns and
|
|
||||||
ignorable patterns.
|
|
||||||
|
|
||||||
:param pathnames:
|
|
||||||
A list of path names that will be filtered based on matching and
|
|
||||||
ignored patterns.
|
|
||||||
:param included_patterns:
|
|
||||||
Allow filenames matching wildcard patterns specified in this list.
|
|
||||||
If no pattern list is specified, ["*"] is used as the default pattern,
|
|
||||||
which matches all files.
|
|
||||||
:param excluded_patterns:
|
|
||||||
Ignores filenames matching wildcard patterns specified in this list.
|
|
||||||
If no pattern list is specified, no files are ignored.
|
|
||||||
:param case_sensitive:
|
|
||||||
``True`` if matching should be case-sensitive; ``False`` otherwise.
|
|
||||||
:returns:
|
|
||||||
A list of pathnames that matched the allowable patterns and passed
|
|
||||||
through the ignored patterns.
|
|
||||||
|
|
||||||
Doctests::
|
|
||||||
>>> pathnames = set(["/users/gorakhargosh/foobar.py", "/var/cache/pdnsd.status", "/etc/pdnsd.conf", "/usr/local/bin/python"])
|
|
||||||
>>> set(filter_paths(pathnames)) == pathnames
|
|
||||||
True
|
|
||||||
>>> set(filter_paths(pathnames, case_sensitive=False)) == pathnames
|
|
||||||
True
|
|
||||||
>>> set(filter_paths(pathnames, ["*.py", "*.conf"], ["*.status"], case_sensitive=True)) == set(["/users/gorakhargosh/foobar.py", "/etc/pdnsd.conf"])
|
|
||||||
True
|
|
||||||
"""
|
|
||||||
included = ["*"] if included_patterns is None else included_patterns
|
|
||||||
excluded = [] if excluded_patterns is None else excluded_patterns
|
|
||||||
|
|
||||||
for pathname in pathnames:
|
|
||||||
# We don't call the public match_path because it checks arguments
|
|
||||||
# and sets default values if none are found. We're already doing that
|
|
||||||
# above.
|
|
||||||
if _match_path(pathname, included, excluded, case_sensitive):
|
|
||||||
yield pathname
|
|
||||||
|
|
||||||
def match_any_paths(pathnames,
|
|
||||||
included_patterns=None,
|
|
||||||
excluded_patterns=None,
|
|
||||||
case_sensitive=True):
|
|
||||||
"""
|
|
||||||
Matches from a set of paths based on acceptable patterns and
|
|
||||||
ignorable patterns.
|
|
||||||
|
|
||||||
:param pathnames:
|
|
||||||
A list of path names that will be filtered based on matching and
|
|
||||||
ignored patterns.
|
|
||||||
:param included_patterns:
|
|
||||||
Allow filenames matching wildcard patterns specified in this list.
|
|
||||||
If no pattern list is specified, ["*"] is used as the default pattern,
|
|
||||||
which matches all files.
|
|
||||||
:param excluded_patterns:
|
|
||||||
Ignores filenames matching wildcard patterns specified in this list.
|
|
||||||
If no pattern list is specified, no files are ignored.
|
|
||||||
:param case_sensitive:
|
|
||||||
``True`` if matching should be case-sensitive; ``False`` otherwise.
|
|
||||||
:returns:
|
|
||||||
``True`` if any of the paths matches; ``False`` otherwise.
|
|
||||||
|
|
||||||
Doctests::
|
|
||||||
>>> pathnames = set(["/users/gorakhargosh/foobar.py", "/var/cache/pdnsd.status", "/etc/pdnsd.conf", "/usr/local/bin/python"])
|
|
||||||
>>> match_any_paths(pathnames)
|
|
||||||
True
|
|
||||||
>>> match_any_paths(pathnames, case_sensitive=False)
|
|
||||||
True
|
|
||||||
>>> match_any_paths(pathnames, ["*.py", "*.conf"], ["*.status"], case_sensitive=True)
|
|
||||||
True
|
|
||||||
>>> match_any_paths(pathnames, ["*.txt"], case_sensitive=False)
|
|
||||||
False
|
|
||||||
>>> match_any_paths(pathnames, ["*.txt"], case_sensitive=True)
|
|
||||||
False
|
|
||||||
"""
|
|
||||||
included = ["*"] if included_patterns is None else included_patterns
|
|
||||||
excluded = [] if excluded_patterns is None else excluded_patterns
|
|
||||||
|
|
||||||
for pathname in pathnames:
|
|
||||||
# We don't call the public match_path because it checks arguments
|
|
||||||
# and sets default values if none are found. We're already doing that
|
|
||||||
# above.
|
|
||||||
if _match_path(pathname, included, excluded, case_sensitive):
|
|
||||||
return True
|
|
||||||
return False
|
|
|
@ -1,31 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# version.py: Version information.
|
|
||||||
# Copyright (C) 2010 Yesudeep Mangalapilly <yesudeep@gmail.com>
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
# When updating this version number, please update the
|
|
||||||
# ``docs/source/global.rst.inc`` file as well.
|
|
||||||
VERSION_MAJOR = 0
|
|
||||||
VERSION_MINOR = 1
|
|
||||||
VERSION_BUILD = 1
|
|
||||||
VERSION_INFO = (VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD)
|
|
||||||
VERSION_STRING = "%d.%d.%d" % VERSION_INFO
|
|
||||||
|
|
||||||
__version__ = VERSION_INFO
|
|
76
resources/lib/pickler.py
Normal file
76
resources/lib/pickler.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
###############################################################################
|
||||||
|
from cPickle import dumps, loads
|
||||||
|
|
||||||
|
from xbmcgui import Window
|
||||||
|
from xbmc import log, LOGDEBUG
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
WINDOW = Window(10000)
|
||||||
|
PREFIX = 'PLEX.%s: ' % __name__
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
def try_encode(input_str, encoding='utf-8'):
|
||||||
|
"""
|
||||||
|
Will try to encode input_str (in unicode) to encoding. This possibly
|
||||||
|
fails with e.g. Android TV's Python, which does not accept arguments for
|
||||||
|
string.encode()
|
||||||
|
|
||||||
|
COPY to avoid importing utils on calling default.py
|
||||||
|
"""
|
||||||
|
if isinstance(input_str, str):
|
||||||
|
# already encoded
|
||||||
|
return input_str
|
||||||
|
try:
|
||||||
|
input_str = input_str.encode(encoding, "ignore")
|
||||||
|
except TypeError:
|
||||||
|
input_str = input_str.encode()
|
||||||
|
return input_str
|
||||||
|
|
||||||
|
|
||||||
|
def pickl_window(property, value=None, clear=False):
|
||||||
|
"""
|
||||||
|
Get or set window property - thread safe! For use with Pickle
|
||||||
|
Property and value must be string
|
||||||
|
"""
|
||||||
|
if clear:
|
||||||
|
WINDOW.clearProperty(property)
|
||||||
|
elif value is not None:
|
||||||
|
WINDOW.setProperty(property, value)
|
||||||
|
else:
|
||||||
|
return try_encode(WINDOW.getProperty(property))
|
||||||
|
|
||||||
|
|
||||||
|
def pickle_me(obj, window_var='plex_result'):
|
||||||
|
"""
|
||||||
|
Pickles the obj to the window variable. Use to transfer Python
|
||||||
|
objects between different PKC python instances (e.g. if default.py is
|
||||||
|
called and you'd want to use the service.py instance)
|
||||||
|
|
||||||
|
obj can be pretty much any Python object. However, classes and
|
||||||
|
functions won't work. See the Pickle documentation
|
||||||
|
"""
|
||||||
|
log('%sStart pickling' % PREFIX, level=LOGDEBUG)
|
||||||
|
pickl_window(window_var, value=dumps(obj))
|
||||||
|
log('%sSuccessfully pickled' % PREFIX, level=LOGDEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
def unpickle_me(window_var='plex_result'):
|
||||||
|
"""
|
||||||
|
Unpickles a Python object from the window variable window_var.
|
||||||
|
Will then clear the window variable!
|
||||||
|
"""
|
||||||
|
result = pickl_window(window_var)
|
||||||
|
pickl_window(window_var, clear=True)
|
||||||
|
log('%sStart unpickling' % PREFIX, level=LOGDEBUG)
|
||||||
|
obj = loads(result)
|
||||||
|
log('%sSuccessfully unpickled' % PREFIX, level=LOGDEBUG)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class Playback_Successful(object):
|
||||||
|
"""
|
||||||
|
Used to communicate with another PKC Python instance
|
||||||
|
"""
|
||||||
|
listitem = None
|
|
@ -1,39 +1,44 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
"""
|
||||||
Used to kick off Kodi playback
|
Used to kick off Kodi playback
|
||||||
"""
|
"""
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
import datetime
|
from os.path import join
|
||||||
|
|
||||||
import xbmc
|
from xbmc import Player, sleep
|
||||||
|
|
||||||
from .plex_api import API
|
from PlexAPI import API
|
||||||
from .plex_db import PlexDB
|
from PlexFunctions import GetPlexMetadata, init_plex_playqueue
|
||||||
from .kodi_db import KodiVideoDB
|
from downloadutils import DownloadUtils as DU
|
||||||
from . import plex_functions as PF, playlist_func as PL, playqueue as PQ
|
import plexdb_functions as plexdb
|
||||||
from . import json_rpc as js, variables as v, utils, transfer
|
import kodidb_functions as kodidb
|
||||||
from . import playback_decision, app
|
import playlist_func as PL
|
||||||
from . import exceptions
|
import playqueue as PQ
|
||||||
|
from playutils import PlayUtils
|
||||||
|
from PKC_listitem import PKC_ListItem
|
||||||
|
from pickler import pickle_me, Playback_Successful
|
||||||
|
import json_rpc as js
|
||||||
|
from utils import settings, dialog, language as lang, try_encode
|
||||||
|
from plexbmchelper.subscribers import LOCKER
|
||||||
|
import variables as v
|
||||||
|
import state
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
LOG = getLogger('PLEX.playback')
|
LOG = getLogger("PLEX." + __name__)
|
||||||
# Do we need to return ultimately with a setResolvedUrl?
|
# Do we need to return ultimately with a setResolvedUrl?
|
||||||
RESOLVE = True
|
RESOLVE = True
|
||||||
TRY_TO_SEEK_FOR = 300 # =30 seconds
|
# We're "failing" playback with a video of 0 length
|
||||||
IGNORE_SECONDS_AT_START = 15
|
NULL_VIDEO = join(v.ADDON_FOLDER, 'addons', v.ADDON_ID, 'empty_video.mp4')
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
def playback_triage(plex_id=None, plex_type=None, path=None, resolve=True,
|
@LOCKER.lockthis
|
||||||
resume=False):
|
def playback_triage(plex_id=None, plex_type=None, path=None, resolve=True):
|
||||||
"""
|
"""
|
||||||
Hit this function for addon path playback, Plex trailers, etc.
|
Hit this function for addon path playback, Plex trailers, etc.
|
||||||
Will setup playback first, then on second call complete playback.
|
Will setup playback first, then on second call complete playback.
|
||||||
|
|
||||||
Will set Playback_Successful() with potentially a PKCListItem() attached
|
Will set Playback_Successful() with potentially a PKC_ListItem() attached
|
||||||
(to be consumed by setResolvedURL in default.py)
|
(to be consumed by setResolvedURL in default.py)
|
||||||
|
|
||||||
If trailers or additional (movie-)parts are added, default.py is released
|
If trailers or additional (movie-)parts are added, default.py is released
|
||||||
|
@ -44,156 +49,56 @@ def playback_triage(plex_id=None, plex_type=None, path=None, resolve=True,
|
||||||
the first pass - e.g. if you're calling this function from the original
|
the first pass - e.g. if you're calling this function from the original
|
||||||
service.py Python instance
|
service.py Python instance
|
||||||
"""
|
"""
|
||||||
try:
|
LOG.info('playback_triage called with plex_id %s, plex_type %s, path %s, '
|
||||||
_playback_triage(plex_id, plex_type, path, resolve, resume)
|
'resolve %s,', plex_id, plex_type, path, resolve)
|
||||||
finally:
|
|
||||||
# Reset some playback variables the user potentially set to init
|
|
||||||
# playback
|
|
||||||
app.PLAYSTATE.context_menu_play = False
|
|
||||||
app.PLAYSTATE.force_transcode = False
|
|
||||||
|
|
||||||
|
|
||||||
def _playback_triage(plex_id, plex_type, path, resolve, resume):
|
|
||||||
plex_id = utils.cast(int, plex_id)
|
|
||||||
LOG.debug('playback_triage called with plex_id %s, plex_type %s, path %s, '
|
|
||||||
'resolve %s, resume %s', plex_id, plex_type, path, resolve, resume)
|
|
||||||
global RESOLVE
|
global RESOLVE
|
||||||
# If started via Kodi context menu, we never resolve
|
# If started via Kodi context menu, we never resolve
|
||||||
RESOLVE = resolve if not app.PLAYSTATE.context_menu_play else False
|
RESOLVE = resolve if not state.CONTEXT_MENU_PLAY else False
|
||||||
if not app.CONN.online or not app.ACCOUNT.authenticated:
|
if not state.AUTHENTICATED:
|
||||||
if not app.CONN.online:
|
LOG.error('Not yet authenticated for PMS, abort starting playback')
|
||||||
LOG.error('PMS not online for playback')
|
# "Unauthorized for PMS"
|
||||||
# "{0} offline"
|
dialog('notification', lang(29999), lang(30017))
|
||||||
utils.dialog('notification',
|
|
||||||
utils.lang(29999),
|
|
||||||
utils.lang(39213).format(app.CONN.server_name),
|
|
||||||
icon='{plex}')
|
|
||||||
else:
|
|
||||||
LOG.error('Not yet authenticated for PMS, abort starting playback')
|
|
||||||
# "Unauthorized for PMS"
|
|
||||||
utils.dialog('notification', utils.lang(29999), utils.lang(30017))
|
|
||||||
_ensure_resolve(abort=True)
|
_ensure_resolve(abort=True)
|
||||||
return
|
return
|
||||||
with app.APP.lock_playqueues:
|
playqueue = PQ.get_playqueue_from_type(
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
pos = js.get_position(playqueue.playlistid)
|
||||||
try:
|
# Can return -1 (as in "no playlist")
|
||||||
pos = js.get_position(playqueue.playlistid)
|
pos = pos if pos != -1 else 0
|
||||||
except KeyError:
|
LOG.debug('playQueue position %s for %s', pos, playqueue)
|
||||||
# Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for
|
# Have we already initiated playback?
|
||||||
# add-on paths
|
try:
|
||||||
LOG.debug('No position returned from player! Assuming playlist')
|
item = playqueue.items[pos]
|
||||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO)
|
except IndexError:
|
||||||
try:
|
initiate = True
|
||||||
pos = js.get_position(playqueue.playlistid)
|
else:
|
||||||
except KeyError:
|
initiate = True if item.plex_id != plex_id else False
|
||||||
LOG.debug('Assuming video instead of audio playlist playback')
|
if initiate:
|
||||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_VIDEO)
|
_playback_init(plex_id, plex_type, playqueue, pos)
|
||||||
try:
|
else:
|
||||||
pos = js.get_position(playqueue.playlistid)
|
# kick off playback on second pass
|
||||||
except KeyError:
|
_conclude_playback(playqueue, pos)
|
||||||
LOG.error('Still no position - abort')
|
|
||||||
# "Play error"
|
|
||||||
utils.dialog('notification',
|
|
||||||
utils.lang(29999),
|
|
||||||
utils.lang(30128),
|
|
||||||
icon='{error}')
|
|
||||||
_ensure_resolve(abort=True)
|
|
||||||
return
|
|
||||||
# HACK to detect playback of playlists for add-on paths
|
|
||||||
items = js.playlist_get_items(playqueue.playlistid)
|
|
||||||
try:
|
|
||||||
item = items[pos]
|
|
||||||
except IndexError:
|
|
||||||
LOG.debug('Could not apply playlist hack! Probably Widget playback')
|
|
||||||
else:
|
|
||||||
if ('id' not in item and
|
|
||||||
item.get('type') == 'unknown' and item.get('title') == ''):
|
|
||||||
LOG.debug('Kodi playlist play detected')
|
|
||||||
_playlist_playback(plex_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Can return -1 (as in "no playlist")
|
|
||||||
pos = pos if pos != -1 else 0
|
|
||||||
LOG.debug('playQueue position %s for %s', pos, playqueue)
|
|
||||||
# Have we already initiated playback?
|
|
||||||
try:
|
|
||||||
item = playqueue.items[pos]
|
|
||||||
except IndexError:
|
|
||||||
LOG.debug('PKC playqueue yet empty, need to initialize playback')
|
|
||||||
initiate = True
|
|
||||||
else:
|
|
||||||
if item.plex_id != plex_id:
|
|
||||||
LOG.debug('Received new plex_id%s, expected %s',
|
|
||||||
plex_id, item.plex_id)
|
|
||||||
initiate = True
|
|
||||||
else:
|
|
||||||
initiate = False
|
|
||||||
if initiate:
|
|
||||||
_playback_init(plex_id, plex_type, playqueue, pos, resume)
|
|
||||||
else:
|
|
||||||
# kick off playback on second pass, resume was already set on first
|
|
||||||
# pass (threaded_playback will seek to resume)
|
|
||||||
_conclude_playback(playqueue, pos)
|
|
||||||
|
|
||||||
|
|
||||||
def _playlist_playback(plex_id):
|
def _playback_init(plex_id, plex_type, playqueue, pos):
|
||||||
"""
|
|
||||||
Really annoying Kodi behavior: Kodi will throw the ENTIRE playlist some-
|
|
||||||
where, causing Playlist.onAdd to fire for each item like this:
|
|
||||||
Playlist.OnAdd Data: {u'item': {u'type': u'episode', u'id': 164},
|
|
||||||
u'playlistid': 0,
|
|
||||||
u'position': 2}
|
|
||||||
This does NOT work for Addon paths, type and id will be unknown:
|
|
||||||
{u'item': {u'type': u'unknown'},
|
|
||||||
u'playlistid': 0,
|
|
||||||
u'position': 7}
|
|
||||||
At the end, only the element being played actually shows up in the Kodi
|
|
||||||
playqueue.
|
|
||||||
Hence: if we fail the first addon paths call, Kodi will start playback
|
|
||||||
for the next item in line :-)
|
|
||||||
(by the way: trying to get active Kodi player id will return [])
|
|
||||||
"""
|
|
||||||
xml = PF.GetPlexMetadata(plex_id, reraise=True)
|
|
||||||
if xml in (None, 401):
|
|
||||||
_ensure_resolve(abort=True)
|
|
||||||
return
|
|
||||||
# Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback
|
|
||||||
# has actually started. Need to tell Kodimonitor
|
|
||||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO)
|
|
||||||
playqueue.clear(kodi=False)
|
|
||||||
# Set the flag for the potentially WRONG audio playlist so Kodimonitor
|
|
||||||
# can pick up on it
|
|
||||||
playqueue.kodi_playlist_playback = True
|
|
||||||
playlist_item = PL.playlist_item_from_xml(xml[0])
|
|
||||||
playqueue.items.append(playlist_item)
|
|
||||||
_conclude_playback(playqueue, pos=0)
|
|
||||||
|
|
||||||
|
|
||||||
def _playback_init(plex_id, plex_type, playqueue, pos, resume):
|
|
||||||
"""
|
"""
|
||||||
Playback setup if Kodi starts playing an item for the first time.
|
Playback setup if Kodi starts playing an item for the first time.
|
||||||
"""
|
"""
|
||||||
LOG.debug('Initializing PKC playback')
|
LOG.info('Initializing PKC playback')
|
||||||
# Stop playback so we don't get an error message that the last item of the
|
xml = GetPlexMetadata(plex_id)
|
||||||
# queue failed to play
|
try:
|
||||||
app.APP.player.stop()
|
xml[0].attrib
|
||||||
xml = PF.GetPlexMetadata(plex_id, reraise=True)
|
except (IndexError, TypeError, AttributeError):
|
||||||
if xml in (None, 401):
|
|
||||||
LOG.error('Could not get a PMS xml for plex id %s', plex_id)
|
LOG.error('Could not get a PMS xml for plex id %s', plex_id)
|
||||||
|
# "Play error"
|
||||||
|
dialog('notification', lang(29999), lang(30128), icon='{error}')
|
||||||
_ensure_resolve(abort=True)
|
_ensure_resolve(abort=True)
|
||||||
return
|
return
|
||||||
if (xbmc.getCondVisibility('Window.IsVisible(Home.xml)') and
|
if playqueue.kodi_pl.size() > 1:
|
||||||
plex_type in v.PLEX_VIDEOTYPES and
|
|
||||||
playqueue.kodi_pl.size() > 1):
|
|
||||||
# playqueue.kodi_pl.size() could return more than one - since playback
|
|
||||||
# was initiated from the audio queue!
|
|
||||||
LOG.debug('Detected widget playback for videos')
|
|
||||||
elif playqueue.kodi_pl.size() > 1:
|
|
||||||
# Special case - we already got a filled Kodi playqueue
|
# Special case - we already got a filled Kodi playqueue
|
||||||
try:
|
try:
|
||||||
_init_existing_kodi_playlist(playqueue, pos)
|
_init_existing_kodi_playlist(playqueue, pos)
|
||||||
except exceptions.PlaylistError:
|
except PL.PlaylistError:
|
||||||
LOG.error('Playback_init for existing Kodi playlist failed')
|
LOG.error('Playback_init for existing Kodi playlist failed')
|
||||||
_ensure_resolve(abort=True)
|
_ensure_resolve(abort=True)
|
||||||
return
|
return
|
||||||
|
@ -203,59 +108,60 @@ def _playback_init(plex_id, plex_type, playqueue, pos, resume):
|
||||||
return
|
return
|
||||||
# "Usual" case - consider trailers and parts and build both Kodi and Plex
|
# "Usual" case - consider trailers and parts and build both Kodi and Plex
|
||||||
# playqueues
|
# playqueues
|
||||||
# Release default.py
|
# Pass dummy PKC video with 0 length so Kodi immediately stops playback
|
||||||
|
# and we can build our own playqueue.
|
||||||
_ensure_resolve()
|
_ensure_resolve()
|
||||||
api = API(xml[0])
|
api = API(xml[0])
|
||||||
if (app.PLAYSTATE.context_menu_play and
|
|
||||||
api.resume_point() and
|
|
||||||
api.plex_type in v.PLEX_VIDEOTYPES):
|
|
||||||
# User chose to either play via PMS or to force transcode
|
|
||||||
# Need to prompt whether we should resume_playback
|
|
||||||
resume = resume_dialog(int(api.resume_point()))
|
|
||||||
if resume is None:
|
|
||||||
# User cancelled dialog
|
|
||||||
return
|
|
||||||
LOG.debug('Using resume %s', resume)
|
|
||||||
resume = resume or False
|
|
||||||
trailers = False
|
trailers = False
|
||||||
if (not resume and plex_type == v.PLEX_TYPE_MOVIE and
|
if (plex_type == v.PLEX_TYPE_MOVIE and not api.resume_point() and
|
||||||
utils.settings('enableCinema') == "true"):
|
settings('enableCinema') == "true"):
|
||||||
if utils.settings('askCinema') == "true":
|
if settings('askCinema') == "true":
|
||||||
# "Play trailers?"
|
# "Play trailers?"
|
||||||
trailers = utils.yesno_dialog(utils.lang(29999), utils.lang(33016))
|
trailers = dialog('yesno', lang(29999), lang(33016))
|
||||||
|
trailers = True if trailers else False
|
||||||
else:
|
else:
|
||||||
trailers = True
|
trailers = True
|
||||||
LOG.debug('Resuming: %s. Playing trailers: %s', resume, trailers)
|
LOG.debug('Playing trailers: %s', trailers)
|
||||||
|
if RESOLVE:
|
||||||
|
# Sleep a bit to let setResolvedUrl do its thing - bit ugly
|
||||||
|
sleep_timer = 0
|
||||||
|
while not state.PKC_CAUSED_STOP_DONE:
|
||||||
|
sleep(50)
|
||||||
|
sleep_timer += 1
|
||||||
|
if sleep_timer > 100:
|
||||||
|
break
|
||||||
playqueue.clear()
|
playqueue.clear()
|
||||||
if plex_type != v.PLEX_TYPE_CLIP:
|
if plex_type != v.PLEX_TYPE_CLIP:
|
||||||
# Post to the PMS to create a playqueue - in any case due to Companion
|
# Post to the PMS to create a playqueue - in any case due to Companion
|
||||||
xml = PF.init_plex_playqueue(plex_id,
|
xml = init_plex_playqueue(plex_id,
|
||||||
plex_type,
|
xml.attrib.get('librarySectionUUID'),
|
||||||
xml.get('librarySectionUUID'),
|
mediatype=plex_type,
|
||||||
trailers=trailers)
|
trailers=trailers)
|
||||||
if xml is None:
|
if xml is None:
|
||||||
LOG.error('Could not get a playqueue xml for plex id %s', plex_id)
|
LOG.error('Could not get a playqueue xml for plex id %s, UUID %s',
|
||||||
|
plex_id, xml.attrib.get('librarySectionUUID'))
|
||||||
# "Play error"
|
# "Play error"
|
||||||
utils.dialog('notification',
|
dialog('notification', lang(29999), lang(30128), icon='{error}')
|
||||||
utils.lang(29999),
|
|
||||||
utils.lang(30128),
|
|
||||||
icon='{error}')
|
|
||||||
# Do NOT use _ensure_resolve() because we resolved above already
|
# Do NOT use _ensure_resolve() because we resolved above already
|
||||||
|
state.CONTEXT_MENU_PLAY = False
|
||||||
|
state.FORCE_TRANSCODE = False
|
||||||
|
state.RESUME_PLAYBACK = False
|
||||||
return
|
return
|
||||||
PL.get_playlist_details_from_xml(playqueue, xml)
|
PL.get_playlist_details_from_xml(playqueue, xml)
|
||||||
stack = _prep_playlist_stack(xml, resume)
|
stack = _prep_playlist_stack(xml)
|
||||||
_process_stack(playqueue, stack)
|
_process_stack(playqueue, stack)
|
||||||
offset = _use_kodi_db_offset(playqueue.items[pos].plex_id,
|
# Always resume if playback initiated via PMS and there IS a resume
|
||||||
playqueue.items[pos].plex_type,
|
# point
|
||||||
playqueue.items[pos].offset) if resume else 0
|
offset = api.resume_point() * 1000 if state.CONTEXT_MENU_PLAY else None
|
||||||
|
# Reset some playback variables
|
||||||
|
state.CONTEXT_MENU_PLAY = False
|
||||||
|
state.FORCE_TRANSCODE = False
|
||||||
# New thread to release this one sooner (e.g. harddisk spinning up)
|
# New thread to release this one sooner (e.g. harddisk spinning up)
|
||||||
thread = Thread(target=threaded_playback,
|
thread = Thread(target=threaded_playback,
|
||||||
args=(playqueue.kodi_pl, pos, offset))
|
args=(playqueue.kodi_pl, pos, offset))
|
||||||
thread.setDaemon(True)
|
thread.setDaemon(True)
|
||||||
LOG.debug('Done initializing playback, starting Kodi player at pos %s and '
|
LOG.info('Done initializing playback, starting Kodi player at pos %s and '
|
||||||
'offset %s', pos, offset)
|
'resume point %s', pos, offset)
|
||||||
# Ensure that PKC playqueue monitor ignores the changes we just made
|
|
||||||
playqueue.pkc_edit = True
|
|
||||||
# By design, PKC will start Kodi playback using Player().play(). Kodi
|
# By design, PKC will start Kodi playback using Player().play(). Kodi
|
||||||
# caches paths like our plugin://pkc. If we use Player().play() between
|
# caches paths like our plugin://pkc. If we use Player().play() between
|
||||||
# 2 consecutive startups of exactly the same Kodi library item, Kodi's
|
# 2 consecutive startups of exactly the same Kodi library item, Kodi's
|
||||||
|
@ -263,6 +169,8 @@ def _playback_init(plex_id, plex_type, playqueue, pos, resume):
|
||||||
# plugin://pkc will be lost; Kodi will try to startup playback for an empty
|
# plugin://pkc will be lost; Kodi will try to startup playback for an empty
|
||||||
# path: log entry is "CGUIWindowVideoBase::OnPlayMedia <missing path>"
|
# path: log entry is "CGUIWindowVideoBase::OnPlayMedia <missing path>"
|
||||||
thread.start()
|
thread.start()
|
||||||
|
# Ensure that PKC playqueue monitor ignores the changes we just made
|
||||||
|
playqueue.pkc_edit = True
|
||||||
|
|
||||||
|
|
||||||
def _ensure_resolve(abort=False):
|
def _ensure_resolve(abort=False):
|
||||||
|
@ -275,33 +183,19 @@ def _ensure_resolve(abort=False):
|
||||||
will be destroyed.
|
will be destroyed.
|
||||||
"""
|
"""
|
||||||
if RESOLVE:
|
if RESOLVE:
|
||||||
# Releases the other Python thread without a ListItem
|
LOG.debug('Passing dummy path to Kodi')
|
||||||
transfer.send(True)
|
# if not state.CONTEXT_MENU_PLAY:
|
||||||
# Wait for default.py to have completed xbmcplugin.setResolvedUrl()
|
# Because playback won't start with context menu play
|
||||||
transfer.wait_for_transfer(source='default')
|
state.PKC_CAUSED_STOP = True
|
||||||
|
state.PKC_CAUSED_STOP_DONE = False
|
||||||
|
result = Playback_Successful()
|
||||||
|
result.listitem = PKC_ListItem(path=NULL_VIDEO)
|
||||||
|
pickle_me(result)
|
||||||
if abort:
|
if abort:
|
||||||
utils.dialog('notification',
|
# Reset some playback variables
|
||||||
heading='{plex}',
|
state.CONTEXT_MENU_PLAY = False
|
||||||
message=utils.lang(30128),
|
state.FORCE_TRANSCODE = False
|
||||||
icon='{error}',
|
state.RESUME_PLAYBACK = False
|
||||||
time=3000)
|
|
||||||
|
|
||||||
|
|
||||||
def resume_dialog(resume):
|
|
||||||
"""
|
|
||||||
Pass the resume [int] point in seconds. Returns True if user chose to
|
|
||||||
resume. Returns None if user cancelled
|
|
||||||
"""
|
|
||||||
# "Resume from {0:s}"
|
|
||||||
# "Start from beginning"
|
|
||||||
resume = datetime.timedelta(seconds=resume)
|
|
||||||
LOG.debug('Showing PKC resume dialog for resume: %s', resume)
|
|
||||||
answ = utils.dialog('contextmenu',
|
|
||||||
[utils.lang(12022).replace('{0:s}', '{0}').format(unicode(resume)),
|
|
||||||
utils.lang(12021)])
|
|
||||||
if answ == -1:
|
|
||||||
return
|
|
||||||
return answ == 0
|
|
||||||
|
|
||||||
|
|
||||||
def _init_existing_kodi_playlist(playqueue, pos):
|
def _init_existing_kodi_playlist(playqueue, pos):
|
||||||
|
@ -313,31 +207,27 @@ def _init_existing_kodi_playlist(playqueue, pos):
|
||||||
kodi_items = js.playlist_get_items(playqueue.playlistid)
|
kodi_items = js.playlist_get_items(playqueue.playlistid)
|
||||||
if not kodi_items:
|
if not kodi_items:
|
||||||
LOG.error('No Kodi items returned')
|
LOG.error('No Kodi items returned')
|
||||||
raise exceptions.PlaylistError('No Kodi items returned')
|
raise PL.PlaylistError('No Kodi items returned')
|
||||||
item = PL.init_plex_playqueue(playqueue, kodi_item=kodi_items[pos])
|
item = PL.init_Plex_playlist(playqueue, kodi_item=kodi_items[pos])
|
||||||
item.force_transcode = app.PLAYSTATE.force_transcode
|
item.force_transcode = state.FORCE_TRANSCODE
|
||||||
# playqueue.py will add the rest - this will likely put the PMS under
|
# playqueue.py will add the rest - this will likely put the PMS under
|
||||||
# a LOT of strain if the following Kodi setting is enabled:
|
# a LOT of strain if the following Kodi setting is enabled:
|
||||||
# Settings -> Player -> Videos -> Play next video automatically
|
# Settings -> Player -> Videos -> Play next video automatically
|
||||||
LOG.debug('Done init_existing_kodi_playlist')
|
LOG.debug('Done init_existing_kodi_playlist')
|
||||||
|
|
||||||
|
|
||||||
def _prep_playlist_stack(xml, resume):
|
def _prep_playlist_stack(xml):
|
||||||
"""
|
|
||||||
resume [bool] will set the resume point of the LAST item of the stack, for
|
|
||||||
part 1 only
|
|
||||||
"""
|
|
||||||
stack = []
|
stack = []
|
||||||
for i, item in enumerate(xml):
|
for item in xml:
|
||||||
api = API(item)
|
api = API(item)
|
||||||
if (app.PLAYSTATE.context_menu_play is False and
|
if (state.CONTEXT_MENU_PLAY is False and
|
||||||
api.plex_type not in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_EPISODE)):
|
api.plex_type() not in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_EPISODE)):
|
||||||
# If user chose to play via PMS or force transcode, do not
|
# If user chose to play via PMS or force transcode, do not
|
||||||
# use the item path stored in the Kodi DB
|
# use the item path stored in the Kodi DB
|
||||||
with PlexDB(lock=False) as plexdb:
|
with plexdb.Get_Plex_DB() as plex_db:
|
||||||
db_item = plexdb.item_by_id(api.plex_id, api.plex_type)
|
plex_dbitem = plex_db.getItem_byId(api.plex_id())
|
||||||
kodi_id = db_item['kodi_id'] if db_item else None
|
kodi_id = plex_dbitem[0] if plex_dbitem else None
|
||||||
kodi_type = db_item['kodi_type'] if db_item else None
|
kodi_type = plex_dbitem[4] if plex_dbitem else None
|
||||||
else:
|
else:
|
||||||
# We will never store clips (trailers) in the Kodi DB.
|
# We will never store clips (trailers) in the Kodi DB.
|
||||||
# Also set kodi_id to None for playback via PMS, so that we're
|
# Also set kodi_id to None for playback via PMS, so that we're
|
||||||
|
@ -348,20 +238,15 @@ def _prep_playlist_stack(xml, resume):
|
||||||
kodi_id = None
|
kodi_id = None
|
||||||
kodi_type = None
|
kodi_type = None
|
||||||
for part, _ in enumerate(item[0]):
|
for part, _ in enumerate(item[0]):
|
||||||
api.part = part
|
api.set_part_number(part)
|
||||||
if kodi_id is None:
|
if kodi_id is None:
|
||||||
# Need to redirect again to PKC to conclude playback
|
# Need to redirect again to PKC to conclude playback
|
||||||
path = api.fullpath(force_addon=True)[0]
|
path = ('plugin://%s/?plex_id=%s&plex_type=%s&mode=play'
|
||||||
# Using different paths than the ones saved in the Kodi DB
|
% (v.ADDON_TYPE[api.plex_type()],
|
||||||
# fixes Kodi immediately resuming the video if one restarts
|
api.plex_id(),
|
||||||
# the same video again after playback
|
api.plex_type()))
|
||||||
# WARNING: This fixes startup, but renders Kodi unstable
|
listitem = api.create_listitem()
|
||||||
# path = path.replace('plugin.video.plexkodiconnect.tvshows',
|
listitem.setPath(try_encode(path))
|
||||||
# 'plugin.video.plexkodiconnect', 1)
|
|
||||||
# path = path.replace('plugin.video.plexkodiconnect.movies',
|
|
||||||
# 'plugin.video.plexkodiconnect', 1)
|
|
||||||
listitem = api.listitem()
|
|
||||||
listitem.setPath(path.encode('utf-8'))
|
|
||||||
else:
|
else:
|
||||||
# Will add directly via the Kodi DB
|
# Will add directly via the Kodi DB
|
||||||
path = None
|
path = None
|
||||||
|
@ -375,7 +260,6 @@ def _prep_playlist_stack(xml, resume):
|
||||||
'part': part,
|
'part': part,
|
||||||
'playcount': api.viewcount(),
|
'playcount': api.viewcount(),
|
||||||
'offset': api.resume_point(),
|
'offset': api.resume_point(),
|
||||||
'resume': resume if part == 0 and i + 1 == len(xml) else None,
|
|
||||||
'id': api.item_id()
|
'id': api.item_id()
|
||||||
})
|
})
|
||||||
return stack
|
return stack
|
||||||
|
@ -407,27 +291,10 @@ def _process_stack(playqueue, stack):
|
||||||
playlist_item.offset = item['offset']
|
playlist_item.offset = item['offset']
|
||||||
playlist_item.part = item['part']
|
playlist_item.part = item['part']
|
||||||
playlist_item.id = item['id']
|
playlist_item.id = item['id']
|
||||||
playlist_item.force_transcode = app.PLAYSTATE.force_transcode
|
playlist_item.force_transcode = state.FORCE_TRANSCODE
|
||||||
playlist_item.resume = item['resume']
|
|
||||||
pos += 1
|
pos += 1
|
||||||
|
|
||||||
|
|
||||||
def _use_kodi_db_offset(plex_id, plex_type, plex_offset):
|
|
||||||
"""
|
|
||||||
Do NOT use item.offset directly but get it from the Kodi DB (Plex might not
|
|
||||||
have gotten the last resume point)
|
|
||||||
"""
|
|
||||||
if plex_type not in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_EPISODE):
|
|
||||||
return plex_offset
|
|
||||||
with PlexDB(lock=False) as plexdb:
|
|
||||||
db_item = plexdb.item_by_id(plex_id, plex_type)
|
|
||||||
if db_item:
|
|
||||||
with KodiVideoDB(lock=False) as kodidb:
|
|
||||||
return kodidb.get_resume(db_item['kodi_fileid'])
|
|
||||||
else:
|
|
||||||
return plex_offset
|
|
||||||
|
|
||||||
|
|
||||||
def _conclude_playback(playqueue, pos):
|
def _conclude_playback(playqueue, pos):
|
||||||
"""
|
"""
|
||||||
ONLY if actually being played (e.g. at 5th position of a playqueue).
|
ONLY if actually being played (e.g. at 5th position of a playqueue).
|
||||||
|
@ -443,30 +310,41 @@ def _conclude_playback(playqueue, pos):
|
||||||
start playback
|
start playback
|
||||||
return PKC listitem attached to result
|
return PKC listitem attached to result
|
||||||
"""
|
"""
|
||||||
LOG.debug('Concluding playback for playqueue position %s', pos)
|
LOG.info('Concluding playback for playqueue position %s', pos)
|
||||||
|
result = Playback_Successful()
|
||||||
|
listitem = PKC_ListItem()
|
||||||
item = playqueue.items[pos]
|
item = playqueue.items[pos]
|
||||||
if item.api.mediastream_number() is None:
|
if item.xml is not None:
|
||||||
# E.g. user could choose between several media streams and cancelled
|
# Got a Plex element
|
||||||
LOG.debug('Did not get a mediastream_number')
|
api = API(item.xml)
|
||||||
_ensure_resolve()
|
api.set_part_number(item.part)
|
||||||
return
|
api.create_listitem(listitem)
|
||||||
item.api.part = item.part or 0
|
playutils = PlayUtils(api, item)
|
||||||
playback_decision.set_pkc_playmethod(item.api, item)
|
playurl = playutils.getPlayUrl()
|
||||||
if not playback_decision.audio_subtitle_prefs(item.api, item):
|
else:
|
||||||
LOG.info('Did not set audio subtitle prefs, aborting silently')
|
playurl = item.file
|
||||||
_ensure_resolve()
|
listitem.setPath(try_encode(playurl))
|
||||||
return
|
if item.playmethod == 'DirectStream':
|
||||||
playback_decision.set_playurl(item.api, item)
|
listitem.setSubtitles(api.cache_external_subs())
|
||||||
if not item.file:
|
elif item.playmethod == 'Transcode':
|
||||||
LOG.info('Did not get a playurl, aborting playback silently')
|
playutils.audio_subtitle_prefs(listitem)
|
||||||
_ensure_resolve()
|
|
||||||
return
|
if state.RESUME_PLAYBACK is True:
|
||||||
listitem = item.api.listitem(listitem=transfer.PKCListItem, resume=False)
|
state.RESUME_PLAYBACK = False
|
||||||
listitem.setPath(item.file.encode('utf-8'))
|
if (item.offset is None and
|
||||||
if item.playmethod != v.PLAYBACK_METHOD_DIRECT_PATH:
|
item.plex_type not in (v.PLEX_TYPE_SONG, v.PLEX_TYPE_CLIP)):
|
||||||
listitem.setSubtitles(item.api.cache_external_subs())
|
with plexdb.Get_Plex_DB() as plex_db:
|
||||||
transfer.send(listitem)
|
plex_dbitem = plex_db.getItem_byId(item.plex_id)
|
||||||
LOG.debug('Done concluding playback')
|
file_id = plex_dbitem[1] if plex_dbitem else None
|
||||||
|
with kodidb.GetKodiDB('video') as kodi_db:
|
||||||
|
item.offset = kodi_db.get_resume(file_id)
|
||||||
|
LOG.info('Resuming playback at %s', item.offset)
|
||||||
|
listitem.setProperty('StartOffset', str(item.offset))
|
||||||
|
listitem.setProperty('resumetime', str(item.offset))
|
||||||
|
# Reset the resumable flag
|
||||||
|
result.listitem = listitem
|
||||||
|
pickle_me(result)
|
||||||
|
LOG.info('Done concluding playback')
|
||||||
|
|
||||||
|
|
||||||
def process_indirect(key, offset, resolve=True):
|
def process_indirect(key, offset, resolve=True):
|
||||||
|
@ -480,65 +358,59 @@ def process_indirect(key, offset, resolve=True):
|
||||||
Set resolve to False if playback should be kicked off directly, not via
|
Set resolve to False if playback should be kicked off directly, not via
|
||||||
setResolvedUrl
|
setResolvedUrl
|
||||||
"""
|
"""
|
||||||
LOG.debug('process_indirect called with key: %s, offset: %s, resolve: %s',
|
LOG.info('process_indirect called with key: %s, offset: %s, resolve: %s',
|
||||||
key, offset, resolve)
|
key, offset, resolve)
|
||||||
global RESOLVE
|
global RESOLVE
|
||||||
RESOLVE = resolve
|
RESOLVE = resolve
|
||||||
offset = int(v.PLEX_TO_KODI_TIMEFACTOR * float(offset)) if offset != '0' else None
|
result = Playback_Successful()
|
||||||
if key.startswith('http') or key.startswith('{server}'):
|
if key.startswith('http') or key.startswith('{server}'):
|
||||||
xml = PF.get_playback_xml(key, app.CONN.server_name)
|
xml = DU().downloadUrl(key)
|
||||||
elif key.startswith('/system/services'):
|
elif key.startswith('/system/services'):
|
||||||
xml = PF.get_playback_xml('http://node.plexapp.com:32400%s' % key,
|
xml = DU().downloadUrl('http://node.plexapp.com:32400%s' % key)
|
||||||
'plexapp.com',
|
|
||||||
authenticate=False,
|
|
||||||
token=app.ACCOUNT.plex_token)
|
|
||||||
else:
|
else:
|
||||||
xml = PF.get_playback_xml('{server}%s' % key, app.CONN.server_name)
|
xml = DU().downloadUrl('{server}%s' % key)
|
||||||
if xml is None:
|
try:
|
||||||
|
xml[0].attrib
|
||||||
|
except (TypeError, IndexError, AttributeError):
|
||||||
|
LOG.error('Could not download PMS metadata')
|
||||||
_ensure_resolve(abort=True)
|
_ensure_resolve(abort=True)
|
||||||
return
|
return
|
||||||
|
if offset != '0':
|
||||||
|
offset = int(v.PLEX_TO_KODI_TIMEFACTOR * float(offset))
|
||||||
|
# Todo: implement offset
|
||||||
api = API(xml[0])
|
api = API(xml[0])
|
||||||
listitem = api.listitem(listitem=transfer.PKCListItem, resume=False)
|
listitem = PKC_ListItem()
|
||||||
|
api.create_listitem(listitem)
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = PQ.get_playqueue_from_type(
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()])
|
||||||
playqueue.clear()
|
playqueue.clear()
|
||||||
item = PL.playlist_item_from_xml(xml[0])
|
item = PL.Playlist_Item()
|
||||||
item.offset = offset
|
item.xml = xml[0]
|
||||||
item.playmethod = v.PLAYBACK_METHOD_DIRECT_PLAY
|
item.offset = int(offset)
|
||||||
|
item.plex_type = v.PLEX_TYPE_CLIP
|
||||||
|
item.playmethod = 'DirectStream'
|
||||||
# Need to get yet another xml to get the final playback url
|
# Need to get yet another xml to get the final playback url
|
||||||
|
xml = DU().downloadUrl('http://node.plexapp.com:32400%s'
|
||||||
|
% xml[0][0][0].attrib['key'])
|
||||||
try:
|
try:
|
||||||
xml = PF.get_playback_xml('http://node.plexapp.com:32400%s'
|
xml[0].attrib
|
||||||
% xml[0][0][0].attrib['key'],
|
|
||||||
'plexapp.com',
|
|
||||||
authenticate=False,
|
|
||||||
token=app.ACCOUNT.plex_token)
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
except (TypeError, IndexError, AttributeError):
|
||||||
LOG.error('XML malformed: %s', xml.attrib)
|
LOG.error('Could not download last xml for playurl')
|
||||||
xml = None
|
|
||||||
if xml is None:
|
|
||||||
_ensure_resolve(abort=True)
|
_ensure_resolve(abort=True)
|
||||||
return
|
return
|
||||||
|
playurl = xml[0].attrib['key']
|
||||||
try:
|
|
||||||
playurl = xml[0].attrib['key']
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error('Last xml malformed: %s', xml.attrib)
|
|
||||||
_ensure_resolve(abort=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
item.file = playurl
|
item.file = playurl
|
||||||
listitem.setPath(utils.try_encode(playurl))
|
listitem.setPath(try_encode(playurl))
|
||||||
playqueue.items.append(item)
|
playqueue.items.append(item)
|
||||||
if resolve is True:
|
if resolve is True:
|
||||||
transfer.send(listitem)
|
result.listitem = listitem
|
||||||
|
pickle_me(result)
|
||||||
else:
|
else:
|
||||||
thread = Thread(target=app.APP.player.play,
|
thread = Thread(target=Player().play,
|
||||||
args={'item': utils.try_encode(playurl),
|
args={'item': try_encode(playurl),
|
||||||
'listitem': listitem})
|
'listitem': listitem})
|
||||||
thread.setDaemon(True)
|
thread.setDaemon(True)
|
||||||
LOG.debug('Done initializing PKC playback, starting Kodi player')
|
LOG.info('Done initializing PKC playback, starting Kodi player')
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
@ -549,67 +421,41 @@ def play_xml(playqueue, xml, offset=None, start_plex_id=None):
|
||||||
Either supply the ratingKey of the starting Plex element. Or set
|
Either supply the ratingKey of the starting Plex element. Or set
|
||||||
playqueue.selectedItemID
|
playqueue.selectedItemID
|
||||||
"""
|
"""
|
||||||
offset = int(offset) / 1000 if offset else None
|
LOG.info("play_xml called with offset %s, start_plex_id %s",
|
||||||
LOG.debug("play_xml called with offset %s, start_plex_id %s",
|
offset, start_plex_id)
|
||||||
offset, start_plex_id)
|
stack = _prep_playlist_stack(xml)
|
||||||
start_item = start_plex_id if start_plex_id is not None \
|
|
||||||
else playqueue.selectedItemID
|
|
||||||
for startpos, video in enumerate(xml):
|
|
||||||
api = API(video)
|
|
||||||
if api.plex_id == start_item:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
startpos = 0
|
|
||||||
stack = _prep_playlist_stack(xml, resume=False)
|
|
||||||
if offset:
|
|
||||||
stack[startpos]['resume'] = True
|
|
||||||
_process_stack(playqueue, stack)
|
_process_stack(playqueue, stack)
|
||||||
LOG.debug('Playqueue after play_xml update: %s', playqueue)
|
LOG.debug('Playqueue after play_xml update: %s', playqueue)
|
||||||
|
if start_plex_id is not None:
|
||||||
|
for startpos, item in enumerate(playqueue.items):
|
||||||
|
if item.plex_id == start_plex_id:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
startpos = 0
|
||||||
|
else:
|
||||||
|
for startpos, item in enumerate(playqueue.items):
|
||||||
|
if item.id == playqueue.selectedItemID:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
startpos = 0
|
||||||
thread = Thread(target=threaded_playback,
|
thread = Thread(target=threaded_playback,
|
||||||
args=(playqueue.kodi_pl, startpos, offset))
|
args=(playqueue.kodi_pl, startpos, offset))
|
||||||
LOG.debug('Done play_xml, starting Kodi player at position %s', startpos)
|
LOG.info('Done play_xml, starting Kodi player at position %s', startpos)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
def threaded_playback(kodi_playlist, startpos, offset):
|
def threaded_playback(kodi_playlist, startpos, offset):
|
||||||
"""
|
"""
|
||||||
Seek immediately after kicking off playback is not reliable. We even seek
|
Seek immediately after kicking off playback is not reliable.
|
||||||
to 0 (starting position) in case Kodi wants to resume but we want to start
|
|
||||||
over.
|
|
||||||
|
|
||||||
offset: resume position in seconds [int/float]
|
|
||||||
"""
|
"""
|
||||||
LOG.debug('threaded_playback with startpos %s, offset %s',
|
player = Player()
|
||||||
startpos, offset)
|
player.play(kodi_playlist, None, False, startpos)
|
||||||
app.APP.player.play(kodi_playlist, None, False, startpos)
|
if offset and offset != '0':
|
||||||
offset = offset if offset else 0
|
i = 0
|
||||||
i = 0
|
while not player.isPlaying():
|
||||||
while not app.APP.is_playing or not js.get_player_ids():
|
sleep(100)
|
||||||
if app.APP.monitor.waitForAbort(0.1):
|
i += 1
|
||||||
# PKC needs to quit
|
if i > 100:
|
||||||
return
|
LOG.error('Could not seek to %s', offset)
|
||||||
i += 1
|
return
|
||||||
if i > TRY_TO_SEEK_FOR:
|
js.seek_to(int(offset))
|
||||||
LOG.error('Could not seek to %s', offset)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
if offset == 0 and app.APP.player.getTime() < IGNORE_SECONDS_AT_START:
|
|
||||||
LOG.debug('Avoiding small jump to the very start of the video')
|
|
||||||
return
|
|
||||||
except RuntimeError:
|
|
||||||
# RuntimeError: XBMC is not playing any media file
|
|
||||||
pass
|
|
||||||
i = 0
|
|
||||||
answ = js.seek_to(offset * 1000)
|
|
||||||
while 'error' in answ:
|
|
||||||
# Kodi sometimes returns {u'message': u'Failed to execute method.',
|
|
||||||
# u'code': -32100} if user quickly switches videos
|
|
||||||
if app.APP.monitor.waitForAbort(0.1):
|
|
||||||
# PKC needs to quit
|
|
||||||
return
|
|
||||||
i += 1
|
|
||||||
if i > TRY_TO_SEEK_FOR:
|
|
||||||
LOG.error('Failed to seek to %s. Error: %s', offset, answ)
|
|
||||||
return
|
|
||||||
answ = js.seek_to(offset * 1000)
|
|
||||||
LOG.debug('Seek to offset %s successful', offset)
|
|
||||||
|
|
|
@ -1,447 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
from requests import exceptions
|
|
||||||
|
|
||||||
from .downloadutils import DownloadUtils as DU
|
|
||||||
from .plex_api import API
|
|
||||||
from . import plex_functions as PF, utils, variables as v
|
|
||||||
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.playback_decision')
|
|
||||||
|
|
||||||
# largest signed 32bit integer: 2147483
|
|
||||||
MAX_SIGNED_INT = int(2**31 - 1)
|
|
||||||
# PMS answer codes
|
|
||||||
DIRECT_PLAY_OK = 1000
|
|
||||||
CONVERSION_OK = 1001 # PMS can either direct stream or transcode
|
|
||||||
|
|
||||||
|
|
||||||
def set_pkc_playmethod(api, item):
|
|
||||||
item.playmethod = int(utils.settings('playType'))
|
|
||||||
LOG.info('User chose playback method %s in PKC settings',
|
|
||||||
v.EXPLICIT_PLAYBACK_METHOD[item.playmethod])
|
|
||||||
_initial_best_playback_method(api, item)
|
|
||||||
LOG.info('PKC decided on playback method %s',
|
|
||||||
v.EXPLICIT_PLAYBACK_METHOD[item.playmethod])
|
|
||||||
|
|
||||||
|
|
||||||
def set_playurl(api, item):
|
|
||||||
try:
|
|
||||||
if item.playmethod == v.PLAYBACK_METHOD_DIRECT_PATH:
|
|
||||||
# No need to ask the PMS whether we can play - we circumvent
|
|
||||||
# the PMS entirely
|
|
||||||
return
|
|
||||||
LOG.info('Lets ask the PMS next')
|
|
||||||
try:
|
|
||||||
_pms_playback_decision(api, item)
|
|
||||||
except (exceptions.RequestException,
|
|
||||||
AttributeError,
|
|
||||||
IndexError,
|
|
||||||
SystemExit):
|
|
||||||
LOG.warn('Could not find suitable settings for playback, aborting')
|
|
||||||
utils.ERROR(notify=True)
|
|
||||||
item.playmethod = None
|
|
||||||
item.file = None
|
|
||||||
else:
|
|
||||||
item.file = api.transcode_video_path(item.playmethod,
|
|
||||||
quality=item.quality)
|
|
||||||
finally:
|
|
||||||
LOG.info('The playurl for %s is: %s',
|
|
||||||
v.EXPLICIT_PLAYBACK_METHOD[item.playmethod], item.file)
|
|
||||||
|
|
||||||
|
|
||||||
def _initial_best_playback_method(api, item):
|
|
||||||
"""
|
|
||||||
Sets the highest available playback method without talking to the PMS
|
|
||||||
Also sets self.path for a direct path, if available and accessible
|
|
||||||
"""
|
|
||||||
item.file = api.file_path()
|
|
||||||
item.file = api.validate_playurl(item.file, api.plex_type, force_check=True)
|
|
||||||
# Check whether we have a strm file that we need to throw at Kodi 1:1
|
|
||||||
if item.file is not None and item.file.endswith('.strm'):
|
|
||||||
# Use direct path in any case, regardless of user setting
|
|
||||||
LOG.debug('.strm file detected')
|
|
||||||
item.playmethod = v.PLAYBACK_METHOD_DIRECT_PATH
|
|
||||||
elif _must_transcode(api, item):
|
|
||||||
item.playmethod = v.PLAYBACK_METHOD_TRANSCODE
|
|
||||||
elif item.playmethod in (v.PLAYBACK_METHOD_DIRECT_PLAY,
|
|
||||||
v.PLAYBACK_METHOD_DIRECT_STREAM):
|
|
||||||
pass
|
|
||||||
elif item.file is None:
|
|
||||||
# E.g. direct path was not possible to access
|
|
||||||
item.playmethod = v.PLAYBACK_METHOD_DIRECT_PLAY
|
|
||||||
else:
|
|
||||||
item.playmethod = v.PLAYBACK_METHOD_DIRECT_PATH
|
|
||||||
|
|
||||||
|
|
||||||
def _pms_playback_decision(api, item):
|
|
||||||
"""
|
|
||||||
We CANNOT distinguish direct playing from direct streaming from the PMS'
|
|
||||||
answer
|
|
||||||
"""
|
|
||||||
ask_for_user_quality_settings = False
|
|
||||||
if item.playmethod <= 2:
|
|
||||||
LOG.info('Asking PMS with maximal quality settings')
|
|
||||||
item.quality = _max_quality()
|
|
||||||
decision_api = _ask_pms(api, item)
|
|
||||||
if decision_api.decision_code() > CONVERSION_OK:
|
|
||||||
ask_for_user_quality_settings = True
|
|
||||||
else:
|
|
||||||
ask_for_user_quality_settings = True
|
|
||||||
if ask_for_user_quality_settings:
|
|
||||||
item.quality = _transcode_quality()
|
|
||||||
LOG.info('Asking PMS with user quality settings')
|
|
||||||
decision_api = _ask_pms(api, item)
|
|
||||||
|
|
||||||
# Process the PMS answer
|
|
||||||
if decision_api.decision_code() > CONVERSION_OK:
|
|
||||||
LOG.error('Neither DirectPlay, DirectStream nor transcoding possible')
|
|
||||||
error = '%s\n%s' % (decision_api.general_play_decision_text(),
|
|
||||||
decision_api.transcode_decision_text())
|
|
||||||
utils.messageDialog(heading=utils.lang(29999),
|
|
||||||
msg=error)
|
|
||||||
raise AttributeError('Neither DirectPlay, DirectStream nor transcoding possible')
|
|
||||||
if (item.playmethod == v.PLAYBACK_METHOD_DIRECT_PLAY and
|
|
||||||
decision_api.decision_code() == DIRECT_PLAY_OK):
|
|
||||||
# All good
|
|
||||||
return
|
|
||||||
LOG.info('PMS video stream decision: %s, PMS audio stream decision: %s, '
|
|
||||||
'PMS subtitle stream decision: %s',
|
|
||||||
decision_api.video_decision(),
|
|
||||||
decision_api.audio_decision(),
|
|
||||||
decision_api.subtitle_decision())
|
|
||||||
# Only look at the video stream since that'll be most CPU-intensive for
|
|
||||||
# the PMS
|
|
||||||
video_direct_streaming = decision_api.video_decision() == 'copy'
|
|
||||||
if video_direct_streaming:
|
|
||||||
if item.playmethod < v.PLAYBACK_METHOD_DIRECT_STREAM:
|
|
||||||
LOG.warn('The PMS forces us to direct stream')
|
|
||||||
# "PMS enforced direct streaming"
|
|
||||||
utils.dialog('notification',
|
|
||||||
utils.lang(29999),
|
|
||||||
utils.lang(33005),
|
|
||||||
icon='{plex}')
|
|
||||||
item.playmethod = v.PLAYBACK_METHOD_DIRECT_STREAM
|
|
||||||
else:
|
|
||||||
if item.playmethod < v.PLAYBACK_METHOD_TRANSCODE:
|
|
||||||
LOG.warn('The PMS forces us to transcode')
|
|
||||||
# "PMS enforced transcoding"
|
|
||||||
utils.dialog('notification',
|
|
||||||
utils.lang(29999),
|
|
||||||
utils.lang(33004),
|
|
||||||
icon='{plex}')
|
|
||||||
item.playmethod = v.PLAYBACK_METHOD_TRANSCODE
|
|
||||||
|
|
||||||
|
|
||||||
def _ask_pms(api, item):
|
|
||||||
xml = PF.playback_decision(path=api.path_and_plex_id(),
|
|
||||||
media=api.mediastream,
|
|
||||||
part=api.part,
|
|
||||||
playmethod=item.playmethod,
|
|
||||||
video=api.plex_type in v.PLEX_VIDEOTYPES,
|
|
||||||
args=item.quality)
|
|
||||||
decision_api = API(xml)
|
|
||||||
LOG.info('PMS general decision %s: %s',
|
|
||||||
decision_api.general_play_decision_code(),
|
|
||||||
decision_api.general_play_decision_text())
|
|
||||||
LOG.info('PMS Direct Play decision %s: %s',
|
|
||||||
decision_api.direct_play_decision_code(),
|
|
||||||
decision_api.direct_play_decision_text())
|
|
||||||
LOG.info('PMS MDE decision %s: %s',
|
|
||||||
decision_api.mde_play_decision_code(),
|
|
||||||
decision_api.mde_play_decision_text())
|
|
||||||
LOG.info('PMS transcoding decision %s: %s',
|
|
||||||
decision_api.transcode_decision_code(),
|
|
||||||
decision_api.transcode_decision_text())
|
|
||||||
return decision_api
|
|
||||||
|
|
||||||
|
|
||||||
def _must_transcode(api, item):
|
|
||||||
"""
|
|
||||||
Returns True if we need to transcode because
|
|
||||||
- codec is in h265
|
|
||||||
- 10bit video codec
|
|
||||||
- HEVC codec
|
|
||||||
- playqueue_item force_transcode is set to True
|
|
||||||
- state variable FORCE_TRANSCODE set to True
|
|
||||||
(excepting trailers etc.)
|
|
||||||
- video bitrate above specified settings bitrate
|
|
||||||
if the corresponding file settings are set to 'true'
|
|
||||||
"""
|
|
||||||
if api.plex_type in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_SONG):
|
|
||||||
LOG.info('Plex clip or music track, not transcoding')
|
|
||||||
return False
|
|
||||||
if item.playmethod == v.PLAYBACK_METHOD_TRANSCODE:
|
|
||||||
return True
|
|
||||||
videoCodec = api.video_codec()
|
|
||||||
LOG.debug("videoCodec received from the PMS: %s", videoCodec)
|
|
||||||
if item.force_transcode is True:
|
|
||||||
LOG.info('User chose to force-transcode')
|
|
||||||
return True
|
|
||||||
codec = videoCodec['videocodec']
|
|
||||||
if codec is None:
|
|
||||||
# e.g. trailers. Avoids TypeError with "'h265' in codec"
|
|
||||||
LOG.info('No codec from PMS, not transcoding.')
|
|
||||||
return False
|
|
||||||
if ((utils.settings('transcodeHi10P') == 'true' and
|
|
||||||
videoCodec['bitDepth'] == '10') and
|
|
||||||
('h264' in codec)):
|
|
||||||
LOG.info('Option to transcode 10bit h264 video content enabled.')
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
bitrate = int(videoCodec['bitrate'])
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
LOG.info('No video bitrate from PMS, not transcoding.')
|
|
||||||
return False
|
|
||||||
if bitrate > _get_max_bitrate():
|
|
||||||
LOG.info('Video bitrate of %s is higher than the maximal video'
|
|
||||||
'bitrate of %s that the user chose. Transcoding',
|
|
||||||
bitrate, _get_max_bitrate())
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
resolution = int(videoCodec['resolution'])
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
if videoCodec['resolution'] == '4k':
|
|
||||||
resolution = 2160
|
|
||||||
else:
|
|
||||||
LOG.info('No video resolution from PMS, not transcoding.')
|
|
||||||
return False
|
|
||||||
if 'h265' in codec or 'hevc' in codec:
|
|
||||||
if resolution >= _getH265():
|
|
||||||
LOG.info('Option to transcode h265/HEVC enabled. Resolution '
|
|
||||||
'of the media: %s, transcoding limit resolution: %s',
|
|
||||||
resolution, _getH265())
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _transcode_quality():
|
|
||||||
return {
|
|
||||||
'maxVideoBitrate': get_bitrate(),
|
|
||||||
'videoResolution': get_resolution(),
|
|
||||||
'videoQuality': 100,
|
|
||||||
'mediaBufferSize': int(float(utils.settings('kodi_video_cache')) / 1024.0),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _max_quality():
|
|
||||||
return {
|
|
||||||
'maxVideoBitrate': MAX_SIGNED_INT,
|
|
||||||
'videoResolution': '3840x2160', # 4K
|
|
||||||
'videoQuality': 100,
|
|
||||||
'mediaBufferSize': int(float(utils.settings('kodi_video_cache')) / 1024.0),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_bitrate():
|
|
||||||
"""
|
|
||||||
Get the desired transcoding bitrate from the settings
|
|
||||||
"""
|
|
||||||
videoQuality = utils.settings('transcoderVideoQualities')
|
|
||||||
bitrate = {
|
|
||||||
'0': 320,
|
|
||||||
'1': 720,
|
|
||||||
'2': 1500,
|
|
||||||
'3': 2000,
|
|
||||||
'4': 3000,
|
|
||||||
'5': 4000,
|
|
||||||
'6': 8000,
|
|
||||||
'7': 10000,
|
|
||||||
'8': 12000,
|
|
||||||
'9': 20000,
|
|
||||||
'10': 40000,
|
|
||||||
'11': 35000,
|
|
||||||
'12': 50000
|
|
||||||
}
|
|
||||||
# max bit rate supported by server (max signed 32bit integer)
|
|
||||||
return bitrate.get(videoQuality, MAX_SIGNED_INT)
|
|
||||||
|
|
||||||
|
|
||||||
def get_resolution():
|
|
||||||
"""
|
|
||||||
Get the desired transcoding resolutions from the settings
|
|
||||||
"""
|
|
||||||
chosen = utils.settings('transcoderVideoQualities')
|
|
||||||
res = {
|
|
||||||
'0': '720x480',
|
|
||||||
'1': '1024x768',
|
|
||||||
'2': '1280x720',
|
|
||||||
'3': '1280x720',
|
|
||||||
'4': '1920x1080',
|
|
||||||
'5': '1920x1080',
|
|
||||||
'6': '1920x1080',
|
|
||||||
'7': '1920x1080',
|
|
||||||
'8': '1920x1080',
|
|
||||||
'9': '3840x2160',
|
|
||||||
'10': '3840x2160'
|
|
||||||
}
|
|
||||||
return res[chosen]
|
|
||||||
|
|
||||||
|
|
||||||
def _get_max_bitrate():
|
|
||||||
max_bitrate = utils.settings('maxVideoQualities')
|
|
||||||
bitrate = {
|
|
||||||
'0': 320,
|
|
||||||
'1': 720,
|
|
||||||
'2': 1500,
|
|
||||||
'3': 2000,
|
|
||||||
'4': 3000,
|
|
||||||
'5': 4000,
|
|
||||||
'6': 8000,
|
|
||||||
'7': 10000,
|
|
||||||
'8': 12000,
|
|
||||||
'9': 20000,
|
|
||||||
'10': 40000,
|
|
||||||
'11': MAX_SIGNED_INT # deactivated
|
|
||||||
}
|
|
||||||
# max bit rate supported by server (max signed 32bit integer)
|
|
||||||
return bitrate.get(max_bitrate, MAX_SIGNED_INT)
|
|
||||||
|
|
||||||
|
|
||||||
def _getH265():
|
|
||||||
"""
|
|
||||||
Returns the user settings for transcoding h265: boundary resolutions
|
|
||||||
of 480, 720 or 1080 as an int
|
|
||||||
|
|
||||||
OR 2147483 (MAX_SIGNED_INT, int) if user chose not to transcode
|
|
||||||
"""
|
|
||||||
H265 = {
|
|
||||||
'0': MAX_SIGNED_INT,
|
|
||||||
'1': 480,
|
|
||||||
'2': 720,
|
|
||||||
'3': 1080,
|
|
||||||
'4': 2160
|
|
||||||
}
|
|
||||||
return H265[utils.settings('transcodeH265')]
|
|
||||||
|
|
||||||
|
|
||||||
def audio_subtitle_prefs(api, item):
|
|
||||||
"""
|
|
||||||
Sets the stage for transcoding, letting the user potentially choose both
|
|
||||||
audio and subtitle streams; subtitle streams to burn-into the video file.
|
|
||||||
|
|
||||||
Uses a PUT request to the PMS, simulating e.g. the user using Plex Web,
|
|
||||||
choosing a different stream in the video's metadata and THEN initiating
|
|
||||||
playback.
|
|
||||||
|
|
||||||
Returns None if user cancelled or we need to abort, True otherwise
|
|
||||||
"""
|
|
||||||
# Set media and part where we're at
|
|
||||||
if api.mediastream is None and api.mediastream_number() is None:
|
|
||||||
return
|
|
||||||
if item.playmethod != v.PLAYBACK_METHOD_TRANSCODE:
|
|
||||||
return True
|
|
||||||
return setup_transcoding_audio_subtitle_prefs(api.plex_media_streams(),
|
|
||||||
api.part_id())
|
|
||||||
|
|
||||||
|
|
||||||
def setup_transcoding_audio_subtitle_prefs(mediastreams, part_id):
|
|
||||||
audio_streams_list = []
|
|
||||||
audio_streams = []
|
|
||||||
audio_default = None
|
|
||||||
subtitle_default = None
|
|
||||||
subtitle_streams_list = []
|
|
||||||
# "Don't burn-in any subtitle"
|
|
||||||
subtitle_streams = ['1 %s' % utils.lang(39706)]
|
|
||||||
# selectAudioIndex = ""
|
|
||||||
select_subs_index = ""
|
|
||||||
audio_numb = 0
|
|
||||||
# Remember 'no subtitles'
|
|
||||||
sub_num = 1
|
|
||||||
|
|
||||||
for stream in mediastreams:
|
|
||||||
# Since Plex returns all possible tracks together, have to sort
|
|
||||||
# them.
|
|
||||||
index = stream.get('id')
|
|
||||||
typus = stream.get('streamType')
|
|
||||||
# Audio
|
|
||||||
if typus == "2":
|
|
||||||
codec = stream.get('codec')
|
|
||||||
channellayout = stream.get('audioChannelLayout', "")
|
|
||||||
try:
|
|
||||||
track = "%s %s - %s %s" % (audio_numb + 1,
|
|
||||||
stream.attrib['language'],
|
|
||||||
codec,
|
|
||||||
channellayout)
|
|
||||||
except KeyError:
|
|
||||||
track = "%s %s - %s %s" % (audio_numb + 1,
|
|
||||||
utils.lang(39707), # unknown
|
|
||||||
codec,
|
|
||||||
channellayout)
|
|
||||||
if stream.get('default'):
|
|
||||||
audio_default = audio_numb
|
|
||||||
audio_streams_list.append(index)
|
|
||||||
audio_streams.append(track.encode('utf-8'))
|
|
||||||
audio_numb += 1
|
|
||||||
|
|
||||||
# Subtitles
|
|
||||||
elif typus == "3":
|
|
||||||
if stream.get('key'):
|
|
||||||
# Subtitle can and will be downloaded - don't let user choose
|
|
||||||
# this subtitle to burn-in
|
|
||||||
continue
|
|
||||||
# Subtitle is available within the video file
|
|
||||||
# Burn in the subtitle, if user chooses to do so
|
|
||||||
forced = stream.get('forced')
|
|
||||||
try:
|
|
||||||
track = '{} {}'.format(sub_num + 1,
|
|
||||||
stream.attrib['displayTitle'])
|
|
||||||
except KeyError:
|
|
||||||
track = '{} {} ({})'.format(sub_num + 1,
|
|
||||||
utils.lang(39707), # unknown
|
|
||||||
stream.get('codec'))
|
|
||||||
if stream.get('default'):
|
|
||||||
subtitle_default = sub_num
|
|
||||||
track = "%s - %s" % (track, utils.lang(39708)) # Default
|
|
||||||
if forced:
|
|
||||||
track = "%s - %s" % (track, utils.lang(39709)) # Forced
|
|
||||||
track = "%s (%s)" % (track, utils.lang(39710)) # burn-in
|
|
||||||
subtitle_streams_list.append(index)
|
|
||||||
subtitle_streams.append(track.encode('utf-8'))
|
|
||||||
sub_num += 1
|
|
||||||
|
|
||||||
if audio_numb > 1:
|
|
||||||
# "Transcoding: Auto-pick audio and subtitle stream using Plex defaults"
|
|
||||||
if utils.settings('bestQuality') == 'true' and audio_default is not None:
|
|
||||||
resp = audio_default
|
|
||||||
else:
|
|
||||||
resp = utils.dialog('select', utils.lang(33013), audio_streams)
|
|
||||||
if resp == -1:
|
|
||||||
LOG.info('User aborted dialog to select audio stream')
|
|
||||||
return
|
|
||||||
args = {
|
|
||||||
'audioStreamID': audio_streams_list[resp],
|
|
||||||
'allParts': 1
|
|
||||||
}
|
|
||||||
DU().downloadUrl('{server}/library/parts/%s' % part_id,
|
|
||||||
action_type='PUT',
|
|
||||||
parameters=args)
|
|
||||||
|
|
||||||
# Zero telling the PMS to deactivate subs altogether
|
|
||||||
select_subs_index = 0
|
|
||||||
if sub_num == 1:
|
|
||||||
# Note: we DO need to tell the PMS that we DONT want any sub
|
|
||||||
# Otherwise, the PMS might pick-up the last one
|
|
||||||
LOG.info('No subtitles to burn-in')
|
|
||||||
else:
|
|
||||||
# "Transcoding: Auto-pick audio and subtitle stream using Plex defaults"
|
|
||||||
if utils.settings('bestQuality') == 'true' and subtitle_default is not None:
|
|
||||||
resp = subtitle_default
|
|
||||||
else:
|
|
||||||
resp = utils.dialog('select', utils.lang(33014), subtitle_streams)
|
|
||||||
if resp == -1:
|
|
||||||
LOG.info('User aborted dialog to select subtitle stream')
|
|
||||||
return
|
|
||||||
elif resp == 0:
|
|
||||||
# User did not select a subtitle or backed out of the dialog
|
|
||||||
LOG.info('User chose to not burn-in any subtitles')
|
|
||||||
else:
|
|
||||||
LOG.info('User chose to burn-in subtitle %s: %s',
|
|
||||||
select_subs_index,
|
|
||||||
subtitle_streams[resp].decode('utf-8'))
|
|
||||||
select_subs_index = subtitle_streams_list[resp - 1]
|
|
||||||
# Now prep the PMS for our choice
|
|
||||||
PF.change_subtitle(select_subs_index, part_id)
|
|
||||||
return True
|
|
|
@ -1,56 +1,73 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
###############################################################################
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
from threading import Thread
|
||||||
|
from urlparse import parse_qsl
|
||||||
|
|
||||||
from . import utils, playback, context_entry, transfer, backgroundthread
|
import playback
|
||||||
|
from context_entry import ContextMenu
|
||||||
|
import state
|
||||||
|
import json_rpc as js
|
||||||
|
from pickler import pickle_me, Playback_Successful
|
||||||
|
import kodidb_functions as kodidb
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
LOG = getLogger('PLEX.playback_starter')
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
class PlaybackTask(backgroundthread.Task):
|
class PlaybackStarter(Thread):
|
||||||
"""
|
"""
|
||||||
Processes new plays
|
Processes new plays
|
||||||
"""
|
"""
|
||||||
def __init__(self, command):
|
@staticmethod
|
||||||
self.command = command
|
def _triage(item):
|
||||||
super(PlaybackTask, self).__init__()
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
LOG.debug('Starting PlaybackTask with %s', self.command)
|
|
||||||
item = self.command
|
|
||||||
try:
|
try:
|
||||||
_, params = item.split('?', 1)
|
_, params = item.split('?', 1)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# E.g. other add-ons scanning for Extras folder
|
# E.g. other add-ons scanning for Extras folder
|
||||||
LOG.debug('Detected 3rd party add-on call - ignoring')
|
LOG.debug('Detected 3rd party add-on call - ignoring')
|
||||||
transfer.send(True)
|
pickle_me(Playback_Successful())
|
||||||
# Wait for default.py to have completed xbmcplugin.setResolvedUrl()
|
|
||||||
transfer.wait_for_transfer(source='default')
|
|
||||||
return
|
return
|
||||||
params = dict(utils.parse_qsl(params))
|
params = dict(parse_qsl(params))
|
||||||
mode = params.get('mode')
|
mode = params.get('mode')
|
||||||
resolve = False if params.get('handle') == '-1' else True
|
resolve = False if params.get('handle') == '-1' else True
|
||||||
LOG.debug('Received mode: %s, params: %s', mode, params)
|
LOG.debug('Received mode: %s, params: %s', mode, params)
|
||||||
if mode == 'play':
|
if mode == 'play':
|
||||||
if params.get('resume'):
|
|
||||||
resume = params.get('resume') == '1'
|
|
||||||
else:
|
|
||||||
resume = None
|
|
||||||
playback.playback_triage(plex_id=params.get('plex_id'),
|
playback.playback_triage(plex_id=params.get('plex_id'),
|
||||||
plex_type=params.get('plex_type'),
|
plex_type=params.get('plex_type'),
|
||||||
path=params.get('path'),
|
path=params.get('path'),
|
||||||
resolve=resolve,
|
resolve=resolve)
|
||||||
resume=resume)
|
|
||||||
elif mode == 'plex_node':
|
elif mode == 'plex_node':
|
||||||
playback.process_indirect(params['key'],
|
playback.process_indirect(params['key'],
|
||||||
params['offset'],
|
params['offset'],
|
||||||
resolve=resolve)
|
resolve=resolve)
|
||||||
|
elif mode == 'navigation':
|
||||||
|
# e.g. when plugin://...tvshows is called for entire season
|
||||||
|
with kodidb.GetKodiDB('video') as kodi_db:
|
||||||
|
show_id = kodi_db.show_id_from_path(params.get('path'))
|
||||||
|
if show_id:
|
||||||
|
js.activate_window('videos',
|
||||||
|
'videodb://tvshows/titles/%s' % show_id)
|
||||||
|
else:
|
||||||
|
LOG.error('Could not find tv show id for %s', item)
|
||||||
|
if resolve:
|
||||||
|
pickle_me(Playback_Successful())
|
||||||
elif mode == 'context_menu':
|
elif mode == 'context_menu':
|
||||||
context_entry.ContextMenu(kodi_id=params.get('kodi_id'),
|
ContextMenu(kodi_id=params.get('kodi_id'),
|
||||||
kodi_type=params.get('kodi_type'))
|
kodi_type=params.get('kodi_type'))
|
||||||
LOG.debug('Finished PlaybackTask')
|
|
||||||
|
def run(self):
|
||||||
|
queue = state.COMMAND_PIPELINE_QUEUE
|
||||||
|
LOG.info("----===## Starting PlaybackStarter ##===----")
|
||||||
|
while True:
|
||||||
|
item = queue.get()
|
||||||
|
if item is None:
|
||||||
|
# Need to shutdown - initiated by command_pipeline
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self._triage(item)
|
||||||
|
queue.task_done()
|
||||||
|
LOG.info("----===## PlaybackStarter stopped ##===----")
|
||||||
|
|
|
@ -1,56 +1,46 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Collection of functions associated with Kodi and Plex playlists and playqueues
|
Collection of functions associated with Kodi and Plex playlists and playqueues
|
||||||
"""
|
"""
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
from urllib import quote
|
||||||
|
from urlparse import parse_qsl, urlsplit
|
||||||
|
from re import compile as re_compile
|
||||||
|
|
||||||
from .plex_api import API
|
import plexdb_functions as plexdb
|
||||||
from .plex_db import PlexDB
|
from downloadutils import DownloadUtils as DU
|
||||||
from . import plex_functions as PF
|
from utils import try_decode, try_encode
|
||||||
from .kodi_db import kodiid_from_filename
|
from PlexAPI import API
|
||||||
from .downloadutils import DownloadUtils as DU
|
from PlexFunctions import GetPlexMetadata
|
||||||
from . import utils
|
from kodidb_functions import kodiid_from_filename
|
||||||
from .utils import cast
|
import json_rpc as js
|
||||||
from . import json_rpc as js
|
import variables as v
|
||||||
from . import variables as v
|
|
||||||
from . import app
|
###############################################################################
|
||||||
from .exceptions import PlaylistError
|
|
||||||
from .subtitles import accessible_plex_subtitles
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
|
REGEX = re_compile(r'''metadata%2F(\d+)''')
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.playlist_func')
|
class PlaylistError(Exception):
|
||||||
|
|
||||||
|
|
||||||
class Playqueue_Object(object):
|
|
||||||
"""
|
"""
|
||||||
PKC object to represent PMS playQueues and Kodi playlist for queueing
|
Exception for our playlist constructs
|
||||||
|
|
||||||
playlistid = None [int] Kodi playlist id (0, 1, 2)
|
|
||||||
type = None [str] Kodi type: 'audio', 'video', 'picture'
|
|
||||||
kodi_pl = None Kodi xbmc.PlayList object
|
|
||||||
items = [] [list] of Playlist_Items
|
|
||||||
id = None [str] Plex playQueueID, unique Plex identifier
|
|
||||||
version = None [int] Plex version of the playQueue
|
|
||||||
selectedItemID = None
|
|
||||||
[str] Plex selectedItemID, playing element in queue
|
|
||||||
selectedItemOffset = None
|
|
||||||
[str] Offset of the playing element in queue
|
|
||||||
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
|
|
||||||
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
|
|
||||||
|
|
||||||
If Companion playback is initiated by another user:
|
|
||||||
plex_transient_token = None
|
|
||||||
"""
|
"""
|
||||||
kind = 'playQueue'
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistObjectBaseclase(object):
|
||||||
|
"""
|
||||||
|
Base class
|
||||||
|
"""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.id = None
|
|
||||||
self.type = None
|
|
||||||
self.playlistid = None
|
self.playlistid = None
|
||||||
|
self.type = None
|
||||||
self.kodi_pl = None
|
self.kodi_pl = None
|
||||||
self.items = []
|
self.items = []
|
||||||
|
self.id = None
|
||||||
self.version = None
|
self.version = None
|
||||||
self.selectedItemID = None
|
self.selectedItemID = None
|
||||||
self.selectedItemOffset = None
|
self.selectedItemOffset = None
|
||||||
|
@ -64,28 +54,25 @@ class Playqueue_Object(object):
|
||||||
self.pkc_edit = False
|
self.pkc_edit = False
|
||||||
# Workaround to avoid endless loops of detecting PL clears
|
# Workaround to avoid endless loops of detecting PL clears
|
||||||
self._clear_list = []
|
self._clear_list = []
|
||||||
# To keep track if Kodi playback was initiated from a Kodi playlist
|
|
||||||
# There are a couple of pitfalls, unfortunately...
|
|
||||||
self.kodi_playlist_playback = False
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
answ = ("{{"
|
"""
|
||||||
"'playlistid': {self.playlistid}, "
|
Print the playlist, e.g. to log. Returns utf-8 encoded string
|
||||||
"'id': {self.id}, "
|
"""
|
||||||
"'version': {self.version}, "
|
answ = u'{\'%s\': {\'id\': %s, ' % (self.__class__.__name__, self.id)
|
||||||
"'type': '{self.type}', "
|
# For some reason, can't use dir directly
|
||||||
"'selectedItemID': {self.selectedItemID}, "
|
for key in self.__dict__:
|
||||||
"'selectedItemOffset': {self.selectedItemOffset}, "
|
if key in ('id', 'items', 'kodi_pl'):
|
||||||
"'shuffled': {self.shuffled}, "
|
continue
|
||||||
"'repeat': {self.repeat}, "
|
if isinstance(getattr(self, key), str):
|
||||||
"'kodi_playlist_playback': {self.kodi_playlist_playback}, "
|
answ += '\'%s\': \'%s\', ' % (key,
|
||||||
"'pkc_edit': {self.pkc_edit}, ".format(self=self))
|
try_decode(getattr(self, key)))
|
||||||
answ = answ.encode('utf-8')
|
elif isinstance(getattr(self, key), unicode):
|
||||||
# Since list.__repr__ will return string, not unicode
|
answ += '\'%s\': \'%s\', ' % (key, getattr(self, key))
|
||||||
return answ + b"'items': {self.items}}}".format(self=self)
|
else:
|
||||||
|
# e.g. int
|
||||||
def __str__(self):
|
answ += '\'%s\': %s, ' % (key, unicode(getattr(self, key)))
|
||||||
return self.__repr__()
|
return try_encode(answ + '\'items\': %s}}') % self.items
|
||||||
|
|
||||||
def is_pkc_clear(self):
|
def is_pkc_clear(self):
|
||||||
"""
|
"""
|
||||||
|
@ -119,270 +106,148 @@ class Playqueue_Object(object):
|
||||||
self.repeat = 0
|
self.repeat = 0
|
||||||
self.plex_transient_token = None
|
self.plex_transient_token = None
|
||||||
self.old_kodi_pl = []
|
self.old_kodi_pl = []
|
||||||
self.kodi_playlist_playback = False
|
|
||||||
LOG.debug('Playlist cleared: %s', self)
|
LOG.debug('Playlist cleared: %s', self)
|
||||||
|
|
||||||
def position_from_plex_id(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns the position [int] for the very first item with plex_id [int]
|
|
||||||
(Plex seems uncapable of adding the same element multiple times to a
|
|
||||||
playqueue or playlist)
|
|
||||||
|
|
||||||
Raises KeyError if not found
|
class Playlist_Object(PlaylistObjectBaseclase):
|
||||||
"""
|
"""
|
||||||
for position, item in enumerate(self.items):
|
To be done for synching Plex playlists to Kodi
|
||||||
if item.plex_id == plex_id:
|
"""
|
||||||
break
|
kind = 'playList'
|
||||||
else:
|
|
||||||
raise KeyError('Did not find plex_id %s in %s', plex_id, self)
|
|
||||||
return position
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistItem(object):
|
class Playqueue_Object(PlaylistObjectBaseclase):
|
||||||
|
"""
|
||||||
|
PKC object to represent PMS playQueues and Kodi playlist for queueing
|
||||||
|
|
||||||
|
playlistid = None [int] Kodi playlist id (0, 1, 2)
|
||||||
|
type = None [str] Kodi type: 'audio', 'video', 'picture'
|
||||||
|
kodi_pl = None Kodi xbmc.PlayList object
|
||||||
|
items = [] [list] of Playlist_Items
|
||||||
|
id = None [str] Plex playQueueID, unique Plex identifier
|
||||||
|
version = None [int] Plex version of the playQueue
|
||||||
|
selectedItemID = None
|
||||||
|
[str] Plex selectedItemID, playing element in queue
|
||||||
|
selectedItemOffset = None
|
||||||
|
[str] Offset of the playing element in queue
|
||||||
|
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
|
||||||
|
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
|
||||||
|
|
||||||
|
If Companion playback is initiated by another user:
|
||||||
|
plex_transient_token = None
|
||||||
|
"""
|
||||||
|
kind = 'playQueue'
|
||||||
|
|
||||||
|
|
||||||
|
class Playlist_Item(object):
|
||||||
"""
|
"""
|
||||||
Object to fill our playqueues and playlists with.
|
Object to fill our playqueues and playlists with.
|
||||||
|
|
||||||
id = None [int] Plex playlist/playqueue id, e.g. playQueueItemID
|
id = None [str] Plex playlist/playqueue id, e.g. playQueueItemID
|
||||||
plex_id = None [int] Plex unique item id, "ratingKey"
|
plex_id = None [str] Plex unique item id, "ratingKey"
|
||||||
plex_type = None [str] Plex type, e.g. 'movie', 'clip'
|
plex_type = None [str] Plex type, e.g. 'movie', 'clip'
|
||||||
kodi_id = None [int] Kodi unique kodi id (unique only within type!)
|
plex_uuid = None [str] Plex librarySectionUUID
|
||||||
|
kodi_id = None Kodi unique kodi id (unique only within type!)
|
||||||
kodi_type = None [str] Kodi type: 'movie'
|
kodi_type = None [str] Kodi type: 'movie'
|
||||||
file = None [str] Path to the item's file. STRING!!
|
file = None [str] Path to the item's file. STRING!!
|
||||||
uri = None [str] PMS path to item; will be auto-set with plex_id
|
uri = None [str] Weird Plex uri path involving plex_uuid. STRING!
|
||||||
guid = None [str] Weird Plex guid
|
guid = None [str] Weird Plex guid
|
||||||
api = None [API] API of xml 1 lvl below <MediaContainer>
|
xml = None [etree] XML from PMS, 1 lvl below <MediaContainer>
|
||||||
playmethod = None [str] either 'DirectPath', 'DirectStream', 'Transcode'
|
playmethod = None [str] either 'DirectPlay', 'DirectStream', 'Transcode'
|
||||||
playcount = None [int] how many times the item has already been played
|
playcount = None [int] how many times the item has already been played
|
||||||
offset = None [int] the item's view offset UPON START in Plex time
|
offset = None [int] the item's view offset UPON START in Plex time
|
||||||
part = 0 [int] part number if Plex video consists of mult. parts
|
part = 0 [int] part number if Plex video consists of mult. parts
|
||||||
force_transcode [bool] defaults to False
|
force_transcode [bool] defaults to False
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.id = None
|
self.id = None
|
||||||
self._plex_id = None
|
self.plex_id = None
|
||||||
self.plex_type = None
|
self.plex_type = None
|
||||||
|
self.plex_uuid = None
|
||||||
self.kodi_id = None
|
self.kodi_id = None
|
||||||
self.kodi_type = None
|
self.kodi_type = None
|
||||||
self.file = None
|
self.file = None
|
||||||
self._uri = None
|
self.uri = None
|
||||||
self.guid = None
|
self.guid = None
|
||||||
self.api = None
|
self.xml = None
|
||||||
self.playmethod = None
|
self.playmethod = None
|
||||||
self.playcount = None
|
self.playcount = None
|
||||||
self.offset = None
|
self.offset = None
|
||||||
# Transcoding quality, if needed
|
|
||||||
self.quality = None
|
|
||||||
# If Plex video consists of several parts; part number
|
# If Plex video consists of several parts; part number
|
||||||
self.part = 0
|
self.part = 0
|
||||||
self.force_transcode = False
|
self.force_transcode = False
|
||||||
# Shall we ask user to resume this item?
|
|
||||||
# None: ask user to resume
|
|
||||||
# False: do NOT resume, don't ask user
|
|
||||||
# True: do resume, don't ask user
|
|
||||||
self.resume = None
|
|
||||||
# Get the Plex audio and subtitle streams in the same order as Kodi
|
|
||||||
# uses them (Kodi uses indexes to activate them, not ids like Plex)
|
|
||||||
self._streams_have_been_processed = False
|
|
||||||
self._audio_streams = None
|
|
||||||
self._subtitle_streams = None
|
|
||||||
# Which Kodi streams are active?
|
|
||||||
self.current_kodi_audio_stream = None
|
|
||||||
# False means "deactivated", None means "we do not have a Kodi
|
|
||||||
# equivalent for this Plex subtitle"
|
|
||||||
self.current_kodi_sub_stream = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def plex_id(self):
|
|
||||||
return self._plex_id
|
|
||||||
|
|
||||||
@plex_id.setter
|
|
||||||
def plex_id(self, value):
|
|
||||||
self._plex_id = value
|
|
||||||
self._uri = ('server://%s/com.plexapp.plugins.library/library/metadata/%s' %
|
|
||||||
(app.CONN.machine_identifier, value))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def uri(self):
|
|
||||||
return self._uri
|
|
||||||
|
|
||||||
@property
|
|
||||||
def audio_streams(self):
|
|
||||||
if not self._streams_have_been_processed:
|
|
||||||
self._process_streams()
|
|
||||||
return self._audio_streams
|
|
||||||
|
|
||||||
@property
|
|
||||||
def subtitle_streams(self):
|
|
||||||
if not self._streams_have_been_processed:
|
|
||||||
self._process_streams()
|
|
||||||
return self._subtitle_streams
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return ("{{"
|
|
||||||
"'id': {self.id}, "
|
|
||||||
"'plex_id': {self.plex_id}, "
|
|
||||||
"'plex_type': '{self.plex_type}', "
|
|
||||||
"'kodi_id': {self.kodi_id}, "
|
|
||||||
"'kodi_type': '{self.kodi_type}', "
|
|
||||||
"'file': '{self.file}', "
|
|
||||||
"'guid': '{self.guid}', "
|
|
||||||
"'playmethod': '{self.playmethod}', "
|
|
||||||
"'playcount': {self.playcount}, "
|
|
||||||
"'resume': {self.resume},"
|
|
||||||
"'offset': {self.offset}, "
|
|
||||||
"'force_transcode': {self.force_transcode}, "
|
|
||||||
"'part': {self.part}".format(self=self))
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.__unicode__().encode('utf-8')
|
|
||||||
|
|
||||||
def _process_streams(self):
|
|
||||||
"""
|
"""
|
||||||
Builds audio and subtitle streams and enables matching between Plex
|
Print the playlist item, e.g. to log. Returns utf-8 encoded string
|
||||||
and Kodi using self.audio_streams and self.subtitle_streams
|
|
||||||
"""
|
"""
|
||||||
# The playqueue response from the PMS does not contain a stream filename
|
answ = (u'{\'%s\': {\'id\': \'%s\', \'plex_id\': \'%s\', '
|
||||||
# thanks Plex
|
% (self.__class__.__name__, self.id, self.plex_id))
|
||||||
self._subtitle_streams = accessible_plex_subtitles(
|
for key in self.__dict__:
|
||||||
self.playmethod,
|
if key in ('id', 'plex_id', 'xml'):
|
||||||
self.file,
|
continue
|
||||||
self.api.plex_media_streams())
|
if isinstance(getattr(self, key), str):
|
||||||
# Audio streams are much easier - they're always available and sorted
|
answ += '\'%s\': \'%s\', ' % (key,
|
||||||
# the same in Kodi and Plex
|
try_decode(getattr(self, key)))
|
||||||
self._audio_streams = [x for x in self.api.plex_media_streams()
|
elif isinstance(getattr(self, key), unicode):
|
||||||
if x.get('streamType') == '2']
|
answ += '\'%s\': \'%s\', ' % (key, getattr(self, key))
|
||||||
self._streams_have_been_processed = True
|
else:
|
||||||
|
# e.g. int
|
||||||
def _get_iterator(self, stream_type):
|
answ += '\'%s\': %s, ' % (key, unicode(getattr(self, key)))
|
||||||
if stream_type == 'audio':
|
if self.xml is None:
|
||||||
return self.audio_streams
|
answ += '\'xml\': None}}'
|
||||||
elif stream_type == 'subtitle':
|
else:
|
||||||
return self.subtitle_streams
|
answ += '\'xml\': \'%s\'}}' % self.xml.tag
|
||||||
|
return try_encode(answ)
|
||||||
|
|
||||||
def plex_stream_index(self, kodi_stream_index, stream_type):
|
def plex_stream_index(self, kodi_stream_index, stream_type):
|
||||||
"""
|
"""
|
||||||
Pass in the kodi_stream_index [int] in order to receive the Plex stream
|
Pass in the kodi_stream_index [int] in order to receive the Plex stream
|
||||||
index [int].
|
index.
|
||||||
|
|
||||||
stream_type: 'video', 'audio', 'subtitle'
|
stream_type: 'video', 'audio', 'subtitle'
|
||||||
|
|
||||||
Returns None if unsuccessful
|
Returns None if unsuccessful
|
||||||
"""
|
"""
|
||||||
if stream_type == 'audio':
|
stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type]
|
||||||
return int(self.audio_streams[kodi_stream_index].get('id'))
|
count = 0
|
||||||
elif stream_type == 'subtitle':
|
# Kodi indexes differently than Plex
|
||||||
try:
|
for stream in self.xml[0][self.part]:
|
||||||
return int(self.subtitle_streams[kodi_stream_index].get('id'))
|
if (stream.attrib['streamType'] == stream_type and
|
||||||
except (IndexError, TypeError):
|
'key' in stream.attrib):
|
||||||
pass
|
if count == kodi_stream_index:
|
||||||
|
return stream.attrib['id']
|
||||||
|
count += 1
|
||||||
|
for stream in self.xml[0][self.part]:
|
||||||
|
if (stream.attrib['streamType'] == stream_type and
|
||||||
|
'key' not in stream.attrib):
|
||||||
|
if count == kodi_stream_index:
|
||||||
|
return stream.attrib['id']
|
||||||
|
count += 1
|
||||||
|
|
||||||
def kodi_stream_index(self, plex_stream_index, stream_type):
|
def kodi_stream_index(self, plex_stream_index, stream_type):
|
||||||
"""
|
"""
|
||||||
Pass in the plex_stream_index [int] in order to receive the Kodi stream
|
Pass in the kodi_stream_index [int] in order to receive the Plex stream
|
||||||
index [int].
|
index.
|
||||||
|
|
||||||
stream_type: 'video', 'audio', 'subtitle'
|
stream_type: 'video', 'audio', 'subtitle'
|
||||||
|
|
||||||
Returns None if unsuccessful
|
Returns None if unsuccessful
|
||||||
"""
|
"""
|
||||||
if plex_stream_index is None:
|
stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type]
|
||||||
return
|
count = 0
|
||||||
for i, stream in enumerate(self._get_iterator(stream_type)):
|
for stream in self.xml[0][self.part]:
|
||||||
if cast(int, stream.get('id')) == plex_stream_index:
|
if (stream.attrib['streamType'] == stream_type and
|
||||||
return i
|
'key' in stream.attrib):
|
||||||
|
if stream.attrib['id'] == plex_stream_index:
|
||||||
def active_plex_stream_index(self, stream_type):
|
return count
|
||||||
"""
|
count += 1
|
||||||
Returns the following tuple for the active stream on the Plex side:
|
for stream in self.xml[0][self.part]:
|
||||||
(Plex stream id [int], languageTag [str] or None)
|
if (stream.attrib['streamType'] == stream_type and
|
||||||
Returns None if no stream has been selected
|
'key' not in stream.attrib):
|
||||||
"""
|
if stream.attrib['id'] == plex_stream_index:
|
||||||
for i, stream in enumerate(self._get_iterator(stream_type)):
|
return count
|
||||||
if stream.get('selected') == '1':
|
count += 1
|
||||||
return (int(stream.get('id')), stream.get('languageTag'))
|
|
||||||
|
|
||||||
def on_kodi_subtitle_stream_change(self, kodi_stream_index, subs_enabled):
|
|
||||||
"""
|
|
||||||
Call this method if Kodi changed its subtitle and you want Plex to
|
|
||||||
know.
|
|
||||||
"""
|
|
||||||
if subs_enabled:
|
|
||||||
try:
|
|
||||||
plex_stream_index = int(self.subtitle_streams[kodi_stream_index].get('id'))
|
|
||||||
except (IndexError, TypeError):
|
|
||||||
LOG.debug('Kodi subtitle change detected to a sub %s that is '
|
|
||||||
'NOT available on the Plex side', kodi_stream_index)
|
|
||||||
self.current_kodi_sub_stream = None
|
|
||||||
return
|
|
||||||
LOG.debug('Kodi subtitle change detected: telling Plex about '
|
|
||||||
'switch to index %s, Plex stream id %s',
|
|
||||||
kodi_stream_index, plex_stream_index)
|
|
||||||
self.current_kodi_sub_stream = kodi_stream_index
|
|
||||||
else:
|
|
||||||
plex_stream_index = 0
|
|
||||||
LOG.debug('Kodi subtitle has been deactivated, telling Plex')
|
|
||||||
self.current_kodi_sub_stream = False
|
|
||||||
PF.change_subtitle(plex_stream_index, self.api.part_id())
|
|
||||||
|
|
||||||
def on_kodi_audio_stream_change(self, kodi_stream_index):
|
|
||||||
"""
|
|
||||||
Call this method if Kodi changed its audio stream and you want Plex to
|
|
||||||
know. kodi_stream_index [int]
|
|
||||||
"""
|
|
||||||
plex_stream_index = int(self.audio_streams[kodi_stream_index].get('id'))
|
|
||||||
LOG.debug('Changing Plex audio stream to %s, Kodi index %s',
|
|
||||||
plex_stream_index, kodi_stream_index)
|
|
||||||
PF.change_audio_stream(plex_stream_index, self.api.part_id())
|
|
||||||
self.current_kodi_audio_stream = kodi_stream_index
|
|
||||||
|
|
||||||
def switch_to_plex_streams(self):
|
|
||||||
self.switch_to_plex_stream('audio')
|
|
||||||
self.switch_to_plex_stream('subtitle')
|
|
||||||
|
|
||||||
def switch_to_plex_stream(self, typus):
|
|
||||||
try:
|
|
||||||
plex_index, language_tag = self.active_plex_stream_index(typus)
|
|
||||||
except TypeError:
|
|
||||||
LOG.debug('Deactivating Kodi subtitles because the PMS '
|
|
||||||
'told us to not show any subtitles')
|
|
||||||
app.APP.player.showSubtitles(False)
|
|
||||||
self.current_kodi_sub_stream = False
|
|
||||||
return
|
|
||||||
LOG.debug('The PMS wants to display %s stream with Plex id %s and '
|
|
||||||
'languageTag %s', typus, plex_index, language_tag)
|
|
||||||
kodi_index = self.kodi_stream_index(plex_index, typus)
|
|
||||||
if kodi_index is None:
|
|
||||||
LOG.debug('Leaving Kodi %s stream settings untouched since we '
|
|
||||||
'could not parse Plex %s stream with id %s to a Kodi'
|
|
||||||
' index', typus, typus, plex_index)
|
|
||||||
else:
|
|
||||||
LOG.debug('Switching to Kodi %s stream number %s because the '
|
|
||||||
'PMS told us to show stream with Plex id %s',
|
|
||||||
typus, kodi_index, plex_index)
|
|
||||||
# If we're choosing an "illegal" index, this function does
|
|
||||||
# need seem to fail nor log any errors
|
|
||||||
if typus == 'audio':
|
|
||||||
app.APP.player.setAudioStream(kodi_index)
|
|
||||||
else:
|
|
||||||
app.APP.player.setSubtitleStream(kodi_index)
|
|
||||||
app.APP.player.showSubtitles(True)
|
|
||||||
if typus == 'audio':
|
|
||||||
self.current_kodi_audio_stream = kodi_index
|
|
||||||
else:
|
|
||||||
self.current_kodi_sub_stream = kodi_index
|
|
||||||
|
|
||||||
def on_av_change(self, playerid):
|
|
||||||
kodi_audio_stream = js.get_current_audio_stream_index(playerid)
|
|
||||||
sub_enabled = js.get_subtitle_enabled(playerid)
|
|
||||||
kodi_sub_stream = js.get_current_subtitle_stream_index(playerid)
|
|
||||||
# Audio
|
|
||||||
if kodi_audio_stream != self.current_kodi_audio_stream:
|
|
||||||
self.on_kodi_audio_stream_change(kodi_audio_stream)
|
|
||||||
# Subtitles - CURRENTLY BROKEN ON THE KODI SIDE!
|
|
||||||
# current_kodi_sub_stream may also be zero
|
|
||||||
subs_off = (None, False)
|
|
||||||
if ((sub_enabled and self.current_kodi_sub_stream in subs_off)
|
|
||||||
or (not sub_enabled and self.current_kodi_sub_stream not in subs_off)
|
|
||||||
or (kodi_sub_stream is not None
|
|
||||||
and kodi_sub_stream != self.current_kodi_sub_stream)):
|
|
||||||
self.on_kodi_subtitle_stream_change(kodi_sub_stream, sub_enabled)
|
|
||||||
|
|
||||||
|
|
||||||
def playlist_item_from_kodi(kodi_item):
|
def playlist_item_from_kodi(kodi_item):
|
||||||
|
@ -392,24 +257,30 @@ def playlist_item_from_kodi(kodi_item):
|
||||||
Supply with data['item'] as returned from Kodi JSON-RPC interface.
|
Supply with data['item'] as returned from Kodi JSON-RPC interface.
|
||||||
kodi_item dict contains keys 'id', 'type', 'file' (if applicable)
|
kodi_item dict contains keys 'id', 'type', 'file' (if applicable)
|
||||||
"""
|
"""
|
||||||
item = PlaylistItem()
|
item = Playlist_Item()
|
||||||
item.kodi_id = kodi_item.get('id')
|
item.kodi_id = kodi_item.get('id')
|
||||||
item.kodi_type = kodi_item.get('type')
|
item.kodi_type = kodi_item.get('type')
|
||||||
if item.kodi_id:
|
if item.kodi_id:
|
||||||
with PlexDB(lock=False) as plexdb:
|
with plexdb.Get_Plex_DB() as plex_db:
|
||||||
db_item = plexdb.item_by_kodi_id(kodi_item['id'], kodi_item['type'])
|
plex_dbitem = plex_db.getItem_byKodiId(kodi_item['id'],
|
||||||
if db_item:
|
kodi_item['type'])
|
||||||
item.plex_id = db_item['plex_id']
|
try:
|
||||||
item.plex_type = db_item['plex_type']
|
item.plex_id = plex_dbitem[0]
|
||||||
|
item.plex_type = plex_dbitem[2]
|
||||||
|
item.plex_uuid = plex_dbitem[0] # we dont need the uuid yet :-)
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
item.file = kodi_item.get('file')
|
item.file = kodi_item.get('file')
|
||||||
if item.plex_id is None and item.file is not None:
|
if item.plex_id is None and item.file is not None:
|
||||||
try:
|
query = dict(parse_qsl(urlsplit(item.file).query))
|
||||||
query = item.file.split('?', 1)[1]
|
item.plex_id = query.get('plex_id')
|
||||||
except IndexError:
|
|
||||||
query = ''
|
|
||||||
query = dict(utils.parse_qsl(query))
|
|
||||||
item.plex_id = cast(int, query.get('plex_id'))
|
|
||||||
item.plex_type = query.get('itemType')
|
item.plex_type = query.get('itemType')
|
||||||
|
if item.plex_id is None and item.file is not None:
|
||||||
|
item.uri = 'library://whatever/item/%s' % quote(item.file, safe='')
|
||||||
|
else:
|
||||||
|
# TO BE VERIFIED - PLEX DOESN'T LIKE PLAYLIST ADDS IN THIS MANNER
|
||||||
|
item.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' %
|
||||||
|
(item.plex_uuid, item.plex_id))
|
||||||
LOG.debug('Made playlist item from Kodi: %s', item)
|
LOG.debug('Made playlist item from Kodi: %s', item)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
@ -423,39 +294,29 @@ def verify_kodi_item(plex_id, kodi_item):
|
||||||
set to None if unsuccessful.
|
set to None if unsuccessful.
|
||||||
|
|
||||||
Will raise a PlaylistError if plex_id is None and kodi_item['file'] starts
|
Will raise a PlaylistError if plex_id is None and kodi_item['file'] starts
|
||||||
with either 'plugin' or 'http'.
|
with either 'plugin' or 'http'
|
||||||
Will raise KeyError if neither plex_id nor kodi_id are found
|
|
||||||
"""
|
"""
|
||||||
if plex_id is not None or kodi_item.get('id') is not None:
|
if plex_id is not None or kodi_item.get('id') is not None:
|
||||||
# Got all the info we need
|
# Got all the info we need
|
||||||
return kodi_item
|
return kodi_item
|
||||||
# Special case playlist startup - got type but no id
|
|
||||||
if (not app.SYNC.direct_paths and app.SYNC.enable_music and
|
|
||||||
kodi_item.get('type') == v.KODI_TYPE_SONG and
|
|
||||||
kodi_item['file'].startswith('http')):
|
|
||||||
kodi_item['id'], _ = kodiid_from_filename(kodi_item['file'],
|
|
||||||
v.KODI_TYPE_SONG)
|
|
||||||
LOG.debug('Detected song. Research results: %s', kodi_item)
|
|
||||||
return kodi_item
|
|
||||||
# Need more info since we don't have kodi_id nor type. Use file path.
|
# Need more info since we don't have kodi_id nor type. Use file path.
|
||||||
if ((kodi_item['file'].startswith('plugin') and
|
if (kodi_item['file'].startswith('plugin') or
|
||||||
not kodi_item['file'].startswith('plugin://%s' % v.ADDON_ID)) or
|
|
||||||
kodi_item['file'].startswith('http')):
|
kodi_item['file'].startswith('http')):
|
||||||
LOG.debug('kodi_item cannot be used for Plex playback: %s', kodi_item)
|
|
||||||
raise PlaylistError('kodi_item cannot be used for Plex playback')
|
raise PlaylistError('kodi_item cannot be used for Plex playback')
|
||||||
LOG.debug('Starting research for Kodi id since we didnt get one: %s',
|
LOG.debug('Starting research for Kodi id since we didnt get one: %s',
|
||||||
kodi_item)
|
kodi_item)
|
||||||
# Try the VIDEO DB first - will find both movies and episodes
|
kodi_id = kodiid_from_filename(kodi_item['file'], v.KODI_TYPE_MOVIE)
|
||||||
kodi_id, kodi_type = kodiid_from_filename(kodi_item['file'],
|
kodi_item['type'] = v.KODI_TYPE_MOVIE
|
||||||
db_type='video')
|
if kodi_id is None:
|
||||||
if not kodi_id:
|
kodi_id = kodiid_from_filename(kodi_item['file'],
|
||||||
# No movie or episode found - try MUSIC DB now for songs
|
v.KODI_TYPE_EPISODE)
|
||||||
kodi_id, kodi_type = kodiid_from_filename(kodi_item['file'],
|
kodi_item['type'] = v.KODI_TYPE_EPISODE
|
||||||
db_type='music')
|
if kodi_id is None:
|
||||||
|
kodi_id = kodiid_from_filename(kodi_item['file'],
|
||||||
|
v.KODI_TYPE_SONG)
|
||||||
|
kodi_item['type'] = v.KODI_TYPE_SONG
|
||||||
kodi_item['id'] = kodi_id
|
kodi_item['id'] = kodi_id
|
||||||
kodi_item['type'] = None if kodi_id is None else kodi_type
|
kodi_item['type'] = None if kodi_id is None else kodi_item['type']
|
||||||
if plex_id is None and kodi_id is None:
|
|
||||||
raise KeyError('Neither Plex nor Kodi id found for %s' % kodi_item)
|
|
||||||
LOG.debug('Research results for kodi_item: %s', kodi_item)
|
LOG.debug('Research results for kodi_item: %s', kodi_item)
|
||||||
return kodi_item
|
return kodi_item
|
||||||
|
|
||||||
|
@ -466,16 +327,19 @@ def playlist_item_from_plex(plex_id):
|
||||||
|
|
||||||
Returns a Playlist_Item
|
Returns a Playlist_Item
|
||||||
"""
|
"""
|
||||||
item = PlaylistItem()
|
item = Playlist_Item()
|
||||||
item.plex_id = plex_id
|
item.plex_id = plex_id
|
||||||
with PlexDB(lock=False) as plexdb:
|
with plexdb.Get_Plex_DB() as plex_db:
|
||||||
db_item = plexdb.item_by_id(plex_id)
|
plex_dbitem = plex_db.getItem_byId(plex_id)
|
||||||
if db_item:
|
try:
|
||||||
item.plex_type = db_item['plex_type']
|
item.plex_type = plex_dbitem[5]
|
||||||
item.kodi_id = db_item['kodi_id']
|
item.kodi_id = plex_dbitem[0]
|
||||||
item.kodi_type = db_item['kodi_type']
|
item.kodi_type = plex_dbitem[4]
|
||||||
else:
|
except (TypeError, IndexError):
|
||||||
raise KeyError('Could not find plex_id %s in database' % plex_id)
|
raise KeyError('Could not find plex_id %s in database' % plex_id)
|
||||||
|
item.plex_uuid = plex_id
|
||||||
|
item.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' %
|
||||||
|
(item.plex_uuid, plex_id))
|
||||||
LOG.debug('Made playlist item from plex: %s', item)
|
LOG.debug('Made playlist item from plex: %s', item)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
@ -486,42 +350,41 @@ def playlist_item_from_xml(xml_video_element, kodi_id=None, kodi_type=None):
|
||||||
|
|
||||||
xml_video_element: etree xml piece 1 level underneath <MediaContainer>
|
xml_video_element: etree xml piece 1 level underneath <MediaContainer>
|
||||||
"""
|
"""
|
||||||
item = PlaylistItem()
|
item = Playlist_Item()
|
||||||
api = API(xml_video_element)
|
api = API(xml_video_element)
|
||||||
item.plex_id = api.plex_id
|
item.plex_id = api.plex_id()
|
||||||
item.plex_type = api.plex_type
|
item.plex_type = api.plex_type()
|
||||||
# item.id will only be set if you passed in an xml_video_element from e.g.
|
# item.id will only be set if you passed in an xml_video_element from e.g.
|
||||||
# a playQueue
|
# a playQueue
|
||||||
item.id = api.item_id()
|
item.id = api.item_id()
|
||||||
if kodi_id is not None:
|
if kodi_id is not None:
|
||||||
item.kodi_id = kodi_id
|
item.kodi_id = kodi_id
|
||||||
item.kodi_type = kodi_type
|
item.kodi_type = kodi_type
|
||||||
elif item.plex_type != v.PLEX_TYPE_CLIP:
|
elif item.plex_id is not None:
|
||||||
with PlexDB(lock=False) as plexdb:
|
with plexdb.Get_Plex_DB() as plex_db:
|
||||||
db_element = plexdb.item_by_id(item.plex_id,
|
db_element = plex_db.getItem_byId(item.plex_id)
|
||||||
plex_type=item.plex_type)
|
try:
|
||||||
if db_element:
|
item.kodi_id, item.kodi_type = db_element[0], db_element[4]
|
||||||
item.kodi_id = db_element['kodi_id']
|
except TypeError:
|
||||||
item.kodi_type = db_element['kodi_type']
|
pass
|
||||||
item.guid = api.guid_html_escaped()
|
item.guid = api.guid_html_escaped()
|
||||||
item.playcount = api.viewcount()
|
item.playcount = api.viewcount()
|
||||||
item.offset = api.resume_point()
|
item.offset = api.resume_point()
|
||||||
item.api = api
|
item.xml = xml_video_element
|
||||||
LOG.debug('Created new playlist item from xml: %s', item)
|
LOG.debug('Created new playlist item from xml: %s', item)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
def _update_playlist_version(playlist, xml):
|
def _get_playListVersion_from_xml(playlist, xml):
|
||||||
"""
|
"""
|
||||||
Takes a PMS xml (one level above the xml-depth where we're usually applying
|
Takes a PMS xml as input to overwrite the playlist version (e.g. Plex
|
||||||
API()) as input to overwrite the playlist version (e.g. Plex
|
|
||||||
playQueueVersion).
|
playQueueVersion).
|
||||||
|
|
||||||
Raises PlaylistError if unsuccessful
|
Raises PlaylistError if unsuccessful
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
playlist.version = int(xml.get('%sVersion' % playlist.kind))
|
playlist.version = int(xml.attrib['%sVersion' % playlist.kind])
|
||||||
except (AttributeError, TypeError):
|
except (TypeError, AttributeError, KeyError):
|
||||||
raise PlaylistError('Could not get new playlist Version for playlist '
|
raise PlaylistError('Could not get new playlist Version for playlist '
|
||||||
'%s' % playlist)
|
'%s' % playlist)
|
||||||
|
|
||||||
|
@ -533,16 +396,18 @@ def get_playlist_details_from_xml(playlist, xml):
|
||||||
|
|
||||||
Raises PlaylistError if something went wrong.
|
Raises PlaylistError if something went wrong.
|
||||||
"""
|
"""
|
||||||
if xml is None:
|
try:
|
||||||
raise PlaylistError('No playlist received for playlist %s' % playlist)
|
playlist.id = xml.attrib['%sID' % playlist.kind]
|
||||||
playlist.id = cast(int, xml.get('%sID' % playlist.kind))
|
playlist.version = xml.attrib['%sVersion' % playlist.kind]
|
||||||
playlist.version = cast(int, xml.get('%sVersion' % playlist.kind))
|
playlist.shuffled = xml.attrib['%sShuffled' % playlist.kind]
|
||||||
playlist.shuffled = cast(int, xml.get('%sShuffled' % playlist.kind))
|
playlist.selectedItemID = xml.attrib.get(
|
||||||
playlist.selectedItemID = cast(int, xml.get('%sSelectedItemID'
|
'%sSelectedItemID' % playlist.kind)
|
||||||
% playlist.kind))
|
playlist.selectedItemOffset = xml.attrib.get(
|
||||||
playlist.selectedItemOffset = cast(int, xml.get('%sSelectedItemOffset'
|
'%sSelectedItemOffset' % playlist.kind)
|
||||||
% playlist.kind))
|
LOG.debug('Updated playlist from xml: %s', playlist)
|
||||||
LOG.debug('Updated playlist from xml: %s', playlist)
|
except (TypeError, KeyError, AttributeError) as msg:
|
||||||
|
raise PlaylistError('Could not get playlist details from xml: %s',
|
||||||
|
msg)
|
||||||
|
|
||||||
|
|
||||||
def update_playlist_from_PMS(playlist, playlist_id=None, xml=None):
|
def update_playlist_from_PMS(playlist, playlist_id=None, xml=None):
|
||||||
|
@ -551,8 +416,6 @@ def update_playlist_from_PMS(playlist, playlist_id=None, xml=None):
|
||||||
need to fetch a new playqueue
|
need to fetch a new playqueue
|
||||||
|
|
||||||
If an xml is passed in, the playlist will be overwritten with its info
|
If an xml is passed in, the playlist will be overwritten with its info
|
||||||
|
|
||||||
Raises PlaylistError if something went wront
|
|
||||||
"""
|
"""
|
||||||
if xml is None:
|
if xml is None:
|
||||||
xml = get_PMS_playlist(playlist, playlist_id)
|
xml = get_PMS_playlist(playlist, playlist_id)
|
||||||
|
@ -566,16 +429,16 @@ def update_playlist_from_PMS(playlist, playlist_id=None, xml=None):
|
||||||
playlist.items.append(playlist_item)
|
playlist.items.append(playlist_item)
|
||||||
|
|
||||||
|
|
||||||
def init_plex_playqueue(playlist, plex_id=None, kodi_item=None):
|
def init_Plex_playlist(playlist, plex_id=None, kodi_item=None):
|
||||||
"""
|
"""
|
||||||
Initializes the Plex side without changing the Kodi playlists
|
Initializes the Plex side without changing the Kodi playlists
|
||||||
WILL ALSO UPDATE OUR PLAYLISTS.
|
WILL ALSO UPDATE OUR PLAYLISTS.
|
||||||
|
|
||||||
Returns the first PKC playlist item or raises PlaylistError
|
Returns the first PKC playlist item or raises PlaylistError
|
||||||
"""
|
"""
|
||||||
LOG.debug('Initializing the playqueue on the Plex side: %s', playlist)
|
LOG.debug('Initializing the playlist on the Plex side: %s', playlist)
|
||||||
verify_kodi_item(plex_id, kodi_item)
|
|
||||||
playlist.clear(kodi=False)
|
playlist.clear(kodi=False)
|
||||||
|
verify_kodi_item(plex_id, kodi_item)
|
||||||
try:
|
try:
|
||||||
if plex_id:
|
if plex_id:
|
||||||
item = playlist_item_from_plex(plex_id)
|
item = playlist_item_from_plex(plex_id)
|
||||||
|
@ -584,23 +447,19 @@ def init_plex_playqueue(playlist, plex_id=None, kodi_item=None):
|
||||||
params = {
|
params = {
|
||||||
'next': 0,
|
'next': 0,
|
||||||
'type': playlist.type,
|
'type': playlist.type,
|
||||||
'uri': item.uri,
|
'uri': item.uri
|
||||||
'includeMarkers': 1, # e.g. start + stop of intros
|
|
||||||
}
|
}
|
||||||
xml = DU().downloadUrl(url="{server}/%ss" % playlist.kind,
|
xml = DU().downloadUrl(url="{server}/%ss" % playlist.kind,
|
||||||
action_type="POST",
|
action_type="POST",
|
||||||
parameters=params)
|
parameters=params)
|
||||||
if xml in (None, 401):
|
|
||||||
raise PlaylistError('Did not receive a valid xml from the PMS')
|
|
||||||
get_playlist_details_from_xml(playlist, xml)
|
get_playlist_details_from_xml(playlist, xml)
|
||||||
# Need to get the details for the playlist item
|
# Need to get the details for the playlist item
|
||||||
item = playlist_item_from_xml(xml[0])
|
item = playlist_item_from_xml(xml[0])
|
||||||
except (KeyError, IndexError, TypeError):
|
except (KeyError, IndexError, TypeError):
|
||||||
LOG.error('Could not init Plex playlist: plex_id %s, kodi_item %s',
|
raise PlaylistError('Could not init Plex playlist with plex_id %s and '
|
||||||
plex_id, kodi_item)
|
'kodi_item %s' % (plex_id, kodi_item))
|
||||||
raise PlaylistError
|
|
||||||
playlist.items.append(item)
|
playlist.items.append(item)
|
||||||
LOG.debug('Initialized the playqueue on the Plex side: %s', playlist)
|
LOG.debug('Initialized the playlist on the Plex side: %s', playlist)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
@ -617,9 +476,9 @@ def add_listitem_to_playlist(playlist, pos, listitem, kodi_id=None,
|
||||||
'%s', pos, playlist)
|
'%s', pos, playlist)
|
||||||
kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file}
|
kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file}
|
||||||
if playlist.id is None:
|
if playlist.id is None:
|
||||||
init_plex_playqueue(playlist, plex_id, kodi_item)
|
init_Plex_playlist(playlist, plex_id, kodi_item)
|
||||||
else:
|
else:
|
||||||
add_item_to_plex_playqueue(playlist, pos, plex_id, kodi_item)
|
add_item_to_PMS_playlist(playlist, pos, plex_id, kodi_item)
|
||||||
if kodi_id is None and playlist.items[pos].kodi_id:
|
if kodi_id is None and playlist.items[pos].kodi_id:
|
||||||
kodi_id = playlist.items[pos].kodi_id
|
kodi_id = playlist.items[pos].kodi_id
|
||||||
kodi_type = playlist.items[pos].kodi_type
|
kodi_type = playlist.items[pos].kodi_type
|
||||||
|
@ -646,9 +505,9 @@ def add_item_to_playlist(playlist, pos, kodi_id=None, kodi_type=None,
|
||||||
LOG.debug('add_item_to_playlist. Playlist before adding: %s', playlist)
|
LOG.debug('add_item_to_playlist. Playlist before adding: %s', playlist)
|
||||||
kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file}
|
kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file}
|
||||||
if playlist.id is None:
|
if playlist.id is None:
|
||||||
item = init_plex_playqueue(playlist, plex_id, kodi_item)
|
item = init_Plex_playlist(playlist, plex_id, kodi_item)
|
||||||
else:
|
else:
|
||||||
item = add_item_to_plex_playqueue(playlist, pos, plex_id, kodi_item)
|
item = add_item_to_PMS_playlist(playlist, pos, plex_id, kodi_item)
|
||||||
params = {
|
params = {
|
||||||
'playlistid': playlist.playlistid,
|
'playlistid': playlist.playlistid,
|
||||||
'position': pos
|
'position': pos
|
||||||
|
@ -664,7 +523,7 @@ def add_item_to_playlist(playlist, pos, kodi_id=None, kodi_type=None,
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None):
|
def add_item_to_PMS_playlist(playlist, pos, plex_id=None, kodi_item=None):
|
||||||
"""
|
"""
|
||||||
Adds a new item to the playlist at position pos [int] only on the Plex
|
Adds a new item to the playlist at position pos [int] only on the Plex
|
||||||
side of things (e.g. because the user changed the Kodi side)
|
side of things (e.g. because the user changed the Kodi side)
|
||||||
|
@ -677,22 +536,16 @@ def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None):
|
||||||
item = playlist_item_from_plex(plex_id)
|
item = playlist_item_from_plex(plex_id)
|
||||||
else:
|
else:
|
||||||
item = playlist_item_from_kodi(kodi_item)
|
item = playlist_item_from_kodi(kodi_item)
|
||||||
url = "{server}/%ss/%s" % (playlist.kind, playlist.id)
|
url = "{server}/%ss/%s?uri=%s" % (playlist.kind, playlist.id, item.uri)
|
||||||
parameters = {
|
|
||||||
'uri': item.uri,
|
|
||||||
'includeMarkers': 1, # e.g. start + stop of intros
|
|
||||||
}
|
|
||||||
# Will always put the new item at the end of the Plex playlist
|
# Will always put the new item at the end of the Plex playlist
|
||||||
xml = DU().downloadUrl(url,
|
xml = DU().downloadUrl(url, action_type="PUT")
|
||||||
action_type="PUT",
|
|
||||||
parameters=parameters)
|
|
||||||
try:
|
try:
|
||||||
xml[-1].attrib
|
xml[-1].attrib
|
||||||
except (TypeError, AttributeError, KeyError, IndexError):
|
except (TypeError, AttributeError, KeyError, IndexError):
|
||||||
raise PlaylistError('Could not add item %s to playlist %s'
|
raise PlaylistError('Could not add item %s to playlist %s'
|
||||||
% (kodi_item, playlist))
|
% (kodi_item, playlist))
|
||||||
api = API(xml[-1])
|
api = API(xml[-1])
|
||||||
item.api = api
|
item.xml = xml[-1]
|
||||||
item.id = api.item_id()
|
item.id = api.item_id()
|
||||||
item.guid = api.guid_html_escaped()
|
item.guid = api.guid_html_escaped()
|
||||||
item.offset = api.resume_point()
|
item.offset = api.resume_point()
|
||||||
|
@ -700,7 +553,7 @@ def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None):
|
||||||
playlist.items.append(item)
|
playlist.items.append(item)
|
||||||
if pos == len(playlist.items) - 1:
|
if pos == len(playlist.items) - 1:
|
||||||
# Item was added at the end
|
# Item was added at the end
|
||||||
_update_playlist_version(playlist, xml)
|
_get_playListVersion_from_xml(playlist, xml)
|
||||||
else:
|
else:
|
||||||
# Move the new item to the correct position
|
# Move the new item to the correct position
|
||||||
move_playlist_item(playlist,
|
move_playlist_item(playlist,
|
||||||
|
@ -743,8 +596,8 @@ def add_item_to_kodi_playlist(playlist, pos, kodi_id=None, kodi_type=None,
|
||||||
item = playlist_item_from_kodi(
|
item = playlist_item_from_kodi(
|
||||||
{'id': kodi_id, 'type': kodi_type, 'file': file})
|
{'id': kodi_id, 'type': kodi_type, 'file': file})
|
||||||
if item.plex_id is not None:
|
if item.plex_id is not None:
|
||||||
xml = PF.GetPlexMetadata(item.plex_id)
|
xml = GetPlexMetadata(item.plex_id)
|
||||||
item.api = API(xml[-1])
|
item.xml = xml[-1]
|
||||||
playlist.items.insert(pos, item)
|
playlist.items.insert(pos, item)
|
||||||
return item
|
return item
|
||||||
|
|
||||||
|
@ -768,10 +621,9 @@ def move_playlist_item(playlist, before_pos, after_pos):
|
||||||
playlist.id,
|
playlist.id,
|
||||||
playlist.items[before_pos].id,
|
playlist.items[before_pos].id,
|
||||||
playlist.items[after_pos - 1].id)
|
playlist.items[after_pos - 1].id)
|
||||||
# Tell the PMS that we're moving items around
|
# We need to increment the playlistVersion
|
||||||
xml = DU().downloadUrl(url, action_type="PUT")
|
_get_playListVersion_from_xml(
|
||||||
# We need to increment the playlist version for communicating with the PMS
|
playlist, DU().downloadUrl(url, action_type="PUT"))
|
||||||
_update_playlist_version(playlist, xml)
|
|
||||||
# Move our item's position in our internal playlist
|
# Move our item's position in our internal playlist
|
||||||
playlist.items.insert(after_pos, playlist.items.pop(before_pos))
|
playlist.items.insert(after_pos, playlist.items.pop(before_pos))
|
||||||
LOG.debug('Done moving for %s', playlist)
|
LOG.debug('Done moving for %s', playlist)
|
||||||
|
@ -782,20 +634,16 @@ def get_PMS_playlist(playlist, playlist_id=None):
|
||||||
Fetches the PMS playlist/playqueue as an XML. Pass in playlist_id if we
|
Fetches the PMS playlist/playqueue as an XML. Pass in playlist_id if we
|
||||||
need to fetch a new playlist
|
need to fetch a new playlist
|
||||||
|
|
||||||
Raises PlaylistError if something went wrong
|
Returns None if something went wrong
|
||||||
"""
|
"""
|
||||||
playlist_id = playlist_id if playlist_id else playlist.id
|
playlist_id = playlist_id if playlist_id else playlist.id
|
||||||
parameters = {'includeMarkers': 1}
|
xml = DU().downloadUrl(
|
||||||
if playlist.kind == 'playList':
|
"{server}/%ss/%s" % (playlist.kind, playlist_id),
|
||||||
xml = DU().downloadUrl("{server}/playlists/%s/items" % playlist_id,
|
headerOptions={'Accept': 'application/xml'})
|
||||||
parameters=parameters)
|
|
||||||
else:
|
|
||||||
xml = DU().downloadUrl("{server}/playQueues/%s" % playlist_id,
|
|
||||||
parameters=parameters)
|
|
||||||
try:
|
try:
|
||||||
xml.attrib
|
xml.attrib['%sID' % playlist.kind]
|
||||||
except AttributeError:
|
except (AttributeError, KeyError):
|
||||||
raise PlaylistError('Did not get a valid xml')
|
xml = None
|
||||||
return xml
|
return xml
|
||||||
|
|
||||||
|
|
||||||
|
@ -818,8 +666,8 @@ def delete_playlist_item_from_PMS(playlist, pos):
|
||||||
playlist.items[pos].id,
|
playlist.items[pos].id,
|
||||||
playlist.repeat),
|
playlist.repeat),
|
||||||
action_type="DELETE")
|
action_type="DELETE")
|
||||||
|
_get_playListVersion_from_xml(playlist, xml)
|
||||||
del playlist.items[pos]
|
del playlist.items[pos]
|
||||||
_update_playlist_version(playlist, xml)
|
|
||||||
|
|
||||||
|
|
||||||
# Functions operating on the Kodi playlist objects ##########
|
# Functions operating on the Kodi playlist objects ##########
|
||||||
|
@ -890,10 +738,9 @@ def get_pms_playqueue(playqueue_id):
|
||||||
"""
|
"""
|
||||||
Returns the Plex playqueue as an etree XML or None if unsuccessful
|
Returns the Plex playqueue as an etree XML or None if unsuccessful
|
||||||
"""
|
"""
|
||||||
parameters = {'includeMarkers': 1}
|
xml = DU().downloadUrl(
|
||||||
xml = DU().downloadUrl("{server}/playQueues/%s" % playqueue_id,
|
"{server}/playQueues/%s" % playqueue_id,
|
||||||
parameters=parameters,
|
headerOptions={'Accept': 'application/xml'})
|
||||||
headerOptions={'Accept': 'application/xml'})
|
|
||||||
try:
|
try:
|
||||||
xml.attrib
|
xml.attrib
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
|
@ -910,15 +757,14 @@ def get_plextype_from_xml(xml):
|
||||||
returns None if unsuccessful
|
returns None if unsuccessful
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
plex_id = utils.REGEX_PLEX_ID_FROM_URL.findall(
|
plex_id = REGEX.findall(xml.attrib['playQueueSourceURI'])[0]
|
||||||
xml.attrib['playQueueSourceURI'])[0]
|
|
||||||
except IndexError:
|
except IndexError:
|
||||||
LOG.error('Could not get plex_id from xml: %s', xml.attrib)
|
LOG.error('Could not get plex_id from xml: %s', xml.attrib)
|
||||||
return
|
return
|
||||||
new_xml = PF.GetPlexMetadata(plex_id)
|
new_xml = GetPlexMetadata(plex_id)
|
||||||
try:
|
try:
|
||||||
new_xml[0].attrib
|
new_xml[0].attrib
|
||||||
except (TypeError, IndexError, AttributeError):
|
except (TypeError, IndexError, AttributeError):
|
||||||
LOG.error('Could not get plex metadata for plex id %s', plex_id)
|
LOG.error('Could not get plex metadata for plex id %s', plex_id)
|
||||||
return
|
return
|
||||||
return new_xml[0].attrib.get('type').decode('utf-8')
|
return new_xml[0].attrib.get('type')
|
||||||
|
|
|
@ -1,479 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
:module: plexkodiconnect.playlists
|
|
||||||
:synopsis: This module syncs Plex playlists to Kodi playlists and vice-versa
|
|
||||||
:author: Croneter
|
|
||||||
|
|
||||||
.. autoclass:: kodi_playlist_monitor
|
|
||||||
|
|
||||||
.. autoclass:: full_sync
|
|
||||||
|
|
||||||
.. autoclass:: websocket
|
|
||||||
"""
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
from sqlite3 import OperationalError
|
|
||||||
|
|
||||||
from .common import Playlist, PlaylistObserver, kodi_playlist_hash
|
|
||||||
from . import pms, db, kodi_pl, plex_pl
|
|
||||||
|
|
||||||
from ..watchdog import events
|
|
||||||
from ..plex_api import API
|
|
||||||
from .. import utils, path_ops, variables as v, app
|
|
||||||
from ..exceptions import PlaylistError
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
LOG = getLogger('PLEX.playlists')
|
|
||||||
|
|
||||||
# Safety margin for playlist filesystem operations
|
|
||||||
FILESYSTEM_TIMEOUT = 1
|
|
||||||
|
|
||||||
# Which playlist formates are supported by PKC?
|
|
||||||
SUPPORTED_FILETYPES = (
|
|
||||||
'm3u',
|
|
||||||
# 'm3u8'
|
|
||||||
# 'pls',
|
|
||||||
# 'cue',
|
|
||||||
)
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
|
|
||||||
def should_cancel():
|
|
||||||
return app.APP.stop_pkc or app.SYNC.stop_sync
|
|
||||||
|
|
||||||
|
|
||||||
def kodi_playlist_monitor():
|
|
||||||
"""
|
|
||||||
Monitor for the Kodi playlist folder special://profile/playlist
|
|
||||||
|
|
||||||
Monitors for all file changes and will thus catch all changes on the Kodi
|
|
||||||
side of things (as soon as the user saves a new or modified playlist). This
|
|
||||||
is accomplished by starting a PlaylistObserver with the
|
|
||||||
PlaylistEventhandler
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
PlaylistObserver
|
|
||||||
Returns an already started PlaylistObserver instance
|
|
||||||
|
|
||||||
Notes
|
|
||||||
-----
|
|
||||||
Be sure to stop the returned PlaylistObserver with observer.stop()
|
|
||||||
(and maybe observer.join()) to shut down properly
|
|
||||||
"""
|
|
||||||
event_handler = PlaylistEventhandler()
|
|
||||||
observer = PlaylistObserver(timeout=FILESYSTEM_TIMEOUT)
|
|
||||||
observer.schedule(event_handler, v.PLAYLIST_PATH, recursive=True)
|
|
||||||
observer.start()
|
|
||||||
return observer
|
|
||||||
|
|
||||||
|
|
||||||
def remove_synced_playlists():
|
|
||||||
"""
|
|
||||||
Deletes all synched playlists on the Kodi side, not on the Plex side
|
|
||||||
"""
|
|
||||||
LOG.info('Removing all playlists that we synced to Kodi')
|
|
||||||
with app.APP.lock_playlists:
|
|
||||||
try:
|
|
||||||
paths = db.get_all_kodi_playlist_paths()
|
|
||||||
except OperationalError:
|
|
||||||
LOG.info('Playlists table has not yet been set-up')
|
|
||||||
return
|
|
||||||
kodi_pl.delete_kodi_playlists(paths)
|
|
||||||
db.wipe_table()
|
|
||||||
LOG.info('Done removing all synced playlists')
|
|
||||||
|
|
||||||
|
|
||||||
def websocket(plex_id, status):
|
|
||||||
"""
|
|
||||||
Call this function to process websocket messages from the PMS
|
|
||||||
|
|
||||||
Will use the playlist lock to process one single websocket message from
|
|
||||||
the PMS, and e.g. create or delete the corresponding Kodi playlist (if
|
|
||||||
applicable settings are set)
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
plex_id : unicode
|
|
||||||
The unqiue Plex id 'ratingKey' as received from the PMS
|
|
||||||
status : int
|
|
||||||
'state' as communicated by the PMS in the websocket message. This
|
|
||||||
function will then take the correct actions to process the message
|
|
||||||
* 0: 'created'
|
|
||||||
* 2: 'matching'
|
|
||||||
* 3: 'downloading'
|
|
||||||
* 4: 'loading'
|
|
||||||
* 5: 'finished'
|
|
||||||
* 6: 'analyzing'
|
|
||||||
* 9: 'deleted'
|
|
||||||
"""
|
|
||||||
create = False
|
|
||||||
plex_id = int(plex_id)
|
|
||||||
with app.APP.lock_playlists:
|
|
||||||
playlist = db.get_playlist(plex_id=plex_id)
|
|
||||||
if plex_id in plex_pl.IGNORE_PLEX_PLAYLIST_CHANGE:
|
|
||||||
LOG.debug('Ignoring detected Plex playlist change for %s',
|
|
||||||
playlist)
|
|
||||||
plex_pl.IGNORE_PLEX_PLAYLIST_CHANGE.remove(plex_id)
|
|
||||||
return
|
|
||||||
if playlist and status == 9:
|
|
||||||
# Won't be able to download metadata of the deleted playlist
|
|
||||||
if sync_plex_playlist(playlist=playlist):
|
|
||||||
LOG.debug('Plex deletion of playlist detected: %s', playlist)
|
|
||||||
try:
|
|
||||||
kodi_pl.delete(playlist)
|
|
||||||
except PlaylistError:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
xml = pms.metadata(plex_id)
|
|
||||||
if xml is None:
|
|
||||||
LOG.debug('Could not download playlist %s, probably deleted',
|
|
||||||
plex_id)
|
|
||||||
return
|
|
||||||
if not sync_plex_playlist(xml=xml[0]):
|
|
||||||
return
|
|
||||||
api = API(xml[0])
|
|
||||||
try:
|
|
||||||
if playlist:
|
|
||||||
if api.updated_at() == playlist.plex_updatedat:
|
|
||||||
LOG.debug('Playlist with id %s already synced: %s',
|
|
||||||
plex_id, playlist)
|
|
||||||
else:
|
|
||||||
LOG.debug('Change of Plex playlist detected: %s',
|
|
||||||
playlist)
|
|
||||||
kodi_pl.delete(playlist)
|
|
||||||
create = True
|
|
||||||
elif not playlist and not status == 9:
|
|
||||||
LOG.debug('Creation of new Plex playlist detected: %s',
|
|
||||||
plex_id)
|
|
||||||
create = True
|
|
||||||
# To the actual work
|
|
||||||
if create:
|
|
||||||
kodi_pl.create(plex_id)
|
|
||||||
except PlaylistError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def full_sync():
|
|
||||||
"""
|
|
||||||
Full sync of playlists between Kodi and Plex
|
|
||||||
|
|
||||||
Call to trigger a full sync both ways, e.g. on Kodi start-up. If issues
|
|
||||||
with a single playlist are encountered on either the Plex or Kodi side,
|
|
||||||
this particular playlist is omitted. Will use the playlist lock.
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
True if successful, False otherwise (actually only if we failed to
|
|
||||||
fetch the PMS playlists)
|
|
||||||
"""
|
|
||||||
LOG.info('Starting playlist full sync')
|
|
||||||
with app.APP.lock_playlists:
|
|
||||||
# Need to lock because we're messing with playlists
|
|
||||||
return _full_sync()
|
|
||||||
|
|
||||||
|
|
||||||
def _full_sync():
|
|
||||||
# Get all Plex playlists
|
|
||||||
xml = pms.all_playlists()
|
|
||||||
if xml is None:
|
|
||||||
return False
|
|
||||||
# For each playlist, check Plex database to see whether we already synced
|
|
||||||
# before. If yes, make sure that hashes are identical. If not, sync it.
|
|
||||||
old_plex_ids = db.plex_playlist_ids()
|
|
||||||
for xml_playlist in xml:
|
|
||||||
if should_cancel():
|
|
||||||
return False
|
|
||||||
api = API(xml_playlist)
|
|
||||||
try:
|
|
||||||
old_plex_ids.remove(api.plex_id)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if not sync_plex_playlist(xml=xml_playlist):
|
|
||||||
continue
|
|
||||||
playlist = db.get_playlist(plex_id=api.plex_id)
|
|
||||||
if not playlist:
|
|
||||||
LOG.debug('New Plex playlist %s discovered: %s',
|
|
||||||
api.plex_id, api.title())
|
|
||||||
try:
|
|
||||||
kodi_pl.create(api.plex_id)
|
|
||||||
except PlaylistError:
|
|
||||||
LOG.info('Skipping creation of playlist %s', api.plex_id)
|
|
||||||
elif playlist.plex_updatedat != api.updated_at():
|
|
||||||
LOG.debug('Detected changed Plex playlist %s: %s',
|
|
||||||
api.plex_id, api.title())
|
|
||||||
# Since we are DELETING a playlist, we need to catch with path!
|
|
||||||
try:
|
|
||||||
kodi_pl.delete(playlist)
|
|
||||||
except PlaylistError:
|
|
||||||
LOG.info('Skipping recreation of playlist %s', api.plex_id)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
kodi_pl.create(api.plex_id)
|
|
||||||
except PlaylistError:
|
|
||||||
LOG.info('Could not recreate playlist %s', api.plex_id)
|
|
||||||
# Get rid of old Plex playlists that were deleted on the Plex side
|
|
||||||
for plex_id in old_plex_ids:
|
|
||||||
if should_cancel():
|
|
||||||
return False
|
|
||||||
playlist = db.get_playlist(plex_id=plex_id)
|
|
||||||
LOG.debug('Removing outdated Plex playlist from Kodi: %s', playlist)
|
|
||||||
if playlist is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
kodi_pl.delete(playlist)
|
|
||||||
except PlaylistError:
|
|
||||||
LOG.debug('Skipping deletion of playlist: %s', playlist)
|
|
||||||
# Look at all supported Kodi playlists. Check whether they are in the DB.
|
|
||||||
old_kodi_paths = db.kodi_playlist_paths()
|
|
||||||
for root, _, files in path_ops.walk(v.PLAYLIST_PATH):
|
|
||||||
for f in files:
|
|
||||||
if should_cancel():
|
|
||||||
return False
|
|
||||||
path = path_ops.path.join(root, f)
|
|
||||||
try:
|
|
||||||
old_kodi_paths.remove(path)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if not sync_kodi_playlist(path):
|
|
||||||
continue
|
|
||||||
kodi_hash = kodi_playlist_hash(path)
|
|
||||||
playlist = db.get_playlist(path=path)
|
|
||||||
if playlist and playlist.kodi_hash == kodi_hash:
|
|
||||||
continue
|
|
||||||
if not playlist:
|
|
||||||
LOG.debug('New Kodi playlist detected: %s', path)
|
|
||||||
playlist = Playlist()
|
|
||||||
playlist.kodi_path = path
|
|
||||||
playlist.kodi_hash = kodi_hash
|
|
||||||
try:
|
|
||||||
plex_pl.create(playlist)
|
|
||||||
except PlaylistError:
|
|
||||||
LOG.info('Skipping Kodi playlist %s', path)
|
|
||||||
else:
|
|
||||||
LOG.debug('Changed Kodi playlist detected: %s', path)
|
|
||||||
plex_pl.delete(playlist)
|
|
||||||
playlist.kodi_hash = kodi_hash
|
|
||||||
try:
|
|
||||||
plex_pl.create(playlist)
|
|
||||||
except PlaylistError:
|
|
||||||
LOG.info('Skipping Kodi playlist %s', path)
|
|
||||||
for kodi_path in old_kodi_paths:
|
|
||||||
if should_cancel():
|
|
||||||
return False
|
|
||||||
playlist = db.get_playlist(path=kodi_path)
|
|
||||||
if not playlist:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
plex_pl.delete(playlist)
|
|
||||||
except PlaylistError:
|
|
||||||
LOG.debug('Skipping deletion of Plex playlist: %s', playlist)
|
|
||||||
LOG.info('Playlist full sync done')
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def sync_kodi_playlist(path):
|
|
||||||
"""
|
|
||||||
Checks whether we should sync a specific Kodi playlist to Plex
|
|
||||||
|
|
||||||
Will check the following conditions for one single Kodi playlist:
|
|
||||||
* Kodi mixed playlists return False
|
|
||||||
* Support of the file type of the playlist, e.g. m3u
|
|
||||||
* Whether filename matches user settings to sync, if enabled
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
path : unicode
|
|
||||||
Absolute file path to the Kodi playlist in question
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
True if we should sync this Kodi playlist to Plex, False otherwise
|
|
||||||
"""
|
|
||||||
if path.startswith(v.PLAYLIST_PATH_MIXED):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
extension = path.rsplit('.', 1)[1].lower()
|
|
||||||
except IndexError:
|
|
||||||
return False
|
|
||||||
if extension not in SUPPORTED_FILETYPES:
|
|
||||||
return False
|
|
||||||
if not app.SYNC.sync_specific_kodi_playlists:
|
|
||||||
return True
|
|
||||||
playlist = Playlist()
|
|
||||||
playlist.kodi_path = path
|
|
||||||
prefix = utils.settings('syncSpecificKodiPlaylistsPrefix').lower()
|
|
||||||
if playlist.kodi_filename.lower().startswith(prefix):
|
|
||||||
return True
|
|
||||||
LOG.debug('User chose to not sync Kodi playlist %s', path)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def sync_plex_playlist(playlist=None, xml=None, plex_id=None):
|
|
||||||
"""
|
|
||||||
Checks whether we should sync a specific Plex playlist to Kodi
|
|
||||||
|
|
||||||
Will check the following conditions for one single Plex playlist:
|
|
||||||
* Plex music playlists return False if PKC audio sync is disabled
|
|
||||||
* Whether filename matches user settings to sync, if enabled
|
|
||||||
* False is returned if we could not retrieve more information about the
|
|
||||||
playlist if only the plex_id was given
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
Pass in either playlist, xml or plex_id (preferably in this order)
|
|
||||||
|
|
||||||
plex_id : unicode
|
|
||||||
Absolute file path to the Kodi playlist in question
|
|
||||||
xml : etree xml
|
|
||||||
PMS metadata for the Plex element in question. API(xml) instead of
|
|
||||||
the usual API(xml[0]) will be used!
|
|
||||||
playlist: PlayList
|
|
||||||
A PlayList instance with Playlist.plex_name and PlayList.kodi_type set
|
|
||||||
|
|
||||||
Returns
|
|
||||||
-------
|
|
||||||
bool
|
|
||||||
True if we should sync this Plex playlist to Kodi, False otherwise
|
|
||||||
"""
|
|
||||||
if playlist:
|
|
||||||
# Mainly once we DELETED a Plex playlist that we're NOT supposed
|
|
||||||
# to sync
|
|
||||||
name = playlist.plex_name
|
|
||||||
typus = playlist.kodi_type
|
|
||||||
else:
|
|
||||||
if xml is None:
|
|
||||||
xml = pms.metadata(plex_id)
|
|
||||||
if xml is None:
|
|
||||||
LOG.info('Could not get Plex metadata for playlist %s',
|
|
||||||
plex_id)
|
|
||||||
return False
|
|
||||||
api = API(xml[0])
|
|
||||||
else:
|
|
||||||
api = API(xml)
|
|
||||||
if api.playlist_type() == v.PLEX_TYPE_PHOTO_PLAYLIST:
|
|
||||||
# Not supported by Kodi
|
|
||||||
return False
|
|
||||||
elif api.playlist_type() is None:
|
|
||||||
# Encountered in logs, seems to be a malformed answer
|
|
||||||
LOG.error('Playlist type is missing: %s', api.xml.attrib)
|
|
||||||
return False
|
|
||||||
name = api.title()
|
|
||||||
typus = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()]
|
|
||||||
if (not app.SYNC.enable_music and typus == v.PLEX_PLAYLIST_TYPE_AUDIO):
|
|
||||||
LOG.debug('Not synching Plex audio playlist')
|
|
||||||
return False
|
|
||||||
if not app.SYNC.sync_specific_plex_playlists:
|
|
||||||
return True
|
|
||||||
prefix = utils.settings('syncSpecificPlexPlaylistsPrefix').lower()
|
|
||||||
if name and name.lower().startswith(prefix):
|
|
||||||
return True
|
|
||||||
LOG.debug('User chose to not sync Plex playlist %s', name)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistEventhandler(events.FileSystemEventHandler):
|
|
||||||
"""
|
|
||||||
PKC eventhandler to monitor Kodi playlists safed to disk
|
|
||||||
"""
|
|
||||||
def dispatch(self, event):
|
|
||||||
"""
|
|
||||||
Dispatches events to the appropriate methods.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
:type event:
|
|
||||||
:class:`FileSystemEvent`
|
|
||||||
The event object representing the file system event.
|
|
||||||
"""
|
|
||||||
path = event.dest_path if event.event_type == events.EVENT_TYPE_MOVED \
|
|
||||||
else event.src_path
|
|
||||||
with app.APP.lock_playlists:
|
|
||||||
if not sync_kodi_playlist(path):
|
|
||||||
return
|
|
||||||
if path in kodi_pl.IGNORE_KODI_PLAYLIST_CHANGE:
|
|
||||||
LOG.debug('Ignoring event %s', event)
|
|
||||||
kodi_pl.IGNORE_KODI_PLAYLIST_CHANGE.remove(path)
|
|
||||||
return
|
|
||||||
_method_map = {
|
|
||||||
events.EVENT_TYPE_MODIFIED: self.on_modified,
|
|
||||||
events.EVENT_TYPE_MOVED: self.on_moved,
|
|
||||||
events.EVENT_TYPE_CREATED: self.on_created,
|
|
||||||
events.EVENT_TYPE_DELETED: self.on_deleted,
|
|
||||||
}
|
|
||||||
_method_map[event.event_type](event)
|
|
||||||
|
|
||||||
def on_created(self, event):
|
|
||||||
LOG.debug('on_created: %s', event.src_path)
|
|
||||||
old_playlist = db.get_playlist(path=event.src_path)
|
|
||||||
kodi_hash = kodi_playlist_hash(event.src_path)
|
|
||||||
if old_playlist and old_playlist.kodi_hash == kodi_hash:
|
|
||||||
LOG.debug('Playlist already in DB - skipping')
|
|
||||||
return
|
|
||||||
elif old_playlist:
|
|
||||||
LOG.debug('Playlist already in DB but it has been changed')
|
|
||||||
self.on_modified(event)
|
|
||||||
return
|
|
||||||
playlist = Playlist()
|
|
||||||
playlist.kodi_path = event.src_path
|
|
||||||
playlist.kodi_hash = kodi_hash
|
|
||||||
try:
|
|
||||||
plex_pl.create(playlist)
|
|
||||||
except PlaylistError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_modified(self, event):
|
|
||||||
LOG.debug('on_modified: %s', event.src_path)
|
|
||||||
old_playlist = db.get_playlist(path=event.src_path)
|
|
||||||
kodi_hash = kodi_playlist_hash(event.src_path)
|
|
||||||
if old_playlist and old_playlist.kodi_hash == kodi_hash:
|
|
||||||
LOG.debug('Nothing modified, playlist already in DB - skipping')
|
|
||||||
return
|
|
||||||
new_playlist = Playlist()
|
|
||||||
if old_playlist:
|
|
||||||
# Retain the name! Might've come from Plex
|
|
||||||
# (rename should fire on_moved)
|
|
||||||
new_playlist.plex_name = old_playlist.plex_name
|
|
||||||
plex_pl.delete(old_playlist)
|
|
||||||
new_playlist.kodi_path = event.src_path
|
|
||||||
new_playlist.kodi_hash = kodi_hash
|
|
||||||
try:
|
|
||||||
plex_pl.create(new_playlist)
|
|
||||||
except PlaylistError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_moved(self, event):
|
|
||||||
LOG.debug('on_moved: %s to %s', event.src_path, event.dest_path)
|
|
||||||
kodi_hash = kodi_playlist_hash(event.dest_path)
|
|
||||||
# First check whether we don't already have destination playlist in
|
|
||||||
# our DB. Just in case....
|
|
||||||
old_playlist = db.get_playlist(path=event.dest_path)
|
|
||||||
if old_playlist:
|
|
||||||
LOG.warning('Found target playlist already in our DB!')
|
|
||||||
new_event = events.FileModifiedEvent(event.dest_path)
|
|
||||||
self.on_modified(new_event)
|
|
||||||
return
|
|
||||||
# All good
|
|
||||||
old_playlist = db.get_playlist(path=event.src_path)
|
|
||||||
if not old_playlist:
|
|
||||||
LOG.debug('Did not have source path in the DB %s', event.src_path)
|
|
||||||
else:
|
|
||||||
plex_pl.delete(old_playlist)
|
|
||||||
new_playlist = Playlist()
|
|
||||||
new_playlist.kodi_path = event.dest_path
|
|
||||||
new_playlist.kodi_hash = kodi_hash
|
|
||||||
try:
|
|
||||||
plex_pl.create(new_playlist)
|
|
||||||
except PlaylistError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def on_deleted(self, event):
|
|
||||||
LOG.debug('on_deleted: %s', event.src_path)
|
|
||||||
playlist = db.get_playlist(path=event.src_path)
|
|
||||||
if not playlist:
|
|
||||||
LOG.debug('Playlist not found in DB for path %s', event.src_path)
|
|
||||||
else:
|
|
||||||
plex_pl.delete(playlist)
|
|
|
@ -1,207 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
import Queue
|
|
||||||
import time
|
|
||||||
import os
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from ..watchdog import events
|
|
||||||
from ..watchdog.observers import Observer
|
|
||||||
from ..watchdog.utils.bricks import OrderedSetQueue
|
|
||||||
|
|
||||||
from .. import path_ops, variables as v, app
|
|
||||||
from ..exceptions import PlaylistError
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
LOG = getLogger('PLEX.playlists.common')
|
|
||||||
|
|
||||||
# These filesystem events are considered similar
|
|
||||||
SIMILAR_EVENTS = (events.EVENT_TYPE_CREATED, events.EVENT_TYPE_MODIFIED)
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
|
|
||||||
class Playlist(object):
|
|
||||||
"""
|
|
||||||
Class representing a synced Playlist with info for both Kodi and Plex.
|
|
||||||
Attributes:
|
|
||||||
Plex:
|
|
||||||
plex_id: unicode
|
|
||||||
plex_name: unicode
|
|
||||||
plex_updatedat: unicode
|
|
||||||
|
|
||||||
Kodi:
|
|
||||||
kodi_path: unicode
|
|
||||||
kodi_filename: unicode
|
|
||||||
kodi_extension: unicode
|
|
||||||
kodi_type: unicode
|
|
||||||
kodi_hash: unicode
|
|
||||||
|
|
||||||
Testing for a Playlist() returns ONLY True if all the following attributes
|
|
||||||
are set; 2 playlists are only equal if all attributes are equal:
|
|
||||||
plex_id
|
|
||||||
plex_name
|
|
||||||
plex_updatedat
|
|
||||||
kodi_path
|
|
||||||
kodi_filename
|
|
||||||
kodi_type
|
|
||||||
kodi_hash
|
|
||||||
"""
|
|
||||||
def __init__(self):
|
|
||||||
# Plex
|
|
||||||
self.plex_id = None
|
|
||||||
self.plex_name = None
|
|
||||||
self.plex_updatedat = None
|
|
||||||
# Kodi
|
|
||||||
self._kodi_path = None
|
|
||||||
self.kodi_filename = None
|
|
||||||
self.kodi_extension = None
|
|
||||||
self.kodi_type = None
|
|
||||||
self.kodi_hash = None
|
|
||||||
|
|
||||||
def __unicode__(self):
|
|
||||||
return ("{{"
|
|
||||||
"'plex_id': {self.plex_id}, "
|
|
||||||
"'plex_name': '{self.plex_name}', "
|
|
||||||
"'kodi_type': '{self.kodi_type}', "
|
|
||||||
"'kodi_filename': '{self.kodi_filename}', "
|
|
||||||
"'kodi_path': '{self._kodi_path}', "
|
|
||||||
"'plex_updatedat': {self.plex_updatedat}, "
|
|
||||||
"'kodi_hash': '{self.kodi_hash}'"
|
|
||||||
"}}").format(self=self)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return self.__unicode__().encode('utf-8')
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.__repr__()
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
return (self.plex_id and self.plex_updatedat and self.plex_name and
|
|
||||||
self._kodi_path and self.kodi_filename and self.kodi_type and
|
|
||||||
self.kodi_hash)
|
|
||||||
|
|
||||||
# Used for comparison of playlists
|
|
||||||
@property
|
|
||||||
def key(self):
|
|
||||||
return (self.plex_id, self.plex_updatedat, self.plex_name,
|
|
||||||
self._kodi_path, self.kodi_filename, self.kodi_type,
|
|
||||||
self.kodi_hash)
|
|
||||||
|
|
||||||
def __eq__(self, playlist):
|
|
||||||
return self.key == playlist.key
|
|
||||||
|
|
||||||
def __ne__(self, playlist):
|
|
||||||
return self.key != playlist.key
|
|
||||||
|
|
||||||
@property
|
|
||||||
def kodi_path(self):
|
|
||||||
return self._kodi_path
|
|
||||||
|
|
||||||
@kodi_path.setter
|
|
||||||
def kodi_path(self, path):
|
|
||||||
f = path_ops.path.basename(path)
|
|
||||||
try:
|
|
||||||
self.kodi_filename, self.kodi_extension = f.rsplit('.', 1)
|
|
||||||
except ValueError:
|
|
||||||
LOG.error('Trying to set invalid path: %s', path)
|
|
||||||
raise PlaylistError('Invalid path: %s' % path)
|
|
||||||
if path.startswith(v.PLAYLIST_PATH_VIDEO):
|
|
||||||
self.kodi_type = v.KODI_TYPE_VIDEO_PLAYLIST
|
|
||||||
elif path.startswith(v.PLAYLIST_PATH_MUSIC):
|
|
||||||
self.kodi_type = v.KODI_TYPE_AUDIO_PLAYLIST
|
|
||||||
else:
|
|
||||||
LOG.error('Playlist type not supported for %s', path)
|
|
||||||
raise PlaylistError('Playlist type not supported: %s' % path)
|
|
||||||
if not self.plex_name:
|
|
||||||
self.plex_name = self.kodi_filename
|
|
||||||
self._kodi_path = path
|
|
||||||
|
|
||||||
|
|
||||||
def kodi_playlist_hash(path):
|
|
||||||
"""
|
|
||||||
Returns a md5 hash [unicode] using os.stat() st_size and st_mtime for the
|
|
||||||
playlist located at path [unicode]
|
|
||||||
(size of file in bytes and time of most recent content modification)
|
|
||||||
|
|
||||||
There are probably way more efficient ways out there to do this
|
|
||||||
"""
|
|
||||||
stat = os.stat(path_ops.encode_path(path))
|
|
||||||
# stat.st_size is of type long; stat.st_mtime is of type float - hash both
|
|
||||||
m = hashlib.md5()
|
|
||||||
m.update(repr(stat.st_size))
|
|
||||||
m.update(repr(stat.st_mtime))
|
|
||||||
return m.hexdigest().decode('utf-8')
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistQueue(OrderedSetQueue):
|
|
||||||
"""
|
|
||||||
OrderedSetQueue that drops all directory events immediately
|
|
||||||
"""
|
|
||||||
def _put(self, item):
|
|
||||||
if item[0].is_directory:
|
|
||||||
self.unfinished_tasks -= 1
|
|
||||||
else:
|
|
||||||
# Can't use super as OrderedSetQueue is old style class
|
|
||||||
OrderedSetQueue._put(self, item)
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistObserver(Observer):
|
|
||||||
"""
|
|
||||||
PKC implementation, overriding the dispatcher. PKC will wait for the
|
|
||||||
duration timeout (in seconds) AFTER receiving a filesystem event. A new
|
|
||||||
("non-similar") event will reset the timer.
|
|
||||||
Creating and modifying will be regarded as equal.
|
|
||||||
"""
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super(PlaylistObserver, self).__init__(*args, **kwargs)
|
|
||||||
# Drop the same events that get into the queue even if there are other
|
|
||||||
# events in between these similar events. Ignore directory events
|
|
||||||
# completely
|
|
||||||
self._event_queue = PlaylistQueue()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _pkc_similar_events(event1, event2):
|
|
||||||
if event1 == event2:
|
|
||||||
return True
|
|
||||||
elif (event1.src_path == event2.src_path and
|
|
||||||
event1.event_type in SIMILAR_EVENTS and
|
|
||||||
event2.event_type in SIMILAR_EVENTS):
|
|
||||||
# Set created and modified events to equal
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _dispatch_iterator(self, event_queue, timeout):
|
|
||||||
"""
|
|
||||||
This iterator will block for timeout (seconds) until an event is
|
|
||||||
received or raise Queue.Empty.
|
|
||||||
"""
|
|
||||||
event, watch = event_queue.get(block=True, timeout=timeout)
|
|
||||||
event_queue.task_done()
|
|
||||||
start = time.time()
|
|
||||||
while time.time() - start < timeout:
|
|
||||||
try:
|
|
||||||
new_event, new_watch = event_queue.get(block=False)
|
|
||||||
except Queue.Empty:
|
|
||||||
app.APP.monitor.waitForAbort(0.2)
|
|
||||||
else:
|
|
||||||
event_queue.task_done()
|
|
||||||
start = time.time()
|
|
||||||
if self._pkc_similar_events(new_event, event):
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
yield event, watch
|
|
||||||
event, watch = new_event, new_watch
|
|
||||||
yield event, watch
|
|
||||||
|
|
||||||
def dispatch_events(self, event_queue, timeout):
|
|
||||||
for event, watch in self._dispatch_iterator(event_queue, timeout):
|
|
||||||
# This is copy-paste of original code
|
|
||||||
with self._lock:
|
|
||||||
# To allow unschedule/stop and safe removal of event handlers
|
|
||||||
# within event handlers itself, check if the handler is still
|
|
||||||
# registered after every dispatch.
|
|
||||||
for handler in list(self._handlers.get(watch, [])):
|
|
||||||
if handler in self._handlers.get(watch, []):
|
|
||||||
handler.dispatch(event)
|
|
|
@ -1,134 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Synced playlists are stored in our plex.db. Interact with it through this
|
|
||||||
module
|
|
||||||
"""
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from .common import Playlist
|
|
||||||
from ..plex_db import PlexDB
|
|
||||||
from ..kodi_db import kodiid_from_filename
|
|
||||||
from .. import path_ops, utils, variables as v
|
|
||||||
from ..exceptions import PlaylistError
|
|
||||||
###############################################################################
|
|
||||||
LOG = getLogger('PLEX.playlists.db')
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
|
|
||||||
def plex_playlist_ids():
|
|
||||||
"""
|
|
||||||
Returns a list of all Plex ids of the playlists already in our DB
|
|
||||||
"""
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
return list(plexdb.playlist_ids())
|
|
||||||
|
|
||||||
|
|
||||||
def kodi_playlist_paths():
|
|
||||||
"""
|
|
||||||
Returns a list of all Kodi playlist paths of the playlists already synced
|
|
||||||
"""
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
return list(plexdb.kodi_playlist_paths())
|
|
||||||
|
|
||||||
|
|
||||||
def update_playlist(playlist, delete=False):
|
|
||||||
"""
|
|
||||||
Assumes that all sync operations are over. Takes playlist [Playlist]
|
|
||||||
and creates/updates the corresponding Plex playlists table entry
|
|
||||||
|
|
||||||
Pass delete=True to delete the playlist entry
|
|
||||||
"""
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
if delete:
|
|
||||||
plexdb.delete_playlist(playlist)
|
|
||||||
else:
|
|
||||||
plexdb.add_playlist(playlist)
|
|
||||||
|
|
||||||
|
|
||||||
def get_playlist(path=None, plex_id=None):
|
|
||||||
"""
|
|
||||||
Returns the playlist as a Playlist for either the plex_id or path
|
|
||||||
"""
|
|
||||||
playlist = Playlist()
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
playlist = plexdb.playlist(playlist, plex_id, path)
|
|
||||||
return playlist
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_kodi_playlist_paths():
|
|
||||||
"""
|
|
||||||
Returns a list with all paths for the playlists on the Kodi side
|
|
||||||
"""
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
paths = list(plexdb.all_kodi_paths())
|
|
||||||
return paths
|
|
||||||
|
|
||||||
|
|
||||||
def wipe_table():
|
|
||||||
"""
|
|
||||||
Deletes all playlists entries in the Plex DB
|
|
||||||
"""
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
plexdb.wipe_playlists()
|
|
||||||
|
|
||||||
|
|
||||||
def _m3u_iterator(text):
|
|
||||||
"""
|
|
||||||
Yields e.g. plugin://plugin.video.plexkodiconnect.movies/?plex_id=xxx
|
|
||||||
"""
|
|
||||||
lines = iter(text.split('\n'))
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith('#EXTINF:'):
|
|
||||||
next_line = next(lines).strip()
|
|
||||||
if next_line.startswith('#EXT-KX-OFFSET:'):
|
|
||||||
yield next(lines).strip()
|
|
||||||
else:
|
|
||||||
yield next_line
|
|
||||||
|
|
||||||
|
|
||||||
def m3u_to_plex_ids(playlist):
|
|
||||||
"""
|
|
||||||
Adapter to process *.m3u playlist files. Encoding is not uniform!
|
|
||||||
"""
|
|
||||||
plex_ids = list()
|
|
||||||
with open(path_ops.encode_path(playlist.kodi_path), 'rb') as f:
|
|
||||||
text = f.read()
|
|
||||||
try:
|
|
||||||
text = text.decode(v.M3U_ENCODING)
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
LOG.warning('Fallback to ISO-8859-1 decoding for %s', playlist)
|
|
||||||
text = text.decode('ISO-8859-1')
|
|
||||||
for entry in _m3u_iterator(text):
|
|
||||||
plex_id = utils.REGEX_PLEX_ID.search(entry)
|
|
||||||
if plex_id:
|
|
||||||
plex_id = plex_id.group(1)
|
|
||||||
plex_ids.append(plex_id)
|
|
||||||
else:
|
|
||||||
# Add-on paths not working, try direct
|
|
||||||
kodi_id, kodi_type = kodiid_from_filename(entry,
|
|
||||||
db_type=playlist.kodi_type)
|
|
||||||
if not kodi_id:
|
|
||||||
continue
|
|
||||||
with PlexDB() as plexdb:
|
|
||||||
item = plexdb.item_by_kodi_id(kodi_id, kodi_type)
|
|
||||||
if item:
|
|
||||||
plex_ids.append(item['plex_id'])
|
|
||||||
return plex_ids
|
|
||||||
|
|
||||||
|
|
||||||
def playlist_file_to_plex_ids(playlist):
|
|
||||||
"""
|
|
||||||
Takes the playlist file located at path [unicode] and parses it.
|
|
||||||
Returns a list of plex_ids (str) or raises PlaylistError if a single
|
|
||||||
item cannot be parsed from Kodi to Plex.
|
|
||||||
"""
|
|
||||||
if playlist.kodi_extension == 'm3u':
|
|
||||||
plex_ids = m3u_to_plex_ids(playlist)
|
|
||||||
else:
|
|
||||||
LOG.error('Unsupported playlist extension: %s',
|
|
||||||
playlist.kodi_extension)
|
|
||||||
raise PlaylistError
|
|
||||||
return plex_ids
|
|
|
@ -1,175 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Create and delete playlists on the Kodi side of things
|
|
||||||
"""
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
import re
|
|
||||||
|
|
||||||
from .common import Playlist, kodi_playlist_hash
|
|
||||||
from . import db, pms
|
|
||||||
|
|
||||||
from ..plex_api import API
|
|
||||||
from .. import utils, path_ops, variables as v
|
|
||||||
from ..exceptions import PlaylistError
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
LOG = getLogger('PLEX.playlists.kodi_pl')
|
|
||||||
REGEX_FILE_NUMBERING = re.compile(r'''_(\d\d)\.\w+$''')
|
|
||||||
# Avoid endless loops. Store the Kodi paths
|
|
||||||
IGNORE_KODI_PLAYLIST_CHANGE = list()
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
|
|
||||||
def create(plex_id):
|
|
||||||
"""
|
|
||||||
Creates a new Kodi playlist file. Will also add (or modify an existing)
|
|
||||||
Plex playlist table entry.
|
|
||||||
Assumes that the Plex playlist is indeed new. A NEW Kodi playlist will be
|
|
||||||
created in any case (not replaced). Thus make sure that the "same" playlist
|
|
||||||
is deleted from both disk and the Plex database.
|
|
||||||
Returns the playlist or raises PlaylistError
|
|
||||||
"""
|
|
||||||
xml_metadata = pms.metadata(plex_id)
|
|
||||||
if xml_metadata is None:
|
|
||||||
LOG.error('Could not get Plex playlist metadata %s', plex_id)
|
|
||||||
raise PlaylistError('Could not get Plex playlist %s' % plex_id)
|
|
||||||
api = API(xml_metadata[0])
|
|
||||||
playlist = Playlist()
|
|
||||||
playlist.plex_id = api.plex_id
|
|
||||||
playlist.kodi_type = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()]
|
|
||||||
playlist.plex_name = api.title()
|
|
||||||
playlist.plex_updatedat = api.updated_at()
|
|
||||||
LOG.debug('Creating new Kodi playlist from Plex playlist: %s', playlist)
|
|
||||||
# Derive filename close to Plex playlist name
|
|
||||||
name = utils.valid_filename(playlist.plex_name)
|
|
||||||
path = path_ops.path.join(v.PLAYLIST_PATH, playlist.kodi_type,
|
|
||||||
'%s.m3u' % name)
|
|
||||||
while path_ops.exists(path) or db.get_playlist(path=path):
|
|
||||||
# In case the Plex playlist names are not unique
|
|
||||||
occurance = REGEX_FILE_NUMBERING.search(path)
|
|
||||||
if not occurance:
|
|
||||||
path = path_ops.path.join(v.PLAYLIST_PATH,
|
|
||||||
playlist.kodi_type,
|
|
||||||
'%s_01.m3u' % name[:min(len(name), 248)])
|
|
||||||
else:
|
|
||||||
number = int(occurance.group(1)) + 1
|
|
||||||
if number > 3:
|
|
||||||
LOG.warn('Detected spanning tree issue, abort sync for %s',
|
|
||||||
playlist)
|
|
||||||
raise PlaylistError('Spanning tree warning')
|
|
||||||
basename = re.sub(REGEX_FILE_NUMBERING, '', path)
|
|
||||||
path = '%s_%02d.m3u' % (basename, number)
|
|
||||||
LOG.debug('Kodi playlist path: %s', path)
|
|
||||||
playlist.kodi_path = path
|
|
||||||
xml_playlist = pms.get_playlist(plex_id)
|
|
||||||
if xml_playlist is None:
|
|
||||||
LOG.error('Could not get Plex playlist %s', plex_id)
|
|
||||||
raise PlaylistError('Could not get Plex playlist %s' % plex_id)
|
|
||||||
IGNORE_KODI_PLAYLIST_CHANGE.append(playlist.kodi_path)
|
|
||||||
try:
|
|
||||||
_write_playlist_to_file(playlist, xml_playlist)
|
|
||||||
except Exception:
|
|
||||||
IGNORE_KODI_PLAYLIST_CHANGE.remove(playlist.kodi_path)
|
|
||||||
raise
|
|
||||||
playlist.kodi_hash = kodi_playlist_hash(path)
|
|
||||||
db.update_playlist(playlist)
|
|
||||||
LOG.debug('Created Kodi playlist based on Plex playlist: %s', playlist)
|
|
||||||
|
|
||||||
|
|
||||||
def delete(playlist):
|
|
||||||
"""
|
|
||||||
Removes the corresponding Kodi file for playlist Playlist from
|
|
||||||
disk. Be sure that playlist.kodi_path is set. Will also delete the entry in
|
|
||||||
the Plex playlist table.
|
|
||||||
Returns None or raises PlaylistError
|
|
||||||
"""
|
|
||||||
if path_ops.exists(playlist.kodi_path):
|
|
||||||
IGNORE_KODI_PLAYLIST_CHANGE.append(playlist.kodi_path)
|
|
||||||
try:
|
|
||||||
path_ops.remove(playlist.kodi_path)
|
|
||||||
LOG.debug('Deleted Kodi playlist: %s', playlist)
|
|
||||||
except (OSError, IOError) as err:
|
|
||||||
LOG.error('Could not delete Kodi playlist file %s. Error:\n%s: %s',
|
|
||||||
playlist, err.errno, err.strerror)
|
|
||||||
IGNORE_KODI_PLAYLIST_CHANGE.remove(playlist.kodi_path)
|
|
||||||
raise PlaylistError('Could not delete %s' % playlist.kodi_path)
|
|
||||||
db.update_playlist(playlist, delete=True)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_kodi_playlists(playlist_paths):
|
|
||||||
"""
|
|
||||||
Deletes all the the playlist files passed in; WILL IGNORE THIS CHANGE ON
|
|
||||||
THE PLEX SIDE!
|
|
||||||
"""
|
|
||||||
for path in playlist_paths:
|
|
||||||
try:
|
|
||||||
path_ops.remove(path)
|
|
||||||
# Ensure we're not deleting the playlists on the Plex side later
|
|
||||||
IGNORE_KODI_PLAYLIST_CHANGE.append(path)
|
|
||||||
LOG.info('Removed playlist %s', path)
|
|
||||||
except (OSError, IOError):
|
|
||||||
LOG.warn('Could not remove playlist %s', path)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_playlist_to_file(playlist, xml):
|
|
||||||
"""
|
|
||||||
Feed with playlist Playlist. Will write the playlist to a m3u file
|
|
||||||
Returns None or raises PlaylistError
|
|
||||||
"""
|
|
||||||
text = '#EXTCPlayListM3U::M3U\n'
|
|
||||||
for xml_element in xml:
|
|
||||||
text += _m3u_element(xml_element)
|
|
||||||
text += '\n'
|
|
||||||
text = text.encode(v.M3U_ENCODING, 'ignore')
|
|
||||||
try:
|
|
||||||
with open(path_ops.encode_path(playlist.kodi_path), 'wb') as f:
|
|
||||||
f.write(text)
|
|
||||||
except EnvironmentError as err:
|
|
||||||
LOG.error('Could not write Kodi playlist file: %s', playlist)
|
|
||||||
LOG.error('Error message %s: %s', err.errno, err.strerror)
|
|
||||||
raise PlaylistError('Cannot write Kodi playlist to path for %s'
|
|
||||||
% playlist)
|
|
||||||
|
|
||||||
|
|
||||||
def _m3u_element(xml_element):
|
|
||||||
api = API(xml_element)
|
|
||||||
if api.plex_type == v.PLEX_TYPE_EPISODE:
|
|
||||||
if api.season_number() is not None and api.index() is not None:
|
|
||||||
return '#EXTINF:{},{} S{:2d}E{:2d} - {}\n{}\n'.format(
|
|
||||||
api.runtime(),
|
|
||||||
api.show_title(),
|
|
||||||
api.season_number(),
|
|
||||||
api.index(),
|
|
||||||
api.title(),
|
|
||||||
api.fullpath(force_addon=True)[0])
|
|
||||||
else:
|
|
||||||
# Only append the TV show name
|
|
||||||
return '#EXTINF:{},{} - {}\n{}\n'.format(
|
|
||||||
api.runtime(),
|
|
||||||
api.show_title(),
|
|
||||||
api.title(),
|
|
||||||
api.fullpath(force_addon=True)[0])
|
|
||||||
elif api.plex_type == v.PLEX_TYPE_SONG:
|
|
||||||
if api.index() is not None:
|
|
||||||
return '#EXTINF:{},{:02d}. {} - {}\n{}\n'.format(
|
|
||||||
api.runtime(),
|
|
||||||
api.index(),
|
|
||||||
api.grandparent_title(),
|
|
||||||
api.title(),
|
|
||||||
api.fullpath(force_first_media=True,
|
|
||||||
omit_check=True)[0])
|
|
||||||
else:
|
|
||||||
return '#EXTINF:{},{} - {}\n{}\n'.format(
|
|
||||||
api.runtime(),
|
|
||||||
api.grandparent_title(),
|
|
||||||
api.title(),
|
|
||||||
api.fullpath(force_first_media=True,
|
|
||||||
omit_check=True)[0])
|
|
||||||
else:
|
|
||||||
return '#EXTINF:{},{}\n{}\n'.format(
|
|
||||||
api.runtime(),
|
|
||||||
api.title(),
|
|
||||||
api.fullpath(force_first_media=True,
|
|
||||||
omit_check=True)[0])
|
|
|
@ -1,49 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Create and delete playlists on the Plex side of things
|
|
||||||
"""
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from . import pms, db
|
|
||||||
from ..exceptions import PlaylistError
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
LOG = getLogger('PLEX.playlists.plex_pl')
|
|
||||||
# Used for updating Plex playlists due to Kodi changes - Plex playlist
|
|
||||||
# will have to be deleted first. Add Plex ids!
|
|
||||||
IGNORE_PLEX_PLAYLIST_CHANGE = list()
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
|
|
||||||
def create(playlist):
|
|
||||||
"""
|
|
||||||
Adds the playlist Playlist to the PMS. If playlist.id is
|
|
||||||
not None the existing Plex playlist will be overwritten; otherwise a new
|
|
||||||
playlist will be generated and stored accordingly in the playlist object.
|
|
||||||
Will also add (or modify an existing) Plex playlist table entry.
|
|
||||||
Make sure that playlist.kodi_hash is set!
|
|
||||||
Returns None or raises PlaylistError
|
|
||||||
"""
|
|
||||||
LOG.debug('Creating Plex playlist from Kodi file: %s', playlist)
|
|
||||||
plex_ids = db.playlist_file_to_plex_ids(playlist)
|
|
||||||
if not plex_ids:
|
|
||||||
LOG.warning('No Plex ids found for playlist %s', playlist)
|
|
||||||
raise PlaylistError
|
|
||||||
pms.add_items(playlist, plex_ids)
|
|
||||||
IGNORE_PLEX_PLAYLIST_CHANGE.append(playlist.plex_id)
|
|
||||||
db.update_playlist(playlist)
|
|
||||||
LOG.debug('Done creating Plex playlist %s', playlist)
|
|
||||||
|
|
||||||
|
|
||||||
def delete(playlist):
|
|
||||||
"""
|
|
||||||
Removes the playlist Playlist from the PMS. Will also delete the
|
|
||||||
entry in the Plex playlist table.
|
|
||||||
Returns None or raises PlaylistError
|
|
||||||
"""
|
|
||||||
LOG.debug('Deleting playlist from PMS: %s', playlist)
|
|
||||||
IGNORE_PLEX_PLAYLIST_CHANGE.append(playlist.plex_id)
|
|
||||||
pms.delete(playlist)
|
|
||||||
db.update_playlist(playlist, delete=True)
|
|
|
@ -1,145 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Functions to communicate with the currently connected PMS in order to
|
|
||||||
manipulate playlists
|
|
||||||
"""
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from ..plex_api import API
|
|
||||||
from ..downloadutils import DownloadUtils as DU
|
|
||||||
from .. import utils, app, variables as v
|
|
||||||
from ..exceptions import PlaylistError
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
LOG = getLogger('PLEX.playlists.pms')
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
|
|
||||||
def all_playlists():
|
|
||||||
"""
|
|
||||||
Returns an XML with all Plex playlists or None
|
|
||||||
"""
|
|
||||||
xml = DU().downloadUrl('{server}/playlists')
|
|
||||||
try:
|
|
||||||
xml.attrib
|
|
||||||
except (AttributeError, TypeError):
|
|
||||||
LOG.error('Could not download a list of all playlists')
|
|
||||||
xml = None
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def get_playlist(plex_id):
|
|
||||||
"""
|
|
||||||
Fetches the PMS playlist/playqueue as an XML. Pass in playlist id
|
|
||||||
Returns None if something went wrong
|
|
||||||
"""
|
|
||||||
xml = DU().downloadUrl("{server}/playlists/%s/items" % plex_id)
|
|
||||||
try:
|
|
||||||
xml.attrib
|
|
||||||
except AttributeError:
|
|
||||||
xml = None
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def initialize(playlist, plex_id):
|
|
||||||
"""
|
|
||||||
Initializes a new playlist on the PMS side. Will set playlist.plex_id and
|
|
||||||
playlist.plex_updatedat. Will raise PlaylistError if something went wrong.
|
|
||||||
"""
|
|
||||||
LOG.debug('Initializing the playlist with Plex id %s on the Plex side: %s',
|
|
||||||
plex_id, playlist)
|
|
||||||
params = {
|
|
||||||
'type': v.PLEX_PLAYLIST_TYPE_FROM_KODI[playlist.kodi_type],
|
|
||||||
'title': playlist.plex_name,
|
|
||||||
'smart': 0,
|
|
||||||
'uri': ('library://None/item/%s' % (utils.quote('/library/metadata/%s'
|
|
||||||
% plex_id, safe='')))
|
|
||||||
}
|
|
||||||
xml = DU().downloadUrl(url='{server}/playlists',
|
|
||||||
action_type='POST',
|
|
||||||
parameters=params)
|
|
||||||
try:
|
|
||||||
xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error('Could not initialize playlist on Plex side with plex id %s',
|
|
||||||
plex_id)
|
|
||||||
raise PlaylistError('Could not initialize Plex playlist %s', plex_id)
|
|
||||||
api = API(xml[0])
|
|
||||||
playlist.plex_id = api.plex_id
|
|
||||||
playlist.plex_updatedat = api.updated_at()
|
|
||||||
|
|
||||||
|
|
||||||
def add_item(playlist, plex_id):
|
|
||||||
"""
|
|
||||||
Adds the item with plex_id to the existing Plex playlist (at the end).
|
|
||||||
Will set playlist.plex_updatedat
|
|
||||||
Raises PlaylistError if that did not work out.
|
|
||||||
"""
|
|
||||||
params = {
|
|
||||||
'uri': ('library://None/item/%s' % (utils.quote('/library/metadata/%s'
|
|
||||||
% plex_id, safe='')))
|
|
||||||
}
|
|
||||||
xml = DU().downloadUrl(url='{server}/playlists/%s/items' % playlist.plex_id,
|
|
||||||
action_type='PUT',
|
|
||||||
parameters=params)
|
|
||||||
try:
|
|
||||||
xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error('Could not initialize playlist on Plex side with plex id %s',
|
|
||||||
plex_id)
|
|
||||||
raise PlaylistError('Could not item %s to Plex playlist %s',
|
|
||||||
plex_id, playlist)
|
|
||||||
api = API(xml[0])
|
|
||||||
playlist.plex_updatedat = api.updated_at()
|
|
||||||
|
|
||||||
|
|
||||||
def add_items(playlist, plex_ids):
|
|
||||||
"""
|
|
||||||
Adds all plex_ids (a list of ints) to a new Plex playlist.
|
|
||||||
Will set playlist.plex_updatedat
|
|
||||||
Raises PlaylistError if that did not work out.
|
|
||||||
"""
|
|
||||||
params = {
|
|
||||||
'type': v.PLEX_PLAYLIST_TYPE_FROM_KODI[playlist.kodi_type],
|
|
||||||
'title': playlist.plex_name,
|
|
||||||
'smart': 0,
|
|
||||||
'uri': ('server://%s/com.plexapp.plugins.library/library/metadata/%s'
|
|
||||||
% (app.CONN.machine_identifier,
|
|
||||||
','.join(unicode(x) for x in plex_ids)))
|
|
||||||
}
|
|
||||||
xml = DU().downloadUrl(url='{server}/playlists/',
|
|
||||||
action_type='POST',
|
|
||||||
parameters=params)
|
|
||||||
try:
|
|
||||||
xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
LOG.error('Could not add items to a new playlist %s on Plex side',
|
|
||||||
playlist)
|
|
||||||
raise PlaylistError('Could not add items to a new Plex playlist %s' %
|
|
||||||
playlist)
|
|
||||||
api = API(xml[0])
|
|
||||||
playlist.plex_id = api.plex_id
|
|
||||||
playlist.plex_updatedat = api.updated_at()
|
|
||||||
|
|
||||||
|
|
||||||
def metadata(plex_id):
|
|
||||||
"""
|
|
||||||
Returns an xml with the entire metadata like updatedAt.
|
|
||||||
"""
|
|
||||||
xml = DU().downloadUrl('{server}/playlists/%s' % plex_id)
|
|
||||||
try:
|
|
||||||
xml.attrib
|
|
||||||
except AttributeError:
|
|
||||||
xml = None
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def delete(playlist):
|
|
||||||
"""
|
|
||||||
Deletes the playlist from the PMS
|
|
||||||
"""
|
|
||||||
DU().downloadUrl('{server}/playlists/%s' % playlist.plex_id,
|
|
||||||
action_type="DELETE")
|
|
|
@ -1,23 +1,27 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
"""
|
||||||
Monitors the Kodi playqueue and adjusts the Plex playqueue accordingly
|
Monitors the Kodi playqueue and adjusts the Plex playqueue accordingly
|
||||||
"""
|
"""
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
import copy
|
from threading import Thread
|
||||||
|
from re import compile as re_compile
|
||||||
|
|
||||||
import xbmc
|
from xbmc import Player, PlayList, PLAYLIST_MUSIC, PLAYLIST_VIDEO, sleep
|
||||||
|
|
||||||
from .plex_api import API
|
from utils import thread_methods
|
||||||
from . import playlist_func as PL, plex_functions as PF
|
import playlist_func as PL
|
||||||
from . import backgroundthread, utils, json_rpc as js, app, variables as v
|
from PlexFunctions import GetAllPlexChildren
|
||||||
from . import exceptions
|
from PlexAPI import API
|
||||||
|
from plexbmchelper.subscribers import LOCK
|
||||||
|
from playback import play_xml
|
||||||
|
import json_rpc as js
|
||||||
|
import variables as v
|
||||||
|
import state
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
LOG = getLogger('PLEX.playqueue')
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
|
||||||
PLUGIN = 'plugin://%s' % v.ADDON_ID
|
PLUGIN = 'plugin://%s' % v.ADDON_ID
|
||||||
|
REGEX = re_compile(r'''plex_id=(\d+)''')
|
||||||
|
|
||||||
# Our PKC playqueues (3 instances of Playqueue_Object())
|
# Our PKC playqueues (3 instances of Playqueue_Object())
|
||||||
PLAYQUEUES = []
|
PLAYQUEUES = []
|
||||||
|
@ -33,7 +37,7 @@ def init_playqueues():
|
||||||
LOG.debug('Playqueues have already been initialized')
|
LOG.debug('Playqueues have already been initialized')
|
||||||
return
|
return
|
||||||
# Initialize Kodi playqueues
|
# Initialize Kodi playqueues
|
||||||
with app.APP.lock_playqueues:
|
with LOCK:
|
||||||
for i in (0, 1, 2):
|
for i in (0, 1, 2):
|
||||||
# Just in case the Kodi response is not sorted correctly
|
# Just in case the Kodi response is not sorted correctly
|
||||||
for queue in js.get_playlists():
|
for queue in js.get_playlists():
|
||||||
|
@ -44,12 +48,12 @@ def init_playqueues():
|
||||||
playqueue.type = queue['type']
|
playqueue.type = queue['type']
|
||||||
# Initialize each Kodi playlist
|
# Initialize each Kodi playlist
|
||||||
if playqueue.type == v.KODI_TYPE_AUDIO:
|
if playqueue.type == v.KODI_TYPE_AUDIO:
|
||||||
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_MUSIC)
|
playqueue.kodi_pl = PlayList(PLAYLIST_MUSIC)
|
||||||
elif playqueue.type == v.KODI_TYPE_VIDEO:
|
elif playqueue.type == v.KODI_TYPE_VIDEO:
|
||||||
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
|
playqueue.kodi_pl = PlayList(PLAYLIST_VIDEO)
|
||||||
else:
|
else:
|
||||||
# Currently, only video or audio playqueues available
|
# Currently, only video or audio playqueues available
|
||||||
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
|
playqueue.kodi_pl = PlayList(PLAYLIST_VIDEO)
|
||||||
# Overwrite 'picture' with 'photo'
|
# Overwrite 'picture' with 'photo'
|
||||||
playqueue.type = v.KODI_TYPE_PHOTO
|
playqueue.type = v.KODI_TYPE_PHOTO
|
||||||
PLAYQUEUES.append(playqueue)
|
PLAYQUEUES.append(playqueue)
|
||||||
|
@ -61,22 +65,23 @@ def get_playqueue_from_type(kodi_playlist_type):
|
||||||
Returns the playqueue according to the kodi_playlist_type ('video',
|
Returns the playqueue according to the kodi_playlist_type ('video',
|
||||||
'audio', 'picture') passed in
|
'audio', 'picture') passed in
|
||||||
"""
|
"""
|
||||||
for playqueue in PLAYQUEUES:
|
with LOCK:
|
||||||
if playqueue.type == kodi_playlist_type:
|
for playqueue in PLAYQUEUES:
|
||||||
break
|
if playqueue.type == kodi_playlist_type:
|
||||||
else:
|
break
|
||||||
raise ValueError('Wrong playlist type passed in: %s',
|
else:
|
||||||
kodi_playlist_type)
|
raise ValueError('Wrong playlist type passed in: %s',
|
||||||
return playqueue
|
kodi_playlist_type)
|
||||||
|
return playqueue
|
||||||
|
|
||||||
|
|
||||||
def init_playqueue_from_plex_children(plex_id, transient_token=None):
|
def init_playqueue_from_plex_children(plex_id, transient_token=None):
|
||||||
"""
|
"""
|
||||||
Init a new playqueue e.g. from an album. Alexa does this
|
Init a new playqueue e.g. from an album. Alexa does this
|
||||||
|
|
||||||
Returns the playqueue
|
Returns the Playlist_Object
|
||||||
"""
|
"""
|
||||||
xml = PF.GetAllPlexChildren(plex_id)
|
xml = GetAllPlexChildren(plex_id)
|
||||||
try:
|
try:
|
||||||
xml[0].attrib
|
xml[0].attrib
|
||||||
except (TypeError, IndexError, AttributeError):
|
except (TypeError, IndexError, AttributeError):
|
||||||
|
@ -87,31 +92,55 @@ def init_playqueue_from_plex_children(plex_id, transient_token=None):
|
||||||
playqueue.clear()
|
playqueue.clear()
|
||||||
for i, child in enumerate(xml):
|
for i, child in enumerate(xml):
|
||||||
api = API(child)
|
api = API(child)
|
||||||
try:
|
PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id())
|
||||||
PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id)
|
|
||||||
except exceptions.PlaylistError:
|
|
||||||
LOG.error('Could not add Plex item to our playlist: %s, %s',
|
|
||||||
child.tag, child.attrib)
|
|
||||||
playqueue.plex_transient_token = transient_token
|
playqueue.plex_transient_token = transient_token
|
||||||
LOG.debug('Firing up Kodi player')
|
LOG.debug('Firing up Kodi player')
|
||||||
app.APP.player.play(playqueue.kodi_pl, None, False, 0)
|
Player().play(playqueue.kodi_pl, None, False, 0)
|
||||||
return playqueue
|
return playqueue
|
||||||
|
|
||||||
|
|
||||||
class PlayqueueMonitor(backgroundthread.KillableThread):
|
def update_playqueue_from_PMS(playqueue,
|
||||||
|
playqueue_id=None,
|
||||||
|
repeat=None,
|
||||||
|
offset=None,
|
||||||
|
transient_token=None):
|
||||||
|
"""
|
||||||
|
Completely updates the Kodi playqueue with the new Plex playqueue. Pass
|
||||||
|
in playqueue_id if we need to fetch a new playqueue
|
||||||
|
|
||||||
|
repeat = 0, 1, 2
|
||||||
|
offset = time offset in Plextime (milliseconds)
|
||||||
|
"""
|
||||||
|
LOG.info('New playqueue %s received from Plex companion with offset '
|
||||||
|
'%s, repeat %s', playqueue_id, offset, repeat)
|
||||||
|
# Safe transient token from being deleted
|
||||||
|
if transient_token is None:
|
||||||
|
transient_token = playqueue.plex_transient_token
|
||||||
|
with LOCK:
|
||||||
|
xml = PL.get_PMS_playlist(playqueue, playqueue_id)
|
||||||
|
playqueue.clear()
|
||||||
|
try:
|
||||||
|
PL.get_playlist_details_from_xml(playqueue, xml)
|
||||||
|
except PL.PlaylistError:
|
||||||
|
LOG.error('Could not get playqueue ID %s', playqueue_id)
|
||||||
|
return
|
||||||
|
playqueue.repeat = 0 if not repeat else int(repeat)
|
||||||
|
playqueue.plex_transient_token = transient_token
|
||||||
|
play_xml(playqueue, xml, offset)
|
||||||
|
|
||||||
|
|
||||||
|
@thread_methods(add_suspends=['PMS_STATUS'])
|
||||||
|
class PlayqueueMonitor(Thread):
|
||||||
"""
|
"""
|
||||||
Unfortunately, Kodi does not tell if items within a Kodi playqueue
|
Unfortunately, Kodi does not tell if items within a Kodi playqueue
|
||||||
(playlist) are swapped. This is what this monitor is for. Don't replace
|
(playlist) are swapped. This is what this monitor is for. Don't replace
|
||||||
this mechanism till Kodi's implementation of playlists has improved
|
this mechanism till Kodi's implementation of playlists has improved
|
||||||
"""
|
"""
|
||||||
def _compare_playqueues(self, playqueue, new_kodi_playqueue):
|
def _compare_playqueues(self, playqueue, new):
|
||||||
"""
|
"""
|
||||||
Used to poll the Kodi playqueue and update the Plex playqueue if needed
|
Used to poll the Kodi playqueue and update the Plex playqueue if needed
|
||||||
"""
|
"""
|
||||||
old = list(playqueue.items)
|
old = list(playqueue.items)
|
||||||
# We might append to new_kodi_playqueue but will need the original
|
|
||||||
# still back in the main loop
|
|
||||||
new = copy.deepcopy(new_kodi_playqueue)
|
|
||||||
index = list(range(0, len(old)))
|
index = list(range(0, len(old)))
|
||||||
LOG.debug('Comparing new Kodi playqueue %s with our play queue %s',
|
LOG.debug('Comparing new Kodi playqueue %s with our play queue %s',
|
||||||
new, old)
|
new, old)
|
||||||
|
@ -121,7 +150,7 @@ class PlayqueueMonitor(backgroundthread.KillableThread):
|
||||||
# Ignore new media added by other addons
|
# Ignore new media added by other addons
|
||||||
continue
|
continue
|
||||||
for j, old_item in enumerate(old):
|
for j, old_item in enumerate(old):
|
||||||
if self.should_suspend() or self.should_cancel():
|
if self.stopped():
|
||||||
# Chances are that we got an empty Kodi playlist due to
|
# Chances are that we got an empty Kodi playlist due to
|
||||||
# Kodi exit
|
# Kodi exit
|
||||||
return
|
return
|
||||||
|
@ -138,7 +167,7 @@ class PlayqueueMonitor(backgroundthread.KillableThread):
|
||||||
old_item.kodi_type == new_item['type'])
|
old_item.kodi_type == new_item['type'])
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
plex_id = int(utils.REGEX_PLEX_ID.findall(new_item['file'])[0])
|
plex_id = REGEX.findall(new_item['file'])[0]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
LOG.debug('Comparing paths directly as a fallback')
|
LOG.debug('Comparing paths directly as a fallback')
|
||||||
identical = old_item.file == new_item['file']
|
identical = old_item.file == new_item['file']
|
||||||
|
@ -148,86 +177,75 @@ class PlayqueueMonitor(backgroundthread.KillableThread):
|
||||||
del old[j], index[j]
|
del old[j], index[j]
|
||||||
break
|
break
|
||||||
elif identical:
|
elif identical:
|
||||||
LOG.debug('Playqueue item %s moved to position %s',
|
LOG.debug('Detected playqueue item %s moved to position %s',
|
||||||
i + j, i)
|
i + j, i)
|
||||||
try:
|
with LOCK:
|
||||||
PL.move_playlist_item(playqueue, i + j, i)
|
PL.move_playlist_item(playqueue, i + j, i)
|
||||||
except exceptions.PlaylistError:
|
|
||||||
LOG.error('Could not modify playqueue positions')
|
|
||||||
LOG.error('This is likely caused by mixing audio and '
|
|
||||||
'video tracks in the Kodi playqueue')
|
|
||||||
del old[j], index[j]
|
del old[j], index[j]
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
LOG.debug('Detected new Kodi element at position %s: %s ',
|
LOG.debug('Detected new Kodi element at position %s: %s ',
|
||||||
i, new_item)
|
i, new_item)
|
||||||
try:
|
with LOCK:
|
||||||
if playqueue.id is None:
|
try:
|
||||||
PL.init_plex_playqueue(playqueue, kodi_item=new_item)
|
if playqueue.id is None:
|
||||||
|
PL.init_Plex_playlist(playqueue, kodi_item=new_item)
|
||||||
|
else:
|
||||||
|
PL.add_item_to_PMS_playlist(playqueue,
|
||||||
|
i,
|
||||||
|
kodi_item=new_item)
|
||||||
|
except PL.PlaylistError:
|
||||||
|
# Could not add the element
|
||||||
|
pass
|
||||||
|
except IndexError:
|
||||||
|
# This is really a hack - happens when using Addon Paths
|
||||||
|
# and repeatedly starting the same element. Kodi will
|
||||||
|
# then not pass kodi id nor file path AND will also not
|
||||||
|
# start-up playback. Hence kodimonitor kicks off
|
||||||
|
# playback. Also see kodimonitor.py - _playlist_onadd()
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
PL.add_item_to_plex_playqueue(playqueue,
|
for j in range(i, len(index)):
|
||||||
i,
|
index[j] += 1
|
||||||
kodi_item=new_item)
|
|
||||||
except exceptions.PlaylistError:
|
|
||||||
# Could not add the element
|
|
||||||
pass
|
|
||||||
except KeyError:
|
|
||||||
# Catches KeyError from PL.verify_kodi_item()
|
|
||||||
# Hack: Kodi already started playback of a new item and we
|
|
||||||
# started playback already using kodimonitors
|
|
||||||
# PlayBackStart(), but the Kodi playlist STILL only shows
|
|
||||||
# the old element. Hence ignore playlist difference here
|
|
||||||
LOG.debug('Detected an outdated Kodi playlist - ignoring')
|
|
||||||
return
|
|
||||||
except IndexError:
|
|
||||||
# This is really a hack - happens when using Addon Paths
|
|
||||||
# and repeatedly starting the same element. Kodi will then
|
|
||||||
# not pass kodi id nor file path AND will also not
|
|
||||||
# start-up playback. Hence kodimonitor kicks off playback.
|
|
||||||
# Also see kodimonitor.py - _playlist_onadd()
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
for j in range(i, len(index)):
|
|
||||||
index[j] += 1
|
|
||||||
for i in reversed(index):
|
for i in reversed(index):
|
||||||
if self.should_suspend() or self.should_cancel():
|
if self.stopped():
|
||||||
# Chances are that we got an empty Kodi playlist due to
|
# Chances are that we got an empty Kodi playlist due to
|
||||||
# Kodi exit
|
# Kodi exit
|
||||||
return
|
return
|
||||||
LOG.debug('Detected deletion of playqueue element at pos %s', i)
|
LOG.debug('Detected deletion of playqueue element at pos %s', i)
|
||||||
try:
|
with LOCK:
|
||||||
PL.delete_playlist_item_from_PMS(playqueue, i)
|
PL.delete_playlist_item_from_PMS(playqueue, i)
|
||||||
except exceptions.PlaylistError:
|
|
||||||
LOG.error('Could not delete PMS element from position %s', i)
|
|
||||||
LOG.error('This is likely caused by mixing audio and '
|
|
||||||
'video tracks in the Kodi playqueue')
|
|
||||||
LOG.debug('Done comparing playqueues')
|
LOG.debug('Done comparing playqueues')
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
|
stopped = self.stopped
|
||||||
|
suspended = self.suspended
|
||||||
LOG.info("----===## Starting PlayqueueMonitor ##===----")
|
LOG.info("----===## Starting PlayqueueMonitor ##===----")
|
||||||
app.APP.register_thread(self)
|
while not stopped():
|
||||||
try:
|
while suspended():
|
||||||
self._run()
|
if stopped():
|
||||||
finally:
|
break
|
||||||
app.APP.deregister_thread(self)
|
sleep(1000)
|
||||||
LOG.info("----===## PlayqueueMonitor stopped ##===----")
|
work = []
|
||||||
|
# Detect changed playqueues first, do the work afterwards
|
||||||
def _run(self):
|
with LOCK:
|
||||||
while not self.should_cancel():
|
|
||||||
if self.should_suspend():
|
|
||||||
if self.wait_while_suspended():
|
|
||||||
return
|
|
||||||
with app.APP.lock_playqueues:
|
|
||||||
for playqueue in PLAYQUEUES:
|
for playqueue in PLAYQUEUES:
|
||||||
kodi_pl = js.playlist_get_items(playqueue.playlistid)
|
kodi_pl = js.playlist_get_items(playqueue.playlistid)
|
||||||
if playqueue.old_kodi_pl != kodi_pl:
|
if playqueue.old_kodi_pl != kodi_pl:
|
||||||
if playqueue.id is None and (not app.SYNC.direct_paths or
|
if playqueue.id is None and (not state.DIRECT_PATHS or
|
||||||
app.PLAYSTATE.context_menu_play):
|
state.CONTEXT_MENU_PLAY):
|
||||||
# Only initialize if directly fired up using direct
|
# Only initialize if directly fired up using direct
|
||||||
# paths. Otherwise let default.py do its magic
|
# paths. Otherwise let default.py do its magic
|
||||||
LOG.debug('Not yet initiating playback')
|
LOG.debug('Not yet initiating playback')
|
||||||
|
elif playqueue.pkc_edit:
|
||||||
|
playqueue.pkc_edit = False
|
||||||
|
LOG.debug('PKC edited the playqueue - skipping')
|
||||||
else:
|
else:
|
||||||
# compare old and new playqueue
|
# We do need to update our playqueues
|
||||||
self._compare_playqueues(playqueue, kodi_pl)
|
work.append((playqueue, kodi_pl))
|
||||||
playqueue.old_kodi_pl = list(kodi_pl)
|
playqueue.old_kodi_pl = kodi_pl
|
||||||
self.sleep(0.2)
|
# Now do the work - LOCK individual playqueue edits
|
||||||
|
for playqueue, kodi_pl in work:
|
||||||
|
self._compare_playqueues(playqueue, kodi_pl)
|
||||||
|
sleep(200)
|
||||||
|
LOG.info("----===## PlayqueueMonitor stopped ##===----")
|
||||||
|
|
364
resources/lib/playutils.py
Normal file
364
resources/lib/playutils.py
Normal file
|
@ -0,0 +1,364 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
from logging import getLogger
|
||||||
|
from downloadutils import DownloadUtils as DU
|
||||||
|
|
||||||
|
from utils import window, settings, language as lang, dialog, try_encode
|
||||||
|
import variables as v
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
LOG = getLogger("PLEX." + __name__)
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
class PlayUtils():
|
||||||
|
|
||||||
|
def __init__(self, api, playqueue_item):
|
||||||
|
"""
|
||||||
|
init with api (PlexAPI wrapper of the PMS xml element) and
|
||||||
|
playqueue_item (Playlist_Item())
|
||||||
|
"""
|
||||||
|
self.api = api
|
||||||
|
self.item = playqueue_item
|
||||||
|
|
||||||
|
def getPlayUrl(self):
|
||||||
|
"""
|
||||||
|
Returns the playurl for the part
|
||||||
|
(movie might consist of several files)
|
||||||
|
|
||||||
|
playurl is in unicode!
|
||||||
|
"""
|
||||||
|
self.api.mediastream_number()
|
||||||
|
playurl = self.isDirectPlay()
|
||||||
|
if playurl is not None:
|
||||||
|
LOG.info("File is direct playing.")
|
||||||
|
self.item.playmethod = 'DirectPlay'
|
||||||
|
elif self.isDirectStream():
|
||||||
|
LOG.info("File is direct streaming.")
|
||||||
|
playurl = self.api.transcode_video_path('DirectStream')
|
||||||
|
self.item.playmethod = 'DirectStream'
|
||||||
|
else:
|
||||||
|
LOG.info("File is transcoding.")
|
||||||
|
playurl = self.api.transcode_video_path(
|
||||||
|
'Transcode',
|
||||||
|
quality={
|
||||||
|
'maxVideoBitrate': self.get_bitrate(),
|
||||||
|
'videoResolution': self.get_resolution(),
|
||||||
|
'videoQuality': '100',
|
||||||
|
'mediaBufferSize': int(settings('kodi_video_cache'))/1024,
|
||||||
|
})
|
||||||
|
self.item.playmethod = 'Transcode'
|
||||||
|
LOG.info("The playurl is: %s", playurl)
|
||||||
|
self.item.file = playurl
|
||||||
|
return playurl
|
||||||
|
|
||||||
|
def isDirectPlay(self):
|
||||||
|
"""
|
||||||
|
Returns the path/playurl if we can direct play, None otherwise
|
||||||
|
"""
|
||||||
|
# True for e.g. plex.tv watch later
|
||||||
|
if self.api.should_stream() is True:
|
||||||
|
LOG.info("Plex item optimized for direct streaming")
|
||||||
|
return
|
||||||
|
# Check whether we have a strm file that we need to throw at Kodi 1:1
|
||||||
|
path = self.api.file_path()
|
||||||
|
if path is not None and path.endswith('.strm'):
|
||||||
|
LOG.info('.strm file detected')
|
||||||
|
playurl = self.api.validate_playurl(path,
|
||||||
|
self.api.plex_type(),
|
||||||
|
force_check=True)
|
||||||
|
return playurl
|
||||||
|
# set to either 'Direct Stream=1' or 'Transcode=2'
|
||||||
|
# and NOT to 'Direct Play=0'
|
||||||
|
if settings('playType') != "0":
|
||||||
|
# User forcing to play via HTTP
|
||||||
|
LOG.info("User chose to not direct play")
|
||||||
|
return
|
||||||
|
if self.mustTranscode():
|
||||||
|
return
|
||||||
|
return self.api.validate_playurl(path,
|
||||||
|
self.api.plex_type(),
|
||||||
|
force_check=True)
|
||||||
|
|
||||||
|
def mustTranscode(self):
|
||||||
|
"""
|
||||||
|
Returns True if we need to transcode because
|
||||||
|
- codec is in h265
|
||||||
|
- 10bit video codec
|
||||||
|
- HEVC codec
|
||||||
|
- playqueue_item force_transcode is set to True
|
||||||
|
- state variable FORCE_TRANSCODE set to True
|
||||||
|
(excepting trailers etc.)
|
||||||
|
- video bitrate above specified settings bitrate
|
||||||
|
if the corresponding file settings are set to 'true'
|
||||||
|
"""
|
||||||
|
if self.api.plex_type() in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_SONG):
|
||||||
|
LOG.info('Plex clip or music track, not transcoding')
|
||||||
|
return False
|
||||||
|
videoCodec = self.api.video_codec()
|
||||||
|
LOG.info("videoCodec: %s" % videoCodec)
|
||||||
|
if self.item.force_transcode is True:
|
||||||
|
LOG.info('User chose to force-transcode')
|
||||||
|
return True
|
||||||
|
codec = videoCodec['videocodec']
|
||||||
|
if codec is None:
|
||||||
|
# e.g. trailers. Avoids TypeError with "'h265' in codec"
|
||||||
|
LOG.info('No codec from PMS, not transcoding.')
|
||||||
|
return False
|
||||||
|
if ((settings('transcodeHi10P') == 'true' and
|
||||||
|
videoCodec['bitDepth'] == '10') and
|
||||||
|
('h264' in codec)):
|
||||||
|
LOG.info('Option to transcode 10bit h264 video content enabled.')
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
bitrate = int(videoCodec['bitrate'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
LOG.info('No video bitrate from PMS, not transcoding.')
|
||||||
|
return False
|
||||||
|
if bitrate > self.get_max_bitrate():
|
||||||
|
LOG.info('Video bitrate of %s is higher than the maximal video'
|
||||||
|
'bitrate of %s that the user chose. Transcoding'
|
||||||
|
% (bitrate, self.get_max_bitrate()))
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
resolution = int(videoCodec['resolution'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
LOG.info('No video resolution from PMS, not transcoding.')
|
||||||
|
return False
|
||||||
|
if 'h265' in codec or 'hevc' in codec:
|
||||||
|
if resolution >= self.getH265():
|
||||||
|
LOG.info("Option to transcode h265/HEVC enabled. Resolution "
|
||||||
|
"of the media: %s, transcoding limit resolution: %s"
|
||||||
|
% (str(resolution), str(self.getH265())))
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def isDirectStream(self):
|
||||||
|
# Never transcode Music
|
||||||
|
if self.api.plex_type() == 'track':
|
||||||
|
return True
|
||||||
|
# set to 'Transcode=2'
|
||||||
|
if settings('playType') == "2":
|
||||||
|
# User forcing to play via HTTP
|
||||||
|
LOG.info("User chose to transcode")
|
||||||
|
return False
|
||||||
|
if self.mustTranscode():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_max_bitrate(self):
|
||||||
|
# get the addon video quality
|
||||||
|
videoQuality = settings('maxVideoQualities')
|
||||||
|
bitrate = {
|
||||||
|
'0': 320,
|
||||||
|
'1': 720,
|
||||||
|
'2': 1500,
|
||||||
|
'3': 2000,
|
||||||
|
'4': 3000,
|
||||||
|
'5': 4000,
|
||||||
|
'6': 8000,
|
||||||
|
'7': 10000,
|
||||||
|
'8': 12000,
|
||||||
|
'9': 20000,
|
||||||
|
'10': 40000,
|
||||||
|
'11': 99999999 # deactivated
|
||||||
|
}
|
||||||
|
# max bit rate supported by server (max signed 32bit integer)
|
||||||
|
return bitrate.get(videoQuality, 2147483)
|
||||||
|
|
||||||
|
def getH265(self):
|
||||||
|
"""
|
||||||
|
Returns the user settings for transcoding h265: boundary resolutions
|
||||||
|
of 480, 720 or 1080 as an int
|
||||||
|
|
||||||
|
OR 9999999 (int) if user chose not to transcode
|
||||||
|
"""
|
||||||
|
H265 = {
|
||||||
|
'0': 99999999,
|
||||||
|
'1': 480,
|
||||||
|
'2': 720,
|
||||||
|
'3': 1080
|
||||||
|
}
|
||||||
|
return H265[settings('transcodeH265')]
|
||||||
|
|
||||||
|
def get_bitrate(self):
|
||||||
|
"""
|
||||||
|
Get the desired transcoding bitrate from the settings
|
||||||
|
"""
|
||||||
|
videoQuality = settings('transcoderVideoQualities')
|
||||||
|
bitrate = {
|
||||||
|
'0': 320,
|
||||||
|
'1': 720,
|
||||||
|
'2': 1500,
|
||||||
|
'3': 2000,
|
||||||
|
'4': 3000,
|
||||||
|
'5': 4000,
|
||||||
|
'6': 8000,
|
||||||
|
'7': 10000,
|
||||||
|
'8': 12000,
|
||||||
|
'9': 20000,
|
||||||
|
'10': 40000,
|
||||||
|
}
|
||||||
|
# max bit rate supported by server (max signed 32bit integer)
|
||||||
|
return bitrate.get(videoQuality, 2147483)
|
||||||
|
|
||||||
|
def get_resolution(self):
|
||||||
|
"""
|
||||||
|
Get the desired transcoding resolutions from the settings
|
||||||
|
"""
|
||||||
|
chosen = settings('transcoderVideoQualities')
|
||||||
|
res = {
|
||||||
|
'0': '420x420',
|
||||||
|
'1': '576x320',
|
||||||
|
'2': '720x480',
|
||||||
|
'3': '1024x768',
|
||||||
|
'4': '1280x720',
|
||||||
|
'5': '1280x720',
|
||||||
|
'6': '1920x1080',
|
||||||
|
'7': '1920x1080',
|
||||||
|
'8': '1920x1080',
|
||||||
|
'9': '1920x1080',
|
||||||
|
'10': '1920x1080',
|
||||||
|
}
|
||||||
|
return res[chosen]
|
||||||
|
|
||||||
|
def audio_subtitle_prefs(self, listitem):
|
||||||
|
"""
|
||||||
|
For transcoding only
|
||||||
|
|
||||||
|
Called at the very beginning of play; used to change audio and subtitle
|
||||||
|
stream by a PUT request to the PMS
|
||||||
|
"""
|
||||||
|
# Set media and part where we're at
|
||||||
|
if self.api.mediastream is None:
|
||||||
|
self.api.mediastream_number()
|
||||||
|
try:
|
||||||
|
mediastreams = self.api.plex_media_streams()
|
||||||
|
except (TypeError, IndexError):
|
||||||
|
LOG.error('Could not get media %s, part %s',
|
||||||
|
self.api.mediastream, self.api.part)
|
||||||
|
return
|
||||||
|
part_id = mediastreams.attrib['id']
|
||||||
|
audio_streams_list = []
|
||||||
|
audio_streams = []
|
||||||
|
subtitle_streams_list = []
|
||||||
|
# No subtitles as an option
|
||||||
|
subtitle_streams = [lang(39706)]
|
||||||
|
downloadable_streams = []
|
||||||
|
download_subs = []
|
||||||
|
# selectAudioIndex = ""
|
||||||
|
select_subs_index = ""
|
||||||
|
audio_numb = 0
|
||||||
|
# Remember 'no subtitles'
|
||||||
|
sub_num = 1
|
||||||
|
default_sub = None
|
||||||
|
|
||||||
|
for stream in mediastreams:
|
||||||
|
# Since Plex returns all possible tracks together, have to sort
|
||||||
|
# them.
|
||||||
|
index = stream.attrib.get('id')
|
||||||
|
typus = stream.attrib.get('streamType')
|
||||||
|
# Audio
|
||||||
|
if typus == "2":
|
||||||
|
codec = stream.attrib.get('codec')
|
||||||
|
channellayout = stream.attrib.get('audioChannelLayout', "")
|
||||||
|
try:
|
||||||
|
track = "%s %s - %s %s" % (audio_numb+1,
|
||||||
|
stream.attrib['language'],
|
||||||
|
codec,
|
||||||
|
channellayout)
|
||||||
|
except KeyError:
|
||||||
|
track = "%s %s - %s %s" % (audio_numb+1,
|
||||||
|
lang(39707), # unknown
|
||||||
|
codec,
|
||||||
|
channellayout)
|
||||||
|
audio_streams_list.append(index)
|
||||||
|
audio_streams.append(try_encode(track))
|
||||||
|
audio_numb += 1
|
||||||
|
|
||||||
|
# Subtitles
|
||||||
|
elif typus == "3":
|
||||||
|
try:
|
||||||
|
track = "%s %s" % (sub_num+1, stream.attrib['language'])
|
||||||
|
except KeyError:
|
||||||
|
track = "%s %s (%s)" % (sub_num+1,
|
||||||
|
lang(39707), # unknown
|
||||||
|
stream.attrib.get('codec'))
|
||||||
|
default = stream.attrib.get('default')
|
||||||
|
forced = stream.attrib.get('forced')
|
||||||
|
downloadable = stream.attrib.get('key')
|
||||||
|
|
||||||
|
if default:
|
||||||
|
track = "%s - %s" % (track, lang(39708)) # Default
|
||||||
|
if forced:
|
||||||
|
track = "%s - %s" % (track, lang(39709)) # Forced
|
||||||
|
if downloadable:
|
||||||
|
# We do know the language - temporarily download
|
||||||
|
if 'language' in stream.attrib:
|
||||||
|
path = self.api.download_external_subtitles(
|
||||||
|
'{server}%s' % stream.attrib['key'],
|
||||||
|
"subtitle.%s.%s" % (stream.attrib['languageCode'],
|
||||||
|
stream.attrib['codec']))
|
||||||
|
# We don't know the language - no need to download
|
||||||
|
else:
|
||||||
|
path = self.api.attach_plex_token_to_url(
|
||||||
|
"%s%s" % (window('pms_server'),
|
||||||
|
stream.attrib['key']))
|
||||||
|
downloadable_streams.append(index)
|
||||||
|
download_subs.append(try_encode(path))
|
||||||
|
else:
|
||||||
|
track = "%s (%s)" % (track, lang(39710)) # burn-in
|
||||||
|
if stream.attrib.get('selected') == '1' and downloadable:
|
||||||
|
# Only show subs without asking user if they can be
|
||||||
|
# turned off
|
||||||
|
default_sub = index
|
||||||
|
|
||||||
|
subtitle_streams_list.append(index)
|
||||||
|
subtitle_streams.append(try_encode(track))
|
||||||
|
sub_num += 1
|
||||||
|
|
||||||
|
if audio_numb > 1:
|
||||||
|
resp = dialog('select', lang(33013), audio_streams)
|
||||||
|
if resp > -1:
|
||||||
|
# User selected some audio track
|
||||||
|
args = {
|
||||||
|
'audioStreamID': audio_streams_list[resp],
|
||||||
|
'allParts': 1
|
||||||
|
}
|
||||||
|
DU().downloadUrl('{server}/library/parts/%s' % part_id,
|
||||||
|
action_type='PUT',
|
||||||
|
parameters=args)
|
||||||
|
|
||||||
|
if sub_num == 1:
|
||||||
|
# No subtitles
|
||||||
|
return
|
||||||
|
|
||||||
|
select_subs_index = None
|
||||||
|
if (settings('pickPlexSubtitles') == 'true' and
|
||||||
|
default_sub is not None):
|
||||||
|
LOG.info('Using default Plex subtitle: %s', default_sub)
|
||||||
|
select_subs_index = default_sub
|
||||||
|
else:
|
||||||
|
resp = dialog('select', lang(33014), subtitle_streams)
|
||||||
|
if resp > 0:
|
||||||
|
select_subs_index = subtitle_streams_list[resp-1]
|
||||||
|
else:
|
||||||
|
# User selected no subtitles or backed out of dialog
|
||||||
|
select_subs_index = ''
|
||||||
|
|
||||||
|
LOG.debug('Adding external subtitles: %s', download_subs)
|
||||||
|
# Enable Kodi to switch autonomously to downloadable subtitles
|
||||||
|
if download_subs:
|
||||||
|
listitem.setSubtitles(download_subs)
|
||||||
|
# Don't additionally burn in subtitles
|
||||||
|
if select_subs_index in downloadable_streams:
|
||||||
|
select_subs_index = ''
|
||||||
|
# Now prep the PMS for our choice
|
||||||
|
args = {
|
||||||
|
'subtitleStreamID': select_subs_index,
|
||||||
|
'allParts': 1
|
||||||
|
}
|
||||||
|
DU().downloadUrl('{server}/library/parts/%s' % part_id,
|
||||||
|
action_type='PUT',
|
||||||
|
parameters=args)
|
|
@ -1,32 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
plex_api interfaces with all Plex Media Server (and plex.tv) xml responses
|
|
||||||
"""
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
from .artwork import Artwork
|
|
||||||
from .file import File
|
|
||||||
from .media import Media
|
|
||||||
from .user import User
|
|
||||||
from .playback import Playback
|
|
||||||
|
|
||||||
from ..plex_db import PlexDB
|
|
||||||
|
|
||||||
|
|
||||||
class API(Base, Artwork, File, Media, User, Playback):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def mass_api(xml):
|
|
||||||
"""
|
|
||||||
Pass in an entire XML PMS response with e.g. several movies or episodes
|
|
||||||
Will Look-up Kodi ids in the Plex.db for every element (thus speeding up
|
|
||||||
this process for several PMS items!)
|
|
||||||
"""
|
|
||||||
apis = [API(x) for x in xml]
|
|
||||||
with PlexDB(lock=False) as plexdb:
|
|
||||||
for api in apis:
|
|
||||||
api.check_db(plexdb=plexdb)
|
|
||||||
return apis
|
|
|
@ -1,287 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from ..kodi_db import KodiVideoDB, KodiMusicDB
|
|
||||||
from ..downloadutils import DownloadUtils as DU
|
|
||||||
from .. import utils, variables as v, app
|
|
||||||
|
|
||||||
from . import fanart_lookup
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.api')
|
|
||||||
|
|
||||||
|
|
||||||
class Artwork(object):
|
|
||||||
def one_artwork(self, art_kind, aspect=None):
|
|
||||||
"""
|
|
||||||
aspect can be: 'square', '16:9', 'poster'. Defaults to 'poster'
|
|
||||||
"""
|
|
||||||
aspect = 'poster' if not aspect else aspect
|
|
||||||
if aspect == 'poster':
|
|
||||||
width = 1000
|
|
||||||
height = 1500
|
|
||||||
elif aspect == '16:9':
|
|
||||||
width = 1920
|
|
||||||
height = 1080
|
|
||||||
elif aspect == 'square':
|
|
||||||
width = 1000
|
|
||||||
height = 1000
|
|
||||||
else:
|
|
||||||
raise NotImplementedError('aspect ratio not yet implemented: %s'
|
|
||||||
% aspect)
|
|
||||||
artwork = self.xml.get(art_kind)
|
|
||||||
if not artwork or artwork.startswith('http'):
|
|
||||||
return artwork
|
|
||||||
if '/composite/' in artwork:
|
|
||||||
try:
|
|
||||||
# e.g. Plex collections where artwork already contains width and
|
|
||||||
# height. Need to upscale for better resolution
|
|
||||||
artwork, args = artwork.split('?')
|
|
||||||
args = dict(utils.parse_qsl(args))
|
|
||||||
width = int(args.get('width', 400))
|
|
||||||
height = int(args.get('height', 400))
|
|
||||||
# Adjust to 4k resolution 1920x1080
|
|
||||||
scaling = 1920.0 / float(max(width, height))
|
|
||||||
width = int(scaling * width)
|
|
||||||
height = int(scaling * height)
|
|
||||||
except ValueError:
|
|
||||||
# e.g. playlists
|
|
||||||
pass
|
|
||||||
artwork = '%s?width=%s&height=%s' % (artwork, width, height)
|
|
||||||
artwork = ('%s/photo/:/transcode?width=1920&height=1920&'
|
|
||||||
'minSize=1&upscale=0&url=%s'
|
|
||||||
% (app.CONN.server, utils.quote(artwork)))
|
|
||||||
artwork = self.attach_plex_token_to_url(artwork)
|
|
||||||
return artwork
|
|
||||||
|
|
||||||
def artwork_episode(self, full_artwork):
|
|
||||||
"""
|
|
||||||
Episodes are special, they only get the thumb, because all the other
|
|
||||||
artwork will be saved under season and show EXCEPT if you're
|
|
||||||
constructing a listitem and the item has NOT been synched to the Kodi db
|
|
||||||
"""
|
|
||||||
artworks = {}
|
|
||||||
# Item is currently NOT in the Kodi DB
|
|
||||||
art = self.one_artwork('thumb')
|
|
||||||
if art:
|
|
||||||
artworks['thumb'] = art
|
|
||||||
if not full_artwork:
|
|
||||||
# For episodes, only get the thumb. Everything else stemms from
|
|
||||||
# either the season or the show
|
|
||||||
return artworks
|
|
||||||
for kodi_artwork, plex_artwork in \
|
|
||||||
v.KODI_TO_PLEX_ARTWORK_EPISODE.iteritems():
|
|
||||||
art = self.one_artwork(plex_artwork)
|
|
||||||
if art:
|
|
||||||
artworks[kodi_artwork] = art
|
|
||||||
return artworks
|
|
||||||
|
|
||||||
def artwork(self, kodi_id=None, kodi_type=None, full_artwork=False):
|
|
||||||
"""
|
|
||||||
Gets the URLs to the Plex artwork. Dict keys will be missing if there
|
|
||||||
is no corresponding artwork.
|
|
||||||
Pass kodi_id and kodi_type to grab the artwork saved in the Kodi DB
|
|
||||||
(thus potentially more artwork, e.g. clearart, discart).
|
|
||||||
|
|
||||||
Output ('max' version)
|
|
||||||
{
|
|
||||||
'thumb'
|
|
||||||
'poster'
|
|
||||||
'banner'
|
|
||||||
'clearart'
|
|
||||||
'clearlogo'
|
|
||||||
'fanart'
|
|
||||||
}
|
|
||||||
'landscape' and 'icon' might be implemented later
|
|
||||||
Passing full_artwork=True returns ALL the artwork for the item, so not
|
|
||||||
just 'thumb' for episodes, but also season and show artwork
|
|
||||||
"""
|
|
||||||
if self.plex_type == v.PLEX_TYPE_EPISODE:
|
|
||||||
return self.artwork_episode(full_artwork)
|
|
||||||
artworks = {}
|
|
||||||
if kodi_id:
|
|
||||||
# in Kodi database, potentially with additional e.g. clearart
|
|
||||||
if self.plex_type in v.PLEX_VIDEOTYPES:
|
|
||||||
with KodiVideoDB(lock=False) as kodidb:
|
|
||||||
return kodidb.get_art(kodi_id, kodi_type)
|
|
||||||
else:
|
|
||||||
with KodiMusicDB(lock=False) as kodidb:
|
|
||||||
return kodidb.get_art(kodi_id, kodi_type)
|
|
||||||
|
|
||||||
for kodi_artwork, plex_artwork in v.KODI_TO_PLEX_ARTWORK.iteritems():
|
|
||||||
art = self.one_artwork(plex_artwork)
|
|
||||||
if art:
|
|
||||||
artworks[kodi_artwork] = art
|
|
||||||
if self.plex_type in (v.PLEX_TYPE_SONG, v.PLEX_TYPE_ALBUM):
|
|
||||||
# Get parent item artwork if the main item is missing artwork
|
|
||||||
if 'fanart' not in artworks:
|
|
||||||
art = self.one_artwork('parentArt')
|
|
||||||
if art:
|
|
||||||
artworks['fanart1'] = art
|
|
||||||
if 'poster' not in artworks:
|
|
||||||
art = self.one_artwork('parentThumb')
|
|
||||||
if art:
|
|
||||||
artworks['poster'] = art
|
|
||||||
if self.plex_type in (v.PLEX_TYPE_SONG,
|
|
||||||
v.PLEX_TYPE_ALBUM,
|
|
||||||
v.PLEX_TYPE_ARTIST):
|
|
||||||
# need to set poster also as thumb
|
|
||||||
art = self.one_artwork('thumb')
|
|
||||||
if art:
|
|
||||||
artworks['thumb'] = art
|
|
||||||
if self.plex_type == v.PLEX_TYPE_PLAYLIST:
|
|
||||||
art = self.one_artwork('composite')
|
|
||||||
if art:
|
|
||||||
artworks['thumb'] = art
|
|
||||||
return artworks
|
|
||||||
|
|
||||||
def fanart_artwork(self, artworks):
|
|
||||||
"""
|
|
||||||
Downloads additional fanart from third party sources (well, link to
|
|
||||||
fanart only).
|
|
||||||
"""
|
|
||||||
external_id = self.retrieve_external_item_id()
|
|
||||||
if external_id is not None:
|
|
||||||
artworks = self.lookup_fanart_tv(external_id[0], artworks)
|
|
||||||
return artworks
|
|
||||||
|
|
||||||
def set_artwork(self):
|
|
||||||
"""
|
|
||||||
Gets the URLs to the Plex artwork, or empty string if not found.
|
|
||||||
Only call on movies!
|
|
||||||
"""
|
|
||||||
artworks = {}
|
|
||||||
# Plex does not get much artwork - go ahead and get the rest from
|
|
||||||
# fanart tv only for movie or tv show
|
|
||||||
external_id = self.retrieve_external_item_id(collection=True)
|
|
||||||
if external_id is not None:
|
|
||||||
external_id, poster, background = external_id
|
|
||||||
if poster is not None:
|
|
||||||
artworks['poster'] = poster
|
|
||||||
if background is not None:
|
|
||||||
artworks['fanart'] = background
|
|
||||||
artworks = self.lookup_fanart_tv(external_id, artworks)
|
|
||||||
else:
|
|
||||||
LOG.info('Did not find a set/collection ID on TheMovieDB using %s.'
|
|
||||||
' Artwork will be missing.', self.title())
|
|
||||||
return artworks
|
|
||||||
|
|
||||||
def retrieve_external_item_id(self, collection=False):
|
|
||||||
"""
|
|
||||||
Returns the set
|
|
||||||
media_id [unicode]: the item's IMDB id for movies or tvdb id for
|
|
||||||
TV shows
|
|
||||||
poster [unicode]: path to the item's poster artwork
|
|
||||||
background [unicode]: path to the item's background artwork
|
|
||||||
|
|
||||||
The last two might be None if not found. Generally None is returned
|
|
||||||
if unsuccessful.
|
|
||||||
|
|
||||||
If not found in item's Plex metadata, check themovidedb.org.
|
|
||||||
"""
|
|
||||||
item = self.xml.attrib
|
|
||||||
media_type = self.plex_type
|
|
||||||
media_id = None
|
|
||||||
# Return the saved Plex id's, if applicable
|
|
||||||
# Always seek collection's ids since not provided by PMS
|
|
||||||
if collection is False:
|
|
||||||
if media_type == v.PLEX_TYPE_MOVIE:
|
|
||||||
media_id = self.guids.get('imdb')
|
|
||||||
elif media_type == v.PLEX_TYPE_SHOW:
|
|
||||||
media_id = self.guids.get('tvdb')
|
|
||||||
if media_id is not None:
|
|
||||||
return media_id, None, None
|
|
||||||
LOG.info('Plex did not provide ID for IMDB or TVDB. Start '
|
|
||||||
'lookup process')
|
|
||||||
else:
|
|
||||||
LOG.debug('Start movie set/collection lookup on themoviedb with %s',
|
|
||||||
item.get('title', ''))
|
|
||||||
return fanart_lookup.external_item_id(self.title(),
|
|
||||||
self.year(),
|
|
||||||
self.plex_type,
|
|
||||||
collection)
|
|
||||||
|
|
||||||
def lookup_fanart_tv(self, media_id, artworks):
|
|
||||||
"""
|
|
||||||
perform artwork lookup on fanart.tv
|
|
||||||
|
|
||||||
media_id: IMDB id for movies, tvdb id for TV shows
|
|
||||||
"""
|
|
||||||
api_key = utils.settings('FanArtTVAPIKey')
|
|
||||||
typus = self.plex_type
|
|
||||||
if typus == v.PLEX_TYPE_SHOW:
|
|
||||||
typus = 'tv'
|
|
||||||
|
|
||||||
if typus == v.PLEX_TYPE_MOVIE:
|
|
||||||
url = 'http://webservice.fanart.tv/v3/movies/%s?api_key=%s' \
|
|
||||||
% (media_id, api_key)
|
|
||||||
elif typus == 'tv':
|
|
||||||
url = 'http://webservice.fanart.tv/v3/tv/%s?api_key=%s' \
|
|
||||||
% (media_id, api_key)
|
|
||||||
else:
|
|
||||||
# Not supported artwork
|
|
||||||
return artworks
|
|
||||||
data = DU().downloadUrl(url,
|
|
||||||
authenticate=False,
|
|
||||||
timeout=15,
|
|
||||||
return_response=True)
|
|
||||||
if not data.ok:
|
|
||||||
LOG.debug('Could not download data from FanartTV')
|
|
||||||
return artworks
|
|
||||||
data = data.json()
|
|
||||||
|
|
||||||
fanart_tv_types = list(v.FANART_TV_TO_KODI_TYPE)
|
|
||||||
|
|
||||||
if typus == v.PLEX_TYPE_ARTIST:
|
|
||||||
fanart_tv_types.append(("thumb", "folder"))
|
|
||||||
else:
|
|
||||||
fanart_tv_types.append(("thumb", "thumb"))
|
|
||||||
|
|
||||||
prefixes = (
|
|
||||||
"hd" + typus,
|
|
||||||
"hd",
|
|
||||||
typus,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
for fanart_tv_type, kodi_type in fanart_tv_types:
|
|
||||||
# Skip the ones we already have
|
|
||||||
if kodi_type in artworks:
|
|
||||||
continue
|
|
||||||
for prefix in prefixes:
|
|
||||||
fanarttvimage = prefix + fanart_tv_type
|
|
||||||
if fanarttvimage not in data:
|
|
||||||
continue
|
|
||||||
# select image in preferred language
|
|
||||||
for entry in data[fanarttvimage]:
|
|
||||||
if entry.get("lang") == v.KODILANGUAGE:
|
|
||||||
artworks[kodi_type] = \
|
|
||||||
entry.get("url", "").replace(' ', '%20')
|
|
||||||
break
|
|
||||||
# just grab the first english OR undefinded one as fallback
|
|
||||||
# (so we're actually grabbing the more popular one)
|
|
||||||
if kodi_type not in artworks:
|
|
||||||
for entry in data[fanarttvimage]:
|
|
||||||
if entry.get("lang") in ("en", "00"):
|
|
||||||
artworks[kodi_type] = \
|
|
||||||
entry.get("url", "").replace(' ', '%20')
|
|
||||||
break
|
|
||||||
|
|
||||||
# grab extrafanarts in list
|
|
||||||
fanartcount = 1 if 'fanart' in artworks else ''
|
|
||||||
for prefix in prefixes:
|
|
||||||
fanarttvimage = prefix + 'background'
|
|
||||||
if fanarttvimage not in data:
|
|
||||||
continue
|
|
||||||
for entry in data[fanarttvimage]:
|
|
||||||
if entry.get("url") is None:
|
|
||||||
continue
|
|
||||||
artworks['fanart%s' % fanartcount] = \
|
|
||||||
entry['url'].replace(' ', '%20')
|
|
||||||
try:
|
|
||||||
fanartcount += 1
|
|
||||||
except TypeError:
|
|
||||||
fanartcount = 1
|
|
||||||
if fanartcount >= v.MAX_BACKGROUND_COUNT:
|
|
||||||
break
|
|
||||||
return artworks
|
|
|
@ -1,697 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
from re import sub
|
|
||||||
|
|
||||||
import xbmcgui
|
|
||||||
|
|
||||||
from ..utils import cast
|
|
||||||
from ..plex_db import PlexDB
|
|
||||||
from .. import utils, timing, variables as v, app, plex_functions as PF
|
|
||||||
from .. import widgets
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.api')
|
|
||||||
|
|
||||||
METADATA_PROVIDERS = (('imdb', utils.REGEX_IMDB),
|
|
||||||
('tvdb', utils.REGEX_TVDB),
|
|
||||||
('tmdb', utils.REGEX_TMDB),
|
|
||||||
('anidb', utils.REGEX_ANIDB))
|
|
||||||
|
|
||||||
|
|
||||||
class Base(object):
|
|
||||||
"""
|
|
||||||
Processes a Plex media server's XML response
|
|
||||||
|
|
||||||
xml: xml.etree.ElementTree element
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, xml):
|
|
||||||
self.xml = xml
|
|
||||||
# which media part in the XML response shall we look at if several
|
|
||||||
# media files are present for the SAME video? (e.g. a 4k and a 1080p
|
|
||||||
# version)
|
|
||||||
self.part = 0
|
|
||||||
self.mediastream = None
|
|
||||||
# Make sure we're only checking our Plex DB once
|
|
||||||
self._checked_db = False
|
|
||||||
# In order to run through the leaves of the xml only once
|
|
||||||
self._scanned_children = False
|
|
||||||
self._genres = []
|
|
||||||
self._countries = []
|
|
||||||
self._collections = []
|
|
||||||
self._people = []
|
|
||||||
self._cast = []
|
|
||||||
self._directors = []
|
|
||||||
self._writers = []
|
|
||||||
self._producers = []
|
|
||||||
self._locations = []
|
|
||||||
self._intro_markers = []
|
|
||||||
self._guids = {}
|
|
||||||
self._coll_match = None
|
|
||||||
# Plex DB attributes
|
|
||||||
self._section_id = None
|
|
||||||
self._kodi_id = None
|
|
||||||
self._last_sync = None
|
|
||||||
self._last_checksum = None
|
|
||||||
self._kodi_fileid = None
|
|
||||||
self._kodi_pathid = None
|
|
||||||
self._fanart_synced = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tag(self):
|
|
||||||
"""
|
|
||||||
Returns the xml etree tag, e.g. 'Directory', 'Playlist', 'Hub', 'Video'
|
|
||||||
"""
|
|
||||||
return self.xml.tag
|
|
||||||
|
|
||||||
def tag_label(self):
|
|
||||||
"""
|
|
||||||
Returns the 'tag' attribute of the xml
|
|
||||||
"""
|
|
||||||
return self.xml.get('tag')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def attrib(self):
|
|
||||||
"""
|
|
||||||
Returns the xml etree attrib dict
|
|
||||||
"""
|
|
||||||
return self.xml.attrib
|
|
||||||
|
|
||||||
@property
|
|
||||||
def plex_id(self):
|
|
||||||
"""
|
|
||||||
Returns the Plex ratingKey as an integer or None
|
|
||||||
"""
|
|
||||||
return cast(int, self.xml.get('ratingKey'))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def fast_key(self):
|
|
||||||
"""
|
|
||||||
Returns the 'fastKey' as unicode or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('fastKey')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def plex_type(self):
|
|
||||||
"""
|
|
||||||
Returns the type of media, e.g. 'movie' or 'clip' for trailers as
|
|
||||||
Unicode or None.
|
|
||||||
"""
|
|
||||||
return self.xml.get('type')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def section_id(self):
|
|
||||||
self.check_db()
|
|
||||||
return self._section_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def kodi_id(self):
|
|
||||||
self.check_db()
|
|
||||||
return self._kodi_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def kodi_type(self):
|
|
||||||
return v.KODITYPE_FROM_PLEXTYPE[self.plex_type]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def last_sync(self):
|
|
||||||
self.check_db()
|
|
||||||
return self._last_sync
|
|
||||||
|
|
||||||
@property
|
|
||||||
def last_checksum(self):
|
|
||||||
self.check_db()
|
|
||||||
return self._last_checksum
|
|
||||||
|
|
||||||
@property
|
|
||||||
def kodi_fileid(self):
|
|
||||||
self.check_db()
|
|
||||||
return self._kodi_fileid
|
|
||||||
|
|
||||||
@property
|
|
||||||
def kodi_pathid(self):
|
|
||||||
self.check_db()
|
|
||||||
return self._kodi_pathid
|
|
||||||
|
|
||||||
@property
|
|
||||||
def fanart_synced(self):
|
|
||||||
self.check_db()
|
|
||||||
return self._fanart_synced
|
|
||||||
|
|
||||||
@property
|
|
||||||
def guids(self):
|
|
||||||
self._scan_children()
|
|
||||||
return self._guids
|
|
||||||
|
|
||||||
def check_db(self, plexdb=None):
|
|
||||||
"""
|
|
||||||
Check's whether we synched this item to Kodi. If so, then retrieve the
|
|
||||||
appropriate Kodi info like the kodi_id and kodi_fileid
|
|
||||||
|
|
||||||
Pass in a plexdb DB-connection for a faster lookup
|
|
||||||
"""
|
|
||||||
if self._checked_db:
|
|
||||||
return
|
|
||||||
self._checked_db = True
|
|
||||||
if self.plex_type == v.PLEX_TYPE_CLIP:
|
|
||||||
# Clips won't ever be synched to Kodi
|
|
||||||
return
|
|
||||||
if plexdb:
|
|
||||||
db_item = plexdb.item_by_id(self.plex_id, self.plex_type)
|
|
||||||
else:
|
|
||||||
with PlexDB(lock=False) as plexdb:
|
|
||||||
db_item = plexdb.item_by_id(self.plex_id, self.plex_type)
|
|
||||||
if not db_item:
|
|
||||||
return
|
|
||||||
self._section_id = db_item['section_id']
|
|
||||||
self._kodi_id = db_item['kodi_id']
|
|
||||||
self._last_sync = db_item['last_sync']
|
|
||||||
self._last_checksum = db_item['checksum']
|
|
||||||
if 'kodi_fileid' in db_item:
|
|
||||||
self._kodi_fileid = db_item['kodi_fileid']
|
|
||||||
if 'kodi_pathid' in db_item:
|
|
||||||
self._kodi_pathid = db_item['kodi_pathid']
|
|
||||||
if 'fanart_synced' in db_item:
|
|
||||||
self._fanart_synced = db_item['fanart_synced']
|
|
||||||
|
|
||||||
def path_and_plex_id(self):
|
|
||||||
"""
|
|
||||||
Returns the Plex key such as '/library/metadata/246922' or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('key')
|
|
||||||
|
|
||||||
def item_id(self):
|
|
||||||
"""
|
|
||||||
Returns current playQueueItemID or if unsuccessful the playListItemID
|
|
||||||
as int.
|
|
||||||
If not found, None is returned
|
|
||||||
"""
|
|
||||||
return (cast(int, self.xml.get('playQueueItemID')) or
|
|
||||||
cast(int, self.xml.get('playListItemID')))
|
|
||||||
|
|
||||||
def playlist_type(self):
|
|
||||||
"""
|
|
||||||
Returns the playlist type ('video', 'audio') or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('playlistType')
|
|
||||||
|
|
||||||
def library_section_id(self):
|
|
||||||
"""
|
|
||||||
Returns the id of the Plex library section (for e.g. a movies section)
|
|
||||||
as an int or None
|
|
||||||
"""
|
|
||||||
return cast(int, self.xml.get('librarySectionID'))
|
|
||||||
|
|
||||||
def guid_html_escaped(self):
|
|
||||||
"""
|
|
||||||
Returns the 'guid' attribute, e.g.
|
|
||||||
'com.plexapp.agents.thetvdb://76648/2/4?lang=en'
|
|
||||||
as an HTML-escaped string or None
|
|
||||||
"""
|
|
||||||
guid = self.xml.get('guid')
|
|
||||||
return utils.escape_html(guid) if guid else None
|
|
||||||
|
|
||||||
def date_created(self):
|
|
||||||
"""
|
|
||||||
Returns the date when this library item was created in Kodi-time as
|
|
||||||
unicode
|
|
||||||
|
|
||||||
If not found, returns 2000-01-01 10:00:00
|
|
||||||
"""
|
|
||||||
res = self.xml.get('addedAt')
|
|
||||||
return timing.plex_date_to_kodi(res) if res else '2000-01-01 10:00:00'
|
|
||||||
|
|
||||||
def updated_at(self):
|
|
||||||
"""
|
|
||||||
Returns the last time this item was updated as an int, e.g.
|
|
||||||
1524739868 or None
|
|
||||||
"""
|
|
||||||
return cast(int, self.xml.get('updatedAt'))
|
|
||||||
|
|
||||||
def checksum(self):
|
|
||||||
"""
|
|
||||||
Returns the unique int <ratingKey><updatedAt>. If updatedAt is not set,
|
|
||||||
addedAt is used.
|
|
||||||
"""
|
|
||||||
return int('%s%s' % (self.xml.get('ratingKey'),
|
|
||||||
abs(int(self.xml.get('updatedAt') or
|
|
||||||
self.xml.get('addedAt', '1541572987')))))
|
|
||||||
|
|
||||||
def title(self):
|
|
||||||
"""
|
|
||||||
Returns the title of the element as unicode or 'Missing Title'
|
|
||||||
"""
|
|
||||||
return self.xml.get('title', 'Missing Title')
|
|
||||||
|
|
||||||
def sorttitle(self):
|
|
||||||
"""
|
|
||||||
Returns an item's sorting name/title or the title itself if not found
|
|
||||||
"Missing Title" if both are not present
|
|
||||||
"""
|
|
||||||
return self.xml.get('titleSort',
|
|
||||||
self.xml.get('title', 'Missing Title'))
|
|
||||||
|
|
||||||
def plex_media_streams(self):
|
|
||||||
"""
|
|
||||||
Returns the media streams directly from the PMS xml.
|
|
||||||
Mind to set self.mediastream and self.part before calling this method!
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return self.xml[self.mediastream][self.part]
|
|
||||||
except TypeError:
|
|
||||||
# Direct Paths when we don't set mediastream and part
|
|
||||||
return self.xml[0][0]
|
|
||||||
|
|
||||||
def part_id(self):
|
|
||||||
"""
|
|
||||||
Returns the unique id of the currently active part [int]
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return int(self.xml[self.mediastream][self.part].attrib['id'])
|
|
||||||
except TypeError:
|
|
||||||
# Direct Paths when we don't set mediastream and part
|
|
||||||
return int(self.xml[0][0].attrib['id'])
|
|
||||||
|
|
||||||
def plot(self):
|
|
||||||
"""
|
|
||||||
Returns the plot or None.
|
|
||||||
"""
|
|
||||||
return self.xml.get('summary')
|
|
||||||
|
|
||||||
def tagline(self):
|
|
||||||
"""
|
|
||||||
Returns a shorter tagline of the plot or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('tagline')
|
|
||||||
|
|
||||||
def shortplot(self):
|
|
||||||
"""
|
|
||||||
Not yet implemented - returns None
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def premiere_date(self):
|
|
||||||
"""
|
|
||||||
Returns the "originallyAvailableAt", e.g. "2018-11-16" or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('originallyAvailableAt')
|
|
||||||
|
|
||||||
def kodi_premiere_date(self):
|
|
||||||
"""
|
|
||||||
Takes Plex' originallyAvailableAt of the form "yyyy-mm-dd" and returns
|
|
||||||
Kodi's "dd.mm.yyyy" or None
|
|
||||||
"""
|
|
||||||
date = self.premiere_date()
|
|
||||||
if date is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
date = sub(r'(\d+)-(\d+)-(\d+)', r'\3.\2.\1', date)
|
|
||||||
except Exception:
|
|
||||||
date = None
|
|
||||||
return date
|
|
||||||
|
|
||||||
def year(self):
|
|
||||||
"""
|
|
||||||
Returns the production(?) year ("year") as Unicode or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('year')
|
|
||||||
|
|
||||||
def studios(self):
|
|
||||||
"""
|
|
||||||
Returns a list of the 'studio' - currently only ever 1 entry.
|
|
||||||
Or returns an empty list
|
|
||||||
"""
|
|
||||||
return [self.xml.get('studio')] if self.xml.get('studio') else []
|
|
||||||
|
|
||||||
def content_rating(self):
|
|
||||||
"""
|
|
||||||
Get the content rating or None
|
|
||||||
"""
|
|
||||||
mpaa = self.xml.get('contentRating')
|
|
||||||
if not mpaa:
|
|
||||||
return
|
|
||||||
# Convert more complex cases
|
|
||||||
if mpaa in ('NR', 'UR'):
|
|
||||||
# Kodi seems to not like NR, but will accept Rated Not Rated
|
|
||||||
mpaa = 'Rated Not Rated'
|
|
||||||
elif mpaa.startswith('gb/'):
|
|
||||||
mpaa = mpaa.replace('gb/', 'UK:', 1)
|
|
||||||
return mpaa
|
|
||||||
|
|
||||||
def rating(self):
|
|
||||||
"""
|
|
||||||
Returns the rating [float] first from 'audienceRating', if that fails
|
|
||||||
from 'rating'.
|
|
||||||
Returns 0.0 if both are not found
|
|
||||||
"""
|
|
||||||
return cast(float, self.xml.get('audienceRating',
|
|
||||||
self.xml.get('rating'))) or 0.0
|
|
||||||
|
|
||||||
def votecount(self):
|
|
||||||
"""
|
|
||||||
Not implemented by Plex yet - returns None
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def runtime(self):
|
|
||||||
"""
|
|
||||||
Returns the total duration of the element in seconds as int.
|
|
||||||
0 if not found
|
|
||||||
"""
|
|
||||||
runtime = cast(float, self.xml.get('duration')) or 0.0
|
|
||||||
return int(runtime * v.PLEX_TO_KODI_TIMEFACTOR)
|
|
||||||
|
|
||||||
def leave_count(self):
|
|
||||||
"""
|
|
||||||
Returns the following dict or None
|
|
||||||
{
|
|
||||||
'totalepisodes': unicode('leafCount'),
|
|
||||||
'watchedepisodes': unicode('viewedLeafCount'),
|
|
||||||
'unwatchedepisodes': unicode(totalepisodes - watchedepisodes)
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
total = int(self.xml.attrib['leafCount'])
|
|
||||||
watched = int(self.xml.attrib['viewedLeafCount'])
|
|
||||||
return {
|
|
||||||
'totalepisodes': unicode(total),
|
|
||||||
'watchedepisodes': unicode(watched),
|
|
||||||
'unwatchedepisodes': unicode(total - watched)
|
|
||||||
}
|
|
||||||
except (KeyError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Stuff having to do with parent and grandparent items
|
|
||||||
######################################################
|
|
||||||
def index(self):
|
|
||||||
"""
|
|
||||||
Returns the 'index' of the element [int]. Depicts e.g. season number of
|
|
||||||
the season or the track number of the song
|
|
||||||
"""
|
|
||||||
return cast(int, self.xml.get('index'))
|
|
||||||
|
|
||||||
def show_id(self):
|
|
||||||
"""
|
|
||||||
Returns the episode's tv show's Plex id [int] or None
|
|
||||||
"""
|
|
||||||
return self.grandparent_id()
|
|
||||||
|
|
||||||
def show_title(self):
|
|
||||||
"""
|
|
||||||
Returns the episode's tv show's name/title [unicode] or None
|
|
||||||
"""
|
|
||||||
return self.grandparent_title()
|
|
||||||
|
|
||||||
def season_id(self):
|
|
||||||
"""
|
|
||||||
Returns the episode's season's Plex id [int] or None
|
|
||||||
"""
|
|
||||||
return self.parent_id()
|
|
||||||
|
|
||||||
def season_number(self):
|
|
||||||
"""
|
|
||||||
Returns the episode's season number (e.g. season '2') as an int or None
|
|
||||||
"""
|
|
||||||
return self.parent_index()
|
|
||||||
|
|
||||||
def season_name(self):
|
|
||||||
"""
|
|
||||||
Returns the season's name/title or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('title')
|
|
||||||
|
|
||||||
def artist_name(self):
|
|
||||||
"""
|
|
||||||
Returns the artist name for an album: first it attempts to return
|
|
||||||
'parentTitle', if that failes 'originalTitle'
|
|
||||||
"""
|
|
||||||
return self.xml.get('parentTitle', self.xml.get('originalTitle'))
|
|
||||||
|
|
||||||
def parent_id(self):
|
|
||||||
"""
|
|
||||||
Returns the 'parentRatingKey' as int or None
|
|
||||||
"""
|
|
||||||
return cast(int, self.xml.get('parentRatingKey'))
|
|
||||||
|
|
||||||
def parent_index(self):
|
|
||||||
"""
|
|
||||||
Returns the 'parentRatingKey' as int or None
|
|
||||||
"""
|
|
||||||
return cast(int, self.xml.get('parentIndex'))
|
|
||||||
|
|
||||||
def grandparent_id(self):
|
|
||||||
"""
|
|
||||||
Returns the ratingKey for the corresponding grandparent, e.g. a TV show
|
|
||||||
for episodes, or None
|
|
||||||
"""
|
|
||||||
return cast(int, self.xml.get('grandparentRatingKey'))
|
|
||||||
|
|
||||||
def grandparent_title(self):
|
|
||||||
"""
|
|
||||||
Returns the title for the corresponding grandparent, e.g. a TV show
|
|
||||||
name for episodes, or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('grandparentTitle')
|
|
||||||
|
|
||||||
def disc_number(self):
|
|
||||||
"""
|
|
||||||
Returns the song's disc number as an int or None if not found
|
|
||||||
"""
|
|
||||||
return self.parent_index()
|
|
||||||
|
|
||||||
def _scan_children(self):
|
|
||||||
"""
|
|
||||||
Ensures that we're scanning the xml's subelements only once
|
|
||||||
"""
|
|
||||||
if self._scanned_children:
|
|
||||||
return
|
|
||||||
self._scanned_children = True
|
|
||||||
cast_order = 0
|
|
||||||
for child in self.xml:
|
|
||||||
if child.tag == 'Role':
|
|
||||||
self._cast.append((child.get('tag'),
|
|
||||||
child.get('thumb'),
|
|
||||||
child.get('role'),
|
|
||||||
cast_order))
|
|
||||||
cast_order += 1
|
|
||||||
elif child.tag == 'Genre':
|
|
||||||
self._genres.append(child.get('tag'))
|
|
||||||
elif child.tag == 'Country':
|
|
||||||
self._countries.append(child.get('tag'))
|
|
||||||
elif child.tag == 'Director':
|
|
||||||
self._directors.append(child.get('tag'))
|
|
||||||
elif child.tag == 'Writer':
|
|
||||||
self._writers.append(child.get('tag'))
|
|
||||||
elif child.tag == 'Producer':
|
|
||||||
self._producers.append(child.get('tag'))
|
|
||||||
elif child.tag == 'Location':
|
|
||||||
self._locations.append(child.get('path'))
|
|
||||||
elif child.tag == 'Collection':
|
|
||||||
self._collections.append((cast(int, child.get('id')),
|
|
||||||
child.get('tag')))
|
|
||||||
elif child.tag == 'Guid':
|
|
||||||
guid = child.get('id')
|
|
||||||
guid = guid.split('://', 1)
|
|
||||||
self._guids[guid[0]] = guid[1]
|
|
||||||
elif child.tag == 'Marker' and child.get('type') == 'intro':
|
|
||||||
intro = (cast(float, child.get('startTimeOffset')),
|
|
||||||
cast(float, child.get('endTimeOffset')))
|
|
||||||
if None in intro:
|
|
||||||
# Safety net if PMS xml is not as expected
|
|
||||||
continue
|
|
||||||
intro = (intro[0] / 1000.0, intro[1] / 1000.0)
|
|
||||||
self._intro_markers.append(intro)
|
|
||||||
# Plex Movie agent (legacy) or "normal" Plex tv show agent
|
|
||||||
if not self._guids:
|
|
||||||
guid = self.xml.get('guid')
|
|
||||||
if not guid:
|
|
||||||
return
|
|
||||||
for provider, regex in METADATA_PROVIDERS:
|
|
||||||
provider_id = regex.findall(guid)
|
|
||||||
try:
|
|
||||||
self._guids[provider] = provider_id[0]
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# There will only ever be one entry
|
|
||||||
break
|
|
||||||
|
|
||||||
def cast(self):
|
|
||||||
"""
|
|
||||||
Returns a list of tuples of the cast:
|
|
||||||
[(<name of actor [unicode]>,
|
|
||||||
<thumb url [unicode, may be None]>,
|
|
||||||
<role [unicode, may be None]>,
|
|
||||||
<order of appearance [int]>)]
|
|
||||||
"""
|
|
||||||
self._scan_children()
|
|
||||||
return self._cast
|
|
||||||
|
|
||||||
def genres(self):
|
|
||||||
"""
|
|
||||||
Returns a list of genres found
|
|
||||||
"""
|
|
||||||
self._scan_children()
|
|
||||||
return self._genres
|
|
||||||
|
|
||||||
def countries(self):
|
|
||||||
"""
|
|
||||||
Returns a list of all countries
|
|
||||||
"""
|
|
||||||
self._scan_children()
|
|
||||||
return self._countries
|
|
||||||
|
|
||||||
def directors(self):
|
|
||||||
"""
|
|
||||||
Returns a list of all directors
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._scan_children()
|
|
||||||
return self._directors
|
|
||||||
|
|
||||||
def writers(self):
|
|
||||||
"""
|
|
||||||
Returns a list of all writers
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._scan_children()
|
|
||||||
return self._writers
|
|
||||||
|
|
||||||
def producers(self):
|
|
||||||
"""
|
|
||||||
Returns a list of all producers
|
|
||||||
"""
|
|
||||||
self._scan_children()
|
|
||||||
return self._producers
|
|
||||||
|
|
||||||
def tv_show_path(self):
|
|
||||||
"""
|
|
||||||
Returns the direct path to the TV show, e.g. '\\NAS\tv\series'
|
|
||||||
or None
|
|
||||||
"""
|
|
||||||
self._scan_children()
|
|
||||||
if self._locations:
|
|
||||||
return self._locations[0]
|
|
||||||
|
|
||||||
def collections(self):
|
|
||||||
"""
|
|
||||||
Returns a list of tuples of the collection id and tags or an empty list
|
|
||||||
[(<collection id 1>, <collection name 1>), ...]
|
|
||||||
"""
|
|
||||||
self._scan_children()
|
|
||||||
return self._collections
|
|
||||||
|
|
||||||
def people(self):
|
|
||||||
"""
|
|
||||||
Returns a dict with lists of tuples:
|
|
||||||
{
|
|
||||||
'actor': [(<name of actor [unicode]>,
|
|
||||||
<thumb url [unicode, may be None]>,
|
|
||||||
<role [unicode, may be None]>,
|
|
||||||
<order of appearance [int]>)]
|
|
||||||
'director': [..., (<name>, ), ...],
|
|
||||||
'writer': [..., (<name>, ), ...]
|
|
||||||
}
|
|
||||||
Everything in unicode, except <cast order> which is an int.
|
|
||||||
Only <art-url> and <role> may be None if not found.
|
|
||||||
"""
|
|
||||||
self._scan_children()
|
|
||||||
return {
|
|
||||||
'actor': self._cast,
|
|
||||||
'director': [(x, ) for x in self._directors],
|
|
||||||
'writer': [(x, ) for x in self._writers]
|
|
||||||
}
|
|
||||||
|
|
||||||
def extras(self):
|
|
||||||
"""
|
|
||||||
Returns an iterator for etree elements for each extra, e.g. trailers
|
|
||||||
Returns None if no extras are found
|
|
||||||
"""
|
|
||||||
extras = self.xml.find('Extras')
|
|
||||||
if extras is None:
|
|
||||||
return
|
|
||||||
return (x for x in extras)
|
|
||||||
|
|
||||||
def trailer(self):
|
|
||||||
"""
|
|
||||||
Returns the URL for a single trailer (local trailer preferred; first
|
|
||||||
trailer found returned) or an add-on path to list all Plex extras
|
|
||||||
if the user setting showExtrasInsteadOfTrailer is set.
|
|
||||||
Returns None if nothing is found.
|
|
||||||
"""
|
|
||||||
url = None
|
|
||||||
for extras in self.xml.iterfind('Extras'):
|
|
||||||
# There will always be only 1 extras element
|
|
||||||
if (len(extras) > 0 and
|
|
||||||
app.SYNC.show_extras_instead_of_playing_trailer):
|
|
||||||
return ('plugin://%s?mode=route_to_extras&plex_id=%s'
|
|
||||||
% (v.ADDON_ID, self.plex_id))
|
|
||||||
for extra in extras:
|
|
||||||
typus = cast(int, extra.get('extraType'))
|
|
||||||
if typus != 1:
|
|
||||||
# Skip non-trailers
|
|
||||||
continue
|
|
||||||
if extra.get('guid', '').startswith('file:'):
|
|
||||||
url = extra.get('ratingKey')
|
|
||||||
# Always prefer local trailers (first one listed)
|
|
||||||
break
|
|
||||||
elif not url:
|
|
||||||
url = extra.get('ratingKey')
|
|
||||||
if url:
|
|
||||||
url = ('plugin://%s.movies/?plex_id=%s&plex_type=%s&mode=play'
|
|
||||||
% (v.ADDON_ID, url, v.PLEX_TYPE_CLIP))
|
|
||||||
return url
|
|
||||||
|
|
||||||
def listitem(self, listitem=xbmcgui.ListItem, resume=True):
|
|
||||||
"""
|
|
||||||
Returns a xbmcgui.ListItem() (or PKCListItem) for this Plex element
|
|
||||||
|
|
||||||
Pass resume=False in order to NOT set a resume point (but let Kodi
|
|
||||||
automatically handle it)
|
|
||||||
"""
|
|
||||||
item = widgets.generate_item(self)
|
|
||||||
if not resume and 'resume' in item:
|
|
||||||
del item['resume']
|
|
||||||
item = widgets.prepare_listitem(item)
|
|
||||||
return widgets.create_listitem(item, as_tuple=False, listitem=listitem)
|
|
||||||
|
|
||||||
def collections_match(self, section_id):
|
|
||||||
"""
|
|
||||||
Downloads one additional xml from the PMS in order to return a list of
|
|
||||||
tuples [(collection_id, plex_id), ...] for all collections of the
|
|
||||||
current item's Plex library sectin
|
|
||||||
Pass in the collection id of e.g. the movie's metadata
|
|
||||||
"""
|
|
||||||
if self._coll_match is None:
|
|
||||||
self._coll_match = PF.collections(section_id)
|
|
||||||
if self._coll_match is None:
|
|
||||||
LOG.error('Could not download collections for %s',
|
|
||||||
self.library_section_id())
|
|
||||||
self._coll_match = []
|
|
||||||
self._coll_match = \
|
|
||||||
[(utils.cast(int, x.get('index')),
|
|
||||||
utils.cast(int, x.get('ratingKey'))) for x in self._coll_match]
|
|
||||||
return self._coll_match
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def attach_plex_token_to_url(url):
|
|
||||||
"""
|
|
||||||
Returns an extended URL with the Plex token included as 'X-Plex-Token='
|
|
||||||
|
|
||||||
url may or may not already contain a '?'
|
|
||||||
"""
|
|
||||||
if not app.ACCOUNT.pms_token:
|
|
||||||
return url
|
|
||||||
if '?' not in url:
|
|
||||||
return "%s?X-Plex-Token=%s" % (url, app.ACCOUNT.pms_token)
|
|
||||||
else:
|
|
||||||
return "%s&X-Plex-Token=%s" % (url, app.ACCOUNT.pms_token)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def list_to_string(input_list):
|
|
||||||
"""
|
|
||||||
Concatenates input_list (list of unicodes) with a separator ' / '
|
|
||||||
Returns None if the list was empty
|
|
||||||
"""
|
|
||||||
return ' / '.join(input_list) or None
|
|
|
@ -1,182 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
from re import sub
|
|
||||||
from string import punctuation
|
|
||||||
|
|
||||||
from ..downloadutils import DownloadUtils as DU
|
|
||||||
from .. import utils, variables as v
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.api.fanartlookup')
|
|
||||||
|
|
||||||
API_KEY = utils.settings('themoviedbAPIKey')
|
|
||||||
|
|
||||||
# How far apart can a video's airing date be (in years)
|
|
||||||
YEARS_APART = 1
|
|
||||||
# levenshtein_distance_ratio() returns a value between 0 (no match) and 1 (full
|
|
||||||
# match). What's the threshold?
|
|
||||||
LEVENSHTEIN_RATIO_THRESHOLD = 0.95
|
|
||||||
# Which character should we ignore when matching video titles?
|
|
||||||
EXCLUDE_CHARS = set(punctuation)
|
|
||||||
|
|
||||||
|
|
||||||
def external_item_id(title, year, plex_type, collection):
|
|
||||||
LOG.debug('Start identifying %s (%s, %s)', title, year, plex_type)
|
|
||||||
year = int(year) if year else None
|
|
||||||
media_type = 'tv' if plex_type == v.PLEX_TYPE_SHOW else plex_type
|
|
||||||
# if the title has the year in remove it as tmdb cannot deal with it...
|
|
||||||
# replace e.g. 'The Americans (2015)' with 'The Americans'
|
|
||||||
title = sub(r'\s*\(\d{4}\)$', '', title, count=1)
|
|
||||||
url = 'https://api.themoviedb.org/3/search/%s' % media_type
|
|
||||||
parameters = {
|
|
||||||
'api_key': API_KEY,
|
|
||||||
'language': v.KODILANGUAGE,
|
|
||||||
'query': title.encode('utf-8')
|
|
||||||
}
|
|
||||||
data = DU().downloadUrl(url,
|
|
||||||
authenticate=False,
|
|
||||||
parameters=parameters,
|
|
||||||
timeout=7)
|
|
||||||
try:
|
|
||||||
data = data['results']
|
|
||||||
except (AttributeError, KeyError, TypeError):
|
|
||||||
LOG.debug('No match found on themoviedb for %s (%s, %s)',
|
|
||||||
title, year, media_type)
|
|
||||||
return
|
|
||||||
LOG.debug('themoviedb returned results: %s', data)
|
|
||||||
# Some entries don't contain a title or id - get rid of them
|
|
||||||
data = [x for x in data if 'title' in x and 'id' in x]
|
|
||||||
# Get rid of all results that do NOT have a matching release year
|
|
||||||
if year:
|
|
||||||
data = [x for x in data if __year_almost_matches(year, x)]
|
|
||||||
if not data:
|
|
||||||
LOG.debug('Empty results returned by themoviedb for %s (%s, %s)',
|
|
||||||
title, year, media_type)
|
|
||||||
return
|
|
||||||
# Calculate how similar the titles are
|
|
||||||
title = sanitize_string(title)
|
|
||||||
for entry in data:
|
|
||||||
entry['match_score'] = levenshtein_distance_ratio(
|
|
||||||
sanitize_string(entry['title']), title)
|
|
||||||
# (one of the possibly many) best match using levenshtein distance ratio
|
|
||||||
entry = max(data, key=lambda x: x['match_score'])
|
|
||||||
if entry['match_score'] < LEVENSHTEIN_RATIO_THRESHOLD:
|
|
||||||
LOG.debug('Best themoviedb match not good enough: %s', entry)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if we got several matches. If so, take the most popular one
|
|
||||||
best_matches = [x for x in data if
|
|
||||||
x['match_score'] == entry['match_score']
|
|
||||||
and 'popularity' in x]
|
|
||||||
entry = max(best_matches, key=lambda x: x['popularity'])
|
|
||||||
LOG.debug('Found themoviedb match: %s', entry)
|
|
||||||
|
|
||||||
# lookup external tmdb_id and perform artwork lookup on fanart.tv
|
|
||||||
tmdb_id = entry.get('id')
|
|
||||||
parameters = {'api_key': API_KEY}
|
|
||||||
if media_type == 'movie':
|
|
||||||
url = 'https://api.themoviedb.org/3/movie/%s' % tmdb_id
|
|
||||||
parameters['append_to_response'] = 'videos'
|
|
||||||
elif media_type == 'tv':
|
|
||||||
url = 'https://api.themoviedb.org/3/tv/%s' % tmdb_id
|
|
||||||
parameters['append_to_response'] = 'external_ids,videos'
|
|
||||||
media_id, poster, background = None, None, None
|
|
||||||
for language in (v.KODILANGUAGE, 'en'):
|
|
||||||
parameters['language'] = language
|
|
||||||
data = DU().downloadUrl(url,
|
|
||||||
authenticate=False,
|
|
||||||
parameters=parameters,
|
|
||||||
timeout=7)
|
|
||||||
try:
|
|
||||||
data.get('test')
|
|
||||||
except AttributeError:
|
|
||||||
LOG.warning('Could not download %s with parameters %s',
|
|
||||||
url, parameters)
|
|
||||||
continue
|
|
||||||
if collection is False:
|
|
||||||
if data.get('imdb_id'):
|
|
||||||
media_id = str(data.get('imdb_id'))
|
|
||||||
break
|
|
||||||
if (data.get('external_ids') and
|
|
||||||
data['external_ids'].get('tvdb_id')):
|
|
||||||
media_id = str(data['external_ids']['tvdb_id'])
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
if not data.get('belongs_to_collection'):
|
|
||||||
continue
|
|
||||||
media_id = data.get('belongs_to_collection').get('id')
|
|
||||||
if not media_id:
|
|
||||||
continue
|
|
||||||
media_id = str(media_id)
|
|
||||||
LOG.debug('Retrieved collections tmdb id %s for %s',
|
|
||||||
media_id, title)
|
|
||||||
url = 'https://api.themoviedb.org/3/collection/%s' % media_id
|
|
||||||
data = DU().downloadUrl(url,
|
|
||||||
authenticate=False,
|
|
||||||
parameters=parameters,
|
|
||||||
timeout=7)
|
|
||||||
try:
|
|
||||||
data.get('poster_path')
|
|
||||||
except AttributeError:
|
|
||||||
LOG.debug('Could not find TheMovieDB poster paths for %s'
|
|
||||||
' in the language %s', title, language)
|
|
||||||
continue
|
|
||||||
if not poster and data.get('poster_path'):
|
|
||||||
poster = ('https://image.tmdb.org/t/p/original%s' %
|
|
||||||
data.get('poster_path'))
|
|
||||||
if not background and data.get('backdrop_path'):
|
|
||||||
background = ('https://image.tmdb.org/t/p/original%s' %
|
|
||||||
data.get('backdrop_path'))
|
|
||||||
return media_id, poster, background
|
|
||||||
|
|
||||||
|
|
||||||
def __year_almost_matches(year, entry):
|
|
||||||
try:
|
|
||||||
entry_year = int(entry['release_date'][0:4])
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
return True
|
|
||||||
return abs(year - entry_year) <= YEARS_APART
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_string(s):
|
|
||||||
s = s.lower().strip()
|
|
||||||
# Get rid of chars in EXCLUDE_CHARS
|
|
||||||
s = ''.join(character for character in s if character not in EXCLUDE_CHARS)
|
|
||||||
# Get rid of multiple spaces
|
|
||||||
s = ' '.join(s.split())
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def levenshtein_distance_ratio(s, t):
|
|
||||||
"""
|
|
||||||
Calculates levenshtein distance ratio between two strings.
|
|
||||||
The more similar the strings, the closer the result will be to 1.
|
|
||||||
The farther disjunct the string, the closer the result to 0
|
|
||||||
|
|
||||||
https://www.datacamp.com/community/tutorials/fuzzy-string-python
|
|
||||||
"""
|
|
||||||
# Initialize matrix of zeros
|
|
||||||
rows = len(s) + 1
|
|
||||||
cols = len(t) + 1
|
|
||||||
distance = [[0 for x in range(cols)] for y in range(rows)]
|
|
||||||
|
|
||||||
# Populate matrix of zeros with the indeces of each character of both strings
|
|
||||||
for i in range(1, rows):
|
|
||||||
for k in range(1,cols):
|
|
||||||
distance[i][0] = i
|
|
||||||
distance[0][k] = k
|
|
||||||
|
|
||||||
# Iterate over the matrix to compute the cost of deletions,insertions and/or substitutions
|
|
||||||
for col in range(1, cols):
|
|
||||||
for row in range(1, rows):
|
|
||||||
if s[row-1] == t[col-1]:
|
|
||||||
cost = 0 # If the characters are the same in the two strings in a given position [i,j] then the cost is 0
|
|
||||||
else:
|
|
||||||
# In order to align the results with those of the Python Levenshtein package, if we choose to calculate the ratio
|
|
||||||
# the cost of a substitution is 2. If we calculate just distance, then the cost of a substitution is 1.
|
|
||||||
cost = 2
|
|
||||||
distance[row][col] = min(distance[row-1][col] + 1, # Cost of deletions
|
|
||||||
distance[row][col-1] + 1, # Cost of insertions
|
|
||||||
distance[row-1][col-1] + cost) # Cost of substitutions
|
|
||||||
return ((len(s)+len(t)) - distance[row][col]) / (len(s)+len(t))
|
|
|
@ -1,207 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
from .. import utils, variables as v, app
|
|
||||||
|
|
||||||
|
|
||||||
def _transcode_image_path(key, AuthToken, path, width, height):
|
|
||||||
"""
|
|
||||||
Transcode Image support
|
|
||||||
|
|
||||||
parameters:
|
|
||||||
key
|
|
||||||
AuthToken
|
|
||||||
path - source path of current XML: path[srcXML]
|
|
||||||
width
|
|
||||||
height
|
|
||||||
result:
|
|
||||||
final path to image file
|
|
||||||
"""
|
|
||||||
# external address - can we get a transcoding request for external images?
|
|
||||||
if key.startswith('http'):
|
|
||||||
path = key
|
|
||||||
elif key.startswith('/'): # internal full path.
|
|
||||||
path = 'http://127.0.0.1:32400' + key
|
|
||||||
else: # internal path, add-on
|
|
||||||
path = 'http://127.0.0.1:32400' + path + '/' + key
|
|
||||||
# This is bogus (note the extra path component) but ATV is stupid when it
|
|
||||||
# comes to caching images, it doesn't use querystrings. Fortunately PMS is
|
|
||||||
# lenient...
|
|
||||||
transcode_path = ('/photo/:/transcode/%sx%s/%s'
|
|
||||||
% (width, height, utils.quote_plus(path)))
|
|
||||||
args = {
|
|
||||||
'width': width,
|
|
||||||
'height': height,
|
|
||||||
'url': path
|
|
||||||
}
|
|
||||||
if AuthToken:
|
|
||||||
args['X-Plex-Token'] = AuthToken
|
|
||||||
return utils.extend_url(transcode_path, args)
|
|
||||||
|
|
||||||
|
|
||||||
class File(object):
|
|
||||||
def fullpath(self, force_first_media=True, force_addon=False,
|
|
||||||
direct_paths=None, omit_check=False, force_check=False):
|
|
||||||
"""
|
|
||||||
Returns a "fully qualified path" add-on paths or direct paths
|
|
||||||
depending on the current settings as the tupple
|
|
||||||
(fullpath, path, filename)
|
|
||||||
as unicode. Add-on paths are returned as a fallback. Returns None
|
|
||||||
if something went wrong.
|
|
||||||
|
|
||||||
firce_first_media=False prompts the user to choose which version of the
|
|
||||||
media should be returned, if several are present
|
|
||||||
force_addon=True will always return the add-on path
|
|
||||||
direct_path=True if you're calling from another Plex python
|
|
||||||
instance - because otherwise direct paths will
|
|
||||||
evaluate to False!
|
|
||||||
"""
|
|
||||||
direct_paths = app.SYNC.direct_paths if direct_paths is None \
|
|
||||||
else direct_paths
|
|
||||||
if (not direct_paths or force_addon or
|
|
||||||
self.plex_type == v.PLEX_TYPE_CLIP):
|
|
||||||
if self.plex_type == v.PLEX_TYPE_SONG:
|
|
||||||
return self._music_addon_paths(force_first_media)
|
|
||||||
if self.plex_type == v.PLEX_TYPE_EPISODE:
|
|
||||||
# need to include the plex show id in the path
|
|
||||||
path = ('plugin://plugin.video.plexkodiconnect.tvshows/%s/'
|
|
||||||
% self.grandparent_id())
|
|
||||||
else:
|
|
||||||
path = 'plugin://%s/' % v.ADDON_TYPE[self.plex_type]
|
|
||||||
# Filename in Kodi will end with actual filename - hopefully
|
|
||||||
# this is useful for other add-ons
|
|
||||||
filename = self.file_path(force_first_media=force_first_media)
|
|
||||||
if filename:
|
|
||||||
if '/' in filename:
|
|
||||||
filename = filename.rsplit('/', 1)[1]
|
|
||||||
else:
|
|
||||||
filename = filename.rsplit('\\', 1)[1]
|
|
||||||
entirepath = ('%s?mode=play&plex_id=%s&plex_type=%s&filename=%s'
|
|
||||||
% (path, self.plex_id, self.plex_type, filename))
|
|
||||||
else:
|
|
||||||
# E.g. clips or albums
|
|
||||||
entirepath = ('%s?mode=play&plex_id=%s&plex_type=%s'
|
|
||||||
% (path, self.plex_id, self.plex_type))
|
|
||||||
# For Kodi DB, we need to safe the ENTIRE path for filenames
|
|
||||||
filename = entirepath
|
|
||||||
else:
|
|
||||||
entirepath = self.validate_playurl(
|
|
||||||
self.file_path(force_first_media=force_first_media),
|
|
||||||
self.plex_type,
|
|
||||||
force_check=force_check,
|
|
||||||
omit_check=omit_check)
|
|
||||||
try:
|
|
||||||
if '/' in entirepath:
|
|
||||||
filename = entirepath.rsplit('/', 1)[1]
|
|
||||||
else:
|
|
||||||
filename = entirepath.rsplit('\\', 1)[1]
|
|
||||||
except (TypeError, IndexError):
|
|
||||||
# Fallback to add-on paths
|
|
||||||
return self.fullpath(force_first_media=force_first_media,
|
|
||||||
force_addon=True)
|
|
||||||
path = utils.rreplace(entirepath, filename, "", 1)
|
|
||||||
return entirepath, path, filename
|
|
||||||
|
|
||||||
def _music_addon_paths(self, force_first_media):
|
|
||||||
"""
|
|
||||||
For songs only. Normal add-on paths plugin://... don't work with the
|
|
||||||
Kodi music DB, hence use a "direct" url to the music file on the PMS.
|
|
||||||
"""
|
|
||||||
if self.mediastream is None and force_first_media is False:
|
|
||||||
if self.mediastream_number() is None:
|
|
||||||
return
|
|
||||||
streamno = 0 if force_first_media else self.mediastream
|
|
||||||
entirepath = "%s%s" % (app.CONN.server,
|
|
||||||
self.xml[streamno][self.part].get('key'))
|
|
||||||
entirepath = self.attach_plex_token_to_url(entirepath)
|
|
||||||
path, filename = entirepath.rsplit('/', 1)
|
|
||||||
return entirepath, path + '/', filename
|
|
||||||
|
|
||||||
def directory_path(self, section_id=None, plex_type=None, old_key=None,
|
|
||||||
synched=True):
|
|
||||||
key = self.xml.get('fastKey')
|
|
||||||
if not key:
|
|
||||||
key = self.xml.get('key')
|
|
||||||
if old_key:
|
|
||||||
key = '%s/%s' % (old_key, key)
|
|
||||||
elif not key.startswith('/'):
|
|
||||||
key = '/library/sections/%s/%s' % (section_id, key)
|
|
||||||
params = {
|
|
||||||
'mode': 'browseplex',
|
|
||||||
'key': key
|
|
||||||
}
|
|
||||||
if plex_type or self.plex_type:
|
|
||||||
params['plex_type'] = plex_type or self.plex_type
|
|
||||||
if not synched:
|
|
||||||
# No item to be found in the Kodi DB
|
|
||||||
params['synched'] = 'false'
|
|
||||||
if self.xml.get('prompt'):
|
|
||||||
# User input needed, e.g. search for a movie or episode
|
|
||||||
params['prompt'] = self.xml.get('prompt')
|
|
||||||
if section_id:
|
|
||||||
params['id'] = section_id
|
|
||||||
return utils.extend_url('plugin://%s/' % v.ADDON_ID, params)
|
|
||||||
|
|
||||||
def file_name(self, force_first_media=False):
|
|
||||||
"""
|
|
||||||
Returns only the filename, e.g. 'movie.mkv' as unicode or None if not
|
|
||||||
found
|
|
||||||
"""
|
|
||||||
ans = self.file_path(force_first_media=force_first_media)
|
|
||||||
if ans is None:
|
|
||||||
return
|
|
||||||
if "\\" in ans:
|
|
||||||
# Local path
|
|
||||||
filename = ans.rsplit("\\", 1)[1]
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
# Network share
|
|
||||||
filename = ans.rsplit("/", 1)[1]
|
|
||||||
except IndexError:
|
|
||||||
# E.g. certain Plex channels
|
|
||||||
filename = None
|
|
||||||
return filename
|
|
||||||
|
|
||||||
def file_path(self, force_first_media=False):
|
|
||||||
"""
|
|
||||||
Returns the direct path to this item, e.g. '\\NAS\movies\movie.mkv'
|
|
||||||
as unicode or None
|
|
||||||
|
|
||||||
force_first_media=True:
|
|
||||||
will always use 1st media stream, e.g. when several different
|
|
||||||
files are present for the same PMS item
|
|
||||||
"""
|
|
||||||
if self.mediastream is None and force_first_media is False:
|
|
||||||
if self.mediastream_number() is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
if force_first_media is False:
|
|
||||||
ans = self.xml[self.mediastream][self.part].attrib['file']
|
|
||||||
else:
|
|
||||||
ans = self.xml[0][self.part].attrib['file']
|
|
||||||
except (TypeError, AttributeError, IndexError, KeyError):
|
|
||||||
return
|
|
||||||
return ans
|
|
||||||
|
|
||||||
def get_picture_path(self):
|
|
||||||
"""
|
|
||||||
Returns the item's picture path (transcode, if necessary) as string.
|
|
||||||
Will always use addon paths, never direct paths
|
|
||||||
"""
|
|
||||||
path = self.xml[0][0].get('key')
|
|
||||||
extension = path[path.rfind('.'):].lower()
|
|
||||||
if app.SYNC.force_transcode_pix or extension not in v.KODI_SUPPORTED_IMAGES:
|
|
||||||
# Let Plex transcode
|
|
||||||
# max width/height supported by plex image transcoder is 1920x1080
|
|
||||||
path = app.CONN.server + _transcode_image_path(
|
|
||||||
path,
|
|
||||||
app.ACCOUNT.pms_token,
|
|
||||||
"%s%s" % (app.CONN.server, path),
|
|
||||||
1920,
|
|
||||||
1080)
|
|
||||||
else:
|
|
||||||
path = self.attach_plex_token_to_url('%s%s' % (app.CONN.server, path))
|
|
||||||
# Attach Plex id to url to let it be picked up by our playqueue agent
|
|
||||||
# later
|
|
||||||
return '%s&plex_id=%s' % (path, self.plex_id)
|
|
|
@ -1,407 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from ..utils import cast
|
|
||||||
from ..downloadutils import DownloadUtils as DU
|
|
||||||
from .. import utils, variables as v, app, path_ops, clientinfo
|
|
||||||
from .. import plex_functions as PF
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.api')
|
|
||||||
|
|
||||||
|
|
||||||
class Media(object):
|
|
||||||
def optimized_for_streaming(self):
|
|
||||||
"""
|
|
||||||
Returns True if the item's 'optimizedForStreaming' is set, False other-
|
|
||||||
wise
|
|
||||||
"""
|
|
||||||
return cast(bool, self.xml[0].get('optimizedForStreaming')) or False
|
|
||||||
|
|
||||||
def _from_part_or_media(self, key):
|
|
||||||
"""
|
|
||||||
Retrieves XML data 'key' first from the active part. If unsuccessful,
|
|
||||||
tries to retrieve the data from the Media response part.
|
|
||||||
|
|
||||||
If all fails, None is returned.
|
|
||||||
"""
|
|
||||||
return self.xml[0][self.part].get(key, self.xml[0].get(key))
|
|
||||||
|
|
||||||
def intro_markers(self):
|
|
||||||
"""
|
|
||||||
Returns a list of tuples with floats (startTimeOffset, endTimeOffset)
|
|
||||||
in Koditime or an empty list.
|
|
||||||
Each entry represents an (episode) intro that Plex detected and that
|
|
||||||
can be skipped
|
|
||||||
"""
|
|
||||||
self._scan_children()
|
|
||||||
return self._intro_markers
|
|
||||||
|
|
||||||
def video_codec(self):
|
|
||||||
"""
|
|
||||||
Returns the video codec and resolution for the child and part selected.
|
|
||||||
If any data is not found on a part-level, the Media-level data is
|
|
||||||
returned.
|
|
||||||
If that also fails (e.g. for old trailers, None is returned)
|
|
||||||
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
'videocodec': xxx, e.g. 'h264'
|
|
||||||
'resolution': xxx, e.g. '720' or '1080'
|
|
||||||
'height': xxx, e.g. '816'
|
|
||||||
'width': xxx, e.g. '1920'
|
|
||||||
'aspectratio': xxx, e.g. '1.78'
|
|
||||||
'bitrate': xxx, e.g. '10642'
|
|
||||||
'container': xxx e.g. 'mkv',
|
|
||||||
'bitDepth': xxx e.g. '8', '10'
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
answ = {
|
|
||||||
'videocodec': self._from_part_or_media('videoCodec'),
|
|
||||||
'resolution': self._from_part_or_media('videoResolution'),
|
|
||||||
'height': self._from_part_or_media('height'),
|
|
||||||
'width': self._from_part_or_media('width'),
|
|
||||||
'aspectratio': self._from_part_or_media('aspectratio'),
|
|
||||||
'bitrate': self._from_part_or_media('bitrate'),
|
|
||||||
'container': self._from_part_or_media('container'),
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
answ['bitDepth'] = self.xml[0][self.part][self.mediastream].get('bitDepth')
|
|
||||||
except (TypeError, AttributeError, KeyError, IndexError):
|
|
||||||
answ['bitDepth'] = None
|
|
||||||
return answ
|
|
||||||
|
|
||||||
def picture_codec(self):
|
|
||||||
"""
|
|
||||||
Returns the exif metadata of pictures. This does NOT seem to be used
|
|
||||||
reliably by Kodi skins! (e.g. not at all)
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
'exif:CameraMake': self.xml[0].get('make'), # e.g. 'Canon'
|
|
||||||
'exif:CameraModel': self.xml[0].get('model'), # e.g. 'Canon XYZ'
|
|
||||||
'exif:DateTime': self.xml.get('originallyAvailableAt', '').replace('-', ':') or None, # e.g. '2017-11-05'
|
|
||||||
'exif:Height': self.xml[0].get('height'), # e.g. '2160'
|
|
||||||
'exif:Width': self.xml[0].get('width'), # e.g. '3240'
|
|
||||||
'exif:Orientation': self.xml[0][self.part].get('orientation'), # e.g. '1'
|
|
||||||
'exif:FocalLength': self.xml[0].get('focalLength'), # TO BE VALIDATED
|
|
||||||
'exif:ExposureTime': self.xml[0].get('exposure'), # e.g. '1/1000'
|
|
||||||
'exif:ApertureFNumber': self.xml[0].get('aperture'), # e.g. 'f/5.0'
|
|
||||||
'exif:ISOequivalent': self.xml[0].get('iso'), # e.g. '1600'
|
|
||||||
# missing on Kodi side: lens, e.g. "EF50mm f/1.8 II"
|
|
||||||
}
|
|
||||||
|
|
||||||
def mediastreams(self):
|
|
||||||
"""
|
|
||||||
Returns the media streams for metadata purposes
|
|
||||||
|
|
||||||
Output: each track contains a dictionaries
|
|
||||||
{
|
|
||||||
'video': videotrack-list, 'codec', 'height', 'width',
|
|
||||||
'aspect', 'video3DFormat'
|
|
||||||
'audio': audiotrack-list, 'codec', 'channels',
|
|
||||||
'language'
|
|
||||||
'subtitle': list of subtitle languages (or "Unknown")
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
videotracks = []
|
|
||||||
audiotracks = []
|
|
||||||
subtitlelanguages = []
|
|
||||||
try:
|
|
||||||
# Sometimes, aspectratio is on the "toplevel"
|
|
||||||
aspect = cast(float, self.xml[0].get('aspectRatio'))
|
|
||||||
except IndexError:
|
|
||||||
# There is no stream info at all, returning empty
|
|
||||||
return {
|
|
||||||
'video': videotracks,
|
|
||||||
'audio': audiotracks,
|
|
||||||
'subtitle': subtitlelanguages
|
|
||||||
}
|
|
||||||
# Loop over parts
|
|
||||||
for child in self.xml[0]:
|
|
||||||
container = child.get('container')
|
|
||||||
# Loop over Streams
|
|
||||||
for stream in child:
|
|
||||||
media_type = int(stream.get('streamType', 999))
|
|
||||||
track = {}
|
|
||||||
if media_type == 1: # Video streams
|
|
||||||
if 'codec' in stream.attrib:
|
|
||||||
track['codec'] = stream.get('codec').lower()
|
|
||||||
if "msmpeg4" in track['codec']:
|
|
||||||
track['codec'] = "divx"
|
|
||||||
elif "mpeg4" in track['codec']:
|
|
||||||
pass
|
|
||||||
elif "h264" in track['codec']:
|
|
||||||
if container in ("mp4", "mov", "m4v"):
|
|
||||||
track['codec'] = "avc1"
|
|
||||||
track['height'] = cast(int, stream.get('height'))
|
|
||||||
track['width'] = cast(int, stream.get('width'))
|
|
||||||
# track['Video3DFormat'] = item.get('Video3DFormat')
|
|
||||||
track['aspect'] = cast(float,
|
|
||||||
stream.get('aspectRatio') or aspect)
|
|
||||||
track['duration'] = self.runtime()
|
|
||||||
track['video3DFormat'] = None
|
|
||||||
videotracks.append(track)
|
|
||||||
elif media_type == 2: # Audio streams
|
|
||||||
if 'codec' in stream.attrib:
|
|
||||||
track['codec'] = stream.get('codec').lower()
|
|
||||||
if ("dca" in track['codec'] and
|
|
||||||
"ma" in stream.get('profile', '').lower()):
|
|
||||||
track['codec'] = "dtshd_ma"
|
|
||||||
track['channels'] = cast(int, stream.get('channels'))
|
|
||||||
# 'unknown' if we cannot get language
|
|
||||||
track['language'] = stream.get('languageCode',
|
|
||||||
utils.lang(39310).lower())
|
|
||||||
audiotracks.append(track)
|
|
||||||
elif media_type == 3: # Subtitle streams
|
|
||||||
# 'unknown' if we cannot get language
|
|
||||||
subtitlelanguages.append(
|
|
||||||
stream.get('languageCode', utils.lang(39310)).lower())
|
|
||||||
return {
|
|
||||||
'video': videotracks,
|
|
||||||
'audio': audiotracks,
|
|
||||||
'subtitle': subtitlelanguages
|
|
||||||
}
|
|
||||||
|
|
||||||
def mediastream_number(self):
|
|
||||||
"""
|
|
||||||
Returns the Media stream as an int (mostly 0). Will let the user choose
|
|
||||||
if several media streams are present for a PMS item (if settings are
|
|
||||||
set accordingly)
|
|
||||||
|
|
||||||
Returns None if the user aborted selection (leaving self.mediastream at
|
|
||||||
its default of None)
|
|
||||||
"""
|
|
||||||
# How many streams do we have?
|
|
||||||
count = 0
|
|
||||||
for entry in self.xml.iterfind('./Media'):
|
|
||||||
count += 1
|
|
||||||
if (count > 1 and (
|
|
||||||
(self.plex_type != v.PLEX_TYPE_CLIP and
|
|
||||||
utils.settings('firstVideoStream') == 'false')
|
|
||||||
or
|
|
||||||
(self.plex_type == v.PLEX_TYPE_CLIP and
|
|
||||||
utils.settings('bestTrailer') == 'false'))):
|
|
||||||
# Several streams/files available.
|
|
||||||
dialoglist = []
|
|
||||||
for entry in self.xml.iterfind('./Media'):
|
|
||||||
# Get additional info (filename / languages)
|
|
||||||
if 'file' in entry[0].attrib:
|
|
||||||
option = entry[0].get('file')
|
|
||||||
option = path_ops.basename(option)
|
|
||||||
else:
|
|
||||||
option = self.title() or ''
|
|
||||||
# Languages of audio streams
|
|
||||||
languages = []
|
|
||||||
for stream in entry[0]:
|
|
||||||
if (cast(int, stream.get('streamType')) == 1 and
|
|
||||||
'language' in stream.attrib):
|
|
||||||
language = stream.get('language')
|
|
||||||
languages.append(language)
|
|
||||||
languages = ', '.join(languages)
|
|
||||||
if languages:
|
|
||||||
if option:
|
|
||||||
option = '%s (%s): ' % (option, languages)
|
|
||||||
else:
|
|
||||||
option = '%s: ' % languages
|
|
||||||
else:
|
|
||||||
option = '%s ' % option
|
|
||||||
if 'videoResolution' in entry.attrib:
|
|
||||||
res = entry.get('videoResolution')
|
|
||||||
option = '%s%sp ' % (option, res)
|
|
||||||
if 'videoCodec' in entry.attrib:
|
|
||||||
codec = entry.get('videoCodec')
|
|
||||||
option = '%s%s' % (option, codec)
|
|
||||||
option = option.strip() + ' - '
|
|
||||||
if 'audioProfile' in entry.attrib:
|
|
||||||
profile = entry.get('audioProfile')
|
|
||||||
option = '%s%s ' % (option, profile)
|
|
||||||
if 'audioCodec' in entry.attrib:
|
|
||||||
codec = entry.get('audioCodec')
|
|
||||||
option = '%s%s ' % (option, codec)
|
|
||||||
option = cast(str, option.strip())
|
|
||||||
dialoglist.append(option)
|
|
||||||
media = utils.dialog('select', 'Select stream', dialoglist)
|
|
||||||
LOG.info('User chose media stream number: %s', media)
|
|
||||||
if media == -1:
|
|
||||||
LOG.info('User cancelled media stream selection')
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
media = 0
|
|
||||||
self.mediastream = media
|
|
||||||
return media
|
|
||||||
|
|
||||||
def transcode_video_path(self, action, quality=None):
|
|
||||||
"""
|
|
||||||
|
|
||||||
To be called on a VIDEO level of PMS xml response!
|
|
||||||
|
|
||||||
Transcode Video support; returns the URL to get a media started
|
|
||||||
|
|
||||||
Input:
|
|
||||||
action 'DirectPlay'
|
|
||||||
'DirectStream'
|
|
||||||
'Transcode'
|
|
||||||
|
|
||||||
quality: {
|
|
||||||
'videoResolution': e.g. '1024x768',
|
|
||||||
'videoQuality': e.g. '60',
|
|
||||||
'maxVideoBitrate': e.g. '2000' (in kbits)
|
|
||||||
}
|
|
||||||
(one or several of these options)
|
|
||||||
Output:
|
|
||||||
final URL to pull in PMS transcoder
|
|
||||||
|
|
||||||
TODO: mediaIndex
|
|
||||||
"""
|
|
||||||
if self.mediastream is None and self.mediastream_number() is None:
|
|
||||||
return
|
|
||||||
headers = clientinfo.getXArgsDeviceInfo()
|
|
||||||
if action == v.PLAYBACK_METHOD_DIRECT_PLAY:
|
|
||||||
path = self.xml[self.mediastream][self.part].get('key')
|
|
||||||
# e.g. Trailers already feature an '?'!
|
|
||||||
return utils.extend_url(app.CONN.server + path, headers)
|
|
||||||
# Direct Streaming and Transcoding
|
|
||||||
arguments = PF.transcoding_arguments(path=self.path_and_plex_id(),
|
|
||||||
media=self.mediastream,
|
|
||||||
part=self.part,
|
|
||||||
playmethod=action,
|
|
||||||
args=quality)
|
|
||||||
headers.update(arguments)
|
|
||||||
# Path/key to VIDEO item of xml PMS response is needed, not part
|
|
||||||
path = self.xml.get('key')
|
|
||||||
transcode_path = app.CONN.server + \
|
|
||||||
'/video/:/transcode/universal/start.m3u8'
|
|
||||||
return utils.extend_url(transcode_path, headers)
|
|
||||||
|
|
||||||
def cache_external_subs(self):
|
|
||||||
"""
|
|
||||||
Downloads external subtitles temporarily to Kodi and returns a list
|
|
||||||
of their paths
|
|
||||||
"""
|
|
||||||
externalsubs = []
|
|
||||||
try:
|
|
||||||
mediastreams = self.xml[0][self.part]
|
|
||||||
except (TypeError, KeyError, IndexError):
|
|
||||||
return externalsubs
|
|
||||||
for stream in mediastreams:
|
|
||||||
# Since plex returns all possible tracks together, have to pull
|
|
||||||
# only external subtitles - only for these a 'key' exists
|
|
||||||
if int(stream.get('streamType')) != 3 or 'key' not in stream.attrib:
|
|
||||||
# Not a subtitle or not not an external subtitle
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
path = self.download_external_subtitles(
|
|
||||||
'{server}%s' % stream.get('key'),
|
|
||||||
stream.get('displayTitle'),
|
|
||||||
stream.get('codec'))
|
|
||||||
except IOError:
|
|
||||||
# Catch "IOError: [Errno 22] invalid mode ('wb') or filename"
|
|
||||||
# Due to stream.get('displayTitle') returning chars that our
|
|
||||||
# OS is not supporting, e.g. "српски језик (SRT External)"
|
|
||||||
path = self.download_external_subtitles(
|
|
||||||
'{server}%s' % stream.get('key'),
|
|
||||||
stream.get('languageCode', 'Unknown'),
|
|
||||||
stream.get('codec'))
|
|
||||||
if path:
|
|
||||||
externalsubs.append(path)
|
|
||||||
LOG.info('Found external subs: %s', externalsubs)
|
|
||||||
return externalsubs
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def download_external_subtitles(url, filename, extension):
|
|
||||||
"""
|
|
||||||
One cannot pass the subtitle language for ListItems. Workaround; will
|
|
||||||
download the subtitle at url to the Kodi PKC directory in a temp dir
|
|
||||||
|
|
||||||
Returns the path to the downloaded subtitle or None
|
|
||||||
"""
|
|
||||||
path = path_ops.create_unique_path(v.EXTERNAL_SUBTITLE_TEMP_PATH,
|
|
||||||
filename,
|
|
||||||
extension)
|
|
||||||
response = DU().downloadUrl(url, return_response=True)
|
|
||||||
if not response.ok:
|
|
||||||
LOG.error('Could not temporarily download subtitle %s', url)
|
|
||||||
LOG.error('HTTP status: %s, message: %s',
|
|
||||||
response.status_code, response.text)
|
|
||||||
return
|
|
||||||
LOG.debug('Writing temp subtitle to %s', path)
|
|
||||||
with open(path_ops.encode_path(path), 'wb') as f:
|
|
||||||
f.write(response.content)
|
|
||||||
return path
|
|
||||||
|
|
||||||
def validate_playurl(self, path, typus, force_check=False, folder=False,
|
|
||||||
omit_check=False):
|
|
||||||
"""
|
|
||||||
Returns a valid path for Kodi, e.g. with '\' substituted to '\\' in
|
|
||||||
Unicode. Returns None if this is not possible
|
|
||||||
|
|
||||||
path : Unicode
|
|
||||||
typus : Plex type from PMS xml
|
|
||||||
force_check : Will always try to check validity of path
|
|
||||||
Will also skip confirmation dialog if path not found
|
|
||||||
folder : Set to True if path is a folder
|
|
||||||
omit_check : Will entirely omit validity check if True
|
|
||||||
"""
|
|
||||||
if path is None:
|
|
||||||
return
|
|
||||||
typus = v.REMAP_TYPE_FROM_PLEXTYPE[typus]
|
|
||||||
if app.SYNC.remap_path:
|
|
||||||
path = path.replace(getattr(app.SYNC, 'remapSMB%sOrg' % typus),
|
|
||||||
getattr(app.SYNC, 'remapSMB%sNew' % typus),
|
|
||||||
1)
|
|
||||||
# There might be backslashes left over:
|
|
||||||
path = path.replace('\\', '/')
|
|
||||||
elif app.SYNC.replace_smb_path:
|
|
||||||
if path.startswith('\\\\'):
|
|
||||||
path = 'smb:' + path.replace('\\', '/')
|
|
||||||
if app.SYNC.escape_path:
|
|
||||||
path = utils.escape_path(path, app.SYNC.escape_path_safe_chars)
|
|
||||||
if (app.SYNC.path_verified and not force_check) or omit_check:
|
|
||||||
return path
|
|
||||||
|
|
||||||
# exist() needs a / or \ at the end to work for directories
|
|
||||||
if not folder:
|
|
||||||
# files
|
|
||||||
check = path_ops.exists(path)
|
|
||||||
else:
|
|
||||||
# directories
|
|
||||||
if "\\" in path:
|
|
||||||
if not path.endswith('\\'):
|
|
||||||
# Add the missing backslash
|
|
||||||
check = path_ops.exists(path + "\\")
|
|
||||||
else:
|
|
||||||
check = path_ops.exists(path)
|
|
||||||
else:
|
|
||||||
if not path.endswith('/'):
|
|
||||||
check = path_ops.exists(path + "/")
|
|
||||||
else:
|
|
||||||
check = path_ops.exists(path)
|
|
||||||
if not check:
|
|
||||||
if force_check is False:
|
|
||||||
# Validate the path is correct with user intervention
|
|
||||||
if self.ask_to_validate(path):
|
|
||||||
app.APP.stop_threads(block=False)
|
|
||||||
path = None
|
|
||||||
app.SYNC.path_verified = True
|
|
||||||
else:
|
|
||||||
path = None
|
|
||||||
elif not force_check:
|
|
||||||
# Only set the flag if we were not force-checking the path
|
|
||||||
app.SYNC.path_verified = True
|
|
||||||
return path
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def ask_to_validate(url):
|
|
||||||
"""
|
|
||||||
Displays a YESNO dialog box:
|
|
||||||
Kodi can't locate file: <url>. Please verify the path.
|
|
||||||
You may need to verify your network credentials in the
|
|
||||||
add-on settings or use different Plex paths. Stop syncing?
|
|
||||||
|
|
||||||
Returns True if sync should stop, else False
|
|
||||||
"""
|
|
||||||
LOG.warn('Cannot access file: %s', url)
|
|
||||||
# Kodi cannot locate the file #s. Please verify your PKC settings. Stop
|
|
||||||
# syncing?
|
|
||||||
return utils.yesno_dialog(utils.lang(29999), utils.lang(39031) % url)
|
|
|
@ -1,107 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
from ..utils import cast
|
|
||||||
|
|
||||||
|
|
||||||
class Playback(object):
|
|
||||||
def decision_code(self):
|
|
||||||
"""
|
|
||||||
Returns the general_play_decision_code or mde_play_decision_code if
|
|
||||||
not available. Returns None if something went wrong
|
|
||||||
"""
|
|
||||||
return self.general_play_decision_code() or self.mde_play_decision_code()
|
|
||||||
|
|
||||||
def general_play_decision_code(self):
|
|
||||||
"""
|
|
||||||
Returns the 'generalDecisionCode' as an int or None
|
|
||||||
Generally, the 1xxx codes constitute a a success decision, 2xxx a
|
|
||||||
general playback error, 3xxx a direct play error, and 4xxx a transcode
|
|
||||||
error.
|
|
||||||
|
|
||||||
General decisions can include:
|
|
||||||
|
|
||||||
1000: Direct play OK.
|
|
||||||
1001: Direct play not available; Conversion OK.
|
|
||||||
2000: Neither direct play nor conversion is available.
|
|
||||||
2001: Not enough bandwidth for any playback of this item.
|
|
||||||
2002: Number of allowed streams has been reached. Stop a playback or ask
|
|
||||||
admin for more permissions.
|
|
||||||
2003: File is unplayable.
|
|
||||||
2004: Streaming Session doesn’t exist or timed out.
|
|
||||||
2005: Client stopped playback.
|
|
||||||
2006: Admin Terminated Playback.
|
|
||||||
"""
|
|
||||||
return cast(int, self.xml.get('generalDecisionCode'))
|
|
||||||
|
|
||||||
def general_play_decision_text(self):
|
|
||||||
"""
|
|
||||||
Returns the text associated with the general_play_decision_code() as
|
|
||||||
text in unicode or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('generalDecisionText')
|
|
||||||
|
|
||||||
def mde_play_decision_code(self):
|
|
||||||
return cast(int, self.xml.get('mdeDecisionCode'))
|
|
||||||
|
|
||||||
def mde_play_decision_text(self):
|
|
||||||
"""
|
|
||||||
Returns the text associated with the mde_play_decision_code() as
|
|
||||||
text in unicode or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('mdeDecisionText')
|
|
||||||
|
|
||||||
def direct_play_decision_code(self):
|
|
||||||
return cast(int, self.xml.get('directPlayDecisionCode'))
|
|
||||||
|
|
||||||
def direct_play_decision_text(self):
|
|
||||||
"""
|
|
||||||
Returns the text associated with the mde_play_decision_code() as
|
|
||||||
text in unicode or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('directPlayDecisionText')
|
|
||||||
|
|
||||||
def transcode_decision_code(self):
|
|
||||||
return cast(int, self.xml.get('directPlayDecisionCode'))
|
|
||||||
|
|
||||||
def transcode_decision_text(self):
|
|
||||||
"""
|
|
||||||
Returns the text associated with the mde_play_decision_code() as
|
|
||||||
text in unicode or None
|
|
||||||
"""
|
|
||||||
return self.xml.get('directPlayDecisionText')
|
|
||||||
|
|
||||||
def video_decision(self):
|
|
||||||
"""
|
|
||||||
Returns "copy" if PMS streaming brain decided to DirectStream, so copy
|
|
||||||
an existing video stream into a new container. Returns "transcode" if
|
|
||||||
the video stream will be transcoded.
|
|
||||||
|
|
||||||
Raises IndexError if something went wrong. Might also return None
|
|
||||||
"""
|
|
||||||
for stream in self.xml[0][0][0]:
|
|
||||||
if stream.get('streamType') == '1':
|
|
||||||
return stream.get('decision')
|
|
||||||
|
|
||||||
def audio_decision(self):
|
|
||||||
"""
|
|
||||||
Returns "copy" if PMS streaming brain decided to DirectStream, so copy
|
|
||||||
an existing audio stream into a new container. Returns "transcode" if
|
|
||||||
the audio stream will be transcoded.
|
|
||||||
|
|
||||||
Raises IndexError if something went wrong. Might also return None
|
|
||||||
"""
|
|
||||||
for stream in self.xml[0][0][0]:
|
|
||||||
if stream.get('streamType') == '2':
|
|
||||||
return stream.get('decision')
|
|
||||||
|
|
||||||
def subtitle_decision(self):
|
|
||||||
"""
|
|
||||||
Returns the PMS' decision on the subtitle stream.
|
|
||||||
|
|
||||||
Raises IndexError if something went wrong. Might also return None
|
|
||||||
"""
|
|
||||||
for stream in self.xml[0][0][0]:
|
|
||||||
if stream.get('streamType') == '3':
|
|
||||||
return stream.get('decision')
|
|
|
@ -1,59 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
from ..utils import cast
|
|
||||||
from .. import timing, variables as v, app
|
|
||||||
|
|
||||||
|
|
||||||
class User(object):
|
|
||||||
def viewcount(self):
|
|
||||||
"""
|
|
||||||
Returns the play count for the item as an int or the int 0 if not found
|
|
||||||
"""
|
|
||||||
return cast(int, self.xml.get('viewCount')) or 0
|
|
||||||
|
|
||||||
def resume_point(self):
|
|
||||||
"""
|
|
||||||
Returns the resume point of time in seconds as float. 0.0 if not found
|
|
||||||
"""
|
|
||||||
resume = cast(float, self.xml.get('viewOffset')) or 0.0
|
|
||||||
return resume * v.PLEX_TO_KODI_TIMEFACTOR
|
|
||||||
|
|
||||||
def resume_point_plex(self):
|
|
||||||
"""
|
|
||||||
Returns the resume point of time in microseconds as float.
|
|
||||||
0.0 if not found
|
|
||||||
"""
|
|
||||||
return cast(float, self.xml.get('viewOffset')) or 0.0
|
|
||||||
|
|
||||||
def userrating(self):
|
|
||||||
"""
|
|
||||||
Returns the userRating [int].
|
|
||||||
If the user chose to replace user ratings with the number of different
|
|
||||||
file versions for a specific video, that number is returned instead
|
|
||||||
(at most 10)
|
|
||||||
|
|
||||||
0 is returned if something goes wrong
|
|
||||||
"""
|
|
||||||
if (app.SYNC.indicate_media_versions is True and
|
|
||||||
self.plex_type in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_EPISODE)):
|
|
||||||
userrating = 0
|
|
||||||
for _ in self.xml.findall('./Media'):
|
|
||||||
userrating += 1
|
|
||||||
# Don't show a value of '1' - which we'll always have for normal
|
|
||||||
# Plex library items
|
|
||||||
return 0 if userrating == 1 else min(userrating, 10)
|
|
||||||
else:
|
|
||||||
return cast(int, self.xml.get('userRating')) or 0
|
|
||||||
|
|
||||||
def lastplayed(self):
|
|
||||||
"""
|
|
||||||
Returns the Kodi timestamp [unicode] for the last point of time, when
|
|
||||||
this item was played.
|
|
||||||
Returns None if this fails - item has never been played
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return timing.plex_date_to_kodi(int(self.xml.get('lastViewedAt')))
|
|
||||||
except TypeError:
|
|
||||||
pass
|
|
|
@ -1,14 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
from .common import PlexDBBase, initialize, wipe, PLEXDB_LOCK
|
|
||||||
from .tvshows import TVShows
|
|
||||||
from .movies import Movies
|
|
||||||
from .music import Music
|
|
||||||
from .playlists import Playlists
|
|
||||||
from .sections import Sections
|
|
||||||
|
|
||||||
|
|
||||||
class PlexDB(PlexDBBase, TVShows, Movies, Music, Playlists, Sections):
|
|
||||||
pass
|
|
|
@ -1,327 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from threading import Lock
|
|
||||||
|
|
||||||
from .. import db, variables as v
|
|
||||||
|
|
||||||
PLEXDB_LOCK = Lock()
|
|
||||||
|
|
||||||
SUPPORTED_KODI_TYPES = (
|
|
||||||
v.KODI_TYPE_MOVIE,
|
|
||||||
v.KODI_TYPE_SHOW,
|
|
||||||
v.KODI_TYPE_SEASON,
|
|
||||||
v.KODI_TYPE_EPISODE,
|
|
||||||
v.KODI_TYPE_ARTIST,
|
|
||||||
v.KODI_TYPE_ALBUM,
|
|
||||||
v.KODI_TYPE_SONG
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PlexDBBase(object):
|
|
||||||
"""
|
|
||||||
Plex database methods used for all types of items.
|
|
||||||
"""
|
|
||||||
def __init__(self, plexconn=None, lock=True, copy=False):
|
|
||||||
# Allows us to use this class with a cursor instead of context mgr
|
|
||||||
self.plexconn = plexconn
|
|
||||||
self.cursor = self.plexconn.cursor() if self.plexconn else None
|
|
||||||
self.lock = lock
|
|
||||||
self.copy = copy
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
if self.lock:
|
|
||||||
PLEXDB_LOCK.acquire()
|
|
||||||
self.plexconn = db.connect('plex-copy' if self.copy else 'plex')
|
|
||||||
self.cursor = self.plexconn.cursor()
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, e_typ, e_val, trcbak):
|
|
||||||
try:
|
|
||||||
if e_typ:
|
|
||||||
# re-raise any exception
|
|
||||||
return False
|
|
||||||
self.plexconn.commit()
|
|
||||||
finally:
|
|
||||||
self.plexconn.close()
|
|
||||||
if self.lock:
|
|
||||||
PLEXDB_LOCK.release()
|
|
||||||
|
|
||||||
def is_recorded(self, plex_id, plex_type):
|
|
||||||
"""
|
|
||||||
FAST method to check whether a plex_id has already been recorded
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT plex_id FROM %s WHERE plex_id = ?' % plex_type,
|
|
||||||
(plex_id, ))
|
|
||||||
return self.cursor.fetchone() is not None
|
|
||||||
|
|
||||||
def item_by_id(self, plex_id, plex_type=None):
|
|
||||||
"""
|
|
||||||
Returns the item for plex_id or None.
|
|
||||||
Supply with the correct plex_type to speed up lookup
|
|
||||||
"""
|
|
||||||
answ = None
|
|
||||||
if plex_type == v.PLEX_TYPE_MOVIE:
|
|
||||||
answ = self.movie(plex_id)
|
|
||||||
elif plex_type == v.PLEX_TYPE_EPISODE:
|
|
||||||
answ = self.episode(plex_id)
|
|
||||||
elif plex_type == v.PLEX_TYPE_SHOW:
|
|
||||||
answ = self.show(plex_id)
|
|
||||||
elif plex_type == v.PLEX_TYPE_SEASON:
|
|
||||||
answ = self.season(plex_id)
|
|
||||||
elif plex_type == v.PLEX_TYPE_SONG:
|
|
||||||
answ = self.song(plex_id)
|
|
||||||
elif plex_type == v.PLEX_TYPE_ALBUM:
|
|
||||||
answ = self.album(plex_id)
|
|
||||||
elif plex_type == v.PLEX_TYPE_ARTIST:
|
|
||||||
answ = self.artist(plex_id)
|
|
||||||
elif plex_type in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_PHOTO, v.PLEX_TYPE_PLAYLIST):
|
|
||||||
# Will never be synched to Kodi
|
|
||||||
pass
|
|
||||||
elif plex_type is None:
|
|
||||||
# SLOW - lookup plex_id in all our tables
|
|
||||||
for kind in (v.PLEX_TYPE_MOVIE,
|
|
||||||
v.PLEX_TYPE_EPISODE,
|
|
||||||
v.PLEX_TYPE_SHOW,
|
|
||||||
v.PLEX_TYPE_SEASON,
|
|
||||||
'song', # darn
|
|
||||||
v.PLEX_TYPE_ALBUM,
|
|
||||||
v.PLEX_TYPE_ARTIST):
|
|
||||||
method = getattr(self, kind)
|
|
||||||
answ = method(plex_id)
|
|
||||||
if answ:
|
|
||||||
break
|
|
||||||
return answ
|
|
||||||
|
|
||||||
def item_by_kodi_id(self, kodi_id, kodi_type):
|
|
||||||
"""
|
|
||||||
"""
|
|
||||||
if kodi_type not in SUPPORTED_KODI_TYPES:
|
|
||||||
return
|
|
||||||
self.cursor.execute('SELECT * from %s WHERE kodi_id = ? LIMIT 1'
|
|
||||||
% v.PLEX_TYPE_FROM_KODI_TYPE[kodi_type],
|
|
||||||
(kodi_id, ))
|
|
||||||
method = getattr(self, 'entry_to_%s' % v.PLEX_TYPE_FROM_KODI_TYPE[kodi_type])
|
|
||||||
return method(self.cursor.fetchone())
|
|
||||||
|
|
||||||
def plex_id_by_last_sync(self, plex_type, last_sync, limit):
|
|
||||||
"""
|
|
||||||
Returns an iterator for all items where the last_sync is NOT identical
|
|
||||||
"""
|
|
||||||
query = '''
|
|
||||||
SELECT plex_id FROM %s WHERE last_sync <> ? LIMIT %s
|
|
||||||
''' % (plex_type, limit)
|
|
||||||
return (x[0] for x in self.cursor.execute(query, (last_sync, )))
|
|
||||||
|
|
||||||
def checksum(self, plex_id, plex_type):
|
|
||||||
"""
|
|
||||||
Returns the checksum for plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT checksum FROM %s WHERE plex_id = ? LIMIT 1' % plex_type,
|
|
||||||
(plex_id, ))
|
|
||||||
try:
|
|
||||||
return self.cursor.fetchone()[0]
|
|
||||||
except TypeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def update_last_sync(self, plex_id, plex_type, last_sync):
|
|
||||||
"""
|
|
||||||
Sets a new timestamp for plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('UPDATE %s SET last_sync = ? WHERE plex_id = ?' % plex_type,
|
|
||||||
(last_sync, plex_id))
|
|
||||||
|
|
||||||
def remove(self, plex_id, plex_type):
|
|
||||||
"""
|
|
||||||
Removes the item from our Plex db
|
|
||||||
"""
|
|
||||||
self.cursor.execute('DELETE FROM %s WHERE plex_id = ?' % plex_type, (plex_id, ))
|
|
||||||
|
|
||||||
def every_plex_id(self, plex_type, offset, limit):
|
|
||||||
"""
|
|
||||||
Returns an iterator for plex_type for every single plex_id
|
|
||||||
Will start with records at DB position offset [int] and return limit
|
|
||||||
[int] number of items
|
|
||||||
"""
|
|
||||||
return (x[0] for x in
|
|
||||||
self.cursor.execute('SELECT plex_id FROM %s LIMIT %s OFFSET %s'
|
|
||||||
% (plex_type, limit, offset)))
|
|
||||||
|
|
||||||
def missing_fanart(self, plex_type, offset, limit):
|
|
||||||
"""
|
|
||||||
Returns an iterator for plex_type for all plex_id, where fanart_synced
|
|
||||||
has not yet been set to 1
|
|
||||||
Will start with records at DB position offset [int] and return limit
|
|
||||||
[int] number of items
|
|
||||||
"""
|
|
||||||
query = '''
|
|
||||||
SELECT plex_id FROM %s WHERE fanart_synced = 0
|
|
||||||
LIMIT %s OFFSET %s
|
|
||||||
''' % (plex_type, limit, offset)
|
|
||||||
return (x[0] for x in self.cursor.execute(query))
|
|
||||||
|
|
||||||
def set_fanart_synced(self, plex_id, plex_type):
|
|
||||||
"""
|
|
||||||
Toggles fanart_synced to 1 for plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('UPDATE %s SET fanart_synced = 1 WHERE plex_id = ?' % plex_type,
|
|
||||||
(plex_id, ))
|
|
||||||
|
|
||||||
def plexid_by_sectionid(self, section_id, plex_type, limit):
|
|
||||||
query = '''
|
|
||||||
SELECT plex_id FROM %s WHERE section_id = ? LIMIT %s
|
|
||||||
''' % (plex_type, limit)
|
|
||||||
return (x[0] for x in self.cursor.execute(query, (section_id, )))
|
|
||||||
|
|
||||||
def kodiid_by_sectionid(self, section_id, plex_type):
|
|
||||||
return (x[0] for x in
|
|
||||||
self.cursor.execute('SELECT kodi_id FROM %s WHERE section_id = ?' % plex_type,
|
|
||||||
(section_id, )))
|
|
||||||
|
|
||||||
|
|
||||||
def initialize():
|
|
||||||
"""
|
|
||||||
Run once upon PKC startup to verify that plex db exists.
|
|
||||||
"""
|
|
||||||
with PlexDBBase() as plexdb:
|
|
||||||
plexdb.cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS version(
|
|
||||||
idVersion TEXT PRIMARY KEY)
|
|
||||||
''')
|
|
||||||
plexdb.cursor.execute('''
|
|
||||||
INSERT OR REPLACE INTO version(idVersion)
|
|
||||||
VALUES (?)
|
|
||||||
''', (v.ADDON_VERSION, ))
|
|
||||||
plexdb.cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS sections(
|
|
||||||
section_id INTEGER PRIMARY KEY,
|
|
||||||
section_name TEXT,
|
|
||||||
plex_type TEXT,
|
|
||||||
kodi_tagid INTEGER,
|
|
||||||
sync_to_kodi INTEGER,
|
|
||||||
last_sync INTEGER)
|
|
||||||
''')
|
|
||||||
plexdb.cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS movie(
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
kodi_id INTEGER,
|
|
||||||
kodi_fileid INTEGER,
|
|
||||||
kodi_pathid INTEGER,
|
|
||||||
fanart_synced INTEGER,
|
|
||||||
last_sync INTEGER)
|
|
||||||
''')
|
|
||||||
plexdb.cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS show(
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
kodi_id INTEGER,
|
|
||||||
kodi_pathid INTEGER,
|
|
||||||
fanart_synced INTEGER,
|
|
||||||
last_sync INTEGER)
|
|
||||||
''')
|
|
||||||
plexdb.cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS season(
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
show_id INTEGER,
|
|
||||||
parent_id INTEGER,
|
|
||||||
kodi_id INTEGER,
|
|
||||||
fanart_synced INTEGER,
|
|
||||||
last_sync INTEGER)
|
|
||||||
''')
|
|
||||||
plexdb.cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS episode(
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
show_id INTEGER,
|
|
||||||
grandparent_id INTEGER,
|
|
||||||
season_id INTEGER,
|
|
||||||
parent_id INTEGER,
|
|
||||||
kodi_id INTEGER,
|
|
||||||
kodi_fileid INTEGER,
|
|
||||||
kodi_fileid_2 INTEGER,
|
|
||||||
kodi_pathid INTEGER,
|
|
||||||
fanart_synced INTEGER,
|
|
||||||
last_sync INTEGER)
|
|
||||||
''')
|
|
||||||
plexdb.cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS artist(
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
kodi_id INTEGER,
|
|
||||||
last_sync INTEGER)
|
|
||||||
''')
|
|
||||||
plexdb.cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS album(
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
artist_id INTEGER,
|
|
||||||
parent_id INTEGER,
|
|
||||||
kodi_id INTEGER,
|
|
||||||
last_sync INTEGER)
|
|
||||||
''')
|
|
||||||
plexdb.cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS track(
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
artist_id INTEGER,
|
|
||||||
grandparent_id INTEGER,
|
|
||||||
album_id INTEGER,
|
|
||||||
parent_id INTEGER,
|
|
||||||
kodi_id INTEGER,
|
|
||||||
kodi_pathid INTEGER,
|
|
||||||
last_sync INTEGER)
|
|
||||||
''')
|
|
||||||
plexdb.cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS playlists(
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
plex_name TEXT,
|
|
||||||
plex_updatedat INTEGER,
|
|
||||||
kodi_path TEXT,
|
|
||||||
kodi_type TEXT,
|
|
||||||
kodi_hash TEXT)
|
|
||||||
''')
|
|
||||||
# DB indicees for faster lookups
|
|
||||||
commands = (
|
|
||||||
'CREATE INDEX IF NOT EXISTS ix_movie_1 ON movie (last_sync)',
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS ix_movie_2 ON movie (kodi_id)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS ix_show_1 ON show (last_sync)',
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS ix_show_2 ON show (kodi_id)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS ix_season_1 ON season (last_sync)',
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS ix_season_2 ON season (kodi_id)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS ix_episode_1 ON episode (last_sync)',
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS ix_episode_2 ON episode (kodi_id)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS ix_artist_1 ON artist (last_sync)',
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS ix_artist_2 ON artist (kodi_id)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS ix_album_1 ON album (last_sync)',
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS ix_album_2 ON album (kodi_id)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS ix_track_1 ON track (last_sync)',
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS ix_track_2 ON track (kodi_id)',
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS ix_playlists_2 ON playlists (kodi_path)',
|
|
||||||
'CREATE INDEX IF NOT EXISTS ix_playlists_3 ON playlists (kodi_hash)',
|
|
||||||
)
|
|
||||||
for cmd in commands:
|
|
||||||
plexdb.cursor.execute(cmd)
|
|
||||||
|
|
||||||
|
|
||||||
def wipe(table=None):
|
|
||||||
"""
|
|
||||||
Completely resets the Plex database.
|
|
||||||
If a table [unicode] name is provided, only that table will be dropped
|
|
||||||
"""
|
|
||||||
with PlexDBBase() as plexdb:
|
|
||||||
if table:
|
|
||||||
tables = [table]
|
|
||||||
else:
|
|
||||||
plexdb.cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
||||||
tables = [i[0] for i in plexdb.cursor.fetchall()]
|
|
||||||
for table in tables:
|
|
||||||
plexdb.cursor.execute('DROP table IF EXISTS %s' % table)
|
|
|
@ -1,69 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from .. import variables as v
|
|
||||||
|
|
||||||
|
|
||||||
class Movies(object):
|
|
||||||
def add_movie(self, plex_id, checksum, section_id, kodi_id, kodi_fileid,
|
|
||||||
kodi_pathid, last_sync):
|
|
||||||
"""
|
|
||||||
Appends or replaces an entry into the plex table for movies
|
|
||||||
"""
|
|
||||||
query = '''
|
|
||||||
INSERT OR REPLACE INTO movie(
|
|
||||||
plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
kodi_id,
|
|
||||||
kodi_fileid,
|
|
||||||
kodi_pathid,
|
|
||||||
fanart_synced,
|
|
||||||
last_sync)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
'''
|
|
||||||
self.cursor.execute(
|
|
||||||
query,
|
|
||||||
(plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
kodi_id,
|
|
||||||
kodi_fileid,
|
|
||||||
kodi_pathid,
|
|
||||||
0,
|
|
||||||
last_sync))
|
|
||||||
|
|
||||||
def movie(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns the show info as a tuple for the TV show with plex_id:
|
|
||||||
plex_id INTEGER PRIMARY KEY ASC,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
kodi_id INTEGER,
|
|
||||||
kodi_fileid INTEGER,
|
|
||||||
kodi_pathid INTEGER,
|
|
||||||
fanart_synced INTEGER,
|
|
||||||
last_sync INTEGER
|
|
||||||
"""
|
|
||||||
if plex_id is None:
|
|
||||||
return
|
|
||||||
self.cursor.execute('SELECT * FROM movie WHERE plex_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.entry_to_movie(self.cursor.fetchone())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def entry_to_movie(entry):
|
|
||||||
if not entry:
|
|
||||||
return
|
|
||||||
return {
|
|
||||||
'plex_type': v.PLEX_TYPE_MOVIE,
|
|
||||||
'kodi_type': v.KODI_TYPE_MOVIE,
|
|
||||||
'plex_id': entry[0],
|
|
||||||
'checksum': entry[1],
|
|
||||||
'section_id': entry[2],
|
|
||||||
'kodi_id': entry[3],
|
|
||||||
'kodi_fileid': entry[4],
|
|
||||||
'kodi_pathid': entry[5],
|
|
||||||
'fanart_synced': entry[6],
|
|
||||||
'last_sync': entry[7]
|
|
||||||
}
|
|
|
@ -1,245 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from .. import variables as v
|
|
||||||
|
|
||||||
|
|
||||||
class Music(object):
|
|
||||||
def add_artist(self, plex_id, checksum, section_id, kodi_id, last_sync):
|
|
||||||
"""
|
|
||||||
Appends or replaces music artist entry into the plex table
|
|
||||||
"""
|
|
||||||
query = '''
|
|
||||||
INSERT OR REPLACE INTO artist(
|
|
||||||
plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
kodi_id,
|
|
||||||
last_sync)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
'''
|
|
||||||
self.cursor.execute(
|
|
||||||
query,
|
|
||||||
(plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
kodi_id,
|
|
||||||
last_sync))
|
|
||||||
|
|
||||||
def add_album(self, plex_id, checksum, section_id, artist_id, parent_id,
|
|
||||||
kodi_id, last_sync):
|
|
||||||
"""
|
|
||||||
Appends or replaces an entry into the plex table
|
|
||||||
"""
|
|
||||||
query = '''
|
|
||||||
INSERT OR REPLACE INTO album(
|
|
||||||
plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
artist_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_id,
|
|
||||||
last_sync)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
'''
|
|
||||||
self.cursor.execute(
|
|
||||||
query,
|
|
||||||
(plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
artist_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_id,
|
|
||||||
last_sync))
|
|
||||||
|
|
||||||
def add_song(self, plex_id, checksum, section_id, artist_id, grandparent_id,
|
|
||||||
album_id, parent_id, kodi_id, kodi_pathid, last_sync):
|
|
||||||
"""
|
|
||||||
Appends or replaces an entry into the plex table
|
|
||||||
"""
|
|
||||||
query = '''
|
|
||||||
INSERT OR REPLACE INTO track(
|
|
||||||
plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
artist_id,
|
|
||||||
grandparent_id,
|
|
||||||
album_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_id,
|
|
||||||
kodi_pathid,
|
|
||||||
last_sync)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
'''
|
|
||||||
self.cursor.execute(
|
|
||||||
query,
|
|
||||||
(plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
artist_id,
|
|
||||||
grandparent_id,
|
|
||||||
album_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_id,
|
|
||||||
kodi_pathid,
|
|
||||||
last_sync))
|
|
||||||
|
|
||||||
def artist(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns the show info as a tuple for the TV show with plex_id:
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
kodi_id INTEGER,
|
|
||||||
last_sync INTEGER
|
|
||||||
"""
|
|
||||||
if plex_id is None:
|
|
||||||
return
|
|
||||||
self.cursor.execute('SELECT * FROM artist WHERE plex_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.entry_to_artist(self.cursor.fetchone())
|
|
||||||
|
|
||||||
def album(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns the show info as a tuple for the TV show with plex_id:
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
artist_id INTEGER, # plex_id of the parent artist
|
|
||||||
parent_id INTEGER, # kodi_id of the parent artist
|
|
||||||
kodi_id INTEGER,
|
|
||||||
last_sync INTEGER
|
|
||||||
"""
|
|
||||||
if plex_id is None:
|
|
||||||
return
|
|
||||||
self.cursor.execute('SELECT * FROM album WHERE plex_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.entry_to_album(self.cursor.fetchone())
|
|
||||||
|
|
||||||
def song(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns the show info as a tuple for the TV show with plex_id:
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
artist_id INTEGER, # plex_id of the parent artist
|
|
||||||
grandparent_id INTEGER, # kodi_id of the parent artist
|
|
||||||
album_id INTEGER, # plex_id of the parent album
|
|
||||||
parent_id INTEGER, # kodi_id of the parent album
|
|
||||||
kodi_id INTEGER,
|
|
||||||
kodi_pathid INTEGER,
|
|
||||||
last_sync INTEGER
|
|
||||||
"""
|
|
||||||
if plex_id is None:
|
|
||||||
return
|
|
||||||
self.cursor.execute('SELECT * FROM track WHERE plex_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.entry_to_track(self.cursor.fetchone())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def entry_to_track(entry):
|
|
||||||
if not entry:
|
|
||||||
return
|
|
||||||
return {
|
|
||||||
'plex_type': v.PLEX_TYPE_SONG,
|
|
||||||
'kodi_type': v.KODI_TYPE_SONG,
|
|
||||||
'plex_id': entry[0],
|
|
||||||
'checksum': entry[1],
|
|
||||||
'section_id': entry[2],
|
|
||||||
'artist_id': entry[3],
|
|
||||||
'grandparent_id': entry[4],
|
|
||||||
'album_id': entry[5],
|
|
||||||
'parent_id': entry[6],
|
|
||||||
'kodi_id': entry[7],
|
|
||||||
'kodi_pathid': entry[8],
|
|
||||||
'last_sync': entry[9]
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def entry_to_album(entry):
|
|
||||||
if not entry:
|
|
||||||
return
|
|
||||||
return {
|
|
||||||
'plex_type': v.PLEX_TYPE_ALBUM,
|
|
||||||
'kodi_type': v.KODI_TYPE_ALBUM,
|
|
||||||
'plex_id': entry[0],
|
|
||||||
'checksum': entry[1],
|
|
||||||
'section_id': entry[2],
|
|
||||||
'artist_id': entry[3],
|
|
||||||
'parent_id': entry[4],
|
|
||||||
'kodi_id': entry[5],
|
|
||||||
'last_sync': entry[6]
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def entry_to_artist(entry):
|
|
||||||
if not entry:
|
|
||||||
return
|
|
||||||
return {
|
|
||||||
'plex_type': v.PLEX_TYPE_ARTIST,
|
|
||||||
'kodi_type': v.KODI_TYPE_ARTIST,
|
|
||||||
'plex_id': entry[0],
|
|
||||||
'checksum': entry[1],
|
|
||||||
'section_id': entry[2],
|
|
||||||
'kodi_id': entry[3],
|
|
||||||
'last_sync': entry[4]
|
|
||||||
}
|
|
||||||
|
|
||||||
def album_has_songs(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns True if there are songs left for the album with plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT plex_id FROM track WHERE album_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.cursor.fetchone() is not None
|
|
||||||
|
|
||||||
def artist_has_albums(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns True if there are albums left for the artist with plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT plex_id FROM album WHERE artist_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.cursor.fetchone() is not None
|
|
||||||
|
|
||||||
def artist_has_songs(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns True if there are episodes left for the show with plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT plex_id FROM track WHERE artist_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.cursor.fetchone() is not None
|
|
||||||
|
|
||||||
def song_by_album(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns an iterator for all songs that have a parent album_id with
|
|
||||||
a value of plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT * FROM track WHERE album_id = ?',
|
|
||||||
(plex_id, ))
|
|
||||||
return (self.entry_to_track(x) for x in self.cursor)
|
|
||||||
|
|
||||||
def song_by_artist(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns an iterator for all songs that have a grandparent artist_id
|
|
||||||
with a value of plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT * FROM track WHERE artist_id = ?',
|
|
||||||
(plex_id, ))
|
|
||||||
return (self.entry_to_track(x) for x in self.cursor)
|
|
||||||
|
|
||||||
def album_by_artist(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns an iterator for all albums that have a parent artist_id
|
|
||||||
with a value of plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT * FROM album WHERE artist_id = ?',
|
|
||||||
(plex_id, ))
|
|
||||||
return (self.entry_to_album(x) for x in self.cursor)
|
|
||||||
|
|
||||||
def songs_have_been_synced(self):
|
|
||||||
"""
|
|
||||||
Returns True if at least one song has been synced - indicating that
|
|
||||||
Plex Music sync has been active at some point
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT plex_id FROM track LIMIT 1')
|
|
||||||
return self.cursor.fetchone() is not None
|
|
|
@ -1,96 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
|
|
||||||
class Playlists(object):
|
|
||||||
def playlist_ids(self):
|
|
||||||
"""
|
|
||||||
Returns an iterator of all Plex ids of playlists.
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT plex_id FROM playlists')
|
|
||||||
return (x[0] for x in self.cursor)
|
|
||||||
|
|
||||||
def kodi_playlist_paths(self):
|
|
||||||
"""
|
|
||||||
Returns an iterator of all Kodi playlist paths.
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT kodi_path FROM playlists')
|
|
||||||
return (x[0] for x in self.cursor)
|
|
||||||
|
|
||||||
def delete_playlist(self, playlist):
|
|
||||||
"""
|
|
||||||
Removes the entry for playlist [Playqueue_Object] from the Plex
|
|
||||||
playlists table.
|
|
||||||
Be sure to either set playlist.id or playlist.kodi_path
|
|
||||||
"""
|
|
||||||
if playlist.plex_id:
|
|
||||||
query = 'DELETE FROM playlists WHERE plex_id = ?'
|
|
||||||
var = playlist.plex_id
|
|
||||||
elif playlist.kodi_path:
|
|
||||||
query = 'DELETE FROM playlists WHERE kodi_path = ?'
|
|
||||||
var = playlist.kodi_path
|
|
||||||
else:
|
|
||||||
raise RuntimeError('Cannot delete playlist: %s' % playlist)
|
|
||||||
self.cursor.execute(query, (var, ))
|
|
||||||
|
|
||||||
def add_playlist(self, playlist):
|
|
||||||
"""
|
|
||||||
Inserts or modifies an existing entry in the Plex playlists table.
|
|
||||||
"""
|
|
||||||
query = '''
|
|
||||||
INSERT OR REPLACE INTO playlists(
|
|
||||||
plex_id,
|
|
||||||
plex_name,
|
|
||||||
plex_updatedat,
|
|
||||||
kodi_path,
|
|
||||||
kodi_type,
|
|
||||||
kodi_hash)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
'''
|
|
||||||
self.cursor.execute(
|
|
||||||
query,
|
|
||||||
(playlist.plex_id,
|
|
||||||
playlist.plex_name,
|
|
||||||
playlist.plex_updatedat,
|
|
||||||
playlist.kodi_path,
|
|
||||||
playlist.kodi_type,
|
|
||||||
playlist.kodi_hash))
|
|
||||||
|
|
||||||
def playlist(self, playlist, plex_id=None, path=None):
|
|
||||||
"""
|
|
||||||
Returns a complete Playlist (empty one passed in via playlist) for the
|
|
||||||
entry with plex_id OR kodi_path.
|
|
||||||
Returns None if not found
|
|
||||||
"""
|
|
||||||
query = 'SELECT * FROM playlists WHERE %s = ? LIMIT 1'
|
|
||||||
if plex_id:
|
|
||||||
query = query % 'plex_id'
|
|
||||||
var = plex_id
|
|
||||||
elif path:
|
|
||||||
query = query % 'kodi_path'
|
|
||||||
var = path
|
|
||||||
self.cursor.execute(query, (var, ))
|
|
||||||
answ = self.cursor.fetchone()
|
|
||||||
if not answ:
|
|
||||||
return
|
|
||||||
playlist.plex_id = answ[0]
|
|
||||||
playlist.plex_name = answ[1]
|
|
||||||
playlist.plex_updatedat = answ[2]
|
|
||||||
playlist.kodi_path = answ[3]
|
|
||||||
playlist.kodi_type = answ[4]
|
|
||||||
playlist.kodi_hash = answ[5]
|
|
||||||
return playlist
|
|
||||||
|
|
||||||
def all_kodi_paths(self):
|
|
||||||
"""
|
|
||||||
Returns a generator for all kodi_paths of all synched playlists
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT kodi_path FROM playlists')
|
|
||||||
return (x[0] for x in self.cursor)
|
|
||||||
|
|
||||||
def wipe_playlists(self):
|
|
||||||
"""
|
|
||||||
Deletes all entries in the playlists table
|
|
||||||
"""
|
|
||||||
self.cursor.execute('DELETE FROM playlists')
|
|
|
@ -1,120 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
|
|
||||||
|
|
||||||
class Sections(object):
|
|
||||||
def all_sections(self):
|
|
||||||
"""
|
|
||||||
Returns an iterator for all sections
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT * FROM sections')
|
|
||||||
return (self.entry_to_section(x) for x in self.cursor)
|
|
||||||
|
|
||||||
def section(self, section_id):
|
|
||||||
"""
|
|
||||||
For section_id, returns the dict
|
|
||||||
section_id INTEGER PRIMARY KEY,
|
|
||||||
section_name TEXT,
|
|
||||||
plex_type TEXT,
|
|
||||||
kodi_tagid INTEGER,
|
|
||||||
sync_to_kodi BOOL,
|
|
||||||
last_sync INTEGER
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT * FROM sections WHERE section_id = ? LIMIT 1',
|
|
||||||
(section_id, ))
|
|
||||||
return self.entry_to_section(self.cursor.fetchone())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def entry_to_section(entry):
|
|
||||||
if not entry:
|
|
||||||
return
|
|
||||||
return {
|
|
||||||
'section_id': entry[0],
|
|
||||||
'section_name': entry[1],
|
|
||||||
'plex_type': entry[2],
|
|
||||||
'kodi_tagid': entry[3],
|
|
||||||
'sync_to_kodi': entry[4] == 1,
|
|
||||||
'last_sync': entry[5]
|
|
||||||
}
|
|
||||||
|
|
||||||
def section_id_by_name(self, section_name):
|
|
||||||
"""
|
|
||||||
Returns the section_id for section_name (or None)
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT section_id FROM sections WHERE section_name = ? LIMIT 1',
|
|
||||||
(section_name, ))
|
|
||||||
try:
|
|
||||||
return self.cursor.fetchone()[0]
|
|
||||||
except TypeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def add_section(self, section_id, section_name, plex_type, kodi_tagid,
|
|
||||||
sync_to_kodi, last_sync):
|
|
||||||
"""
|
|
||||||
Appends a Plex section to the Plex sections table
|
|
||||||
sync=False: Plex library won't be synced to Kodi
|
|
||||||
"""
|
|
||||||
query = '''
|
|
||||||
INSERT OR REPLACE INTO sections(
|
|
||||||
section_id,
|
|
||||||
section_name,
|
|
||||||
plex_type,
|
|
||||||
kodi_tagid,
|
|
||||||
sync_to_kodi,
|
|
||||||
last_sync)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
'''
|
|
||||||
self.cursor.execute(query,
|
|
||||||
(section_id,
|
|
||||||
section_name,
|
|
||||||
plex_type,
|
|
||||||
kodi_tagid,
|
|
||||||
sync_to_kodi,
|
|
||||||
last_sync))
|
|
||||||
|
|
||||||
def update_section(self, section_id, section_name):
|
|
||||||
"""
|
|
||||||
Updates the section with section_id
|
|
||||||
"""
|
|
||||||
query = 'UPDATE sections SET section_name = ? WHERE section_id = ?'
|
|
||||||
self.cursor.execute(query, (section_name, section_id))
|
|
||||||
|
|
||||||
def remove_section(self, section_id):
|
|
||||||
"""
|
|
||||||
Removes the Plex db entry for the section with section_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('DELETE FROM sections WHERE section_id = ?',
|
|
||||||
(section_id, ))
|
|
||||||
|
|
||||||
def update_section_sync(self, section_id, sync_to_kodi):
|
|
||||||
"""
|
|
||||||
Updates whether we should sync sections_id (sync=True) or not
|
|
||||||
"""
|
|
||||||
if sync_to_kodi:
|
|
||||||
query = '''
|
|
||||||
UPDATE sections
|
|
||||||
SET sync_to_kodi = ?
|
|
||||||
WHERE section_id = ?
|
|
||||||
'''
|
|
||||||
else:
|
|
||||||
# Set last_sync = 0 in order to force a full sync if reactivated
|
|
||||||
query = '''
|
|
||||||
UPDATE sections
|
|
||||||
SET sync_to_kodi = ?, last_sync = 0
|
|
||||||
WHERE section_id = ?
|
|
||||||
'''
|
|
||||||
self.cursor.execute(query, (sync_to_kodi, section_id))
|
|
||||||
|
|
||||||
def update_section_last_sync(self, section_id, last_sync):
|
|
||||||
"""
|
|
||||||
Updates the timestamp for the section
|
|
||||||
"""
|
|
||||||
self.cursor.execute('UPDATE sections SET last_sync = ? WHERE section_id = ?',
|
|
||||||
(last_sync, section_id))
|
|
||||||
|
|
||||||
def force_full_sync(self):
|
|
||||||
"""
|
|
||||||
Sets the last_sync flag to 0 for every section
|
|
||||||
"""
|
|
||||||
self.cursor.execute('UPDATE sections SET last_sync = 0')
|
|
|
@ -1,244 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
|
||||||
from .. import variables as v
|
|
||||||
|
|
||||||
|
|
||||||
class TVShows(object):
|
|
||||||
def add_show(self, plex_id, checksum, section_id, kodi_id, kodi_pathid,
|
|
||||||
last_sync):
|
|
||||||
"""
|
|
||||||
Appends or replaces tv show entry into the plex table
|
|
||||||
"""
|
|
||||||
self.cursor.execute(
|
|
||||||
'''
|
|
||||||
INSERT OR REPLACE INTO show(
|
|
||||||
plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
kodi_id,
|
|
||||||
kodi_pathid,
|
|
||||||
fanart_synced,
|
|
||||||
last_sync)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''',
|
|
||||||
(plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
kodi_id,
|
|
||||||
kodi_pathid,
|
|
||||||
0,
|
|
||||||
last_sync))
|
|
||||||
|
|
||||||
def add_season(self, plex_id, checksum, section_id, show_id, parent_id,
|
|
||||||
kodi_id, last_sync):
|
|
||||||
"""
|
|
||||||
Appends or replaces an entry into the plex table
|
|
||||||
"""
|
|
||||||
self.cursor.execute(
|
|
||||||
'''
|
|
||||||
INSERT OR REPLACE INTO season(
|
|
||||||
plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
show_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_id,
|
|
||||||
fanart_synced,
|
|
||||||
last_sync)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''',
|
|
||||||
(plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
show_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_id,
|
|
||||||
0,
|
|
||||||
last_sync))
|
|
||||||
|
|
||||||
def add_episode(self, plex_id, checksum, section_id, show_id,
|
|
||||||
grandparent_id, season_id, parent_id, kodi_id, kodi_fileid,
|
|
||||||
kodi_fileid_2, kodi_pathid, last_sync):
|
|
||||||
"""
|
|
||||||
Appends or replaces an entry into the plex table
|
|
||||||
"""
|
|
||||||
self.cursor.execute(
|
|
||||||
'''
|
|
||||||
INSERT OR REPLACE INTO episode(
|
|
||||||
plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
show_id,
|
|
||||||
grandparent_id,
|
|
||||||
season_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_id,
|
|
||||||
kodi_fileid,
|
|
||||||
kodi_fileid_2,
|
|
||||||
kodi_pathid,
|
|
||||||
fanart_synced,
|
|
||||||
last_sync)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
''',
|
|
||||||
(plex_id,
|
|
||||||
checksum,
|
|
||||||
section_id,
|
|
||||||
show_id,
|
|
||||||
grandparent_id,
|
|
||||||
season_id,
|
|
||||||
parent_id,
|
|
||||||
kodi_id,
|
|
||||||
kodi_fileid,
|
|
||||||
kodi_fileid_2,
|
|
||||||
kodi_pathid,
|
|
||||||
0,
|
|
||||||
last_sync))
|
|
||||||
|
|
||||||
def show(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns the show info as a tuple for the TV show with plex_id:
|
|
||||||
plex_id INTEGER PRIMARY KEY ASC,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
kodi_id INTEGER,
|
|
||||||
kodi_pathid INTEGER,
|
|
||||||
fanart_synced INTEGER,
|
|
||||||
last_sync INTEGER
|
|
||||||
"""
|
|
||||||
if plex_id is None:
|
|
||||||
return
|
|
||||||
self.cursor.execute('SELECT * FROM show WHERE plex_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.entry_to_show(self.cursor.fetchone())
|
|
||||||
|
|
||||||
def season(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns the show info as a tuple for the TV show with plex_id:
|
|
||||||
plex_id INTEGER PRIMARY KEY,
|
|
||||||
checksum INTEGER UNIQUE,
|
|
||||||
section_id INTEGER,
|
|
||||||
show_id INTEGER, # plex_id of the parent show
|
|
||||||
parent_id INTEGER, # kodi_id of the parent show
|
|
||||||
kodi_id INTEGER,
|
|
||||||
fanart_synced INTEGER,
|
|
||||||
last_sync INTEGER
|
|
||||||
"""
|
|
||||||
if plex_id is None:
|
|
||||||
return
|
|
||||||
self.cursor.execute('SELECT * FROM season WHERE plex_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.entry_to_season(self.cursor.fetchone())
|
|
||||||
|
|
||||||
def episode(self, plex_id):
|
|
||||||
if plex_id is None:
|
|
||||||
return
|
|
||||||
self.cursor.execute('SELECT * FROM episode WHERE plex_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.entry_to_episode(self.cursor.fetchone())
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def entry_to_episode(entry):
|
|
||||||
if not entry:
|
|
||||||
return
|
|
||||||
return {
|
|
||||||
'plex_type': v.PLEX_TYPE_EPISODE,
|
|
||||||
'kodi_type': v.KODI_TYPE_EPISODE,
|
|
||||||
'plex_id': entry[0],
|
|
||||||
'checksum': entry[1],
|
|
||||||
'section_id': entry[2],
|
|
||||||
'show_id': entry[3],
|
|
||||||
'grandparent_id': entry[4],
|
|
||||||
'season_id': entry[5],
|
|
||||||
'parent_id': entry[6],
|
|
||||||
'kodi_id': entry[7],
|
|
||||||
'kodi_fileid': entry[8],
|
|
||||||
'kodi_fileid_2': entry[9],
|
|
||||||
'kodi_pathid': entry[10],
|
|
||||||
'fanart_synced': entry[11],
|
|
||||||
'last_sync': entry[12]
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def entry_to_show(entry):
|
|
||||||
if not entry:
|
|
||||||
return
|
|
||||||
return {
|
|
||||||
'plex_type': v.PLEX_TYPE_SHOW,
|
|
||||||
'kodi_type': v.KODI_TYPE_SHOW,
|
|
||||||
'plex_id': entry[0],
|
|
||||||
'checksum': entry[1],
|
|
||||||
'section_id': entry[2],
|
|
||||||
'kodi_id': entry[3],
|
|
||||||
'kodi_pathid': entry[4],
|
|
||||||
'fanart_synced': entry[5],
|
|
||||||
'last_sync': entry[6]
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def entry_to_season(entry):
|
|
||||||
if not entry:
|
|
||||||
return
|
|
||||||
return {
|
|
||||||
'plex_type': v.PLEX_TYPE_SEASON,
|
|
||||||
'kodi_type': v.KODI_TYPE_SEASON,
|
|
||||||
'plex_id': entry[0],
|
|
||||||
'checksum': entry[1],
|
|
||||||
'section_id': entry[2],
|
|
||||||
'show_id': entry[3],
|
|
||||||
'parent_id': entry[4],
|
|
||||||
'kodi_id': entry[5],
|
|
||||||
'fanart_synced': entry[6],
|
|
||||||
'last_sync': entry[7]
|
|
||||||
}
|
|
||||||
|
|
||||||
def season_has_episodes(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns True if there are episodes left for the season with plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT plex_id FROM episode WHERE season_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.cursor.fetchone() is not None
|
|
||||||
|
|
||||||
def show_has_seasons(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns True if there are seasons left for the show with plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT plex_id FROM season WHERE show_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.cursor.fetchone() is not None
|
|
||||||
|
|
||||||
def show_has_episodes(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns True if there are episodes left for the show with plex_id
|
|
||||||
"""
|
|
||||||
self.cursor.execute('SELECT plex_id FROM episode WHERE show_id = ? LIMIT 1',
|
|
||||||
(plex_id, ))
|
|
||||||
return self.cursor.fetchone() is not None
|
|
||||||
|
|
||||||
def episode_by_season(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns an iterator for all episodes that have a parent season_id with
|
|
||||||
a value of plex_id
|
|
||||||
"""
|
|
||||||
return (self.entry_to_episode(x) for x in
|
|
||||||
self.cursor.execute('SELECT * FROM episode WHERE season_id = ?',
|
|
||||||
(plex_id, )))
|
|
||||||
|
|
||||||
def episode_by_show(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns an iterator for all episodes that have a grandparent show_id
|
|
||||||
with a value of plex_id
|
|
||||||
"""
|
|
||||||
return (self.entry_to_episode(x) for x in
|
|
||||||
self.cursor.execute('SELECT * FROM episode WHERE show_id = ?',
|
|
||||||
(plex_id, )))
|
|
||||||
|
|
||||||
def season_by_show(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns an iterator for all seasons that have a parent show_id
|
|
||||||
with a value of plex_id
|
|
||||||
"""
|
|
||||||
return (self.entry_to_season(x) for x in
|
|
||||||
self.cursor.execute('SELECT * FROM season WHERE show_id = ?',
|
|
||||||
(plex_id, )))
|
|
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue