Compare commits
2569 commits
pms-dialog
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
ee1eb14476 | ||
|
9e54f59fd4 | ||
|
4cfcc4c1f8 | ||
|
5a6623a1dc | ||
|
e5fa5de670 | ||
|
191a3131e3 | ||
|
f96c246244 | ||
|
a4bf3d061a | ||
|
24c1ada5b1 | ||
|
61114e0d2e | ||
|
bdc98d0352 | ||
|
436d2e4391 | ||
|
2bc98f9ff1 | ||
|
a4fba553f3 | ||
|
097fd4cfa2 | ||
|
d80d3525b3 | ||
|
d54307ffd5 | ||
|
53e3258517 | ||
|
dae123acee | ||
|
f4a0789fc0 | ||
|
887f659b2f | ||
|
2bd692e173 | ||
|
176fa07e80 | ||
|
9da61a059f | ||
|
11d06d909e | ||
|
e96df700c1 | ||
|
057921b05e | ||
|
63bd85d5c8 | ||
|
9d6bae3957 | ||
|
2432ce5ee6 | ||
|
5a009b7ea0 | ||
|
26073e5dac | ||
|
47cd15baa0 | ||
|
560fc5b9c8 | ||
|
9495e1e27d | ||
|
5720811a7e | ||
|
45afba1840 | ||
|
cb1a3e74e0 | ||
|
76c4fba8e6 | ||
|
516a09ce56 | ||
|
41855882ab | ||
|
289266bb81 | ||
|
0490ce766e | ||
|
c182b8f5f8 | ||
|
e6a0af4621 | ||
|
ea877b55d5 | ||
|
7c2478a568 | ||
|
c99db1edff | ||
|
2f25ba2eae | ||
|
7602f02bcd | ||
|
de9c935a40 | ||
|
e049f37da9 | ||
|
ce6ab2c258 | ||
|
0f7410e0e3 | ||
|
4de0920bf5 | ||
|
2b9594dd90 | ||
|
4f75502a8a | ||
|
6bf41116cb | ||
|
6201a04513 | ||
|
74ec9eff97 | ||
|
295f403c64 | ||
|
cb8dc30c7c | ||
|
262315c3e7 | ||
|
a18b971564 | ||
|
1bd1da9f5a | ||
|
2fd91ff9d6 | ||
|
1001df5e30 | ||
|
0f2fd110db | ||
|
ada337c2c4 | ||
|
1066f857a2 | ||
|
858a33f816 | ||
|
fce964cc7b | ||
|
7c903d0c94 | ||
|
3ff97d0669 | ||
|
7553061945 | ||
|
6105a571c8 | ||
|
2484cf10ac | ||
|
f171785602 | ||
|
3e9c8c6361 | ||
|
cac32cc66a | ||
|
c4d14c02e2 | ||
|
c6056b4efc | ||
|
2c979fba57 | ||
|
f877c37e76 | ||
|
038960c538 | ||
|
cdf1514215 | ||
|
f15ef8886a | ||
|
7f8339a753 | ||
|
0cf35b7b87 | ||
|
09b0c61f11 | ||
|
675a8150cc | ||
|
a2194a5ce8 | ||
|
166b94c4cd | ||
|
46f99901cc | ||
|
36befcf46a | ||
|
abd8b04ff9 | ||
|
dbf2117a30 | ||
|
a2e08a30ec | ||
|
d38fe789b3 | ||
|
f262fba18a | ||
|
29822db781 | ||
|
7c12b7aa36 | ||
|
019bd1aeae | ||
|
c29be48cac | ||
|
4916bbb46e | ||
|
f7ae807167 | ||
|
966cf6f526 | ||
|
ce14d394d4 | ||
|
46f115de68 | ||
|
e98aca1f00 | ||
|
cb6ba50904 | ||
|
2d02f4af07 | ||
|
1493ac0c58 | ||
|
7c57dca0ec | ||
|
04e2d09835 | ||
|
fbfcffbb0c | ||
|
6b6464dac3 | ||
|
34045c0136 | ||
|
1c4b15e357 | ||
|
3d139b0929 | ||
|
4c0634bc13 | ||
|
060880e754 | ||
|
95758b5dc8 | ||
|
3d7d2d0993 | ||
|
808136bff8 | ||
|
98b6b681fd | ||
|
0a1edcd24a | ||
|
c8caf2f11b | ||
|
4bef20da32 | ||
|
886d2e5df7 | ||
|
f6c2a7c08f | ||
|
0fd7d11631 | ||
|
c69d131084 | ||
|
dc5402abcc | ||
|
9d7d33c0d0 | ||
|
1885d3fc94 | ||
|
bb7b2de44b | ||
|
f134266efc | ||
|
66771c53a2 | ||
|
16cbe430af | ||
|
8aa5890e67 | ||
|
12587a985c | ||
|
9150e168f6 | ||
|
a12e07da6a | ||
|
fad755745a | ||
|
07ed0d1105 | ||
|
f524018160 | ||
|
cf6a301d70 | ||
|
09d4ed597b | ||
|
faf8575537 | ||
|
c4cfdddb91 | ||
|
f469627d33 | ||
|
8bccff05b6 | ||
|
08bbf38128 | ||
|
474e4ac5d1 | ||
|
10326882bd | ||
|
e980de05a8 | ||
|
0051ed316e | ||
|
c79938e08b | ||
|
3e1f52802f | ||
|
06a20a8358 | ||
|
31549a1ffb | ||
|
a3d654c65c | ||
|
dad8d58824 | ||
|
e5585aec44 | ||
|
a7ffceb631 | ||
|
538832bed5 | ||
|
94e474513c | ||
|
1b56f5cef9 | ||
|
f192c0912c | ||
|
269dedf398 | ||
|
63144ba070 | ||
|
625d4c91b4 | ||
|
011d20473e | ||
|
2884054fd4 | ||
|
01fb1d5da6 | ||
|
f187111411 | ||
|
281c7d1599 | ||
|
89afd46b56 | ||
|
94a86b43c1 | ||
|
7393023fcc | ||
|
f544c4065f | ||
|
8ae2fdc10a | ||
|
b9c1aaac20 | ||
|
e887e7162b | ||
|
b2139ce150 | ||
|
22efe274a1 | ||
|
828a580031 | ||
|
151e3a5eef | ||
|
939cdd4615 | ||
|
a867acb0f8 | ||
|
acf446dcc0 | ||
|
70e6e4350e | ||
|
86dab2ab66 | ||
|
4bae675181 | ||
|
250859d3a7 | ||
|
3cc939f320 | ||
|
aac16f38b3 | ||
|
5014a0fafa | ||
|
7cf8cb59f1 | ||
|
a648d8941a | ||
|
d1fdf5d25f | ||
|
5eb1c2aacd | ||
|
9e0ac64bb9 | ||
|
5816235062 | ||
|
0982c3bae2 | ||
|
17d84c1f29 | ||
|
d096854b14 | ||
|
ddf8637bb6 | ||
|
089294681e | ||
|
a0280fdbd3 | ||
|
e2ebe98fde | ||
|
e60816c022 | ||
|
fb53ba3a0a | ||
|
e0a27bdb4e | ||
|
7a7ead863d | ||
|
56516d3e1c | ||
|
681179683f | ||
|
690b8c1c94 | ||
|
e5e92b851a | ||
|
4a4aecd669 | ||
|
c753d97d3f | ||
|
1207ab485c | ||
|
71ebdc1e90 | ||
|
4c2fe6dd59 | ||
|
e551a9451a | ||
|
3fd9fc4e3f | ||
|
955a600356 | ||
|
8ae6a8df48 | ||
|
e66f492389 | ||
|
3f1f41f5b6 | ||
|
5d67d4a602 | ||
|
ca64d54b4e | ||
|
27202d2ab2 | ||
|
3f74113e02 | ||
|
385bddf2da | ||
|
249c0993e2 | ||
|
b50414ebd4 | ||
|
12b863a5ba | ||
|
d192f924b5 | ||
|
548be35c10 | ||
|
4f9f7bc7c9 | ||
|
06cc2b6cde | ||
|
61e4056a13 | ||
|
ba6c46afac | ||
|
096046347b | ||
|
618c7388b4 | ||
|
2f9ae3c21c | ||
|
ccb7fa3e44 | ||
|
4dba45f32a | ||
|
07e13e0985 | ||
|
f1a4ef35c5 | ||
|
20c7ca0d05 | ||
|
9c1a753fa9 | ||
|
5b53feb1b4 | ||
|
92411bcb7b | ||
|
b3e41555ee | ||
|
9d97d2b788 | ||
|
075b28aa51 | ||
|
f747086957 | ||
|
da671c8ee5 | ||
|
97c3239657 | ||
|
28500d2cdf | ||
|
97078fda2c | ||
|
2ce1a6e639 | ||
|
038477fa77 | ||
|
50888c445c | ||
|
c7480339cd | ||
|
8e05ace380 | ||
|
7937757de6 | ||
|
e9218bf311 | ||
|
c34f43cead | ||
|
5ffcd5782d | ||
|
54a147da41 | ||
|
dccd3e512b | ||
|
92a28b6eda | ||
|
188dcf2cc1 | ||
|
7ff40ee9cc | ||
|
941ac4ef3b | ||
|
d9d89f3e6c | ||
|
c48b6f48a8 | ||
|
7c215e73d7 | ||
|
791a31bb65 | ||
|
493ac7f49a | ||
|
e3d4a7f7ca | ||
|
0a9ac40cf0 | ||
|
33b6358133 | ||
|
7bb1a895e8 | ||
|
db48ffb419 | ||
|
1ff487d649 | ||
|
d8dc959879 | ||
|
b40b03efc5 | ||
|
d1c6807ce6 | ||
|
04b5fa7c43 | ||
|
7e3dcbe332 | ||
|
5459f6ce9a | ||
|
403c34826c | ||
|
166bf40696 | ||
|
7ede0566c8 | ||
|
4bc4400100 | ||
|
cd6a0f6fe4 | ||
|
10fd6363b4 | ||
|
0f90abab59 | ||
|
f573a29d37 | ||
|
2f34ece3f5 | ||
|
25f972f30f | ||
|
6c9870f581 | ||
|
5aceb223ee | ||
|
6a7ca3c4d1 | ||
|
f524674b68 | ||
|
cc44c72cd6 | ||
|
8bc9ff974f | ||
|
3196bfce64 | ||
|
21e9e460a8 | ||
|
41aef50463 | ||
|
781426ba36 | ||
|
872c313092 | ||
|
87cff3557d | ||
|
7a0cad9734 | ||
|
98f983a830 | ||
|
a1d32447ba | ||
|
a2197eb8a6 | ||
|
4f4b89ca77 | ||
|
9abca33a53 | ||
|
1021c47b04 | ||
|
690d0ce459 | ||
|
f2fa3bfc41 | ||
|
ddf4999caa | ||
|
cad5923546 | ||
|
83598ff3f1 | ||
|
a1bda39e9d | ||
|
9c67283085 | ||
|
310ae54e7b | ||
|
0f914c52ab | ||
|
238ce2557c | ||
|
15241aab5d | ||
|
94b4ed52d6 | ||
|
a67d39609e | ||
|
64af58172b | ||
|
78c8ff73f2 | ||
|
b56d67d3fa | ||
|
fbf484cde4 | ||
|
9952a9b44a | ||
|
8a65a86cb2 | ||
|
6f553e5c94 | ||
|
51d1538f95 | ||
|
73ffb706f8 | ||
|
a4a0b075bf | ||
|
9a0ce533ee | ||
|
ddd356deda | ||
|
b69070275f | ||
|
d116bbdfe9 | ||
|
9b0075d6bb | ||
|
31323665e4 | ||
|
8219932245 | ||
|
25172c2f57 | ||
|
e55f16f61d | ||
|
e8242e0bcf | ||
|
2d20f0436e | ||
|
23ac39a860 | ||
|
4a95b1007b | ||
|
0255551ea9 | ||
|
fe857cb609 | ||
|
15371f35ec | ||
|
a1e6cdcf29 | ||
|
9328ebeecb | ||
|
e8d601d7d7 | ||
|
fd80bc9cf3 | ||
|
d84b7ccbe3 | ||
|
8502c00d89 | ||
|
b611a66ff5 | ||
|
58a86d34f1 | ||
|
6510d5e399 | ||
|
b55b22efb0 | ||
|
136af95351 | ||
|
654748218e | ||
|
0d537f108e | ||
|
a715b3a473 | ||
|
b4e132af85 | ||
|
6d39adbd8c | ||
|
70b7a44514 | ||
|
3000bfcd7d | ||
|
9182e0ad76 | ||
|
ed3301a523 | ||
|
d4d7c0f98c | ||
|
baa33f19b1 | ||
|
59424b2a7c | ||
|
3fa067aca6 | ||
|
0c337d8aae | ||
|
80181873d1 | ||
|
f9755cc39c | ||
|
ab998f7941 | ||
|
9ab35b0a49 | ||
|
4ebe11fcc4 | ||
|
c3749c0bd2 | ||
|
c3bad7c954 | ||
|
2f1cae5026 | ||
|
9080ca89b9 | ||
|
e257e5426e | ||
|
2744b9da7e | ||
|
a87dfa0a7a | ||
|
8f86f43a93 | ||
|
f4ea051c81 | ||
|
343fce2102 | ||
|
aad68340cf | ||
|
71f5b7169b | ||
|
c85e1e2bd0 | ||
|
e01e50a650 | ||
|
56324b1e88 | ||
|
bafd3545f4 | ||
|
0987b43095 | ||
|
764f132f66 | ||
|
b9aaf92aed | ||
|
d050c5451d | ||
|
e52b67c3a9 | ||
|
19a964ccb2 | ||
|
2446cdc41a | ||
|
c7cd15a670 | ||
|
999743c6c1 | ||
|
5273e874e0 | ||
|
aff0fd7a5f | ||
|
8e72033aef | ||
|
24ec8dd8e4 | ||
|
1654c3175e | ||
|
ffeb79e4b5 | ||
|
2359430260 | ||
|
e17d9bf1dd | ||
|
3b8f712289 | ||
|
2fcbc1f9b7 | ||
|
7a8cec5968 | ||
|
f67ff2f136 | ||
|
6652f764b0 | ||
|
938b82da9c | ||
|
a027fe96ed | ||
|
1ae4aa2185 | ||
|
1523ab1166 | ||
|
5dc7b96072 | ||
|
4947b561ef | ||
|
9e930f09ca | ||
|
4f59a1e2a9 | ||
|
52aaa80714 | ||
|
c7da016c85 | ||
|
07c8a1c67d | ||
|
cca78faa42 | ||
|
940d11a00e | ||
|
b6d6482c4a | ||
|
4b46b11a59 | ||
|
ab77ddbe8b | ||
|
e9824158ef | ||
|
6376fecbf7 | ||
|
bae4178c5b | ||
|
94120ee233 | ||
|
d70f27df2c | ||
|
a9b5ba4162 | ||
|
af06fdc84b | ||
|
38636b6943 | ||
|
14113d0ff7 | ||
|
ca1e0d7b3d | ||
|
dc12c89385 | ||
|
f56004f92a | ||
|
db80f2b69a | ||
|
8abbe74145 | ||
|
5e3f3daf90 | ||
|
da90e61ca8 | ||
|
2ba406bd4e | ||
|
389752b041 | ||
|
2a11b37857 | ||
|
e73c14bcf4 | ||
|
71935bf6ac | ||
|
cdb8141adf | ||
|
1618d96699 | ||
|
8e8a42ad12 | ||
|
ee5a71c5ce | ||
|
265b2dcf6e | ||
|
c063694999 | ||
|
0a06a6ad78 | ||
|
845cfb44d5 | ||
|
80d55a5388 | ||
|
8830cf22db | ||
|
730ac203ad | ||
|
9f2210a5e7 | ||
|
76e1b1c629 | ||
|
ca52117c4f | ||
|
45cd1aa0fc | ||
|
2481606cd7 | ||
|
fbb0d1542a | ||
|
7a3db9ea2e | ||
|
feb73b7e47 | ||
|
1fa1035a43 | ||
|
af23a4aabd | ||
|
2506dbeb43 | ||
|
a601311cd8 | ||
|
8a3e580975 | ||
|
8fc76386e2 | ||
|
413e856a11 | ||
|
a5ac528329 | ||
|
2f0767d086 | ||
|
b0fbb3ac09 | ||
|
04272a9d3f | ||
|
7ea9222e47 | ||
|
d75e2a0109 | ||
|
ae0e121b13 | ||
|
9933480f8d | ||
|
3208d1d71d | ||
|
21bf68e148 | ||
|
58111cf701 | ||
|
d4973d355a | ||
|
eef8a94b35 | ||
|
211362aaf3 | ||
|
a19b789c7e | ||
|
6b911e53aa | ||
|
5fb2279c53 | ||
|
1e60dd11df | ||
|
eb78087b12 | ||
|
055fe9aaa7 | ||
|
a8ee68ca4c | ||
|
2e90a30dba | ||
|
801695fdd4 | ||
|
f8ec9bbf9e | ||
|
e26cf09fef | ||
|
51a09ffb11 | ||
|
a0c55d5b15 | ||
|
c9e3d79acd | ||
|
9d3eebb8cd | ||
|
5a7d997da2 | ||
|
3ae447012a | ||
|
56494cff7c | ||
|
1d210c2fde | ||
|
ddaa26c385 | ||
|
5205964c60 | ||
|
bf04a64ca5 | ||
|
52c1a0e47d | ||
|
d3450b1266 | ||
|
a1a174a9e1 | ||
|
fbb65913db | ||
|
58e26be021 | ||
|
f7d8bccca3 | ||
|
9b12647957 | ||
|
7bd02861dc | ||
|
b5fec41448 | ||
|
e498736a96 | ||
|
69049c62f1 | ||
|
0765583dda | ||
|
7bb469702a | ||
|
816913031c | ||
|
fbad92a9b3 | ||
|
7f1b9f4d5b | ||
|
080eef441e | ||
|
e009c371b2 | ||
|
65a921c3cc | ||
|
228cdc437d | ||
|
fe0b224047 | ||
|
0541d38b3d | ||
|
12fe644b85 | ||
|
f1aa42b957 | ||
|
ac633d99e5 | ||
|
cfe3f55234 | ||
|
7ff4baac3a | ||
|
c0f01db7c1 | ||
|
eac27032fa | ||
|
80abe0b34b | ||
|
a4526080db | ||
|
95b469efb5 | ||
|
24ebb38f74 | ||
|
4f6156cf30 | ||
|
6b5750910c | ||
|
0d7a1b3a9f | ||
|
b7f13a8842 | ||
|
f5026b637d | ||
|
b1d59e65be | ||
|
b5e13d0ab6 | ||
|
9139669f44 | ||
|
09989b814b | ||
|
09bdda94bc | ||
|
d88b62ccd9 | ||
|
1dc458cca0 | ||
|
34e84cd037 | ||
|
bf19a66394 | ||
|
d2b5bc8d23 | ||
|
064e573af9 | ||
|
b5bd13f7bd | ||
|
335bfc34c5 | ||
|
91da038413 | ||
|
2cee5512d9 | ||
|
46830d29a9 | ||
|
b17e5c124a | ||
|
8560bf11a8 | ||
|
bae923d34a | ||
|
33ed1bed83 | ||
|
bcc97df209 | ||
|
7d8802467f | ||
|
26fa1ff909 | ||
|
dca1bb6835 | ||
|
53f77a7a97 | ||
|
a83cf93150 | ||
|
85e0909105 | ||
|
ae7578eeb5 | ||
|
92a7fa7c7a | ||
|
3cdec739a6 | ||
|
335b994635 | ||
|
9b98ee9ec3 | ||
|
1ed8de2e0f | ||
|
3e9abbfaaa | ||
|
1070f3ed1f | ||
|
640876f961 | ||
|
10b01a7aec | ||
|
45ce7ae932 | ||
|
245dcb77bc | ||
|
4b598200b9 | ||
|
140030df33 | ||
|
61f13f516b | ||
|
024bf31b83 | ||
|
a12ee6695e | ||
|
116dd76475 | ||
|
5438fe4388 | ||
|
38977a8ca6 | ||
|
a43e0801b4 | ||
|
f126c8005a | ||
|
ac8a15f2b4 | ||
|
cf295e5452 | ||
|
814f431a11 | ||
|
e01f720e16 | ||
|
7c5c91b590 | ||
|
6a5d655a50 | ||
|
3436035530 | ||
|
84d4e5aa99 | ||
|
1fcae8b6de | ||
|
33d20f0d0b | ||
|
e718fd3276 | ||
|
d912d8be96 | ||
|
76cbd27ae3 | ||
|
53139a7e45 | ||
|
4f1cc5f15d | ||
|
868cecfe35 | ||
|
a5aa0c2594 | ||
|
fc237383aa | ||
|
befe090661 | ||
|
9a670f498c | ||
|
fb4de9fb92 | ||
|
112d177c50 | ||
|
416c424f52 | ||
|
8c60050bcd | ||
|
ee1cbc9feb | ||
|
5466bb759c | ||
|
1bd29587e1 | ||
|
f5e8569584 | ||
|
eb3e655213 | ||
|
58ba03b94b | ||
|
fb76f49fbd | ||
|
f32a8c534f | ||
|
49a0528161 | ||
|
6e67429133 | ||
|
1f4baae970 | ||
|
6ee1a4b695 | ||
|
2643798393 | ||
|
5c67f0c0b0 | ||
|
671bbbd9a9 | ||
|
8aa511a3d3 | ||
|
0c4a7da5e7 | ||
|
f506b971ce | ||
|
c91f62108b | ||
|
0b9297e7c9 | ||
|
9eb596954e | ||
|
68cf59293f | ||
|
67dfca32b5 | ||
|
b99b17d0f9 | ||
|
495e0ef99b | ||
|
966d368261 | ||
|
0ae7c8ccfc | ||
|
b5b78669a2 | ||
|
39892a2aeb | ||
|
097eb9d077 | ||
|
3627c9e6bb | ||
|
d4f23db945 | ||
|
ccc9372e9f | ||
|
b913486da0 | ||
|
a463bd521f | ||
|
0bf7ada6e3 | ||
|
1c245683ad | ||
|
12b84b42bb | ||
|
a761a8987d | ||
|
fde67483f4 | ||
|
cd524cb978 | ||
|
7018aba655 | ||
|
5ccf5d0b3e | ||
|
e433f6f719 | ||
|
e08e9b0d32 | ||
|
563e86c8db | ||
|
04a6db6a21 | ||
|
03a9f84b9e | ||
|
15f34e2859 | ||
|
c5a720aea9 | ||
|
15f6a2a919 | ||
|
1cbd65861c | ||
|
d96ac192d1 | ||
|
a46cb731cf | ||
|
98c4a18ebf | ||
|
4c6e71779b | ||
|
df53f52a5d | ||
|
7d38b205b5 | ||
|
039dfebf82 | ||
|
df7c757573 | ||
|
b6fb97cec9 | ||
|
89ec68e22f | ||
|
1b18edd3da | ||
|
00f69d8568 | ||
|
844de523d4 | ||
|
f805f2b265 | ||
|
dce9206571 | ||
|
3076be10e6 | ||
|
dab699345b | ||
|
8e8cf18c0a | ||
|
6bd5b28e0c | ||
|
3ba09e1ff0 | ||
|
d0c0ab2a56 | ||
|
04d328de3e | ||
|
bcb89aed88 | ||
|
ccb95a0169 | ||
|
65f04ddefc | ||
|
ab47b63bc5 | ||
|
58189ecf60 | ||
|
32399decb6 | ||
|
9171169956 | ||
|
54998edf9f | ||
|
9684540189 | ||
|
ca4e238359 | ||
|
10c7c79035 | ||
|
14bec79d03 | ||
|
3e2e538e70 | ||
|
3e7533d965 | ||
|
59aa9f3a55 | ||
|
a22ac545ac | ||
|
1453b6c2f1 | ||
|
23547a354c | ||
|
f993373a4f | ||
|
7e829c1bad | ||
|
1a7a36820a | ||
|
edb9d6e2b0 | ||
|
ab8089d3b1 | ||
|
e76fd03915 | ||
|
ab5ab966e4 | ||
|
6a292d29f6 | ||
|
71a150ea09 | ||
|
a603bd33ae | ||
|
cc854a120f | ||
|
5c11ab21ed | ||
|
8dd1565fc1 | ||
|
01cabf0dac | ||
|
9b45a84d7e | ||
|
453098ef4f | ||
|
0c60f984d1 | ||
|
37f1bddf9d | ||
|
e554195228 | ||
|
86c6e44ad6 | ||
|
203edf90f0 | ||
|
2791dc7b57 | ||
|
4dcf057631 | ||
|
6f0cba6732 | ||
|
cdabb973f8 | ||
|
b6f2a23622 | ||
|
15c4da46a9 | ||
|
b22ae7aee4 | ||
|
5cc67f41e1 | ||
|
5263f49c46 | ||
|
eb1214f0c7 | ||
|
75a6ecd1cf | ||
|
9c4c6bf07f | ||
|
34f1c7d1b9 | ||
|
65e377a49f | ||
|
02a03bd0f2 | ||
|
e93fd94a71 | ||
|
25c2b5e782 | ||
|
b178526ca1 | ||
|
fb879f39fb | ||
|
b1aceb1e90 | ||
|
11f636c4b9 | ||
|
229c76eb39 | ||
|
26689e16d4 | ||
|
d92ce36890 | ||
|
bca657ab08 | ||
|
d5a7bab8db | ||
|
fdc82d1d5b | ||
|
4262e52648 | ||
|
aadb22e531 | ||
|
c1bb083933 | ||
|
1ac19109ba | ||
|
b4d036ed6b | ||
|
d5781d0906 | ||
|
88cc4e220c | ||
|
029123334b | ||
|
2929162671 | ||
|
3208655598 | ||
|
d1bd785e5a | ||
|
5aa7e1d39b | ||
|
068379c0eb | ||
|
c849caa5de | ||
|
2c4fcfa581 | ||
|
1a9af2f8a4 | ||
|
e1bdce2b99 | ||
|
90f445cc75 | ||
|
2013b498d6 | ||
|
2bbc41d24d | ||
|
456897a3cc | ||
|
38cedfa706 | ||
|
e438c00b9b | ||
|
de80885aa5 | ||
|
c308c06929 | ||
|
0373029238 | ||
|
aedb16dc71 | ||
|
de19813a85 | ||
|
648d88b9b2 | ||
|
c072f0f439 | ||
|
83660310bb | ||
|
de888be777 | ||
|
5a8eb0db4b | ||
|
8a2ce01aee | ||
|
448714e6a2 | ||
|
d05c29776c | ||
|
ae328927f5 | ||
|
8d8b631889 | ||
|
2c101d9145 | ||
|
afc15af9cf | ||
|
f987b49048 | ||
|
c996d51085 | ||
|
7cb6c75f3b | ||
|
9665f34ee6 | ||
|
34bc708d7b | ||
|
3b0a746af6 | ||
|
a8a229967c | ||
|
58b15e69a9 | ||
|
392f3f6359 | ||
|
71af3d0ca6 | ||
|
b5b9dd51a5 | ||
|
0ee65b9f6c | ||
|
815cd20d39 | ||
|
9d07a58c50 | ||
|
39157385f2 | ||
|
d068c37c49 | ||
|
ba4f1d15d6 | ||
|
3c70a84704 | ||
|
37bbf61a63 | ||
|
41483e6731 | ||
|
9a6485737f | ||
|
a2b57909cb | ||
|
3d4642dc56 | ||
|
68ca41545c | ||
|
db0f7f283e | ||
|
eef0eda426 | ||
|
c6e635b39b | ||
|
0900e462c0 | ||
|
98975ff23d | ||
|
dfe281268f | ||
|
7d040e37ae | ||
|
bda58deb6e | ||
|
86cde7c69a | ||
|
a2cd5bec76 | ||
|
e50c0c011e | ||
|
5b7595c681 | ||
|
4ea77370e8 | ||
|
c99cead6f5 | ||
|
8c51ee5c7a | ||
|
62ecefdcca | ||
|
c69176d4a1 | ||
|
416cf20686 | ||
|
9ba49a663e | ||
|
526f7d9fc7 | ||
|
07eeffbabc | ||
|
349829a754 | ||
|
652b23bd8d | ||
|
3b9518d747 | ||
|
c17dda40dd | ||
|
c695df2520 | ||
|
05998c3b82 | ||
|
33cae69cc4 | ||
|
36c78b0d6e | ||
|
6af36be046 | ||
|
dffbaac7e0 | ||
|
5e77dfb71e | ||
|
a9ff70fac7 | ||
|
2536cb8a79 | ||
|
fa9ca95e2e | ||
|
2c12f2e705 | ||
|
3c9eb71324 | ||
|
1ce5ccda62 | ||
|
2ac9358cd4 | ||
|
f63ef92b8f | ||
|
c88dbc8687 | ||
|
78def504bc | ||
|
2942300f10 | ||
|
596dbcc7dc | ||
|
ba967b212b | ||
|
a45cb02bc0 | ||
|
bf9ef7afbe | ||
|
098ad30432 | ||
|
4a83aae573 | ||
|
056150a2da | ||
|
823f6b6e3f | ||
|
f6e3d25413 | ||
|
0e64f50b95 | ||
|
0d94a633e0 | ||
|
d483a5b6ee | ||
|
deffd73fa3 | ||
|
b31a2c6b35 | ||
|
7fa86ac097 | ||
|
94da14744d | ||
|
d8aada43a6 | ||
|
a9bfae2b13 | ||
|
7a0b9c8fca | ||
|
80f3542737 | ||
|
da1cb6e3d7 | ||
|
a0d6674f7e | ||
|
aa27b4ad1f | ||
|
d617535a05 | ||
|
873c9e74f5 | ||
|
e6cd80a7dd | ||
|
1a1e4b113d | ||
|
50d770718d | ||
|
6447281404 | ||
|
f98e2df42f | ||
|
cd728b4c32 | ||
|
3b18a92ab4 | ||
|
f24266fb54 | ||
|
bdb85293a5 | ||
|
05a6700d55 | ||
|
8621c57de9 | ||
|
c7eab63960 | ||
|
1787e51c7c | ||
|
63201db07d | ||
|
d8796a174e | ||
|
6bf0cf4894 | ||
|
950a2de0f5 | ||
|
d8770603ba | ||
|
74dc249a37 | ||
|
63241b127e | ||
|
9208cf2cb3 | ||
|
01c0c36244 | ||
|
ca16f73637 | ||
|
4786372490 | ||
|
2234e49cf8 | ||
|
e65c9d6c94 | ||
|
0e16eb703a | ||
|
7b21caceae | ||
|
a6dd429b79 | ||
|
548d83874b | ||
|
03e7fc6791 | ||
|
bf6e52bf20 | ||
|
664470df6a | ||
|
edb72fba36 | ||
|
8d509369e8 | ||
|
426318a552 | ||
|
76004f1aa6 | ||
|
20225f12b4 | ||
|
8169907192 | ||
|
85f73126ff | ||
|
29ec8cabf7 | ||
|
2c15e0e1f7 | ||
|
995d5a5ba8 | ||
|
b206d1a99f | ||
|
2b0d63da6f | ||
|
033919d09b | ||
|
7acad7c268 | ||
|
f90b470f5b | ||
|
ae3f24f6ca | ||
|
a7222af648 | ||
|
4edea1c7a0 | ||
|
2c77bd28af | ||
|
662ed1bdfc | ||
|
8f8ccd1daf | ||
|
01609e4d72 | ||
|
1071d75857 | ||
|
09c2ad1b80 | ||
|
412dc254fc | ||
|
1dbeb95e24 | ||
|
e0d3670299 | ||
|
6c8b17d7b8 | ||
|
0e1ea79f3c | ||
|
fb5d84034c | ||
|
0c567509eb | ||
|
5acfca44e5 | ||
|
a80be43e13 | ||
|
0d8692c336 | ||
|
2126a8e2c2 | ||
|
ee68703da9 | ||
|
a9883d8a3f | ||
|
7f0abcee7d | ||
|
cb97734cf9 | ||
|
0de47c7b3e | ||
|
10e6caf3cd | ||
|
1d17dd867c | ||
|
06fe382a3f | ||
|
7d7d40bc0d | ||
|
b858373aeb | ||
|
65fe1ed399 | ||
|
fb9c560ccd | ||
|
996adc2c03 | ||
|
3a9fcacd5c | ||
|
d09d2e6aaf | ||
|
fdbe42a05a | ||
|
ad607f61e7 | ||
|
b3402fc3d5 | ||
|
dfeba07ab6 | ||
|
4acd826e39 | ||
|
5f72d50ed0 | ||
|
ae7eaadd1e | ||
|
635f941a30 | ||
|
c388789fbd | ||
|
a39127af1c | ||
|
4e10a9da8b | ||
|
8cb4e5f8fc | ||
|
624fe87fd1 | ||
|
a279cf5198 | ||
|
dcff85e203 | ||
|
5298370413 | ||
|
5f1241adde | ||
|
1e499ebfe6 | ||
|
5acd936b05 | ||
|
29f3aa84aa | ||
|
d10011cc50 | ||
|
b5d0e4f428 | ||
|
e566dc8515 | ||
|
2511567e96 | ||
|
a09dfd2b0d | ||
|
3450bc5294 | ||
|
91fe4f827d | ||
|
0c400b3563 | ||
|
8f2a0c3742 | ||
|
0278e16de0 | ||
|
9ec9278888 | ||
|
77ca3c428e | ||
|
ee805301a3 | ||
|
06964fab7d | ||
|
96db37c588 | ||
|
62e914d7e5 | ||
|
75c5bcae15 | ||
|
1d95ef69b5 | ||
|
652a43e05c | ||
|
e04c74392f | ||
|
a79ff338cc | ||
|
2ad95f4cd1 | ||
|
907b22a60f | ||
|
cffebf2f65 | ||
|
097cc79c5a | ||
|
4be8b3da9b | ||
|
25aacc8495 | ||
|
a3d2c5530c | ||
|
aa2b1e5e15 | ||
|
4779f3e994 | ||
|
352402bc35 | ||
|
1d991fd606 | ||
|
cb563ba020 | ||
|
9e2805b622 | ||
|
c351b93635 | ||
|
15c0322f27 | ||
|
83ee4c29ba | ||
|
6d0ad14339 | ||
|
69cb09e009 | ||
|
67db141d73 | ||
|
211d3d6a5a | ||
|
2ee3d86c27 | ||
|
087f0bcd23 | ||
|
32d8083b60 | ||
|
e181b4cac2 | ||
|
4739337ed5 | ||
|
5de91ff9b5 | ||
|
d6246a1cab | ||
|
1ed0deebc3 | ||
|
9e6b841296 | ||
|
7527f38c3f | ||
|
f821134ed3 | ||
|
5b5af1ceed | ||
|
9f0d0026e6 | ||
|
04da3572cb | ||
|
d2f1a34092 | ||
|
5f79e944ab | ||
|
5dd346b657 | ||
|
605318c1f0 | ||
|
e4af13bbd5 | ||
|
3b9fce7470 | ||
|
889b6094d9 | ||
|
b86a6549ac | ||
|
3e3aa49d0d | ||
|
4c2332ca99 | ||
|
e255958b57 | ||
|
332a2eca68 | ||
|
4d17a9747f | ||
|
5cd5b81198 | ||
|
3fe0d51614 | ||
|
51cc5e8a2a | ||
|
ef51a67779 | ||
|
ce82725116 | ||
|
73bad8fa05 | ||
|
5a3ad09a04 | ||
|
1fccd23c4f | ||
|
4110c335c0 | ||
|
0f289393c1 | ||
|
c08a8f744e | ||
|
251263eec7 | ||
|
f5d98d66d4 | ||
|
2d19cd4d63 | ||
|
e590968d52 | ||
|
b8ebed35f4 | ||
|
262a2dda21 | ||
|
3d4ba1e165 | ||
|
1f35caba54 | ||
|
df24ab88c9 | ||
|
bd192e4b6a | ||
|
2e4e1e0aea | ||
|
70632f1749 | ||
|
56331bead5 | ||
|
0f95889b75 | ||
|
1ab9ff7790 | ||
|
25f7c21018 | ||
|
74e8d1cc0f | ||
|
e4cb07df68 | ||
|
2dff87dc4b | ||
|
134b76cb09 | ||
|
122bc56c31 | ||
|
769f093492 | ||
|
6aa1a09b56 | ||
|
dc369a0453 | ||
|
6d8885fae9 | ||
|
a9cf7eb294 | ||
|
376e66b5f0 | ||
|
50686ae191 | ||
|
4ee828dfe9 | ||
|
6076da724b | ||
|
4fb4643ac0 | ||
|
a9f51f9ac4 | ||
|
323a4482e5 | ||
|
e9f49be7f3 | ||
|
db6a7ba9b4 | ||
|
6950a3d979 | ||
|
b0563f5dcc | ||
|
6237d932d8 | ||
|
16ff6a51f5 | ||
|
ff8bfd9523 | ||
|
79e4ff4509 | ||
|
c95330c0f7 | ||
|
7966dd5ee4 | ||
|
512479e47e | ||
|
825237dfed | ||
|
519c9675ae | ||
|
3b3d671f37 | ||
|
5cf52aa2a3 | ||
|
9b9d2be53d | ||
|
9087f27f2a | ||
|
9a5239ab1d | ||
|
8803d3353c | ||
|
bd1b0cc533 | ||
|
5d6b8f1273 | ||
|
9682d7a313 | ||
|
4fbcdcac7f | ||
|
48b08b4ba4 | ||
|
1c25441e99 | ||
|
1156a4efa1 | ||
|
f6aab94d10 | ||
|
b87ba4c753 | ||
|
531b2dedb3 | ||
|
1a46664051 | ||
|
97d829779f | ||
|
d2e0479225 | ||
|
e0fbccabc8 | ||
|
6eb36f0560 | ||
|
74e801cf4d | ||
|
edbb99b2e2 | ||
|
3632faeb67 | ||
|
d406843e78 | ||
|
f3647aaf24 | ||
|
3f173f9677 | ||
|
22ea4a6a7c | ||
|
8ed61410ab | ||
|
e13273c5b8 | ||
|
07174a39f3 | ||
|
3a73b9de44 | ||
|
f13950590e | ||
|
70cf483fe2 | ||
|
2baafa77bb | ||
|
aeda4d97ba | ||
|
adeeee7162 | ||
|
1cc8cb7ad3 | ||
|
27d34167e9 | ||
|
de90ab225f | ||
|
866412a720 | ||
|
9cc731e18f | ||
|
c8405414d1 | ||
|
aa11761ae9 | ||
|
01bbaaa90a | ||
|
dac7cdd47e | ||
|
4f750379f1 | ||
|
80414ea0d4 | ||
|
d33ba7e502 | ||
|
f6415ae444 | ||
|
1ab1f18e07 | ||
|
28e4561461 | ||
|
2753309149 | ||
|
1864adf151 | ||
|
d198808cfc | ||
|
8216892427 | ||
|
acc1b24480 | ||
|
260ff230c5 | ||
|
64b8c522af | ||
|
f2ca67c418 | ||
|
0400eec0c9 | ||
|
a55c0c6ecb | ||
|
59af1d09a3 | ||
|
8b3272aa7b | ||
|
89aa7bc1d9 | ||
|
b3b1d64484 | ||
|
f20341b983 | ||
|
bb88c24e11 | ||
|
fb3f1d4669 | ||
|
3c0bb49fbf | ||
|
e9f811f2cd | ||
|
2b3366923a | ||
|
2e0d11a7bb | ||
|
861af0f170 | ||
|
7fce226d47 | ||
|
056463da55 | ||
|
459bd72299 | ||
|
a776f940de | ||
|
d972594553 | ||
|
07cf25b324 | ||
|
41bbdbc206 | ||
|
79785d0400 | ||
|
abfb386e13 | ||
|
024e0e5e09 | ||
|
5b22cbcd52 | ||
|
061dcb77e0 | ||
|
a10e3925d5 | ||
|
36d14ffdcf | ||
|
9261a8b143 | ||
|
ccdb76709e | ||
|
0e335dd35e | ||
|
55ec381bfe | ||
|
752a57c15e | ||
|
4515559dff | ||
|
a0e14ca2cb | ||
|
a7243d813d | ||
|
0146c2e2b6 | ||
|
1f5eae8267 | ||
|
27b2d4cbf2 | ||
|
bd2c467183 | ||
|
6219ba6834 | ||
|
fa1dcdffaa | ||
|
b0a68b255e | ||
|
a8fbcc6be4 | ||
|
fada7f707f | ||
|
030c381f65 | ||
|
7d2f785a8d | ||
|
c4156cb865 | ||
|
a149d8de27 | ||
|
0fe2de1705 | ||
|
ded8a59dd7 | ||
|
202d33d7c0 | ||
|
2ceb0cd7a2 | ||
|
69e65f5ca6 | ||
|
697b66167c | ||
|
90e13cb8ee | ||
|
67807d3eb7 | ||
|
36eb5e9646 | ||
|
b1f04c85a6 | ||
|
3f42e24b7d | ||
|
81de319715 | ||
|
09f0492fa4 | ||
|
9fd4a022a2 | ||
|
a9bed6a3f8 | ||
|
7670aa7a14 | ||
|
6dc436da91 | ||
|
6d450b2be9 | ||
|
fb364a2275 | ||
|
3e754dfd1b | ||
|
69b7f91542 | ||
|
c25d6bee48 | ||
|
f32b5c1e71 | ||
|
45fb84e697 | ||
|
e87edb07f0 | ||
|
8110c104f1 | ||
|
02475bc8a6 | ||
|
5422b6d233 | ||
|
3e41f63c62 | ||
|
1b11c55d84 | ||
|
d49ea8b383 | ||
|
9d6f729b21 | ||
|
d2922d61f5 | ||
|
14c135a634 | ||
|
5ad5bc1681 | ||
|
dcb10249a3 | ||
|
7387b89d28 | ||
|
fddd374b79 | ||
|
c8b0e203ef | ||
|
ce191f6eeb | ||
|
45b3c0e3f5 | ||
|
0d22b72112 | ||
|
37ed6c2347 | ||
|
b35cec106a | ||
|
f835e84d80 | ||
|
5305d7bdfd | ||
|
2aadcbd198 | ||
|
d668b3e640 | ||
|
0f740b1a02 | ||
|
70a6ee5ed6 | ||
|
3acaad5663 | ||
|
f68b4c8820 | ||
|
f2df4fade6 | ||
|
6fe0e23f53 | ||
|
fff791e3d1 | ||
|
e5c723d14f | ||
|
f9f7b74ef3 | ||
|
a4dd5d8711 | ||
|
3a514365ee | ||
|
a045063769 | ||
|
b2615c19bd | ||
|
a566dc566d | ||
|
5c7968abdb | ||
|
789b214b50 | ||
|
c168981a5b | ||
|
0c9e544e9e | ||
|
40e3a852a2 | ||
|
5a30b1c86a | ||
|
4ff2a8cb18 | ||
|
e10d92cecb | ||
|
aafba74ccd | ||
|
2d5e22c146 | ||
|
52dfd49080 | ||
|
bd1c2c3a0a | ||
|
51745fd838 | ||
|
dfde9533d8 | ||
|
ead799d38b | ||
|
bc7fc4db1b | ||
|
7aec7fe776 | ||
|
8535852699 | ||
|
18891a67fc | ||
|
14359dd5f4 | ||
|
46137763e0 | ||
|
54113a715b | ||
|
afedf03ac9 | ||
|
6de47490c3 | ||
|
be45d914d3 | ||
|
48f5d67d63 | ||
|
da7d871af8 | ||
|
4fca9b56c4 | ||
|
e624edc7ae | ||
|
136d242780 | ||
|
d16da6fa6f | ||
|
fd542936b8 | ||
|
0255bd3584 | ||
|
ab718bae2a | ||
|
76728d7319 | ||
|
5b85b2b71a | ||
|
4e1f975647 | ||
|
0b81fcd39c | ||
|
7830a0e0e3 | ||
|
e37223f016 | ||
|
c8e8e04697 | ||
|
463d0540a4 | ||
|
096af4dc7c | ||
|
5f57b0af5f | ||
|
a16eae143a | ||
|
150229061b | ||
|
2319d2fc9c | ||
|
05c96dc1c6 | ||
|
3d837fcf33 | ||
|
015451d2fd | ||
|
8ef0af3ec7 | ||
|
0db29dd568 | ||
|
e460aea7e8 | ||
|
923c413921 | ||
|
009fc9937a | ||
|
a5ce8e0a93 | ||
|
a81fd527c1 | ||
|
20f26364b8 | ||
|
c996e5c9be | ||
|
f767342e13 | ||
|
30a1a0a70c | ||
|
ff6f056bca | ||
|
a75afc109c | ||
|
440f399917 | ||
|
b08b3dc443 | ||
|
41ec923a30 | ||
|
892a1afdcb | ||
|
06d00b2a12 | ||
|
124a289081 | ||
|
52a225eb92 | ||
|
df6cb5718a | ||
|
2e8be9ec6b | ||
|
b7fa4f2c7b | ||
|
1b94e23386 | ||
|
3414a0a688 | ||
|
3123af6426 | ||
|
7180595e05 | ||
|
bc1ad1d998 | ||
|
0fa8fe1144 | ||
|
e4c9a7a259 | ||
|
d44d6983b3 | ||
|
07c4d64a84 | ||
|
e761567592 | ||
|
30d85eebc0 | ||
|
03fb6506f4 | ||
|
58d34e75f0 | ||
|
99d0fe7538 | ||
|
2ffd4491cf | ||
|
f321baab82 | ||
|
6e83a549d3 | ||
|
8205e19668 | ||
|
0536a7c151 | ||
|
0d5f59ab84 | ||
|
b29b80ebe3 | ||
|
b781a764ef | ||
|
b7a6a58da3 | ||
|
263b7d7684 | ||
|
d2dd631b4b | ||
|
48b78fe73f | ||
|
49b75d89c0 | ||
|
16b59d7cbe | ||
|
fcb580a62a | ||
|
a4bd3e469f | ||
|
8ea787cc49 | ||
|
35c87856fd | ||
|
417aefd588 | ||
|
66ecaa155f | ||
|
c9b6e67771 | ||
|
0b5fcb855c | ||
|
e8621acdf3 | ||
|
f8c6b21f51 | ||
|
120a616331 | ||
|
0fa72faf61 | ||
|
7f431f1923 | ||
|
2917bc982f | ||
|
91ea8c8d00 | ||
|
9245f44f70 | ||
|
41b5ec1b8e | ||
|
48a530b49a | ||
|
2fcb43b9d9 | ||
|
8bffc5a30f | ||
|
9c3c8b0d35 | ||
|
bfef7a346e | ||
|
85c5e15b91 | ||
|
f32d1dde0f | ||
|
b3eeabc9ad | ||
|
2d0869b589 | ||
|
80691861f8 | ||
|
b9a8d66e3d | ||
|
4cbad1f1f7 | ||
|
d0d6798bb1 | ||
|
335c0175a7 | ||
|
5673abc19b | ||
|
c967cfc8b1 | ||
|
835c047fb1 | ||
|
aabdf15072 | ||
|
146290c03e | ||
|
28a38c63a1 | ||
|
fbaccdf4bf | ||
|
ce002a0fa8 | ||
|
8c3764e8ad | ||
|
3d77bd64d1 | ||
|
01fc08b027 | ||
|
facf2d5e2d | ||
|
17a7d306ae | ||
|
8f86fc1038 | ||
|
60f7d0fce2 | ||
|
0dfcebbee3 | ||
|
c10afd1920 | ||
|
7640f1e2d2 | ||
|
d5f4ad3e62 | ||
|
62c07b2ee0 | ||
|
b7aedca7fa | ||
|
3a411cc36b | ||
|
4b7eace923 | ||
|
eee4554213 | ||
|
587df50c54 | ||
|
dfccefe2e8 | ||
|
14f7b56b08 | ||
|
ff73318157 | ||
|
59dc295dc8 | ||
|
9bab18367c | ||
|
a060b1fcaa | ||
|
91533aa89f | ||
|
a603fbadca | ||
|
c2efe0d57f | ||
|
2a97678ba4 | ||
|
e0fdfac063 | ||
|
3558182b7e | ||
|
a9138cbd71 | ||
|
e74a4ba2e9 | ||
|
ae37e21aeb | ||
|
b94952ba4a | ||
|
c7e7ac65a4 | ||
|
31a0939b42 | ||
|
4edeaba365 | ||
|
8799ec8592 | ||
|
3bc1caebca | ||
|
842df1773f | ||
|
4246711b1e | ||
|
e7899d656d | ||
|
205dc3fab8 | ||
|
f520cebf66 | ||
|
2f96749fc7 | ||
|
23dada9fe5 | ||
|
35a25a7f15 | ||
|
e935b7c97b | ||
|
3f4c43e373 | ||
|
e6692a9012 | ||
|
3c6979813b | ||
|
cabae3f6d4 | ||
|
02b20a9b74 | ||
|
76fbf3ac83 | ||
|
36c627651e | ||
|
6021407929 | ||
|
e45a133f51 | ||
|
c5e1d7a7df | ||
|
8199c2ce5b | ||
|
c2c13b715d | ||
|
740cc5a6ff | ||
|
c5741c7225 | ||
|
b7a9a1eca9 | ||
|
770b3704e8 | ||
|
90688b0e8e | ||
|
85d89f2fa2 | ||
|
c7511e90a7 | ||
|
6be1f40373 | ||
|
d09933a68a | ||
|
25675a9136 | ||
|
06f1045656 | ||
|
c9341169c3 | ||
|
27e92afe02 | ||
|
594e908508 | ||
|
6718182411 | ||
|
b3f222f117 | ||
|
8d9d135595 | ||
|
ab81c88b8f | ||
|
8e1b3444ac | ||
|
9f35e1d99e | ||
|
a0f99951a2 | ||
|
0d0a5948ac | ||
|
be57db9200 | ||
|
a20205f2b1 | ||
|
7b4e7cbb22 | ||
|
7a3016a31b | ||
|
baf4f5dc44 | ||
|
c6e1a7029a | ||
|
5fabaf6a8e | ||
|
21fb1ad015 | ||
|
35d0f6a49d | ||
|
cbcdc74a8c | ||
|
88ebf718d5 | ||
|
4a98bf27a6 | ||
|
406d83bf56 | ||
|
e38d50bc54 | ||
|
e3328ad061 | ||
|
e9e1b7b7de | ||
|
ad444a5da0 | ||
|
759ea44ad4 | ||
|
f8218ffa4c | ||
|
6666d3fc3a | ||
|
9efaa11830 | ||
|
075f3638ac | ||
|
e1c01b184b | ||
|
dd62cded43 | ||
|
c6073e2426 | ||
|
1fe244e8a6 | ||
|
c4f6a441b6 | ||
|
c7bb333e97 | ||
|
4cf191cbb4 | ||
|
1a4135c6a4 | ||
|
9dc86c9731 | ||
|
a82fda85ca | ||
|
3110d8c0ee | ||
|
93fd45a4e1 | ||
|
029ea93268 | ||
|
038bce1a27 | ||
|
a7791bad0a | ||
|
2b684941b7 | ||
|
1bf98cb27e | ||
|
3088078ff8 | ||
|
b5524d1206 | ||
|
0147e29cef | ||
|
82a7e021ef | ||
|
6a32fd2c33 | ||
|
66d5ae1347 | ||
|
e26753cc0c | ||
|
98e38ae9a8 | ||
|
233f6065ee | ||
|
b29e07846f | ||
|
0933dea407 | ||
|
ea7f730264 | ||
|
2200ae7535 | ||
|
d63f976da4 | ||
|
7fe12968a1 | ||
|
f591c6e4de | ||
|
9db059845c | ||
|
cda881c25c | ||
|
e14994cf74 | ||
|
4b3f641f25 | ||
|
80d079312a | ||
|
986981527e | ||
|
535163b675 | ||
|
7601b7dad0 | ||
|
04558ae73c | ||
|
db5857f1aa | ||
|
456c0401cd | ||
|
40eeae18a9 | ||
|
72a33060c4 | ||
|
c2d9470fe9 | ||
|
fd4a0e8e0d | ||
|
d10990b4e0 | ||
|
9a8fcbb8a5 | ||
|
229a04e65b | ||
|
e3a209c24b | ||
|
ce4ca71766 | ||
|
ac2a57b28d | ||
|
abca9c32f9 | ||
|
7b437a4878 | ||
|
904e9fb467 | ||
|
3b60e6ffd2 | ||
|
d48c0b5ce5 | ||
|
21a4703419 | ||
|
3bd9b3b5a4 | ||
|
3fd40b64c6 | ||
|
06f008b8b3 | ||
|
bccd79da38 | ||
|
5003fd87c9 | ||
|
7a2752f4b0 | ||
|
632dae37be | ||
|
57e064efa2 | ||
|
53c10b0847 | ||
|
d8386c4cc0 | ||
|
04da98bcc6 | ||
|
11a66a8465 | ||
|
7e9c5bbd62 | ||
|
a46fc22fb2 | ||
|
f5ea8cc3ec | ||
|
e66b4a280c | ||
|
8e5f6d5d6d | ||
|
3f6f557dc3 | ||
|
899c029ab0 | ||
|
b7faecb46c | ||
|
82a1771473 | ||
|
8322609ef9 | ||
|
396e594292 | ||
|
90b66882c5 | ||
|
d8fc7bed59 | ||
|
2c0499107b | ||
|
065e723517 | ||
|
3c6096896a | ||
|
43a48ffcbe | ||
|
5a4319c548 | ||
|
5eafcbafb1 | ||
|
ce30a3f03f | ||
|
bb9702550e | ||
|
a5614b5d34 | ||
|
5968e845d2 | ||
|
04725a8aca | ||
|
1ca9099a0e | ||
|
f0db5a82f8 | ||
|
352cbece62 | ||
|
633f23c02e | ||
|
25d80521c7 | ||
|
777b9e15e4 | ||
|
ad3c0a51d5 | ||
|
ccd953704e | ||
|
1721aad580 | ||
|
232d3a3199 | ||
|
30f7cdf701 | ||
|
6957b9a522 | ||
|
6e6d6cc110 | ||
|
abea138ac6 | ||
|
cc01f3009c | ||
|
38f5f9c694 | ||
|
27c3e2761e | ||
|
976cb506b9 | ||
|
06bfb95623 | ||
|
59a64d227d | ||
|
eb975dc5f5 | ||
|
551c06ac7c | ||
|
4e0429fcc9 | ||
|
141234c8b8 | ||
|
726addd117 | ||
|
8275c943b8 | ||
|
7192a2a08b | ||
|
61e185ae4f | ||
|
109fafcf4c | ||
|
1f5ce87bdc | ||
|
b8286c9b14 | ||
|
c0d78bd273 | ||
|
e0108eeb89 | ||
|
f2d782c15a | ||
|
1626436094 | ||
|
ad54059289 | ||
|
8bb6236c4a | ||
|
873601ec5a | ||
|
2e0b23e299 | ||
|
c557cbdb93 | ||
|
bd34ee20e3 | ||
|
0d601d7309 | ||
|
baebd11825 | ||
|
99ea9dd61f | ||
|
9e7868141d | ||
|
fcb5e131dc | ||
|
e1c9690b57 | ||
|
321930b2f0 | ||
|
6d571a9bb5 | ||
|
662dbba2e8 | ||
|
6f38472b17 | ||
|
70d809f179 | ||
|
e015770dd1 | ||
|
33afc448fd | ||
|
641520dcbb | ||
|
d44e782543 | ||
|
0166aaf7ba | ||
|
c29b47319f | ||
|
5926168427 | ||
|
88c7ee3d73 | ||
|
44bbcddbdf | ||
|
4fe95fdf12 | ||
|
080e9184cc | ||
|
f9571f009b | ||
|
248cbe2c90 | ||
|
2bed767a76 | ||
|
197bee9348 | ||
|
038e557e7b | ||
|
e28a7af7ed | ||
|
f6a0b70ca4 | ||
|
89d55b8b60 | ||
|
e62b909a75 | ||
|
70ffc7b826 | ||
|
df7cbed268 | ||
|
94e3edb703 | ||
|
7e0439b146 | ||
|
ea311aba1a | ||
|
25c6d7c0d0 | ||
|
92f3a9fd63 | ||
|
8777a7b0af | ||
|
4f461ed02b | ||
|
768de346b1 | ||
|
f169e3da42 | ||
|
50b457cb15 | ||
|
98ba2b8caa | ||
|
1eed16dd52 | ||
|
ef6aec132b | ||
|
016208f18f | ||
|
fbe027e9e3 | ||
|
ee24df155a | ||
|
d918a26a00 | ||
|
1cd037d4b6 | ||
|
d0999aec42 | ||
|
df764bce75 | ||
|
aacd882e8b | ||
|
832b1862e3 | ||
|
75d2557da8 | ||
|
108944c4b2 | ||
|
b646f89970 | ||
|
66a8b1d723 | ||
|
832863ad71 | ||
|
a89a935ede | ||
|
25c777bcad | ||
|
7ee68937bc | ||
|
981e7a18e5 | ||
|
463514186d | ||
|
79281853ba | ||
|
e8a35e06a9 | ||
|
137425dcb1 | ||
|
1234f61fc0 | ||
|
074c439e99 | ||
|
c03abddc27 | ||
|
9b76795ea4 | ||
|
c440dc7779 | ||
|
657ba47714 | ||
|
9c6fa31fda | ||
|
2389f61b91 | ||
|
ba04d85310 | ||
|
1136178381 | ||
|
7297267613 | ||
|
82283dbf44 | ||
|
de626f5cd9 | ||
|
1fe962b1d5 | ||
|
0220c84554 | ||
|
9963163f0e | ||
|
12f1486f53 | ||
|
0d77102117 | ||
|
51444111d2 | ||
|
1a58967111 | ||
|
b3c0374477 | ||
|
2c2029437a | ||
|
391cbab0be | ||
|
0653b79306 | ||
|
c580638fb5 | ||
|
210701c366 | ||
|
bf8f238af4 | ||
|
18c6d8f784 | ||
|
2037ba432b | ||
|
96dff29afe | ||
|
28ccd1a3b3 | ||
|
c14b9b4ed8 | ||
|
f427210f14 | ||
|
de846e7241 | ||
|
73403a8a7b | ||
|
1d277c26e1 | ||
|
d4bd51379d | ||
|
0d1275a1bb | ||
|
67d2638bc1 | ||
|
c2f0a193b5 | ||
|
3419e8869f | ||
|
c03b7c52c4 | ||
|
a36307e0aa | ||
|
0a55e7fee8 | ||
|
e09cfa8cb3 | ||
|
b135980981 | ||
|
7ccfb61a7c | ||
|
108c88114b | ||
|
01d269f995 | ||
|
2152c789c7 | ||
|
0dc1d70c1d | ||
|
f1e1d3772e | ||
|
df2b0bf345 | ||
|
25192e695d | ||
|
77378c2fd9 | ||
|
ece0223d31 | ||
|
0c41bb1604 | ||
|
45c4d7f479 | ||
|
08a863f707 | ||
|
602966e035 | ||
|
15f3313c1f | ||
|
69b8e98b39 | ||
|
0f16ebe78f | ||
|
1f144d693c | ||
|
c5ca8910d7 | ||
|
ffb0496e13 | ||
|
807fb1614e | ||
|
ee1a33cc4c | ||
|
65561eea47 | ||
|
2f90a29acf | ||
|
136461160f | ||
|
cfecee44bf | ||
|
9b1085c134 | ||
|
0486934d81 | ||
|
f9121d281c | ||
|
87b22f1588 | ||
|
48810a227f | ||
|
147d35ca24 | ||
|
bd73c03078 | ||
|
658762dbab | ||
|
c10d4381c8 | ||
|
e85e514c8b | ||
|
faf17f34d1 | ||
|
ff4217b488 | ||
|
a4273c6c6e | ||
|
a6881a8a32 | ||
|
7bf6d19708 | ||
|
114895c183 | ||
|
b729cf5423 | ||
|
7c92c01047 | ||
|
1f0977ec77 | ||
|
06f9f6a7a5 | ||
|
8a2622c3f8 | ||
|
ecc67b5707 | ||
|
60bfff16dd | ||
|
6a66acb44c | ||
|
7b4a3da023 | ||
|
5c33f3c02a | ||
|
ac7b7bb96d | ||
|
04044ac896 | ||
|
f68b167c0d | ||
|
a6bc0b9e69 | ||
|
8fbca537f4 | ||
|
d1390c25c7 | ||
|
95354fe564 | ||
|
ca1151bc6d | ||
|
ed213a4b34 | ||
|
f00b5fe59d | ||
|
fe3293e986 | ||
|
a7702573ad | ||
|
45e6baa34d | ||
|
80acc668ec | ||
|
75994bff47 | ||
|
7b7ee9fa8d | ||
|
978278db7b | ||
|
a65981c1de | ||
|
611ca5f138 | ||
|
4e1634447f | ||
|
0a7752e392 | ||
|
5a993a2bf0 | ||
|
8bdad62747 | ||
|
ba2098ac93 | ||
|
84c2aa7fa0 | ||
|
f7dfc25058 | ||
|
e4fef8297e | ||
|
d706f38f91 | ||
|
c63adaf2e3 | ||
|
76193329d6 | ||
|
43c31ce419 | ||
|
7ea2fbf417 | ||
|
0e1902cc91 | ||
|
abdfd00374 | ||
|
e129c94053 | ||
|
6541b16810 | ||
|
ce284f668a | ||
|
f0195f14aa | ||
|
8b1d04af79 | ||
|
6c93934026 | ||
|
8348b3a150 | ||
|
1a261a3b9e | ||
|
2191f59092 | ||
|
4a948c1639 | ||
|
5241baef28 | ||
|
f8560aec4d | ||
|
9b2291dd47 | ||
|
9b47d0970c | ||
|
d16296395f | ||
|
f9cc0f249d | ||
|
8d76bc53e5 | ||
|
7e53932cc3 | ||
|
ad9de09027 | ||
|
63f7d5615d | ||
|
ce29f5a60e | ||
|
569cb71ca8 | ||
|
5bfe9b7c7f | ||
|
25a9a3c4ce | ||
|
73655d354f | ||
|
2a862b5169 | ||
|
6bcddc8382 | ||
|
ca1033801d | ||
|
c61fc3241f | ||
|
df7bfd2e0c | ||
|
f5a457a87d | ||
|
a8d02bffdc | ||
|
aa1afba417 | ||
|
b357f43e79 | ||
|
0c3db3e2f8 | ||
|
5b4ed1d6a6 | ||
|
b00ec8989c | ||
|
63c829b042 | ||
|
81b1d18192 | ||
|
3016c9747f | ||
|
c3b649c1b1 | ||
|
799f9ba25a | ||
|
c98a8456ff | ||
|
2971dd3f7c | ||
|
0baa081dc6 | ||
|
5fd6587ff7 | ||
|
2f25453fe2 | ||
|
02e1917072 | ||
|
94641b9ed6 | ||
|
c4841ed946 | ||
|
c36746dbdf | ||
|
b33ed4ccbe | ||
|
61b0645314 | ||
|
e637f36a21 | ||
|
376338a9b0 | ||
|
ba0aff0f54 | ||
|
6c3c9a4f93 | ||
|
9f4dbe6ee5 | ||
|
341571e232 | ||
|
8ee20b1bba | ||
|
20fef65b3d | ||
|
7ddfc0143d | ||
|
7dde71734c | ||
|
1a3a94894b | ||
|
ff72648570 | ||
|
f8682fb8cb | ||
|
e2c90ac0f4 | ||
|
7b64b794f2 | ||
|
6f6fb16352 | ||
|
b448d1c06f | ||
|
e8aba6b77b | ||
|
26c588828e | ||
|
4e16756829 | ||
|
1780b3948b | ||
|
0cedfd7a7d | ||
|
df2b3810bf | ||
|
1de2390cdd | ||
|
892458981d | ||
|
151c679e29 | ||
|
5ce97246d1 | ||
|
f622eab809 | ||
|
b50506b891 | ||
|
e38f99f088 | ||
|
ac8b8e6153 | ||
|
952261fb5a | ||
|
e1c7ab5b04 | ||
|
b7644198c7 | ||
|
9b5dfac3a6 | ||
|
0e421d16e3 | ||
|
29d9a29cb7 | ||
|
0807ce5314 | ||
|
f87a631674 | ||
|
559bd5408f | ||
|
1ca2bdba79 | ||
|
62d6c8fe45 | ||
|
d3ef80ed22 | ||
|
51f47452f2 | ||
|
5cea57e935 | ||
|
0a83d6c084 | ||
|
7fb0f32bcf | ||
|
54c132d3e3 | ||
|
4e7829969b | ||
|
7863329c66 | ||
|
8a08d85cce | ||
|
7bc5f3ad16 | ||
|
a376dbe2a5 | ||
|
3bba2199e8 | ||
|
00613e7ef5 | ||
|
d4e15d6dfb | ||
|
4285e8f28a | ||
|
165b85c52d | ||
|
32a880cef0 | ||
|
78f3fdb31c | ||
|
f8ed5646c5 | ||
|
57d95e57f3 | ||
|
d03b68b4e1 | ||
|
678544d236 | ||
|
88ef5f9eda | ||
|
42ededd751 | ||
|
3fe5cf571c | ||
|
83e85a3ea9 | ||
|
b4b05b70ec | ||
|
5f26692e0f | ||
|
ecc8df014c | ||
|
8bea313865 | ||
|
ba31050aac | ||
|
0f1e2e7dec | ||
|
8f30a466ff | ||
|
7be194a9c3 | ||
|
a87b40c612 | ||
|
1e43f1cc77 | ||
|
f6b3dfdf12 | ||
|
e6199819c4 | ||
|
76e721b78a | ||
|
74c0b32440 | ||
|
d65b8ec0c0 | ||
|
ec5dff4a8d | ||
|
a53bd0f89c | ||
|
7a4997da7a | ||
|
30abe0f2fb | ||
|
7ae831f7c3 | ||
|
244df4184f | ||
|
c1b2d3d54f | ||
|
cac60d695d | ||
|
db80353251 | ||
|
3660149b79 | ||
|
488fa632ba | ||
|
460b33d6d6 | ||
|
349cac86d8 | ||
|
414c33bde4 | ||
|
9462d4e08e | ||
|
8f9c485156 | ||
|
4a2b816360 | ||
|
dedf90f9be | ||
|
b7adbd8653 | ||
|
61065e0cc5 | ||
|
c12c9c08d8 | ||
|
14ef7ae247 | ||
|
01d1d342aa | ||
|
fe19451a2d | ||
|
f481bd2980 | ||
|
97a78eb403 | ||
|
5b58db6cec | ||
|
98a544a764 | ||
|
8c5baf80ee | ||
|
5facbddfc7 | ||
|
0a978188b4 | ||
|
35ff51e39f | ||
|
7d38ccf504 | ||
|
cb55654402 | ||
|
c05b772e90 | ||
|
dcf2b9b4e4 | ||
|
e81bee0101 | ||
|
9a4533d7e0 | ||
|
27c6fedc9d | ||
|
3dd10ba29c | ||
|
cd5b3a3e2b | ||
|
f23f6da627 | ||
|
9f82b05c11 | ||
|
6aa3e612cf | ||
|
fc836bebe6 | ||
|
b23c6e2932 | ||
|
f0393771a9 | ||
|
d7891d6ec2 | ||
|
bfd4415fa1 | ||
|
2f99ac8282 | ||
|
bb15f62648 | ||
|
79d87c5b01 | ||
|
22503657d2 | ||
|
baf60c2cc8 | ||
|
1a7ac665db | ||
|
4e4e1cea6b | ||
|
82349bca88 | ||
|
d8555ee0cc | ||
|
524466360f | ||
|
3c1bb34f86 | ||
|
19770240aa | ||
|
4d2b040c08 | ||
|
f25eccb22c | ||
|
88cece3066 | ||
|
206c2a319b | ||
|
303adbf02e | ||
|
ea57eb5f93 | ||
|
6fcbf29779 | ||
|
7096aa35b2 | ||
|
c54da58d87 | ||
|
bb2f4601f5 | ||
|
600a22d158 | ||
|
1d718c99c6 | ||
|
491aa32586 | ||
|
229b0491b6 | ||
|
f0c1562ab5 | ||
|
8e1b77fcfe | ||
|
4e85b65318 | ||
|
c48ef5012f | ||
|
2ba74bb95d | ||
|
bc8546b4ff | ||
|
ff53086d0b | ||
|
5012ab84c8 | ||
|
46adc51cf6 | ||
|
93e3d42e23 | ||
|
8943083533 | ||
|
3961c8bc21 | ||
|
058d417e78 | ||
|
2144995a29 | ||
|
9101f49895 | ||
|
456ef5cb34 | ||
|
a7939f8b24 | ||
|
97dc1c1856 | ||
|
5882a6ef3b | ||
|
5a2d3f4238 | ||
|
f2fea1bcde | ||
|
54a231a67f | ||
|
e642e30978 | ||
|
04f94f0828 | ||
|
44073a3201 | ||
|
d74c26fd4c | ||
|
344e4337e1 | ||
|
2cd00f21b7 | ||
|
fe01405d3e | ||
|
79dba00f27 | ||
|
11db94f84f | ||
|
a8ac23e74a | ||
|
4332462075 | ||
|
476e88dbcc | ||
|
2fb79b97f8 | ||
|
62e973dbe2 | ||
|
5af5412009 | ||
|
377f721f1d | ||
|
e9abce7d12 | ||
|
60c122523b | ||
|
48cc6e3471 | ||
|
80b810c7e0 | ||
|
cb8a3abdd8 | ||
|
275283616e | ||
|
8272a67b5f | ||
|
b4716ba511 | ||
|
22ddd28f0b | ||
|
688023c906 | ||
|
f31046bed1 | ||
|
ae15030bb5 | ||
|
f4681011b9 | ||
|
f6336feb72 | ||
|
f0bbcb5086 | ||
|
db3be4cf09 | ||
|
6c851bd3a6 | ||
|
82ed5afb02 | ||
|
8e2aaa6c09 | ||
|
72d222144a | ||
|
b6fc820f81 | ||
|
5f7426da1c | ||
|
e21c16f846 | ||
|
725132131c | ||
|
769fe8b926 | ||
|
9540e3505c | ||
|
1a2e8bf6ee | ||
|
652f5757cf | ||
|
bad32e90ab | ||
|
eedabf5888 | ||
|
1a77427591 | ||
|
818f370c46 | ||
|
411f691547 | ||
|
b79ed87ea7 | ||
|
5c944cd092 | ||
|
b42a9e2062 | ||
|
ae6fb9ecfa | ||
|
eb0d1d21bb | ||
|
3f1da3c1ea | ||
|
ca001a951f | ||
|
af0f03e534 | ||
|
919cd6ddfd | ||
|
bf56160690 | ||
|
c059856691 | ||
|
e6631c3c78 | ||
|
6ece9ab5cf | ||
|
60b90b1f52 | ||
|
edff54bb7e | ||
|
11ac4fbe46 | ||
|
733e915506 | ||
|
0b2592be5e | ||
|
861f6213f1 | ||
|
4909b4bc14 | ||
|
d004152bd8 | ||
|
a33b93a6a1 | ||
|
7f20309dc5 | ||
|
eeeb3efb7e | ||
|
b62a7a1a1d | ||
|
be5c1e6b8a | ||
|
faacbc6108 | ||
|
d4b5dc99a1 | ||
|
4be376faac | ||
|
40d670d002 | ||
|
933bd44ad5 | ||
|
cc37ffd809 | ||
|
bba42bb1bb | ||
|
aac22c3369 | ||
|
0173129ffc | ||
|
fe6ccad959 | ||
|
a1eb926dc3 | ||
|
8cd9deef40 | ||
|
ab1f28bb88 | ||
|
121e8e0243 | ||
|
7ce157accd | ||
|
4df5851bc0 | ||
|
9e2ff58bc7 | ||
|
9f8c9a1636 | ||
|
e321559121 | ||
|
74bed60c32 | ||
|
a2d0f98c9b | ||
|
7d61f153c3 | ||
|
fc1d77eff2 | ||
|
c55b687495 | ||
|
aa756e60bc | ||
|
9b654f034c | ||
|
55a64d56b1 | ||
|
be0eb19794 | ||
|
d46b7b0225 | ||
|
2e7e7fef60 | ||
|
66b8559eab | ||
|
feb91127cd | ||
|
952ad796dd | ||
|
97d777fdee | ||
|
0f2c3813a2 | ||
|
7421018403 | ||
|
3fabb21dac | ||
|
754432f5bc | ||
|
af961dbaf4 | ||
|
1d79190574 | ||
|
e595bd5e79 | ||
|
66a24a39b6 | ||
|
0b5cd46d6c | ||
|
e02e9bcd1f | ||
|
5068327408 | ||
|
ca11528593 | ||
|
ca8ad96a05 | ||
|
406c2b9f63 | ||
|
055aadc048 | ||
|
b2d37ec9b7 | ||
|
1151076660 | ||
|
4fca4ecf63 | ||
|
90a0c4b545 | ||
|
96be262f78 | ||
|
31d42d0b04 | ||
|
8c10c66bdc | ||
|
bbb35856e0 | ||
|
e3882acf50 | ||
|
15e97a63c2 | ||
|
c86ba92837 | ||
|
e393547e13 | ||
|
0731ae0179 | ||
|
5f3aa91a54 | ||
|
e744ff2b97 | ||
|
5ca0f7d6af | ||
|
bdad905df3 | ||
|
447d233df1 | ||
|
5250e56f7c | ||
|
4cc8ffdd39 | ||
|
9a96f70f63 | ||
|
f279efb255 | ||
|
acef9017c8 | ||
|
dd3f7f4915 | ||
|
ee130a89a6 | ||
|
724b17d25c | ||
|
16f26b3f2e | ||
|
22c387093d | ||
|
e7e6b67a6c | ||
|
88a3a79ef6 | ||
|
2544f18c24 | ||
|
90dc7e5272 | ||
|
6a9ef8cbaa | ||
|
39b729a804 | ||
|
a1672b62db | ||
|
c6ba6b42a8 | ||
|
a9fb2e127e | ||
|
0d5d35c263 | ||
|
d8de492d97 | ||
|
a2c2649bc9 | ||
|
01cbc0d6cb | ||
|
764937e0b5 | ||
|
a4f4d0b7a7 | ||
|
0111b66cd1 | ||
|
ed4ae181ec | ||
|
b4f8b435fb | ||
|
66cdd4b176 | ||
|
6cb69ada3f | ||
|
dea8e6d5f5 | ||
|
e82d5fec6c | ||
|
14635fea4d | ||
|
b9c1bbd8d3 | ||
|
a1d790c741 | ||
|
820b514740 | ||
|
57ec06ae4d | ||
|
3174521475 | ||
|
199939c8b7 | ||
|
8d1bd52328 | ||
|
a6ce6ae8d2 | ||
|
fd4422fa65 | ||
|
adb43b2bbf | ||
|
68887772df | ||
|
a2b4b48ddc | ||
|
698217d374 | ||
|
c6edaf4304 | ||
|
bee845ca95 | ||
|
35536fdc2f | ||
|
48dc22ee35 | ||
|
76bd6e934a | ||
|
6075642e9e | ||
|
c8c453c031 | ||
|
6cf5a08038 | ||
|
5613d76d95 | ||
|
0d11c6db58 | ||
|
73f7fc7644 | ||
|
a2193ab01f | ||
|
bd85bb445e | ||
|
a6a8c18711 | ||
|
16510092e8 | ||
|
0daf18d5d4 | ||
|
187a6131f0 | ||
|
3fe1f184d6 | ||
|
ff09ae6457 | ||
|
128582bf96 | ||
|
ef1baa2d1d | ||
|
c5a3741289 | ||
|
a95e07d32b | ||
|
3aa5ee0408 | ||
|
0eb526add4 | ||
|
336d50cd3a | ||
|
fc9ea2444e | ||
|
a6e9869a14 | ||
|
fd2c6115fc | ||
|
e661236440 | ||
|
2d8bd3051a | ||
|
83833d76b3 | ||
|
dfd5297cd3 | ||
|
0e3a7a1673 | ||
|
8c4d00da3d | ||
|
88f9ec3dfa | ||
|
bc26d53945 | ||
|
8da730ed8d | ||
|
ec0d382206 | ||
|
15f6d7bf18 | ||
|
f32d2cfcfc | ||
|
d1fc9c0bff | ||
|
2243bc42aa | ||
|
05f9f56a4d | ||
|
dde330a704 | ||
|
906f61a847 | ||
|
6e6fbadb02 | ||
|
cfff75926a | ||
|
510952f9de | ||
|
db0d629302 | ||
|
307806e65f | ||
|
e8d9252891 | ||
|
4b0fa90f5e | ||
|
4d79a17738 | ||
|
66f6605406 | ||
|
e6520ad2e8 | ||
|
287b888b6f | ||
|
2791da9f65 | ||
|
7ecaa376a2 | ||
|
fb7eafb27a | ||
|
24f2f60209 | ||
|
f0a2955b83 | ||
|
671424ecbe | ||
|
36bcd70c9d | ||
|
607fdab326 | ||
|
eb6b1fbe48 | ||
|
e17824609a | ||
|
e0f1225c21 | ||
|
546e79d925 | ||
|
ec4a5d2b7c | ||
|
359a8d0221 | ||
|
f4e83f6be5 | ||
|
93b878ad78 | ||
|
6bfd67a41d | ||
|
b84a833e0d | ||
|
ac3be93894 | ||
|
95356d9483 | ||
|
14183cccca | ||
|
3a9f65d908 | ||
|
18a9e77b33 | ||
|
2e5249ca4f | ||
|
6caa759ce1 | ||
|
d8e4093696 | ||
|
6c0ab38193 | ||
|
5337ae5715 | ||
|
cf15799df2 | ||
|
ba0f22ac1e | ||
|
bfefef548e | ||
|
2f90674f51 | ||
|
e4ea7692b2 | ||
|
4b5f7868bb | ||
|
f5a6531386 | ||
|
e358e9b3a5 | ||
|
771520cd96 | ||
|
11df634c91 | ||
|
3b14373bd3 | ||
|
48c05c415d | ||
|
244a8e308f | ||
|
6e00838ef0 | ||
|
1ca8a46473 | ||
|
02f48dd15f | ||
|
4547ec52af | ||
|
47779bbbee | ||
|
72de3b6796 | ||
|
f0a3cd8c55 | ||
|
39d7bfd80f | ||
|
135912a132 | ||
|
5e7250356d | ||
|
8d66c9a8c1 | ||
|
5f4016e22e | ||
|
bb0ba08329 | ||
|
7100802cab | ||
|
502c013af0 | ||
|
b520fe2b79 | ||
|
8189eb6b4c | ||
|
0b54e24947 | ||
|
c0e7c78a11 | ||
|
80c106d57f | ||
|
9cac51d5c9 | ||
|
cc347d5654 | ||
|
b1e2791ca8 | ||
|
c3b5054477 | ||
|
41abcc8d2c | ||
|
90c76aa997 | ||
|
5223f7620c | ||
|
39014fe7f4 | ||
|
cdd38c6ef7 | ||
|
73c7f866e6 | ||
|
843bedbee6 | ||
|
cceb110354 | ||
|
9380a23867 | ||
|
dfdc6eefd0 | ||
|
34cd0fadb4 | ||
|
f2bc95813a | ||
|
f6b666e892 | ||
|
a09b6a4562 | ||
|
18a5bcd7db | ||
|
e6a5b1c157 | ||
|
65a48ebe7b | ||
|
2a6d8757e6 | ||
|
208997b167 | ||
|
dc590d7ed1 | ||
|
a2a925edc0 | ||
|
116a2956ac | ||
|
9052b84011 | ||
|
f86582689b | ||
|
3e9e572e3c | ||
|
f1c784d458 | ||
|
eb66435d2d | ||
|
60b9d31bd5 | ||
|
a8d4e2b8c1 | ||
|
39d95adda4 | ||
|
eaff533489 | ||
|
e3dba1974f | ||
|
f3c71fadf2 | ||
|
38d611aa27 | ||
|
1b61a05656 | ||
|
2f073c3a15 | ||
|
1e8ec2f0d7 | ||
|
02a60fac20 | ||
|
cb285f97e7 | ||
|
345a24f896 | ||
|
6e5a14cf20 | ||
|
12cf23a4b5 | ||
|
2bddec60db | ||
|
aa83776a8b | ||
|
ee9be516aa | ||
|
5cebbcb763 | ||
|
80875a15ec | ||
|
741c00ed64 | ||
|
14b8df4f9c | ||
|
0dc27dd98c | ||
|
d7c3be5a68 | ||
|
eaff13998b | ||
|
a3514ec104 | ||
|
14fc334422 | ||
|
02ba51bc15 | ||
|
256d2c3f87 | ||
|
9c17b8503a | ||
|
6e482c04d8 | ||
|
6ed00a7b11 | ||
|
907c542950 | ||
|
47675bc60f | ||
|
cb39dbd19d | ||
|
0c20716c9b | ||
|
425915beaa | ||
|
65ba59678e | ||
|
826712340b | ||
|
b0813177d7 | ||
|
6d4ad61c7b | ||
|
060bc6f1d1 | ||
|
1f0baf5128 | ||
|
274ed4b430 | ||
|
5fcccba105 | ||
|
fc03ebc8d4 | ||
|
3ada7d1a98 | ||
|
da4be6d7e4 | ||
|
81084ea479 | ||
|
6dfed36dbe | ||
|
b555df1061 | ||
|
b0c62be75f | ||
|
32c43855f7 | ||
|
41b4493072 | ||
|
d4bb8eed84 | ||
|
882c592e45 | ||
|
1a91149b5f | ||
|
ff1eb674b3 | ||
|
9c6ad22309 | ||
|
6268768a4b | ||
|
430b10ec1c | ||
|
e7de0f9218 | ||
|
ee02d5c9f4 | ||
|
cb459f2fd5 | ||
|
261a0aad4c | ||
|
ce508257a3 | ||
|
66eb599a14 | ||
|
7b6834b326 | ||
|
5f45cc1c9b | ||
|
31be5f30f3 | ||
|
5585f8a4e0 | ||
|
b045c49ad0 | ||
|
a2b145e4ec | ||
|
743d8dbb2f | ||
|
473bf6f5ed | ||
|
e5a77a1839 | ||
|
7f74dd93f4 | ||
|
c0bef37dd5 | ||
|
3daf82ef3d | ||
|
0d108577ab | ||
|
40fc88c8f6 | ||
|
4494add298 | ||
|
8267fb4832 | ||
|
d5c92f89d9 | ||
|
a41e6ce821 | ||
|
27d356e3c5 | ||
|
cda68d14b4 | ||
|
86b4f02e09 | ||
|
12db99203f | ||
|
d636271525 | ||
|
334bbf418c | ||
|
b544ad93f3 | ||
|
1aee66a565 | ||
|
bc36750d52 | ||
|
b103309ceb | ||
|
8af180968b | ||
|
fb89167b35 | ||
|
e4ca63b42c | ||
|
9e275b23d4 | ||
|
73d6bfde89 | ||
|
3d58b93107 | ||
|
105b78b3f9 | ||
|
368c902458 | ||
|
5f79214148 | ||
|
83b18faac1 | ||
|
f9037dcbd8 |
541 changed files with 96485 additions and 35985 deletions
5
.codacy.yaml
Normal file
5
.codacy.yaml
Normal file
|
@ -0,0 +1,5 @@
|
|||
exclude_paths:
|
||||
- 'resources/lib/watchdog/**'
|
||||
- 'resources/lib/pathtools/**'
|
||||
- 'resources/lib/pathtools/**'
|
||||
- 'resources/lib/defused_etree.py'
|
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
ko_fi: A8182EB
|
41
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
41
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,41 @@
|
|||
---
|
||||
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)
|
96
README.md
96
README.md
|
@ -1,9 +1,12 @@
|
|||
[![stable version](https://img.shields.io/badge/stable_version-1.8.5-blue.svg?maxAge=60&style=flat) ](https://dl.bintray.com/croneter/PlexKodiConnect/bin/repository.plexkodiconnect/repository.plexkodiconnect-1.0.0.zip)
|
||||
[![beta version](https://img.shields.io/badge/beta_version-1.8.7-red.svg?maxAge=60&style=flat) ](https://dl.bintray.com/croneter/PlexKodiConnect_BETA/bin-BETA/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.0.zip)
|
||||
[![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)
|
||||
[![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)
|
||||
[![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)
|
||||
[![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)
|
||||
[![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)
|
||||
|
||||
|
@ -11,52 +14,54 @@
|
|||
# PlexKodiConnect (PKC)
|
||||
**Combine the best frontend media player Kodi with the best multimedia backend server Plex**
|
||||
|
||||
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.
|
||||
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 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.
|
||||
|
||||
### Please Help Translating
|
||||
|
||||
Please help translate PlexKodiConnect into your language: [Transifex.com](https://www.transifex.com/croneter/pkc)
|
||||
### Update Your PKC Repo to Receive Updates!
|
||||
|
||||
Unfortunately, the PKC Kodi repository had to move because it stopped working (thanks https://bintray.com). If you installed PKC before December 15, 2017, you need to [**MANUALLY** update the repo once](https://github.com/croneter/PlexKodiConnect/wiki/Update-PKC-Repository).
|
||||
|
||||
### Content
|
||||
* [**Warning**](#warning)
|
||||
* [**What does PKC do?**](#what-does-pkc-do)
|
||||
* [**PKC Features**](#pkc-features)
|
||||
* [**Download and Installation**](#download-and-installation)
|
||||
* [**Warning**](#warning)
|
||||
* [**PKC Features**](#pkc-features)
|
||||
* [**Additional Artwork**](#additional-artwork)
|
||||
* [**Important notes**](#important-notes)
|
||||
* [**Donations**](#donations)
|
||||
* [**Request a New Feature**](#request-a-new-feature)
|
||||
* [**Known Larger Issues**](#known-larger-issues)
|
||||
* [**Issues being worked on**](#issues-being-worked-on)
|
||||
* [**Issues and Bugs**](#issues-and-bugs)
|
||||
* [**Credits**](#credits)
|
||||
|
||||
### 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.
|
||||
|
||||
### 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 ;-)).
|
||||
|
||||
Some people argue that PKC is 'hacky' because of the way it directly accesses the Kodi database. See [here for a more thorough discussion](https://github.com/croneter/PlexKodiConnect/wiki/Is-PKC-'hacky'%3F).
|
||||
|
||||
### 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
|
||||
|
||||
### PKC Features
|
||||
|
||||
- Support for Kodi 18 Leia and Kodi 19 Matrix
|
||||
- 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)
|
||||
- [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 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)
|
||||
- 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)
|
||||
- [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
|
||||
- PKC is available in the following languages:
|
||||
- PKC is available in the following languages. [Please help and easily translate PKC!](https://www.transifex.com/croneter/pkc)
|
||||
+ English
|
||||
+ German
|
||||
+ Czech, thanks @Pavuucek
|
||||
|
@ -69,53 +74,34 @@ PKC synchronizes your media from your Plex server to the native Kodi database. H
|
|||
+ Chinese Simplified, thanks @everdream
|
||||
+ Norwegian, thanks @mjorud
|
||||
+ Portuguese, thanks @goncalo532
|
||||
+ [Please help translating](https://www.transifex.com/croneter/pkc)
|
||||
|
||||
### Download and Installation
|
||||
|
||||
Install PKC via the PlexKodiConnect Kodi repository below (we cannot use the official Kodi repository as PKC messes with Kodi's databases). 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://dl.bintray.com/croneter/PlexKodiConnect/bin/repository.plexkodiconnect/repository.plexkodiconnect-1.0.0.zip) | [![beta version](https://img.shields.io/badge/beta_version-latest-red.svg?maxAge=60&style=flat) ](https://dl.bintray.com/croneter/PlexKodiConnect_BETA/bin-BETA/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.0.zip) |
|
||||
+ Russian, thanks @UncleStark
|
||||
+ Hungarian, thanks @savage93
|
||||
+ Ukrainian, thanks @uniss
|
||||
+ Lithuanian, thanks @egidusm
|
||||
+ Korean, thanks @so-o-bima
|
||||
|
||||
### Additional Artwork
|
||||
PKC uses additional artwork for free from [TheMovieDB](https://www.themoviedb.org). Many thanks for lettings us use the API, guys!
|
||||
[![Logo of TheMovieDB](themoviedb.png)](https://www.themoviedb.org)
|
||||
|
||||
### Important Notes
|
||||
|
||||
1. If you are using a **low CPU device like a Raspberry Pi or a CuBox**, PKC might be instable or crash during initial sync. Lower the number of threads in the [PKC settings under Sync Options](https://github.com/croneter/PlexKodiConnect/wiki/PKC-settings#sync-options). Don't forget to reboot Kodi after that.
|
||||
2. **Compatibility**:
|
||||
* PKC is currently not compatible with Kodi's Video Extras plugin. **Deactivate Video Extras** if trailers/movies start randomly playing.
|
||||
* PKC is not (and will never be) compatible with the **MySQL database replacement** in Kodi. In fact, PKC replaces the MySQL functionality because it acts as a "man in the middle" for your entire media library.
|
||||
* If **another plugin is not working** like it's supposed to, try to use [PKC direct paths](https://github.com/croneter/PlexKodiConnect/wiki/Direct-Paths-Explained)
|
||||
|
||||
### Donations
|
||||
I'm not in any way affiliated with Plex. Thank you very much for a small donation via ko-fi.com and PayPal if you appreciate PKC.
|
||||
**Full disclaimer:** I will see your name and address on my PayPal account. Rest assured that I will not share this with anyone.
|
||||
I'm not in any way affiliated with Plex. Thank you very much for a small donation via ko-fi.com and PayPal, Bitcoin or Ether if you appreciate PKC.
|
||||
**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)
|
||||
|
||||
**Ethereum address for donations:
|
||||
0x0f57D98E08e617292D8bC0B3448dd79BF4Cf8e7F**
|
||||
|
||||
**Bitcoin address for donations:
|
||||
3BhwvUsqAGtAZodGUx4mTP7pTECjf1AejT**
|
||||
|
||||
|
||||
### Request a New Feature
|
||||
|
||||
[![Feature Requests](http://feathub.com/croneter/PlexKodiConnect?format=svg)](http://feathub.com/croneter/PlexKodiConnect)
|
||||
|
||||
### Known Larger Issues
|
||||
|
||||
Solutions are unlikely due to the nature of these issues
|
||||
- A Plex Media Server "bug" leads to frequent and slow syncs, see [here for more info](https://github.com/croneter/PlexKodiConnect/issues/135)
|
||||
- *Plex Music when using Addon paths instead of Native Direct Paths:* Kodi tries to scan every(!) single Plex song on startup. This leads to errors in the Kodi log file and potentially even crashes. See the [Github issue](https://github.com/croneter/PlexKodiConnect/issues/14) for more details
|
||||
|
||||
*Background Sync:*
|
||||
The Plex Server does not tell anyone of the following changes. Hence PKC cannot detect these changes instantly but will notice them only on full/delta syncs (standard settings is every 60 minutes)
|
||||
- Toggle the viewstate of an item to (un)watched outside of Kodi
|
||||
- Changing details of an item, e.g. replacing a poster
|
||||
|
||||
However, some changes to individual items are instantly detected, e.g. if you match a yet unrecognized movie.
|
||||
|
||||
|
||||
### Issues being worked on
|
||||
### Issues and Bugs
|
||||
|
||||
Have a look at the [Github Issues Page](https://github.com/croneter/PlexKodiConnect/issues). Before you open your own issue, please read [How to report a bug](https://github.com/croneter/PlexKodiConnect/wiki/How-to-Report-A-Bug).
|
||||
|
||||
|
|
367
addon.xml
367
addon.xml
|
@ -1,8 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="1.8.7" provider-name="croneter">
|
||||
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="2.15.0" provider-name="croneter">
|
||||
<requires>
|
||||
<import addon="xbmc.python" version="2.1.0"/>
|
||||
<import addon="script.module.requests" version="2.3.0" />
|
||||
<import addon="script.module.requests" version="2.9.1" />
|
||||
<import addon="script.module.defusedxml" version="0.5.0"/>
|
||||
<import addon="script.module.six" />
|
||||
<import addon="plugin.video.plexkodiconnect.movies" version="2.1.3" />
|
||||
<import addon="plugin.video.plexkodiconnect.tvshows" version="2.1.3" />
|
||||
</requires>
|
||||
<extension point="xbmc.python.pluginsource" library="default.py">
|
||||
<provides>video audio image</provides>
|
||||
|
@ -13,19 +17,19 @@
|
|||
<item>
|
||||
<label>30401</label>
|
||||
<description>30416</description>
|
||||
<visible>[!IsEmpty(ListItem.DBID) + !StringCompare(ListItem.DBID,-1) | !IsEmpty(ListItem.Property(plexid))] + !IsEmpty(Window(10000).Property(plex_context))</visible>
|
||||
<visible>[!IsEmpty(ListItem.DBID) + !StringCompare(ListItem.DBID,-1) | !IsEmpty(ListItem.Property(plexid))]</visible>
|
||||
</item>
|
||||
</extension>
|
||||
<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>
|
||||
<license>GNU GENERAL PUBLIC LICENSE. Version 2, June 1991</license>
|
||||
<forum>https://forums.plex.tv</forum>
|
||||
<website>https://github.com/croneter/PlexKodiConnect</website>
|
||||
<email></email>
|
||||
<source>https://github.com/croneter/PlexKodiConnect</source>
|
||||
<summary lang="en_GB">Native Integration of Plex into Kodi</summary>
|
||||
<description lang="en_GB">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_GB">Use at your own risk</disclaimer>
|
||||
<summary lang="nl_NL">Directe integratie van Plex in Kodi</summary>
|
||||
<description lang="nl_NL">Verbind Kodi met je Plex Media Server. Deze plugin gaat ervan uit dat je al je video's met Plex (en niet met Kodi) beheerd. Je kunt gegevens reeds opgeslagen in de databases voor video en muziek van Kodi (deze plugin wijzigt deze gegevens direct) verliezen. Gebruik op eigen risico!</description>
|
||||
<disclaimer lang="nl_NL">Gebruik op eigen risico</disclaimer>
|
||||
|
@ -36,8 +40,8 @@
|
|||
<description lang="fr_FR">Connecter Kodi à votre Plex Media Server. Ce plugin assume que vous souhaitez gérer toutes vos vidéos avec Plex (et aucune avec Kodi). Vous pourriez perdre les données déjà stockées dans les bases de données vidéo et musique de Kodi (ce plugin les modifie directement). Utilisez à vos propres risques !</description>
|
||||
<disclaimer lang="fr_FR">A utiliser à vos propres risques</disclaimer>
|
||||
<summary lang="de_DE">Komplette Integration von Plex in Kodi</summary>
|
||||
<description lang="de_DE">Verbindet Kodi mit deinem Plex Media Server. Dieses Addon geht davon aus, dass du all deine Videos mit Plex verwaltest (und keine direkt mit Kodi). Du wirst möglicherweise Daten verlieren, die bereits in der Kodi Video- und/oder Musik-Datenbank gespeichert sind (da dieses Addon beide Datenbanken direkt verändert). Verwendung auf eigene Gefahr!</description>
|
||||
<disclaimer lang="de_DE">Verwendung auf eigene Gefahr</disclaimer>
|
||||
<description lang="de_DE">Verbindet Kodi mit deinem Plex Media Server. Dieses Addon geht davon aus, dass du all deine Videos mit Plex verwaltest (und keine direkt mit Kodi). Du wirst möglicherweise Daten verlieren, die bereits in der Kodi Video- und/oder Musik-Datenbank gespeichert sind (da dieses Addon beide Datenbanken direkt verändert). Benutzung auf eigene Gefahr!</description>
|
||||
<disclaimer lang="de_DE">Benutzung auf eigene Gefahr</disclaimer>
|
||||
<summary lang="pt_PT">Integração nativa do Plex no Kodi</summary>
|
||||
<description lang="pt_PT">Conectar o Kodi ao Servidor Plex Media. Este plugin assume que gerirá todos os vídeos com o Plex (e nenhum com Kodi). Poderá perder dados guardados nas bases de dados de vídeo e musica do Kodi (pois este plugin interfere diretamente com as mesmas). Use por risco de conta própria</description>
|
||||
<disclaimer lang="pt_PT">Use por risco de conta própria</disclaimer>
|
||||
|
@ -59,179 +63,218 @@
|
|||
<summary lang="da_DK">Indbygget Integration af Plex i Kodi</summary>
|
||||
<description lang="da_DK">Tilslut Kodi til din Plex Media Server. Dette plugin forudsætter, at du administrere alle dine videoer med Plex (og ikke med Kodi). Du kan miste data som allerede er gemt i Kodi video og musik-databaser (dette plugin ændrer direkte i dem). Brug på eget ansvar!</description>
|
||||
<disclaimer lang="da_DK">Brug på eget ansvar</disclaimer>
|
||||
<news>version 1.8.7:
|
||||
- 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
|
||||
<summary lang="it_IT">Integrazione nativa di Plex su Kodi</summary>
|
||||
<description lang="it_IT">Connetti Kodi al tuo Plex Media Server. Questo plugin assume che tu gestisca tutti i video con Plex (e non con Kodi). Potresti perdere i dati dei film e della musica già memorizzati nel database di Kodi (questo plugin modifica direttamente il database stesso). Usa a tuo rischio e pericolo!</description>
|
||||
<disclaimer lang="it_IT">Usa a tuo rischio e pericolo</disclaimer>
|
||||
<summary lang="no_NO">Naturlig integrasjon av Plex til Kodi</summary>
|
||||
<description lang="no_NO">Koble Kodi til din Plex Media Server. Denne plugin forventer at du organiserer alle dine videor med Plex (og ingen med Kodi). Du kan miste all data allerede lagret i Kodi video- og musikkdatabasene (da denne plugin umiddelbart forandrer dem). Bruk på egen risiko!</description>
|
||||
<disclaimer lang="no_NO">Bruk på eget ansvar</disclaimer>
|
||||
<summary lang="hu_HU">a Plex natív integrációja a Kodi-ba</summary>
|
||||
<description lang="hu_HU">Csatlakoztassa a Kodi-t a Plex médiaszerveréhez. Ez a kiegészítő feltételezi, hogy az összes videóját a Plex-szel kezeli (és egyiket sem a Kodi-val). Elveszítheti a már a Kodi videó- és zene-adatbázisában tárolt adatokat (mivel ez a kiegészítő közvetlenül módosítja az adatbázisokat). Csak saját felelősségére használja!</description>
|
||||
<disclaimer lang="hu_HU">Csak saját felelősségre használja</disclaimer>
|
||||
<summary lang="ru_RU">Нативная интеграция сервера Plex в Kodi</summary>
|
||||
<description lang="ru_RU">Подключите Kodi к своему серверу Plex. Плагин предполагает что вы управляете своими видео с помощью Plex (а не в Kodi). Вы можете потерять текущие базы данных музыки и видео в Kodi (так как плагин напрямую их изменяет). Используйте на свой страх и риск</description>
|
||||
<disclaimer lang="ru_RU">Используйте на свой страх и риск</disclaimer>
|
||||
<summary lang="uk_UA">Нативна інтеграція Plex в Kodi</summary>
|
||||
<description lang="uk_UA">Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик!</description>
|
||||
<disclaimer lang="uk_UA">Використовуйте на свій ризик</disclaimer>
|
||||
<disclaimer lang="lv_LV">Lieto uz savu atbildību</disclaimer>
|
||||
<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>
|
||||
<disclaimer lang="sv_SE">Använd på egen risk</disclaimer>
|
||||
<summary lang="lt_LT">Natūralioji „Plex“ integracija į „Kodi“</summary>
|
||||
<description lang="lt_LT">Prijunkite „Kodi“ prie „Plex Medija Serverio“. Šiame papildinyje daroma prielaida, kad valdote visus savo vaizdo įrašus naudodami „Plex“ (ir nė vieno su „Kodi“). Galite prarasti jau saugomus „Kodi“ vaizdo įrašų ir muzikos duomenų bazių duomenis (kadangi šis papildinys juos tiesiogiai pakeičia). Naudokite savo pačių rizika!</description>
|
||||
<disclaimer lang="lt_LT">Naudokite savo pačių rizika</disclaimer>
|
||||
<summary lang="ko_KR">Plex를 Kodi에 기본 통합</summary>
|
||||
<description lang="ko_KR">Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오!</description>
|
||||
<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 1.8.6:
|
||||
- Portuguese translation, thanks @goncalo532
|
||||
- Updated other translations
|
||||
version 2.14.4 (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
|
||||
- Transcoding: Fix Plex burning-in subtitles when it should not
|
||||
- Fix logging if fanart.tv lookup fails: be less verbose
|
||||
- Large refactoring of playlist and playqueue code
|
||||
- Refactor usage of a media part's id
|
||||
|
||||
version 1.8.5:
|
||||
- version 1.8.4 for everyone
|
||||
version 2.14.3 (beta only):
|
||||
- Implement "Reset resume position" from the Kodi context menu
|
||||
|
||||
version 1.8.5:
|
||||
- version 1.8.4 for everyone
|
||||
version 2.14.2:
|
||||
- version 2.14.1 for everyone
|
||||
|
||||
version 1.8.4 (beta only):
|
||||
- Plex cloud should now work: Request pictures with transcoding API
|
||||
- Fix Plex companion feedback for Android
|
||||
version 2.14.1 (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
|
||||
- Fix PlexKodiConnect setting the Plex subtitle to None
|
||||
- Download landscape artwork from fanart.tv, thanks @geropan
|
||||
- Revert "Fix PlexKodiConnect changing subtitles for all videos on the PMS"
|
||||
|
||||
version 2.14.0:
|
||||
- Fix PlexKodiConnect changing or removing subtitles for every video on the PMS
|
||||
- version 2.13.1-2.13.2 for everyone
|
||||
|
||||
version 2.13.2 (beta only):
|
||||
- Fix a racing condition that could lead to the sync getting stuck
|
||||
- Fix RecursionError: maximum recursion depth exceeded
|
||||
- Websocket Fix AttributeError: 'NoneType' object has no attribute 'is_ssl'
|
||||
|
||||
version 2.13.1 (beta only):
|
||||
- Fix a racing condition that could lead to the sync process getting stuck
|
||||
- Fix likelyhood of `database is locked` error occuring
|
||||
|
||||
version 2.13.0:
|
||||
- Support for the Plex HAMA agent to let Kodi identify animes (using Kodi's uniqueID 'anidb')
|
||||
- Support forced HAMA IDs when using tvdb uniqueID
|
||||
- version 2.12.26 for everyone
|
||||
|
||||
version 2.12.26 (beta only):
|
||||
- Add an additional Plex Hub "PKC Continue Watching" that merges the Plex Continue Watching with On Deck
|
||||
- Fix auto-picking of video stream if several video versions are available
|
||||
- Update translations
|
||||
|
||||
version 1.8.3:
|
||||
- Fix Kodi playlists being empty
|
||||
version 2.12.25:
|
||||
- Update websocket client to 0.59.0. Fix threading issues and AttributeErrors
|
||||
|
||||
version 1.8.2:
|
||||
- Choose to replace user ratings with the number of available versions of a media file
|
||||
- More collection artwork: use TheMovieDB art
|
||||
- Support new Companion command "refreshPlayQueue"
|
||||
- Use https for TheMovieDB
|
||||
version 2.12.24:
|
||||
- version 2.12.23 for everyone
|
||||
|
||||
version 2.12.23 (beta only):
|
||||
- Fix Alexa and RuntimeError: dictionary keys changed during iteration
|
||||
- Fix a rare AttributeError when using playlists
|
||||
|
||||
version 2.12.22:
|
||||
- version 2.12.20 and 2.12.21 for everyone
|
||||
|
||||
version 2.12.21 (beta only):
|
||||
- Switch to new websocket implementation
|
||||
- Hopefully fix RuntimeError: no add-on id "plugin.video.plexkodiconnect"
|
||||
- Update translations
|
||||
|
||||
version 1.8.1:
|
||||
- Fix library sync crash due to UnicodeDecodeError
|
||||
- Fix fanart for collections
|
||||
- Comply with themoviedb.org terms of use
|
||||
- Add some translations
|
||||
version 2.12.20 (beta only):
|
||||
- Add information to PKC settings for background sync and Alexa whether a connection has been successfully made
|
||||
|
||||
version 1.8.0
|
||||
Featuring:
|
||||
- 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.19:
|
||||
- 2.12.17 and 2.12.18 for everyone
|
||||
- Rename skip intro skin file
|
||||
|
||||
version 1.7.22 (beta only)
|
||||
- Fix playback stop not being recognized by the PMS
|
||||
- Better way to sync progress to another account
|
||||
version 2.12.18 (beta only):
|
||||
- Quickly sync recently watched items before synching the playstates of the entire Plex library
|
||||
- Improve logging for websocket JSON loads
|
||||
|
||||
version 1.7.21 (beta only)
|
||||
- Fix Playback and watched status not syncing
|
||||
- Fix PKC syncing progress to wrong account
|
||||
- Warn user if a xml cannot be parsed
|
||||
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 1.7.20 (beta only)
|
||||
- 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.16:
|
||||
- versions 2.12.14 and 2.12.15 for everyone
|
||||
|
||||
version 1.7.19 (beta only)
|
||||
- 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 1.7.18 (beta only)
|
||||
- Fix OperationalError when resetting PKC
|
||||
- Fix possible OperationalErrors
|
||||
- Companion: ensure sockets get closed
|
||||
- Fix TypeError for Plex Companion
|
||||
- Update Czech
|
||||
|
||||
version 1.7.17 (beta only)
|
||||
- Don't add media by other add-ons to queue
|
||||
- Fix KeyError for Plex Companion
|
||||
- Repace Kodi mkdirs with os.makedirs
|
||||
- Use xbmcvfs exists instead of os.path.exists
|
||||
|
||||
version 1.7.16 (beta only)
|
||||
- Fix PKC complaining about files not found
|
||||
- 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
|
||||
version 2.12.15 (beta only):
|
||||
- Fix skip intros sometimes not working due to a RuntimeError
|
||||
- Update translations
|
||||
- Rewire Kodi ListItem stuff
|
||||
- Fix TypeError for setting ListItem streams
|
||||
- Fix Kodi setContent for images
|
||||
- Fix AttributeError due to missing Kodi sort methods
|
||||
|
||||
version 1.7.3 (beta only)
|
||||
- Fix KeyError for channels if no media streams
|
||||
- Move plex node navigation, playback to main thread
|
||||
- Fix TypeError for malformed browsing xml
|
||||
- Fix IndexError if we can't get a valid xml from PMS
|
||||
- Pass 'None' instead of empty string in url args
|
||||
version 2.12.14:
|
||||
- Add skip intro functionality
|
||||
|
||||
version 1.7.2
|
||||
- Fix for some channels not starting playback
|
||||
version 2.12.13:
|
||||
- Fix KeyError: u'game' if Plex Arcade has been activated
|
||||
- Fix AttributeError: 'App' object has no attribute 'threads' when sync is cancelled
|
||||
|
||||
version 1.7.1
|
||||
- Fix Alexa not doing anything
|
||||
version 2.12.12:
|
||||
- Hopefully fix rare case when sync would get stuck indefinitely
|
||||
- Fix ValueError: invalid literal for int() for invalid dates sent by Plex
|
||||
- version 2.12.11 for everyone
|
||||
|
||||
version 1.7.0
|
||||
- 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
|
||||
- Plex Channels!
|
||||
- Browse video nodes by folder/path
|
||||
- Fix IndexError for playqueues
|
||||
version 2.12.11 (beta only):
|
||||
- Fix PKC not auto-picking audio/subtitle stream when transcoding
|
||||
- Fix ValueError when deleting a music album
|
||||
- Fix OSError: Invalid argument when Plex returns an invalid timestamp
|
||||
|
||||
version 2.12.10:
|
||||
- Fix pictures from Plex picture libraries not working/displaying
|
||||
|
||||
version 2.12.9:
|
||||
- Fix Local variable 'user' referenced before assignement
|
||||
|
||||
version 2.12.8:
|
||||
- version 2.12.7 for everyone
|
||||
|
||||
version 2.12.7 (beta only):
|
||||
- Fix PKC suddenly using main Plex user's credentials, e.g. when the PMS address changed
|
||||
- Fix missing Kodi tags for movie collections/sets
|
||||
|
||||
version 2.12.6:
|
||||
- Fix rare KeyError when using PKC widgets
|
||||
- Fix suspension of artwork caching and PKC becoming unresponsive
|
||||
- Update translations
|
||||
- Code optimization</news>
|
||||
- Versions 2.12.4 and 2.12.5 for everyone
|
||||
|
||||
version 2.12.5 (beta only):
|
||||
- Greatly improve matching logic for The Movie Database if Plex does not provide an appropriate id
|
||||
- Fix high transcoding resolutions not being available for Win10
|
||||
- Fix rare playback progress report failing and KeyError: u'containerKey'
|
||||
- Fix rare KeyError: None when trying to sync playlists
|
||||
- Fix TypeError when canceling Plex sync section dialog
|
||||
|
||||
version 2.12.4 (beta only):
|
||||
- Hopefully fix freeze during sync: Don't assign multiple sets/collections for a specific movie
|
||||
- Support metadata provider ids (e.g. for IMDB) for the new Plex Movie Agent
|
||||
|
||||
version 2.12.3:
|
||||
- Fix playback failing due to caching of subtitles with non-ascii chars
|
||||
- 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 2.12.0 and 2.12.1 for everyone
|
||||
- Fix regression: sync dialog not showing up when it should
|
||||
|
||||
version 2.12.1 (beta only):
|
||||
- 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
|
||||
|
||||
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>
|
||||
</addon>
|
||||
|
|
1247
changelog.txt
1247
changelog.txt
File diff suppressed because it is too large
Load diff
|
@ -1,52 +1,50 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from sys import listitem
|
||||
from urllib import urlencode
|
||||
|
||||
from xbmc import getCondVisibility, sleep
|
||||
from xbmcgui import Window
|
||||
|
||||
###############################################################################
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import xbmc
|
||||
import xbmcaddon
|
||||
def _get_kodi_type():
|
||||
kodi_type = listitem.getVideoInfoTag().getMediaType().decode('utf-8')
|
||||
if not kodi_type:
|
||||
if getCondVisibility('Container.Content(albums)'):
|
||||
kodi_type = "album"
|
||||
elif getCondVisibility('Container.Content(artists)'):
|
||||
kodi_type = "artist"
|
||||
elif getCondVisibility('Container.Content(songs)'):
|
||||
kodi_type = "song"
|
||||
elif getCondVisibility('Container.Content(pictures)'):
|
||||
kodi_type = "picture"
|
||||
return kodi_type
|
||||
|
||||
###############################################################################
|
||||
|
||||
_addon = xbmcaddon.Addon(id='plugin.video.plexkodiconnect')
|
||||
try:
|
||||
_addon_path = _addon.getAddonInfo('path').decode('utf-8')
|
||||
except TypeError:
|
||||
_addon_path = _addon.getAddonInfo('path').decode()
|
||||
try:
|
||||
_base_resource = xbmc.translatePath(os.path.join(
|
||||
_addon_path,
|
||||
'resources',
|
||||
'lib')).decode('utf-8')
|
||||
except TypeError:
|
||||
_base_resource = xbmc.translatePath(os.path.join(
|
||||
_addon_path,
|
||||
'resources',
|
||||
'lib')).decode()
|
||||
sys.path.append(_base_resource)
|
||||
def main():
|
||||
"""
|
||||
Grabs kodi_id and kodi_type and sends a request to our main python instance
|
||||
that context menu needs to be displayed
|
||||
"""
|
||||
window = Window(10000)
|
||||
kodi_id = listitem.getVideoInfoTag().getDbId()
|
||||
if kodi_id == -1:
|
||||
# There is no getDbId() method for getMusicInfoTag
|
||||
# YET TO BE IMPLEMENTED - lookup ID using path
|
||||
kodi_id = listitem.getMusicInfoTag().getURL()
|
||||
kodi_type = _get_kodi_type()
|
||||
args = {
|
||||
'kodi_id': kodi_id,
|
||||
'kodi_type': kodi_type
|
||||
}
|
||||
while window.getProperty('plexkodiconnect.command'):
|
||||
sleep(20)
|
||||
window.setProperty('plexkodiconnect.command',
|
||||
'CONTEXT_menu?%s' % urlencode(args))
|
||||
|
||||
###############################################################################
|
||||
|
||||
import loghandler
|
||||
from context_entry import ContextMenu
|
||||
|
||||
###############################################################################
|
||||
|
||||
loghandler.config()
|
||||
log = logging.getLogger("PLEX.contextmenu")
|
||||
|
||||
###############################################################################
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
try:
|
||||
# Start the context menu
|
||||
ContextMenu()
|
||||
except Exception as error:
|
||||
log.exception(error)
|
||||
import traceback
|
||||
log.exception("Traceback:\n%s" % traceback.format_exc())
|
||||
raise
|
||||
main()
|
||||
|
|
256
default.py
256
default.py
|
@ -1,49 +1,22 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
###############################################################################
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
import logging
|
||||
from os import path as os_path
|
||||
from sys import path as sys_path, argv
|
||||
from sys import argv
|
||||
from urlparse import parse_qsl
|
||||
|
||||
from xbmc import translatePath, sleep, executebuiltin
|
||||
from xbmcaddon import Addon
|
||||
from xbmcgui import ListItem
|
||||
from xbmcplugin import setResolvedUrl
|
||||
import xbmc
|
||||
import xbmcgui
|
||||
import xbmcplugin
|
||||
|
||||
_addon = Addon(id='plugin.video.plexkodiconnect')
|
||||
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)
|
||||
from resources.lib import entrypoint, utils, transfer, variables as v, loghandler
|
||||
from resources.lib.tools import unicode_paths
|
||||
|
||||
###############################################################################
|
||||
|
||||
import entrypoint
|
||||
from utils import window, pickl_window, reset, passwordsXML, language as lang,\
|
||||
dialog
|
||||
from pickler import unpickle_me
|
||||
from PKC_listitem import convert_PKC_to_listitem
|
||||
import variables as v
|
||||
|
||||
###############################################################################
|
||||
|
||||
import loghandler
|
||||
|
||||
loghandler.config()
|
||||
log = logging.getLogger('PLEX.default')
|
||||
LOG = logging.getLogger('PLEX.default')
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
@ -54,9 +27,15 @@ class Main():
|
|||
# MAIN ENTRY POINT
|
||||
# @utils.profiling()
|
||||
def __init__(self):
|
||||
log.debug('Full sys.argv received: %s' % argv)
|
||||
LOG.debug('Full sys.argv received: %s', argv)
|
||||
# Parse parameters
|
||||
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', '')
|
||||
itemid = params.get('id', '')
|
||||
|
||||
|
@ -66,156 +45,165 @@ class Main():
|
|||
elif mode == 'plex_node':
|
||||
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':
|
||||
entrypoint.browse_plex(key=params.get('key'),
|
||||
plex_section_id=params.get('id'))
|
||||
plex_type=params.get('plex_type'),
|
||||
section_id=params.get('section_id'),
|
||||
synched=params.get('synched') != 'false',
|
||||
prompt=params.get('prompt'),
|
||||
query=params.get('query'))
|
||||
|
||||
elif mode == 'getsubfolders':
|
||||
entrypoint.GetSubFolders(itemid)
|
||||
elif mode == 'show_section':
|
||||
entrypoint.show_section(params.get('section_index'))
|
||||
|
||||
elif mode == 'watchlater':
|
||||
entrypoint.watchlater()
|
||||
|
||||
elif mode == 'channels':
|
||||
entrypoint.channels()
|
||||
entrypoint.browse_plex(key='/channels/all')
|
||||
|
||||
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':
|
||||
entrypoint.extras(plex_id=params.get('plex_id'))
|
||||
|
||||
elif mode == 'settings':
|
||||
executebuiltin('Addon.OpenSettings(%s)' % v.ADDON_ID)
|
||||
xbmc.executebuiltin('Addon.OpenSettings(%s)' % v.ADDON_ID)
|
||||
|
||||
elif mode == 'enterPMS':
|
||||
entrypoint.enterPMS()
|
||||
LOG.info('Request to manually enter new PMS address')
|
||||
transfer.plex_command('enter_new_pms_address')
|
||||
|
||||
elif mode == 'reset':
|
||||
reset()
|
||||
transfer.plex_command('RESET-PKC')
|
||||
|
||||
elif mode == 'togglePlexTV':
|
||||
entrypoint.togglePlexTV()
|
||||
|
||||
elif mode == 'resetauth':
|
||||
entrypoint.resetAuth()
|
||||
LOG.info('Toggle of Plex.tv sign-in requested')
|
||||
transfer.plex_command('toggle_plex_tv_sign_in')
|
||||
|
||||
elif mode == 'passwords':
|
||||
passwordsXML()
|
||||
from resources.lib.windows import direct_path_sources
|
||||
direct_path_sources.start()
|
||||
|
||||
elif mode == 'switchuser':
|
||||
entrypoint.switchPlexUser()
|
||||
LOG.info('Plex home user switch requested')
|
||||
transfer.plex_command('switch_plex_user')
|
||||
|
||||
elif mode in ('manualsync', 'repair'):
|
||||
if window('plex_online') != 'true':
|
||||
# Server is not online, do not run the sync
|
||||
dialog('ok', lang(29999), lang(39205))
|
||||
log.error('Not connected to a PMS.')
|
||||
else:
|
||||
if mode == 'repair':
|
||||
window('plex_runLibScan', value='repair')
|
||||
log.info('Requesting repair lib sync')
|
||||
LOG.info('Requesting repair lib sync')
|
||||
transfer.plex_command('repair-scan')
|
||||
elif mode == 'manualsync':
|
||||
log.info('Requesting full library scan')
|
||||
window('plex_runLibScan', value='full')
|
||||
LOG.info('Requesting full library scan')
|
||||
transfer.plex_command('full-scan')
|
||||
|
||||
elif mode == 'texturecache':
|
||||
window('plex_runLibScan', value='del_textures')
|
||||
LOG.info('Requesting texture caching of all textures')
|
||||
transfer.plex_command('textures-scan')
|
||||
|
||||
elif mode == 'chooseServer':
|
||||
self.__exec('function=choose_server')
|
||||
|
||||
elif mode == 'refreshplaylist':
|
||||
log.info('Requesting playlist/nodes refresh')
|
||||
window('plex_runLibScan', value='views')
|
||||
LOG.info("Choosing PMS server requested, starting")
|
||||
transfer.plex_command('choose_pms_server')
|
||||
|
||||
elif mode == 'deviceid':
|
||||
self.deviceid()
|
||||
|
||||
elif mode == 'fanart':
|
||||
log.info('User requested fanarttv refresh')
|
||||
window('plex_runLibScan', value='fanart')
|
||||
LOG.info('User requested fanarttv refresh')
|
||||
transfer.plex_command('fanart-scan')
|
||||
|
||||
elif '/extrafanart' in argv[0]:
|
||||
plexpath = argv[2][1:]
|
||||
elif '/extrafanart' in path:
|
||||
plexpath = arguments[1:]
|
||||
plexid = itemid
|
||||
entrypoint.getExtraFanArt(plexid, plexpath)
|
||||
entrypoint.getVideoFiles(plexid, plexpath)
|
||||
entrypoint.extra_fanart(plexid, plexpath)
|
||||
entrypoint.get_video_files(plexid, plexpath)
|
||||
|
||||
# Called by e.g. 3rd party plugin video extras
|
||||
elif ('/Extras' in argv[0] or '/VideoFiles' in argv[0] or
|
||||
'/Extras' in argv[2]):
|
||||
elif ('/Extras' in path or '/VideoFiles' in path or
|
||||
'/Extras' in arguments):
|
||||
plexId = itemid or None
|
||||
entrypoint.getVideoFiles(plexId, params)
|
||||
entrypoint.get_video_files(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:
|
||||
entrypoint.doMainListing(content_type=params.get('content_type'))
|
||||
entrypoint.show_main_menu(content_type=params.get('content_type'))
|
||||
|
||||
def play(self):
|
||||
@staticmethod
|
||||
def play():
|
||||
"""
|
||||
Start up playback_starter in main Python thread
|
||||
"""
|
||||
request = '%s&handle=%s' % (argv[2], HANDLE)
|
||||
# Put the request into the 'queue'
|
||||
while window('plex_command'):
|
||||
sleep(50)
|
||||
window('plex_command', value='play_%s' % argv[2])
|
||||
# Wait for the result
|
||||
while not pickl_window('plex_result'):
|
||||
sleep(50)
|
||||
result = unpickle_me()
|
||||
if result is None:
|
||||
log.error('Error encountered, aborting')
|
||||
dialog('notification',
|
||||
heading='{plex}',
|
||||
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)
|
||||
transfer.plex_command('PLAY-%s' % request)
|
||||
if HANDLE == -1:
|
||||
# Handle -1 received, not waiting for main thread
|
||||
return
|
||||
# Wait for the result from the main PKC thread
|
||||
result = transfer.wait_for_transfer(source='main')
|
||||
if result is True:
|
||||
xbmcplugin.setResolvedUrl(HANDLE, False, xbmcgui.ListItem())
|
||||
# Tell main thread that we're done
|
||||
transfer.send(True, target='main')
|
||||
else:
|
||||
# Received a xbmcgui.ListItem()
|
||||
xbmcplugin.setResolvedUrl(HANDLE, True, result)
|
||||
|
||||
@staticmethod
|
||||
def __exec(command):
|
||||
"""
|
||||
Used to funnel commands to the main PKC python instance (like play())
|
||||
"""
|
||||
# Put the request into the 'queue'
|
||||
while window('plex_command'):
|
||||
sleep(50)
|
||||
window('plex_command', value='exec_%s' % command)
|
||||
# No need to wait for the result
|
||||
|
||||
def deviceid(self):
|
||||
deviceId_old = window('plex_client_Id')
|
||||
from clientinfo import getDeviceId
|
||||
def deviceid():
|
||||
window = xbmcgui.Window(10000)
|
||||
deviceId_old = window.getProperty('plex_client_Id')
|
||||
from resources.lib import clientinfo
|
||||
try:
|
||||
deviceId = getDeviceId(reset=True)
|
||||
deviceId = clientinfo.getDeviceId(reset=True)
|
||||
except Exception as e:
|
||||
log.error('Failed to generate a new device Id: %s' % e)
|
||||
dialog('ok', lang(29999), lang(33032))
|
||||
LOG.error('Failed to generate a new device Id: %s' % e)
|
||||
utils.messageDialog(utils.lang(29999), utils.lang(33032))
|
||||
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))
|
||||
# 'Kodi will now restart to apply the changes'
|
||||
dialog('ok', lang(29999), lang(33033))
|
||||
executebuiltin('RestartApp')
|
||||
utils.messageDialog(utils.lang(29999), utils.lang(33033))
|
||||
xbmc.executebuiltin('RestartApp')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
log.info('%s started' % v.ADDON_ID)
|
||||
LOG.info('%s started' % v.ADDON_ID)
|
||||
try:
|
||||
v.database_paths()
|
||||
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)
|
||||
LOG.info('%s stopped' % v.ADDON_ID)
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1603
resources/language/resource.language.el_GR/strings.po
Normal file
1603
resources/language/resource.language.el_GR/strings.po
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1714
resources/language/resource.language.hu_HU/strings.po
Normal file
1714
resources/language/resource.language.hu_HU/strings.po
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1634
resources/language/resource.language.ko_KR/strings.po
Normal file
1634
resources/language/resource.language.ko_KR/strings.po
Normal file
File diff suppressed because it is too large
Load diff
1699
resources/language/resource.language.lt_LT/strings.po
Normal file
1699
resources/language/resource.language.lt_LT/strings.po
Normal file
File diff suppressed because it is too large
Load diff
1634
resources/language/resource.language.lv_LV/strings.po
Normal file
1634
resources/language/resource.language.lv_LV/strings.po
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1608
resources/language/resource.language.pl_PL/strings.po
Normal file
1608
resources/language/resource.language.pl_PL/strings.po
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
1693
resources/language/resource.language.ru_RU/strings.po
Normal file
1693
resources/language/resource.language.ru_RU/strings.po
Normal file
File diff suppressed because it is too large
Load diff
1687
resources/language/resource.language.sv_SE/strings.po
Normal file
1687
resources/language/resource.language.sv_SE/strings.po
Normal file
File diff suppressed because it is too large
Load diff
1693
resources/language/resource.language.uk_UA/strings.po
Normal file
1693
resources/language/resource.language.uk_UA/strings.po
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -1,291 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from threading import Thread
|
||||
import Queue
|
||||
from socket import SHUT_RDWR
|
||||
from urllib import urlencode
|
||||
|
||||
from xbmc import sleep, executebuiltin
|
||||
|
||||
from utils import settings, thread_methods
|
||||
from plexbmchelper import listener, plexgdm, subscribers, functions, \
|
||||
httppersist, plexsettings
|
||||
from PlexFunctions import ParseContainerKey, GetPlexMetadata
|
||||
from PlexAPI import API
|
||||
from playlist_func import get_pms_playqueue, get_plextype_from_xml
|
||||
import player
|
||||
import variables as v
|
||||
import state
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = logging.getLogger("PLEX."+__name__)
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
@thread_methods(add_suspends=['PMS_STATUS'])
|
||||
class PlexCompanion(Thread):
|
||||
"""
|
||||
"""
|
||||
def __init__(self, callback=None):
|
||||
log.info("----===## Starting PlexCompanion ##===----")
|
||||
if callback is not None:
|
||||
self.mgr = callback
|
||||
self.settings = plexsettings.getSettings()
|
||||
# Start GDM for server/client discovery
|
||||
self.client = plexgdm.plexgdm()
|
||||
self.client.clientDetails(self.settings)
|
||||
log.debug("Registration string is:\n%s"
|
||||
% self.client.getClientDetails())
|
||||
# kodi player instance
|
||||
self.player = player.Player()
|
||||
|
||||
Thread.__init__(self)
|
||||
|
||||
def _getStartItem(self, string):
|
||||
"""
|
||||
Grabs the Plex id from e.g. '/library/metadata/12987'
|
||||
|
||||
and returns the tuple (typus, id) where typus is either 'queueId' or
|
||||
'plexId' and id is the corresponding id as a string
|
||||
"""
|
||||
typus = 'plexId'
|
||||
if string.startswith('/library/metadata'):
|
||||
try:
|
||||
string = string.split('/')[3]
|
||||
except IndexError:
|
||||
string = ''
|
||||
else:
|
||||
log.error('Unknown string! %s' % string)
|
||||
return typus, string
|
||||
|
||||
def processTasks(self, task):
|
||||
"""
|
||||
Processes tasks picked up e.g. by Companion listener, e.g.
|
||||
{'action': 'playlist',
|
||||
'data': {'address': 'xyz.plex.direct',
|
||||
'commandID': '7',
|
||||
'containerKey': '/playQueues/6669?own=1&repeat=0&window=200',
|
||||
'key': '/library/metadata/220493',
|
||||
'machineIdentifier': 'xyz',
|
||||
'offset': '0',
|
||||
'port': '32400',
|
||||
'protocol': 'https',
|
||||
'token': 'transient-cd2527d1-0484-48e0-a5f7-f5caa7d591bd',
|
||||
'type': 'video'}}
|
||||
"""
|
||||
log.debug('Processing: %s' % task)
|
||||
data = task['data']
|
||||
|
||||
# Get the token of the user flinging media (might be different one)
|
||||
token = data.get('token')
|
||||
if task['action'] == 'alexa':
|
||||
# e.g. Alexa
|
||||
xml = GetPlexMetadata(data['key'])
|
||||
try:
|
||||
xml[0].attrib
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
log.error('Could not download Plex metadata')
|
||||
return
|
||||
api = API(xml[0])
|
||||
if api.getType() == v.PLEX_TYPE_ALBUM:
|
||||
log.debug('Plex music album detected')
|
||||
queue = self.mgr.playqueue.init_playqueue_from_plex_children(
|
||||
api.getRatingKey())
|
||||
queue.plex_transient_token = token
|
||||
else:
|
||||
state.PLEX_TRANSIENT_TOKEN = token
|
||||
params = {
|
||||
'mode': 'plex_node',
|
||||
'key': '{server}%s' % data.get('key'),
|
||||
'view_offset': data.get('offset'),
|
||||
'play_directly': 'true',
|
||||
'node': 'false'
|
||||
}
|
||||
executebuiltin('RunPlugin(plugin://%s?%s)'
|
||||
% (v.ADDON_ID, urlencode(params)))
|
||||
|
||||
elif (task['action'] == 'playlist' and
|
||||
data.get('address') == 'node.plexapp.com'):
|
||||
# E.g. watch later initiated by Companion
|
||||
state.PLEX_TRANSIENT_TOKEN = token
|
||||
params = {
|
||||
'mode': 'plex_node',
|
||||
'key': '{server}%s' % data.get('key'),
|
||||
'view_offset': data.get('offset'),
|
||||
'play_directly': 'true'
|
||||
}
|
||||
executebuiltin('RunPlugin(plugin://%s?%s)'
|
||||
% (v.ADDON_ID, urlencode(params)))
|
||||
|
||||
elif task['action'] == 'playlist':
|
||||
# Get the playqueue ID
|
||||
try:
|
||||
typus, ID, query = ParseContainerKey(data['containerKey'])
|
||||
except Exception as e:
|
||||
log.error('Exception while processing: %s' % e)
|
||||
import traceback
|
||||
log.error("Traceback:\n%s" % traceback.format_exc())
|
||||
return
|
||||
try:
|
||||
playqueue = self.mgr.playqueue.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
|
||||
except KeyError:
|
||||
# E.g. Plex web does not supply the media type
|
||||
# Still need to figure out the type (video vs. music vs. pix)
|
||||
xml = GetPlexMetadata(data['key'])
|
||||
try:
|
||||
xml[0].attrib
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
log.error('Could not download Plex metadata')
|
||||
return
|
||||
api = API(xml[0])
|
||||
playqueue = self.mgr.playqueue.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.getType()])
|
||||
self.mgr.playqueue.update_playqueue_from_PMS(
|
||||
playqueue,
|
||||
ID,
|
||||
repeat=query.get('repeat'),
|
||||
offset=data.get('offset'))
|
||||
playqueue.plex_transient_token = token
|
||||
|
||||
elif task['action'] == 'refreshPlayQueue':
|
||||
# example data: {'playQueueID': '8475', 'commandID': '11'}
|
||||
xml = get_pms_playqueue(data['playQueueID'])
|
||||
if xml is None:
|
||||
return
|
||||
if len(xml) == 0:
|
||||
log.debug('Empty playqueue received - clearing playqueue')
|
||||
plex_type = get_plextype_from_xml(xml)
|
||||
if plex_type is None:
|
||||
return
|
||||
playqueue = self.mgr.playqueue.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
||||
playqueue.clear()
|
||||
return
|
||||
playqueue = self.mgr.playqueue.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']])
|
||||
self.mgr.playqueue.update_playqueue_from_PMS(
|
||||
playqueue,
|
||||
data['playQueueID'])
|
||||
|
||||
def run(self):
|
||||
# Ensure that sockets will be closed no matter what
|
||||
try:
|
||||
self.__run()
|
||||
finally:
|
||||
try:
|
||||
self.httpd.socket.shutdown(SHUT_RDWR)
|
||||
except AttributeError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
self.httpd.socket.close()
|
||||
except AttributeError:
|
||||
pass
|
||||
log.info("----===## Plex Companion stopped ##===----")
|
||||
|
||||
def __run(self):
|
||||
self.httpd = False
|
||||
httpd = self.httpd
|
||||
# Cache for quicker while loops
|
||||
client = self.client
|
||||
thread_stopped = self.thread_stopped
|
||||
thread_suspended = self.thread_suspended
|
||||
|
||||
# Start up instances
|
||||
requestMgr = httppersist.RequestMgr()
|
||||
jsonClass = functions.jsonClass(requestMgr, self.settings)
|
||||
subscriptionManager = subscribers.SubscriptionManager(
|
||||
jsonClass, requestMgr, self.player, self.mgr)
|
||||
|
||||
queue = Queue.Queue(maxsize=100)
|
||||
self.queue = queue
|
||||
|
||||
if settings('plexCompanion') == 'true':
|
||||
# Start up httpd
|
||||
start_count = 0
|
||||
while True:
|
||||
try:
|
||||
httpd = listener.ThreadedHTTPServer(
|
||||
client,
|
||||
subscriptionManager,
|
||||
jsonClass,
|
||||
self.settings,
|
||||
queue,
|
||||
('', self.settings['myport']),
|
||||
listener.MyHandler)
|
||||
httpd.timeout = 0.95
|
||||
break
|
||||
except:
|
||||
log.error("Unable to start PlexCompanion. Traceback:")
|
||||
import traceback
|
||||
log.error(traceback.print_exc())
|
||||
|
||||
sleep(3000)
|
||||
|
||||
if start_count == 3:
|
||||
log.error("Error: Unable to start web helper.")
|
||||
httpd = False
|
||||
break
|
||||
|
||||
start_count += 1
|
||||
else:
|
||||
log.info('User deactivated Plex Companion')
|
||||
|
||||
client.start_all()
|
||||
|
||||
message_count = 0
|
||||
if httpd:
|
||||
t = Thread(target=httpd.handle_request)
|
||||
|
||||
while not thread_stopped():
|
||||
# If we are not authorized, sleep
|
||||
# Otherwise, we trigger a download which leads to a
|
||||
# re-authorizations
|
||||
while thread_suspended():
|
||||
if thread_stopped():
|
||||
break
|
||||
sleep(1000)
|
||||
try:
|
||||
message_count += 1
|
||||
if httpd:
|
||||
if not t.isAlive():
|
||||
# Use threads cause the method will stall
|
||||
t = Thread(target=httpd.handle_request)
|
||||
t.start()
|
||||
|
||||
if message_count == 3000:
|
||||
message_count = 0
|
||||
if client.check_client_registration():
|
||||
log.debug("Client is still registered")
|
||||
else:
|
||||
log.debug("Client is no longer registered. "
|
||||
"Plex Companion still running on port %s"
|
||||
% self.settings['myport'])
|
||||
client.register_as_client()
|
||||
# Get and set servers
|
||||
if message_count % 30 == 0:
|
||||
subscriptionManager.serverlist = client.getServerList()
|
||||
subscriptionManager.notify()
|
||||
if not httpd:
|
||||
message_count = 0
|
||||
except:
|
||||
log.warn("Error in loop, continuing anyway. Traceback:")
|
||||
import traceback
|
||||
log.warn(traceback.format_exc())
|
||||
# See if there's anything we need to process
|
||||
try:
|
||||
task = queue.get(block=False)
|
||||
except Queue.Empty:
|
||||
pass
|
||||
else:
|
||||
# Got instructions, process them
|
||||
self.processTasks(task)
|
||||
queue.task_done()
|
||||
# Don't sleep
|
||||
continue
|
||||
sleep(50)
|
||||
|
||||
client.stop_all()
|
|
@ -1,535 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from logging import getLogger
|
||||
from urllib import urlencode
|
||||
from ast import literal_eval
|
||||
from urlparse import urlparse, parse_qsl
|
||||
from urllib import quote_plus
|
||||
import re
|
||||
from copy import deepcopy
|
||||
|
||||
from downloadutils import DownloadUtils
|
||||
from utils import settings, tryEncode
|
||||
from variables import PLEX_TO_KODI_TIMEFACTOR
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = getLogger("PLEX."+__name__)
|
||||
|
||||
CONTAINERSIZE = int(settings('limitindex'))
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
regex = re.compile(r'''/(.+)/(\d+)$''')
|
||||
try:
|
||||
result = regex.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 XbmcItemtypes():
|
||||
return ['photo', 'video', 'audio']
|
||||
|
||||
|
||||
def PlexItemtypes():
|
||||
return ['photo', 'video', 'audio']
|
||||
|
||||
|
||||
def PlexLibraryItemtypes():
|
||||
return ['movie', 'show']
|
||||
# later add: 'artist', 'photo'
|
||||
|
||||
|
||||
def EmbyItemtypes():
|
||||
return ['Movie', 'Series', 'Season', 'Episode']
|
||||
|
||||
|
||||
def SelectStreams(url, args):
|
||||
"""
|
||||
Does a PUT request to tell the PMS what audio and subtitle streams we have
|
||||
chosen.
|
||||
"""
|
||||
DownloadUtils().downloadUrl(
|
||||
url + '?' + urlencode(args), action_type='PUT')
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
headerOptions = {'X-Plex-Token': token} if token is not None else None
|
||||
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 = DownloadUtils().downloadUrl(url,
|
||||
authenticate=False,
|
||||
headerOptions=headerOptions,
|
||||
verifySSL=verifySSL)
|
||||
if answer is None:
|
||||
log.debug("Could not connect to %s" % url)
|
||||
return False
|
||||
try:
|
||||
# xml received?
|
||||
answer.attrib
|
||||
except:
|
||||
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 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 = DownloadUtils().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
|
||||
errorCounter = 0
|
||||
while errorCounter < 10:
|
||||
args = {
|
||||
'X-Plex-Container-Size': CONTAINERSIZE,
|
||||
'X-Plex-Container-Start': pos
|
||||
}
|
||||
xmlpart = DownloadUtils().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
|
||||
errorCounter += 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 errorCounter == 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 DownloadUtils().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 = DownloadUtils().downloadUrl(
|
||||
url + '?' + urlencode(args), action_type="POST")
|
||||
try:
|
||||
xml[0].tag
|
||||
except (IndexError, TypeError, AttributeError):
|
||||
log.error("Error retrieving metadata for %s" % url)
|
||||
return None
|
||||
return xml
|
||||
|
||||
|
||||
def getPlexRepeat(kodiRepeat):
|
||||
plexRepeat = {
|
||||
'off': '0',
|
||||
'one': '1',
|
||||
'all': '2' # does this work?!?
|
||||
}
|
||||
return plexRepeat.get(kodiRepeat)
|
||||
|
||||
|
||||
def PMSHttpsEnabled(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
|
||||
"""
|
||||
doUtils = DownloadUtils().downloadUrl
|
||||
res = doUtils('https://%s/identity' % url,
|
||||
authenticate=False,
|
||||
verifySSL=False)
|
||||
try:
|
||||
res.attrib
|
||||
except AttributeError:
|
||||
# Might have SSL deactivated. Try with http
|
||||
res = doUtils('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 = DownloadUtils().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 = DownloadUtils().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
|
||||
DownloadUtils().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 DownloadUtils().downloadUrl(
|
||||
'{server}/library/metadata/%s' % plexid,
|
||||
action_type="DELETE") is True:
|
||||
log.info('Successfully deleted Plex id %s from the PMS' % plexid)
|
||||
return True
|
||||
else:
|
||||
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>/:/
|
||||
|
||||
Call with url: scheme://ip:port
|
||||
"""
|
||||
return DownloadUtils().downloadUrl(
|
||||
'%s/:/prefs' % url,
|
||||
authenticate=False,
|
||||
verifySSL=False,
|
||||
headerOptions={'X-Plex-Token': token} if token else None)
|
||||
|
||||
|
||||
def get_transcode_image_path(self, 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 = tryEncode(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...
|
||||
transcodePath = '/photo/:/transcode/' + \
|
||||
str(width) + 'x' + str(height) + '/' + quote_plus(path)
|
||||
|
||||
args = dict()
|
||||
args['width'] = width
|
||||
args['height'] = height
|
||||
args['url'] = path
|
||||
|
||||
if not AuthToken == '':
|
||||
args['X-Plex-Token'] = AuthToken
|
||||
|
||||
return transcodePath + '?' + urlencode(args)
|
40
resources/lib/app/__init__.py
Normal file
40
resources/lib/app/__init__.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
#!/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()
|
147
resources/lib/app/account.py
Normal file
147
resources/lib/app/account.py
Normal file
|
@ -0,0 +1,147 @@
|
|||
#!/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)
|
173
resources/lib/app/application.py
Normal file
173
resources/lib/app/application.py
Normal file
|
@ -0,0 +1,173 @@
|
|||
#!/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'))
|
98
resources/lib/app/connection.py
Normal file
98
resources/lib/app/connection.py
Normal file
|
@ -0,0 +1,98 @@
|
|||
#!/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
|
130
resources/lib/app/libsync.py
Normal file
130
resources/lib/app/libsync.py
Normal file
|
@ -0,0 +1,130 @@
|
|||
#!/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'
|
66
resources/lib/app/playstate.py
Normal file
66
resources/lib/app/playstate.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
#!/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,430 +1,139 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
###############################################################################
|
||||
import logging
|
||||
from json import dumps, loads
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
import requests
|
||||
from shutil import rmtree
|
||||
from urllib import quote_plus, unquote
|
||||
from threading import Thread
|
||||
from Queue import Queue, Empty
|
||||
|
||||
from xbmc import executeJSONRPC, sleep, translatePath
|
||||
from xbmcvfs import exists
|
||||
from .kodi_db import KodiVideoDB, KodiMusicDB, KodiTextureDB
|
||||
from . import app, backgroundthread, utils
|
||||
|
||||
from utils import window, settings, language as lang, kodiSQL, tryEncode, \
|
||||
thread_methods, dialog, exists_dir, tryDecode
|
||||
LOG = getLogger('PLEX.artwork')
|
||||
|
||||
# Disable annoying requests warnings
|
||||
import requests.packages.urllib3
|
||||
requests.packages.urllib3.disable_warnings()
|
||||
###############################################################################
|
||||
|
||||
log = logging.getLogger("PLEX."+__name__)
|
||||
|
||||
###############################################################################
|
||||
|
||||
ARTWORK_QUEUE = Queue()
|
||||
|
||||
|
||||
def setKodiWebServerDetails():
|
||||
"""
|
||||
Get the Kodi webserver details - used to set the texture cache
|
||||
"""
|
||||
xbmc_port = None
|
||||
xbmc_username = None
|
||||
xbmc_password = None
|
||||
web_query = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "Settings.GetSettingValue",
|
||||
"params": {
|
||||
"setting": "services.webserver"
|
||||
}
|
||||
}
|
||||
result = executeJSONRPC(dumps(web_query))
|
||||
result = loads(result)
|
||||
try:
|
||||
xbmc_webserver_enabled = result['result']['value']
|
||||
except (KeyError, TypeError):
|
||||
xbmc_webserver_enabled = False
|
||||
if not xbmc_webserver_enabled:
|
||||
# Enable the webserver, it is disabled
|
||||
web_port = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "Settings.SetSettingValue",
|
||||
"params": {
|
||||
"setting": "services.webserverport",
|
||||
"value": 8080
|
||||
}
|
||||
}
|
||||
result = executeJSONRPC(dumps(web_port))
|
||||
xbmc_port = 8080
|
||||
web_user = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "Settings.SetSettingValue",
|
||||
"params": {
|
||||
"setting": "services.webserver",
|
||||
"value": True
|
||||
}
|
||||
}
|
||||
result = executeJSONRPC(dumps(web_user))
|
||||
xbmc_username = "kodi"
|
||||
# Webserver already enabled
|
||||
web_port = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "Settings.GetSettingValue",
|
||||
"params": {
|
||||
"setting": "services.webserverport"
|
||||
}
|
||||
}
|
||||
result = executeJSONRPC(dumps(web_port))
|
||||
result = loads(result)
|
||||
try:
|
||||
xbmc_port = result['result']['value']
|
||||
except (TypeError, KeyError):
|
||||
pass
|
||||
web_user = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "Settings.GetSettingValue",
|
||||
"params": {
|
||||
"setting": "services.webserverusername"
|
||||
}
|
||||
}
|
||||
result = executeJSONRPC(dumps(web_user))
|
||||
result = loads(result)
|
||||
try:
|
||||
xbmc_username = result['result']['value']
|
||||
except (TypeError, KeyError):
|
||||
pass
|
||||
web_pass = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "Settings.GetSettingValue",
|
||||
"params": {
|
||||
"setting": "services.webserverpassword"
|
||||
}
|
||||
}
|
||||
result = executeJSONRPC(dumps(web_pass))
|
||||
result = loads(result)
|
||||
try:
|
||||
xbmc_password = result['result']['value']
|
||||
except TypeError:
|
||||
pass
|
||||
return (xbmc_port, xbmc_username, xbmc_password)
|
||||
# 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):
|
||||
return quote_plus(quote_plus(text))
|
||||
return utils.quote_plus(utils.quote_plus(text))
|
||||
|
||||
|
||||
def double_urldecode(text):
|
||||
return unquote(unquote(text))
|
||||
return utils.unquote(utils.unquote(text))
|
||||
|
||||
|
||||
@thread_methods(add_stops=['STOP_SYNC'],
|
||||
add_suspends=['SUSPEND_LIBRARY_THREAD', 'DB_SCAN'])
|
||||
class Image_Cache_Thread(Thread):
|
||||
xbmc_host = 'localhost'
|
||||
xbmc_port, xbmc_username, xbmc_password = setKodiWebServerDetails()
|
||||
sleep_between = 50
|
||||
# Potentially issues with limited number of threads
|
||||
# Hence let Kodi wait till download is successful
|
||||
timeout = (35.1, 35.1)
|
||||
|
||||
class ImageCachingThread(backgroundthread.KillableThread):
|
||||
def __init__(self):
|
||||
self.queue = ARTWORK_QUEUE
|
||||
Thread.__init__(self)
|
||||
super(ImageCachingThread, self).__init__()
|
||||
self.suspend_points = [(self, '_suspended')]
|
||||
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):
|
||||
thread_stopped = self.thread_stopped
|
||||
thread_suspended = self.thread_suspended
|
||||
queue = self.queue
|
||||
sleep_between = self.sleep_between
|
||||
while not thread_stopped():
|
||||
# In the event the server goes offline
|
||||
while thread_suspended():
|
||||
# Set in service.py
|
||||
if thread_stopped():
|
||||
# Abort was requested while waiting. We should exit
|
||||
log.info("---===### Stopped Image_Cache_Thread ###===---")
|
||||
return
|
||||
sleep(1000)
|
||||
LOG.info("---===### Starting ImageCachingThread ###===---")
|
||||
app.APP.register_caching_thread(self)
|
||||
try:
|
||||
url = queue.get(block=False)
|
||||
except Empty:
|
||||
sleep(1000)
|
||||
continue
|
||||
self._run()
|
||||
except Exception:
|
||||
utils.ERROR()
|
||||
finally:
|
||||
app.APP.deregister_caching_thread(self)
|
||||
LOG.info("---===### Stopped ImageCachingThread ###===---")
|
||||
|
||||
def _loop(self):
|
||||
kinds = [KodiVideoDB]
|
||||
if app.SYNC.enable_music:
|
||||
kinds.append(KodiMusicDB)
|
||||
for kind in kinds:
|
||||
for kodi_type in ('poster', 'fanart'):
|
||||
for url in self._url_generator(kind, kodi_type):
|
||||
if self.should_suspend() or self.should_cancel():
|
||||
return False
|
||||
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):
|
||||
url = double_urlencode(url)
|
||||
sleeptime = 0
|
||||
while True:
|
||||
try:
|
||||
requests.head(
|
||||
url="http://%s:%s/image/image://%s"
|
||||
% (self.xbmc_host, self.xbmc_port, url),
|
||||
auth=(self.xbmc_username, self.xbmc_password),
|
||||
timeout=self.timeout)
|
||||
% (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 thread_stopped():
|
||||
# Kodi terminated
|
||||
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))
|
||||
LOG.error('Repeatedly got ConnectionError for url %s',
|
||||
double_urldecode(url))
|
||||
break
|
||||
log.debug('Were trying too hard to download art, server '
|
||||
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)
|
||||
'again to download %s',
|
||||
2**sleeptime, double_urldecode(url))
|
||||
app.APP.monitor.waitForAbort((2**sleeptime))
|
||||
sleeptime += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
log.error('Unknown exception for url %s: %s'
|
||||
% (double_urldecode(url), e))
|
||||
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())
|
||||
LOG.error("Traceback:\n%s", traceback.format_exc())
|
||||
break
|
||||
# We did not even get a timeout
|
||||
break
|
||||
queue.task_done()
|
||||
log.debug('Cached art: %s' % double_urldecode(url))
|
||||
# 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 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 = tryDecode(translatePath("special://thumbnails/"))
|
||||
if exists_dir(path):
|
||||
rmtree(path, ignore_errors=True)
|
||||
|
||||
# remove all existing data from texture DB
|
||||
connection = kodiSQL('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 = kodiSQL('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.cacheTexture(url[0])
|
||||
# Cache all entries in music DB
|
||||
connection = kodiSQL('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.cacheTexture(url[0])
|
||||
|
||||
def cacheTexture(self, url):
|
||||
# Cache a single image url to the texture cache
|
||||
if url and self.enableTextureCache:
|
||||
self.queue.put(double_urlencode(tryEncode(url)))
|
||||
|
||||
def addArtwork(self, artwork, kodiId, mediaType, cursor):
|
||||
# Kodi conversion table
|
||||
kodiart = {
|
||||
'Primary': ["thumb", "poster"],
|
||||
'Banner': "banner",
|
||||
'Logo': "clearlogo",
|
||||
'Art': "clearart",
|
||||
'Thumb': "landscape",
|
||||
'Disc': "discart",
|
||||
'Backdrop': "fanart",
|
||||
'BoxRear': "poster"
|
||||
}
|
||||
|
||||
# Artwork is a dictionary
|
||||
for art in artwork:
|
||||
if art == "Backdrop":
|
||||
# Backdrop entry is a list
|
||||
# Process extra fanart for artwork downloader (fanart, fanart1,
|
||||
# fanart2...)
|
||||
backdrops = artwork[art]
|
||||
backdropsNumber = len(backdrops)
|
||||
|
||||
query = ' '.join((
|
||||
"SELECT url",
|
||||
"FROM art",
|
||||
"WHERE media_id = ?",
|
||||
"AND media_type = ?",
|
||||
"AND type LIKE ?"
|
||||
))
|
||||
cursor.execute(query, (kodiId, mediaType, "fanart%",))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if len(rows) > backdropsNumber:
|
||||
# More backdrops in database. Delete extra fanart.
|
||||
query = ' '.join((
|
||||
"DELETE FROM art",
|
||||
"WHERE media_id = ?",
|
||||
"AND media_type = ?",
|
||||
"AND type LIKE ?"
|
||||
))
|
||||
cursor.execute(query, (kodiId, mediaType, "fanart_",))
|
||||
|
||||
# Process backdrops and extra fanart
|
||||
index = ""
|
||||
for backdrop in backdrops:
|
||||
self.addOrUpdateArt(
|
||||
imageUrl=backdrop,
|
||||
kodiId=kodiId,
|
||||
mediaType=mediaType,
|
||||
imageType="%s%s" % ("fanart", index),
|
||||
cursor=cursor)
|
||||
|
||||
if backdropsNumber > 1:
|
||||
try: # Will only fail on the first try, str to int.
|
||||
index += 1
|
||||
except TypeError:
|
||||
index = 1
|
||||
|
||||
elif art == "Primary":
|
||||
# Primary art is processed as thumb and poster for Kodi.
|
||||
for artType in kodiart[art]:
|
||||
self.addOrUpdateArt(
|
||||
imageUrl=artwork[art],
|
||||
kodiId=kodiId,
|
||||
mediaType=mediaType,
|
||||
imageType=artType,
|
||||
cursor=cursor)
|
||||
|
||||
elif kodiart.get(art):
|
||||
# Process the rest artwork type that Kodi can use
|
||||
self.addOrUpdateArt(
|
||||
imageUrl=artwork[art],
|
||||
kodiId=kodiId,
|
||||
mediaType=mediaType,
|
||||
imageType=kodiart[art],
|
||||
cursor=cursor)
|
||||
|
||||
def addOrUpdateArt(self, imageUrl, kodiId, mediaType, imageType, cursor):
|
||||
if not imageUrl:
|
||||
# Possible that the imageurl is an empty string
|
||||
return
|
||||
|
||||
query = ' '.join((
|
||||
"SELECT url",
|
||||
"FROM art",
|
||||
"WHERE media_id = ?",
|
||||
"AND media_type = ?",
|
||||
"AND type = ?"
|
||||
))
|
||||
cursor.execute(query, (kodiId, mediaType, imageType,))
|
||||
try:
|
||||
# Update the artwork
|
||||
url = cursor.fetchone()[0]
|
||||
except TypeError:
|
||||
# Add the artwork
|
||||
log.debug("Adding Art Link for kodiId: %s (%s)"
|
||||
% (kodiId, imageUrl))
|
||||
query = (
|
||||
'''
|
||||
INSERT INTO art(media_id, media_type, type, url)
|
||||
VALUES (?, ?, ?, ?)
|
||||
'''
|
||||
)
|
||||
cursor.execute(query, (kodiId, mediaType, imageType, imageUrl))
|
||||
else:
|
||||
if url == imageUrl:
|
||||
# Only cache artwork if it changed
|
||||
return
|
||||
# Only for the main backdrop, poster
|
||||
if (window('plex_initialScan') != "true" and
|
||||
imageType in ("fanart", "poster")):
|
||||
# Delete current entry before updating with the new one
|
||||
self.deleteCachedArtwork(url)
|
||||
log.debug("Updating Art url for %s kodiId %s %s -> (%s)"
|
||||
% (imageType, kodiId, url, imageUrl))
|
||||
query = ' '.join((
|
||||
"UPDATE art",
|
||||
"SET url = ?",
|
||||
"WHERE media_id = ?",
|
||||
"AND media_type = ?",
|
||||
"AND type = ?"
|
||||
))
|
||||
cursor.execute(query, (imageUrl, kodiId, mediaType, imageType))
|
||||
|
||||
# Cache fanart and poster in Kodi texture cache
|
||||
if mediaType != 'actor':
|
||||
self.cacheTexture(imageUrl)
|
||||
|
||||
def deleteArtwork(self, kodiId, mediaType, cursor):
|
||||
query = ' '.join((
|
||||
"SELECT url",
|
||||
"FROM art",
|
||||
"WHERE media_id = ?",
|
||||
"AND media_type = ?"
|
||||
))
|
||||
cursor.execute(query, (kodiId, mediaType,))
|
||||
rows = cursor.fetchall()
|
||||
for row in rows:
|
||||
self.deleteCachedArtwork(row[0])
|
||||
|
||||
def deleteCachedArtwork(self, url):
|
||||
# Only necessary to remove and apply a new backdrop or poster
|
||||
connection = kodiSQL('texture')
|
||||
cursor = connection.cursor()
|
||||
try:
|
||||
cursor.execute("SELECT cachedurl FROM texture WHERE url = ?",
|
||||
(url,))
|
||||
cachedurl = cursor.fetchone()[0]
|
||||
except TypeError:
|
||||
log.info("Could not find cached url.")
|
||||
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(tryDecode(path), ignore_errors=True)
|
||||
cursor.execute("DELETE FROM texture WHERE url = ?", (url,))
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
|
520
resources/lib/backgroundthread.py
Normal file
520
resources/lib/backgroundthread.py
Normal file
|
@ -0,0 +1,520 @@
|
|||
#!/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,19 +1,21 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
|
||||
###############################################################################
|
||||
import logging
|
||||
import xbmc
|
||||
|
||||
from utils import window, settings
|
||||
import variables as v
|
||||
from . import utils
|
||||
from . import variables as v
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = logging.getLogger("PLEX."+__name__)
|
||||
LOG = getLogger('PLEX.clientinfo')
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
def getXArgsDeviceInfo(options=None):
|
||||
def getXArgsDeviceInfo(options=None, include_token=True):
|
||||
"""
|
||||
Returns a dictionary that can be used as headers for GET and POST
|
||||
requests. An authentication option is NOT yet added.
|
||||
|
@ -21,6 +23,8 @@ def getXArgsDeviceInfo(options=None):
|
|||
Inputs:
|
||||
options: dictionary of options that will override the
|
||||
standard header options otherwise set.
|
||||
include_token: set to False if you don't want to include the Plex token
|
||||
(e.g. for Companion communication)
|
||||
Output:
|
||||
header dictionary
|
||||
"""
|
||||
|
@ -29,20 +33,19 @@ def getXArgsDeviceInfo(options=None):
|
|||
'Connection': 'keep-alive',
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
# "Access-Control-Allow-Origin": "*",
|
||||
# 'X-Plex-Language': 'en',
|
||||
'X-Plex-Device': v.ADDON_NAME,
|
||||
'X-Plex-Client-Platform': v.PLATFORM,
|
||||
'Accept-Language': xbmc.getLanguage(xbmc.ISO_639_1),
|
||||
'X-Plex-Device': v.DEVICE,
|
||||
'X-Plex-Model': v.MODEL,
|
||||
'X-Plex-Device-Name': v.DEVICENAME,
|
||||
'X-Plex-Platform': v.PLATFORM,
|
||||
# 'X-Plex-Platform-Version': 'unknown',
|
||||
# 'X-Plex-Model': 'unknown',
|
||||
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
|
||||
'X-Plex-Product': v.ADDON_NAME,
|
||||
'X-Plex-Version': v.ADDON_VERSION,
|
||||
'X-Plex-Client-Identifier': getDeviceId(),
|
||||
'X-Plex-Provides': 'client,controller,player,pubsub-player',
|
||||
}
|
||||
if window('pms_token'):
|
||||
xargs['X-Plex-Token'] = window('pms_token')
|
||||
if include_token and utils.window('pms_token'):
|
||||
xargs['X-Plex-Token'] = utils.window('pms_token')
|
||||
if options is not None:
|
||||
xargs.update(options)
|
||||
return xargs
|
||||
|
@ -57,24 +60,27 @@ def getDeviceId(reset=False):
|
|||
If id does not exist, create one and save in Kodi settings file.
|
||||
"""
|
||||
if reset is True:
|
||||
window('plex_client_Id', clear=True)
|
||||
settings('plex_client_Id', value="")
|
||||
v.PKC_MACHINE_IDENTIFIER = None
|
||||
utils.window('plex_client_Id', clear=True)
|
||||
utils.settings('plex_client_Id', value="")
|
||||
|
||||
clientId = window('plex_client_Id')
|
||||
if clientId:
|
||||
return clientId
|
||||
client_id = v.PKC_MACHINE_IDENTIFIER
|
||||
if client_id:
|
||||
return client_id
|
||||
|
||||
clientId = settings('plex_client_Id')
|
||||
client_id = utils.settings('plex_client_Id')
|
||||
# Because Kodi appears to cache file settings!!
|
||||
if clientId != "" and reset is False:
|
||||
window('plex_client_Id', value=clientId)
|
||||
log.warn("Unique device Id plex_client_Id loaded: %s" % clientId)
|
||||
return clientId
|
||||
if client_id != "" and reset is False:
|
||||
v.PKC_MACHINE_IDENTIFIER = client_id
|
||||
utils.window('plex_client_Id', value=client_id)
|
||||
LOG.info("Unique device Id plex_client_Id loaded: %s", client_id)
|
||||
return client_id
|
||||
|
||||
log.warn("Generating a new deviceid.")
|
||||
LOG.info("Generating a new deviceid.")
|
||||
from uuid import uuid4
|
||||
clientId = str(uuid4())
|
||||
settings('plex_client_Id', value=clientId)
|
||||
window('plex_client_Id', value=clientId)
|
||||
log.warn("Unique device Id plex_client_Id loaded: %s" % clientId)
|
||||
return clientId
|
||||
client_id = str(uuid4())
|
||||
utils.settings('plex_client_Id', value=client_id)
|
||||
v.PKC_MACHINE_IDENTIFIER = client_id
|
||||
utils.window('plex_client_Id', value=client_id)
|
||||
LOG.info("Unique device Id plex_client_Id generated: %s", client_id)
|
||||
return client_id
|
||||
|
|
|
@ -1,101 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
import logging
|
||||
from threading import Thread
|
||||
from Queue import Queue
|
||||
from urlparse import parse_qsl
|
||||
|
||||
from xbmc import sleep
|
||||
|
||||
from utils import window, thread_methods
|
||||
import state
|
||||
import entrypoint
|
||||
|
||||
###############################################################################
|
||||
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.
|
||||
|
||||
Possible values of window('plex_command'):
|
||||
'play_....': to start playback using playback_starter
|
||||
|
||||
Adjusts state.py accordingly
|
||||
"""
|
||||
# Borg - multiple instances, shared state
|
||||
def __init__(self, callback=None):
|
||||
self.mgr = callback
|
||||
self.playback_queue = Queue()
|
||||
Thread.__init__(self)
|
||||
|
||||
@staticmethod
|
||||
def __execute(value):
|
||||
"""
|
||||
Kick off with new threads. Pass in a string with the information url-
|
||||
encoded:
|
||||
function=<function-name in entrypoint.py>
|
||||
params=<function parameters> (optional)
|
||||
"""
|
||||
values = dict(parse_qsl(value))
|
||||
function = values.get('function')
|
||||
params = values.get('params')
|
||||
log.debug('Execution called for function %s with parameters %s'
|
||||
% (function, params))
|
||||
function = getattr(entrypoint, function)
|
||||
try:
|
||||
if params is not None:
|
||||
function(params)
|
||||
else:
|
||||
function()
|
||||
except:
|
||||
log.error('Failed to execute function %s with params %s'
|
||||
% (function, params))
|
||||
raise
|
||||
|
||||
def run(self):
|
||||
thread_stopped = self.thread_stopped
|
||||
queue = self.playback_queue
|
||||
log.info("----===## Starting Kodi_Play_Client ##===----")
|
||||
while not thread_stopped():
|
||||
if window('plex_command'):
|
||||
value = window('plex_command')
|
||||
window('plex_command', clear=True)
|
||||
if value.startswith('play_'):
|
||||
queue.put(value)
|
||||
elif value.startswith('exec_'):
|
||||
t = Thread(target=self.__execute, args=(value[5:], ))
|
||||
t.start()
|
||||
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
|
||||
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,192 +1,120 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
|
||||
"""
|
||||
Processes Plex companion inputs from the plexbmchelper to Kodi commands
|
||||
"""
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
from xbmc import Player
|
||||
|
||||
from utils import JSONRPC
|
||||
from variables import ALEXA_TO_COMPANION
|
||||
from playqueue import Playqueue
|
||||
from PlexFunctions import GetPlexKeyNumber
|
||||
from . import playqueue as PQ, plex_functions as PF
|
||||
from . import json_rpc as js, variables as v, app
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = logging.getLogger("PLEX."+__name__)
|
||||
LOG = getLogger('PLEX.companion')
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
def getPlayers():
|
||||
info = JSONRPC("Player.GetActivePlayers").execute()['result'] or []
|
||||
ret = {}
|
||||
for player in info:
|
||||
player['playerid'] = int(player['playerid'])
|
||||
ret[player['type']] = player
|
||||
return ret
|
||||
|
||||
|
||||
def getPlayerIds():
|
||||
ret = []
|
||||
for player in getPlayers().values():
|
||||
ret.append(player['playerid'])
|
||||
return ret
|
||||
|
||||
|
||||
def getPlaylistId(typus):
|
||||
def skip_to(params):
|
||||
"""
|
||||
typus: one of the Kodi types, e.g. audio or video
|
||||
Skip to a specific playlist position.
|
||||
|
||||
Returns None if nothing was found
|
||||
Does not seem to be implemented yet by Plex!
|
||||
"""
|
||||
for playlist in getPlaylists():
|
||||
if playlist.get('type') == typus:
|
||||
return playlist.get('playlistid')
|
||||
|
||||
|
||||
def getPlaylists():
|
||||
"""
|
||||
Returns a list, e.g.
|
||||
[
|
||||
{u'playlistid': 0, u'type': u'audio'},
|
||||
{u'playlistid': 1, u'type': u'video'},
|
||||
{u'playlistid': 2, u'type': u'picture'}
|
||||
]
|
||||
"""
|
||||
return JSONRPC('Playlist.GetPlaylists').execute()
|
||||
|
||||
|
||||
def millisToTime(t):
|
||||
millis = int(t)
|
||||
seconds = millis / 1000
|
||||
minutes = seconds / 60
|
||||
hours = minutes / 60
|
||||
seconds = seconds % 60
|
||||
minutes = minutes % 60
|
||||
millis = millis % 1000
|
||||
return {'hours': hours,
|
||||
'minutes': minutes,
|
||||
'seconds': seconds,
|
||||
'milliseconds': millis}
|
||||
|
||||
|
||||
def skipTo(params):
|
||||
# Does not seem to be implemented yet
|
||||
playQueueItemID = params.get('playQueueItemID', 'not available')
|
||||
library, plex_id = GetPlexKeyNumber(params.get('key'))
|
||||
log.debug('Skipping to playQueueItemID %s, plex_id %s'
|
||||
% (playQueueItemID, plex_id))
|
||||
playqueue_item_id = params.get('playQueueItemID')
|
||||
_, plex_id = PF.GetPlexKeyNumber(params.get('key'))
|
||||
LOG.debug('Skipping to playQueueItemID %s, plex_id %s',
|
||||
playqueue_item_id, plex_id)
|
||||
found = True
|
||||
playqueues = Playqueue()
|
||||
for (player, ID) in getPlayers().iteritems():
|
||||
playqueue = playqueues.get_playqueue_from_type(player)
|
||||
for player in js.get_players().values():
|
||||
playqueue = PQ.PLAYQUEUES[player['playerid']]
|
||||
for i, item in enumerate(playqueue.items):
|
||||
if item.ID == playQueueItemID or item.plex_id == plex_id:
|
||||
if item.id == playqueue_item_id:
|
||||
found = True
|
||||
break
|
||||
else:
|
||||
log.debug('Item not found to skip to')
|
||||
found = False
|
||||
if found:
|
||||
for i, item in enumerate(playqueue.items):
|
||||
if item.plex_id == plex_id:
|
||||
found = True
|
||||
break
|
||||
if found is True:
|
||||
Player().play(playqueue.kodi_pl, None, False, i)
|
||||
else:
|
||||
LOG.error('Item not found to skip to')
|
||||
|
||||
|
||||
def convert_alexa_to_companion(dictionary):
|
||||
for key in dictionary:
|
||||
if key in ALEXA_TO_COMPANION:
|
||||
dictionary[ALEXA_TO_COMPANION[key]] = dictionary[key]
|
||||
"""
|
||||
The params passed by Alexa must first be converted to Companion talk
|
||||
"""
|
||||
for key in list(dictionary):
|
||||
if key in v.ALEXA_TO_COMPANION:
|
||||
dictionary[v.ALEXA_TO_COMPANION[key]] = dictionary[key]
|
||||
del dictionary[key]
|
||||
|
||||
|
||||
def process_command(request_path, params, queue=None):
|
||||
def process_command(request_path, params):
|
||||
"""
|
||||
queue: Queue() of PlexCompanion.py
|
||||
"""
|
||||
if params.get('deviceName') == 'Alexa':
|
||||
convert_alexa_to_companion(params)
|
||||
log.debug('Received request_path: %s, params: %s' % (request_path, params))
|
||||
if "/playMedia" in request_path:
|
||||
LOG.debug('Received request_path: %s, params: %s', request_path, params)
|
||||
if request_path == 'player/playback/playMedia':
|
||||
# We need to tell service.py
|
||||
action = 'alexa' if params.get('deviceName') == 'Alexa' else 'playlist'
|
||||
queue.put({
|
||||
app.APP.companion_queue.put({
|
||||
'action': action,
|
||||
'data': params
|
||||
})
|
||||
|
||||
elif request_path == 'player/playback/refreshPlayQueue':
|
||||
queue.put({
|
||||
app.APP.companion_queue.put({
|
||||
'action': 'refreshPlayQueue',
|
||||
'data': params
|
||||
})
|
||||
|
||||
elif request_path == "player/playback/setParameters":
|
||||
if 'volume' in params:
|
||||
volume = int(params['volume'])
|
||||
log.debug("Adjusting the volume to %s" % volume)
|
||||
JSONRPC('Application.SetVolume').execute({"volume": volume})
|
||||
js.set_volume(int(params['volume']))
|
||||
else:
|
||||
log.error('Unknown parameters: %s' % params)
|
||||
|
||||
LOG.error('Unknown parameters: %s', params)
|
||||
elif request_path == "player/playback/play":
|
||||
for playerid in getPlayerIds():
|
||||
JSONRPC("Player.PlayPause").execute({"playerid": playerid,
|
||||
"play": True})
|
||||
|
||||
js.play()
|
||||
elif request_path == "player/playback/pause":
|
||||
for playerid in getPlayerIds():
|
||||
JSONRPC("Player.PlayPause").execute({"playerid": playerid,
|
||||
"play": False})
|
||||
|
||||
js.pause()
|
||||
elif request_path == "player/playback/stop":
|
||||
for playerid in getPlayerIds():
|
||||
JSONRPC("Player.Stop").execute({"playerid": playerid})
|
||||
|
||||
js.stop()
|
||||
elif request_path == "player/playback/seekTo":
|
||||
for playerid in getPlayerIds():
|
||||
JSONRPC("Player.Seek").execute(
|
||||
{"playerid": playerid,
|
||||
"value": millisToTime(params.get('offset', 0))})
|
||||
|
||||
js.seek_to(int(params.get('offset', 0)))
|
||||
elif request_path == "player/playback/stepForward":
|
||||
for playerid in getPlayerIds():
|
||||
JSONRPC("Player.Seek").execute({"playerid": playerid,
|
||||
"value": "smallforward"})
|
||||
|
||||
js.smallforward()
|
||||
elif request_path == "player/playback/stepBack":
|
||||
for playerid in getPlayerIds():
|
||||
JSONRPC("Player.Seek").execute({"playerid": playerid,
|
||||
"value": "smallbackward"})
|
||||
|
||||
js.smallbackward()
|
||||
elif request_path == "player/playback/skipNext":
|
||||
for playerid in getPlayerIds():
|
||||
JSONRPC("Player.GoTo").execute({"playerid": playerid,
|
||||
"to": "next"})
|
||||
|
||||
js.skipnext()
|
||||
elif request_path == "player/playback/skipPrevious":
|
||||
for playerid in getPlayerIds():
|
||||
JSONRPC("Player.GoTo").execute({"playerid": playerid,
|
||||
"to": "previous"})
|
||||
|
||||
js.skipprevious()
|
||||
elif request_path == "player/playback/skipTo":
|
||||
skipTo(params)
|
||||
|
||||
skip_to(params)
|
||||
elif request_path == "player/navigation/moveUp":
|
||||
JSONRPC("Input.Up").execute()
|
||||
|
||||
js.input_up()
|
||||
elif request_path == "player/navigation/moveDown":
|
||||
JSONRPC("Input.Down").execute()
|
||||
|
||||
js.input_down()
|
||||
elif request_path == "player/navigation/moveLeft":
|
||||
JSONRPC("Input.Left").execute()
|
||||
|
||||
js.input_left()
|
||||
elif request_path == "player/navigation/moveRight":
|
||||
JSONRPC("Input.Right").execute()
|
||||
|
||||
js.input_right()
|
||||
elif request_path == "player/navigation/select":
|
||||
JSONRPC("Input.Select").execute()
|
||||
|
||||
js.input_select()
|
||||
elif request_path == "player/navigation/home":
|
||||
JSONRPC("Input.Home").execute()
|
||||
|
||||
js.input_home()
|
||||
elif request_path == "player/navigation/back":
|
||||
JSONRPC("Input.Back").execute()
|
||||
|
||||
js.input_back()
|
||||
elif request_path == "player/playback/setStreams":
|
||||
app.APP.companion_queue.put({
|
||||
'action': 'setStreams',
|
||||
'data': params
|
||||
})
|
||||
else:
|
||||
log.error('Unknown request path: %s' % request_path)
|
||||
LOG.error('Unknown request path: %s', request_path)
|
||||
|
|
|
@ -1,831 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
from logging import getLogger
|
||||
from hashlib import md5
|
||||
import requests
|
||||
from struct import pack
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as etree
|
||||
from Queue import Queue
|
||||
from threading import Thread
|
||||
|
||||
from xbmc import sleep
|
||||
|
||||
import credentials as cred
|
||||
from utils import tryDecode
|
||||
from PlexFunctions import PMSHttpsEnabled
|
||||
|
||||
|
||||
###############################################################################
|
||||
|
||||
# Disable annoying requests warnings
|
||||
import requests.packages.urllib3
|
||||
requests.packages.urllib3.disable_warnings()
|
||||
|
||||
log = getLogger("PLEX."+__name__)
|
||||
|
||||
###############################################################################
|
||||
|
||||
CONNECTIONSTATE = {
|
||||
'Unavailable': 0,
|
||||
'ServerSelection': 1,
|
||||
'ServerSignIn': 2,
|
||||
'SignedIn': 3,
|
||||
'ConnectSignIn': 4,
|
||||
'ServerUpdateNeeded': 5
|
||||
}
|
||||
|
||||
CONNECTIONMODE = {
|
||||
'Local': 0,
|
||||
'Remote': 1,
|
||||
'Manual': 2
|
||||
}
|
||||
|
||||
# multicast to PMS
|
||||
IP_PLEXGDM = '239.0.0.250'
|
||||
PORT_PLEXGDM = 32414
|
||||
MSG_PLEXGDM = 'M-SEARCH * HTTP/1.0'
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
def getServerAddress(server, mode):
|
||||
|
||||
modes = {
|
||||
CONNECTIONMODE['Local']: server.get('LocalAddress'),
|
||||
CONNECTIONMODE['Remote']: server.get('RemoteAddress'),
|
||||
CONNECTIONMODE['Manual']: server.get('ManualAddress')
|
||||
}
|
||||
return (modes.get(mode) or
|
||||
server.get('ManualAddress',
|
||||
server.get('LocalAddress',
|
||||
server.get('RemoteAddress'))))
|
||||
|
||||
|
||||
class ConnectionManager(object):
|
||||
default_timeout = 30
|
||||
apiClients = []
|
||||
minServerVersion = "1.7.0.0"
|
||||
connectUser = None
|
||||
# Token for plex.tv
|
||||
plexToken = None
|
||||
|
||||
def __init__(self, appName, appVersion, deviceName, deviceId,
|
||||
capabilities=None, devicePixelRatio=None):
|
||||
log.debug("Instantiating")
|
||||
|
||||
self.credentialProvider = cred.Credentials()
|
||||
self.appName = appName
|
||||
self.appVersion = appVersion
|
||||
self.deviceName = deviceName
|
||||
self.deviceId = deviceId
|
||||
self.capabilities = capabilities
|
||||
self.devicePixelRatio = devicePixelRatio
|
||||
|
||||
def setFilePath(self, path):
|
||||
# Set where to save persistant data
|
||||
self.credentialProvider.setPath(path)
|
||||
|
||||
def _getAppVersion(self):
|
||||
return self.appVersion
|
||||
|
||||
def _getCapabilities(self):
|
||||
return self.capabilities
|
||||
|
||||
def _getDeviceId(self):
|
||||
return self.deviceId
|
||||
|
||||
def _connectUserId(self):
|
||||
return self.credentialProvider.getCredentials().get('ConnectUserId')
|
||||
|
||||
def _connectToken(self):
|
||||
return self.credentialProvider.getCredentials().get('ConnectAccessToken')
|
||||
|
||||
def getServerInfo(self, id_):
|
||||
|
||||
servers = self.credentialProvider.getCredentials()['Servers']
|
||||
|
||||
for s in servers:
|
||||
if s['Id'] == id_:
|
||||
return s
|
||||
|
||||
def _getLastUsedServer(self):
|
||||
|
||||
servers = self.credentialProvider.getCredentials()['Servers']
|
||||
|
||||
if not len(servers):
|
||||
return
|
||||
|
||||
try:
|
||||
servers.sort(key=lambda x: datetime.strptime(x['DateLastAccessed'], "%Y-%m-%dT%H:%M:%SZ"), reverse=True)
|
||||
except TypeError:
|
||||
servers.sort(key=lambda x: datetime(*(time.strptime(x['DateLastAccessed'], "%Y-%m-%dT%H:%M:%SZ")[0:6])), reverse=True)
|
||||
|
||||
return servers[0]
|
||||
|
||||
def _mergeServers(self, list1, list2):
|
||||
|
||||
for i in range(0, len(list2), 1):
|
||||
try:
|
||||
self.credentialProvider.addOrUpdateServer(list1, list2[i])
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
return list1
|
||||
|
||||
def _connectUser(self):
|
||||
|
||||
return self.connectUser
|
||||
|
||||
def _resolveFailure(self):
|
||||
|
||||
return {
|
||||
'State': CONNECTIONSTATE['Unavailable'],
|
||||
'ConnectUser': self._connectUser()
|
||||
}
|
||||
|
||||
def _getMinServerVersion(self, val=None):
|
||||
|
||||
if val is not None:
|
||||
self.minServerVersion = val
|
||||
|
||||
return self.minServerVersion
|
||||
|
||||
def _updateServerInfo(self, server, systemInfo):
|
||||
|
||||
if server is None or systemInfo is None:
|
||||
return
|
||||
|
||||
server['Id'] = systemInfo.attrib['machineIdentifier']
|
||||
|
||||
if systemInfo.get('LocalAddress'):
|
||||
server['LocalAddress'] = systemInfo['LocalAddress']
|
||||
if systemInfo.get('WanAddress'):
|
||||
server['RemoteAddress'] = systemInfo['WanAddress']
|
||||
if systemInfo.get('MacAddress'):
|
||||
server['WakeOnLanInfos'] = [{'MacAddress': systemInfo['MacAddress']}]
|
||||
|
||||
def _getHeaders(self, request):
|
||||
headers = request.setdefault('headers', {})
|
||||
headers['Accept'] = '*/*'
|
||||
headers['Content-type'] = request.get(
|
||||
'contentType',
|
||||
"application/x-www-form-urlencoded")
|
||||
|
||||
def requestUrl(self, request):
|
||||
"""
|
||||
request: dict with the following (optional) keys:
|
||||
type: GET, POST, ... (mandatory)
|
||||
url: (mandatory)
|
||||
timeout
|
||||
verify: set to False to disable SSL certificate check
|
||||
|
||||
...and all the other requests settings
|
||||
"""
|
||||
self._getHeaders(request)
|
||||
request['timeout'] = request.get('timeout') or self.default_timeout
|
||||
|
||||
action = request['type']
|
||||
request.pop('type', None)
|
||||
|
||||
log.debug("Requesting %s" % request)
|
||||
|
||||
try:
|
||||
r = self._requests(action, **request)
|
||||
log.info("ConnectionManager response status: %s" % r.status_code)
|
||||
r.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
# Elaborate on exceptions?
|
||||
log.error(e)
|
||||
raise
|
||||
else:
|
||||
try:
|
||||
return etree.fromstring(r.content)
|
||||
except etree.ParseError:
|
||||
# Read response to release connection
|
||||
log.error('Could not parse PMS response: %s' % r.content)
|
||||
raise requests.RequestException
|
||||
|
||||
def _requests(self, action, **kwargs):
|
||||
|
||||
if action == "GET":
|
||||
r = requests.get(**kwargs)
|
||||
elif action == "POST":
|
||||
r = requests.post(**kwargs)
|
||||
|
||||
return r
|
||||
|
||||
def getEmbyServerUrl(self, baseUrl, handler):
|
||||
return "%s/emby/%s" % (baseUrl, handler)
|
||||
|
||||
def getConnectUrl(self, handler):
|
||||
return "https://connect.emby.media/service/%s" % handler
|
||||
|
||||
@staticmethod
|
||||
def _findServers(foundServers):
|
||||
servers = []
|
||||
for server in foundServers:
|
||||
if '200 OK' not in server['data']:
|
||||
continue
|
||||
ip = server['from'][0]
|
||||
info = {'LastCONNECTIONMODE': CONNECTIONMODE['Local']}
|
||||
for line in server['data'].split('\n'):
|
||||
if line.startswith('Name:'):
|
||||
info['Name'] = tryDecode(line.split(':')[1].strip())
|
||||
elif line.startswith('Port:'):
|
||||
info['Port'] = line.split(':')[1].strip()
|
||||
elif line.startswith('Resource-Identifier:'):
|
||||
info['Id'] = line.split(':')[1].strip()
|
||||
elif line.startswith('Updated-At:'):
|
||||
pass
|
||||
elif line.startswith('Version:'):
|
||||
pass
|
||||
# Need to check whether we need HTTPS or only HTTP
|
||||
https = PMSHttpsEnabled('%s:%s' % (ip, info['Port']))
|
||||
if https is None:
|
||||
# Error contacting url. Skip for now
|
||||
continue
|
||||
elif https is True:
|
||||
info['LocalAddress'] = 'https://%s:%s' % (ip, info['Port'])
|
||||
else:
|
||||
info['LocalAddress'] = 'http://%s:%s' % (ip, info['Port'])
|
||||
servers.append(info)
|
||||
return servers
|
||||
|
||||
def _serverDiscovery(self):
|
||||
"""
|
||||
PlexGDM
|
||||
"""
|
||||
servers = []
|
||||
# setup socket for discovery -> multicast message
|
||||
try:
|
||||
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 = pack('b', 2)
|
||||
GDM.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
|
||||
except (socket.error, socket.herror, socket.gaierror):
|
||||
log.error('Socket error, abort PlexGDM')
|
||||
return servers
|
||||
try:
|
||||
# Send data to the multicast group
|
||||
GDM.sendto(MSG_PLEXGDM, (IP_PLEXGDM, PORT_PLEXGDM))
|
||||
# Look for responses from all recipients
|
||||
while True:
|
||||
try:
|
||||
data, server = GDM.recvfrom(1024)
|
||||
servers.append({'from': server, 'data': data})
|
||||
except socket.timeout:
|
||||
break
|
||||
except:
|
||||
# Probably error: (101, 'Network is unreachable')
|
||||
log.error('Could not find Plex servers using PlexGDM')
|
||||
import traceback
|
||||
log.error("Traceback:\n%s" % traceback.format_exc())
|
||||
finally:
|
||||
GDM.close()
|
||||
return servers
|
||||
|
||||
def connectToAddress(self, address, options=None):
|
||||
log.debug('connectToAddress %s with options %s' % (address, options))
|
||||
|
||||
def _onFail():
|
||||
log.error("connectToAddress %s failed with options %s" %
|
||||
(address, options))
|
||||
return self._resolveFailure()
|
||||
|
||||
try:
|
||||
publicInfo = self._tryConnect(address, options=options)
|
||||
except requests.RequestException:
|
||||
return _onFail()
|
||||
else:
|
||||
server = {
|
||||
'ManualAddress': address,
|
||||
'LastCONNECTIONMODE': CONNECTIONMODE['Manual'],
|
||||
'options': options
|
||||
}
|
||||
self._updateServerInfo(server, publicInfo)
|
||||
server = self.connectToServer(server)
|
||||
if server is False:
|
||||
return _onFail()
|
||||
else:
|
||||
return server
|
||||
|
||||
def onAuthenticated(self, result, options={}):
|
||||
|
||||
credentials = self.credentialProvider.getCredentials()
|
||||
for s in credentials['Servers']:
|
||||
if s['Id'] == result['ServerId']:
|
||||
server = s
|
||||
break
|
||||
else: # Server not found?
|
||||
return
|
||||
|
||||
if options.get('updateDateLastAccessed') is not False:
|
||||
server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
server['UserId'] = result['User']['Id']
|
||||
server['AccessToken'] = result['AccessToken']
|
||||
|
||||
self.credentialProvider.addOrUpdateServer(credentials['Servers'], server)
|
||||
self._saveUserInfoIntoCredentials(server, result['User'])
|
||||
self.credentialProvider.getCredentials(credentials)
|
||||
|
||||
def _tryConnect(self, url, timeout=None, options=None):
|
||||
request = {
|
||||
'type': 'GET',
|
||||
'url': '%s/identity' % url,
|
||||
'timeout': timeout
|
||||
}
|
||||
if options:
|
||||
request.update(options)
|
||||
return self.requestUrl(request)
|
||||
|
||||
def _addAppInfoToConnectRequest(self):
|
||||
return "%s/%s" % (self.appName, self.appVersion)
|
||||
|
||||
def __get_PMS_servers_from_plex_tv(self):
|
||||
"""
|
||||
Retrieves Plex Media Servers from plex.tv/pms/resources
|
||||
"""
|
||||
servers = []
|
||||
try:
|
||||
xml = self.requestUrl({
|
||||
'url': 'https://plex.tv/api/resources?includeHttps=1',
|
||||
'type': 'GET',
|
||||
'headers': {'X-Plex-Token': self.plexToken},
|
||||
'timeout': 5.0,
|
||||
'verify': True})
|
||||
except requests.RequestException:
|
||||
log.error('Could not get list of PMS from plex.tv')
|
||||
return servers
|
||||
|
||||
maxAgeSeconds = 2*60*60*24
|
||||
for device in xml.findall('Device'):
|
||||
if 'server' not in device.attrib.get('provides'):
|
||||
# No PMS - skip
|
||||
continue
|
||||
cons = device.find('Connection')
|
||||
if cons is None:
|
||||
# no valid connection - skip
|
||||
continue
|
||||
# check MyPlex data age - skip if >2 days
|
||||
server = {'Name': device.attrib.get('name')}
|
||||
infoAge = time.time() - int(device.attrib.get('lastSeenAt'))
|
||||
if infoAge > maxAgeSeconds:
|
||||
log.info("Server %s not seen for 2 days - skipping."
|
||||
% server['Name'])
|
||||
continue
|
||||
server['Id'] = device.attrib['clientIdentifier']
|
||||
server['ConnectServerId'] = device.attrib['clientIdentifier']
|
||||
# server['AccessToken'] = device.attrib['accessToken']
|
||||
server['ExchangeToken'] = device.attrib['accessToken']
|
||||
# One's own Plex home?
|
||||
server['UserLinkType'] = 'Guest' if device.attrib['owned'] == '0' \
|
||||
else 'LinkedUser'
|
||||
# Foreign PMS' user name
|
||||
server['UserId'] = device.attrib.get('sourceTitle')
|
||||
for con in cons:
|
||||
if con.attrib['local'] == '1':
|
||||
# Local LAN address; there might be several!!
|
||||
server['LocalAddress'] = con.attrib['uri']
|
||||
else:
|
||||
server['RemoteAddress'] = con.attrib['uri']
|
||||
|
||||
# Additional stuff, not yet implemented
|
||||
server['local'] = device.attrib.get('publicAddressMatches')
|
||||
|
||||
servers.append(server)
|
||||
return servers
|
||||
|
||||
def _getConnectServers(self, credentials):
|
||||
|
||||
log.info("Begin getConnectServers")
|
||||
|
||||
servers = []
|
||||
|
||||
if not credentials.get('ConnectAccessToken') or not credentials.get('ConnectUserId'):
|
||||
return servers
|
||||
|
||||
url = self.getConnectUrl("servers?userId=%s" % credentials['ConnectUserId'])
|
||||
request = {
|
||||
|
||||
'type': "GET",
|
||||
'url': url,
|
||||
'headers': {
|
||||
'X-Connect-UserToken': credentials['ConnectAccessToken']
|
||||
}
|
||||
}
|
||||
for server in self.requestUrl(request):
|
||||
|
||||
servers.append({
|
||||
|
||||
'ExchangeToken': server['AccessKey'],
|
||||
'ConnectServerId': server['Id'],
|
||||
'Id': server['SystemId'],
|
||||
'Name': server['Name'],
|
||||
'RemoteAddress': server['Url'],
|
||||
'LocalAddress': server['LocalAddress'],
|
||||
'UserLinkType': "Guest" if server['UserType'].lower() == "guest" else "LinkedUser",
|
||||
})
|
||||
|
||||
return servers
|
||||
|
||||
def getAvailableServers(self):
|
||||
log.info("Begin getAvailableServers")
|
||||
|
||||
credentials = self.credentialProvider.getCredentials()
|
||||
servers = list(credentials['Servers'])
|
||||
|
||||
if self.plexToken:
|
||||
connectServers = self.__get_PMS_servers_from_plex_tv()
|
||||
self._mergeServers(servers, connectServers)
|
||||
foundServers = self._findServers(self._serverDiscovery())
|
||||
self._mergeServers(servers, foundServers)
|
||||
|
||||
servers = self._filterServers(servers, connectServers)
|
||||
|
||||
try:
|
||||
servers.sort(key=lambda x: datetime.strptime(x['DateLastAccessed'], "%Y-%m-%dT%H:%M:%SZ"), reverse=True)
|
||||
except TypeError:
|
||||
servers.sort(key=lambda x: datetime(*(time.strptime(x['DateLastAccessed'], "%Y-%m-%dT%H:%M:%SZ")[0:6])), reverse=True)
|
||||
|
||||
credentials['Servers'] = servers
|
||||
self.credentialProvider.getCredentials(credentials)
|
||||
|
||||
return servers
|
||||
|
||||
def _filterServers(self, servers, connectServers):
|
||||
|
||||
filtered = []
|
||||
|
||||
for server in servers:
|
||||
# It's not a connect server, so assume it's still valid
|
||||
if server.get('ExchangeToken') is None:
|
||||
filtered.append(server)
|
||||
continue
|
||||
|
||||
for connectServer in connectServers:
|
||||
if server['Id'] == connectServer['Id']:
|
||||
filtered.append(server)
|
||||
break
|
||||
else:
|
||||
return filtered
|
||||
|
||||
def _getConnectPasswordHash(self, password):
|
||||
|
||||
password = self._cleanConnectPassword(password)
|
||||
|
||||
return md5(password).hexdigest()
|
||||
|
||||
def _saveUserInfoIntoCredentials(self, server, user):
|
||||
|
||||
info = {
|
||||
'Id': user['Id'],
|
||||
'IsSignedInOffline': True
|
||||
}
|
||||
|
||||
self.credentialProvider.addOrUpdateUser(server, info)
|
||||
|
||||
def _compareVersions(self, a, b):
|
||||
"""
|
||||
-1 a is smaller
|
||||
1 a is larger
|
||||
0 equal
|
||||
"""
|
||||
a = a.split('.')
|
||||
b = b.split('.')
|
||||
|
||||
for i in range(0, max(len(a), len(b)), 1):
|
||||
try:
|
||||
aVal = a[i]
|
||||
except IndexError:
|
||||
aVal = 0
|
||||
|
||||
try:
|
||||
bVal = b[i]
|
||||
except IndexError:
|
||||
bVal = 0
|
||||
|
||||
if aVal < bVal:
|
||||
return -1
|
||||
|
||||
if aVal > bVal:
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
def connectToServer(self, server, settings=None):
|
||||
# First test manual connections, then local, then remote
|
||||
tests = [
|
||||
CONNECTIONMODE['Manual'],
|
||||
CONNECTIONMODE['Local'],
|
||||
CONNECTIONMODE['Remote']
|
||||
]
|
||||
return self._testNextCONNECTIONMODE(tests, 0, server, settings)
|
||||
|
||||
def _stringEqualsIgnoreCase(self, str1, str2):
|
||||
|
||||
return (str1 or "").lower() == (str2 or "").lower()
|
||||
|
||||
def _testNextCONNECTIONMODE(self, tests, index, server, settings):
|
||||
if index >= len(tests):
|
||||
log.info("Tested all connection modes. Failing server connection.")
|
||||
return self._resolveFailure()
|
||||
|
||||
mode = tests[index]
|
||||
address = getServerAddress(server, mode)
|
||||
skipTest = False
|
||||
if mode == CONNECTIONMODE['Local']:
|
||||
if self._stringEqualsIgnoreCase(address,
|
||||
server.get('ManualAddress')):
|
||||
# skipping LocalAddress test because it is the same as
|
||||
# ManualAddress
|
||||
skipTest = True
|
||||
if skipTest or not address:
|
||||
log.debug("skipping test for %s" % mode)
|
||||
return self._testNextCONNECTIONMODE(tests,
|
||||
index+1,
|
||||
server,
|
||||
settings)
|
||||
|
||||
log.debug("testing connection %s with settings %s for server %s"
|
||||
% (address, settings, server.get('Name')))
|
||||
try:
|
||||
result = self._tryConnect(address, options=server.get('options'))
|
||||
except requests.RequestException:
|
||||
log.info("Connection test failed for %s with server %s"
|
||||
% (address, server.get('Name')))
|
||||
return self._testNextCONNECTIONMODE(tests,
|
||||
index+1,
|
||||
server,
|
||||
settings)
|
||||
else:
|
||||
if self._compareVersions(self._getMinServerVersion(),
|
||||
result.attrib['version']) == 1:
|
||||
log.warn("Minimal PMS version requirement not met. PMS version"
|
||||
" is: %s" % result.attrib['version'])
|
||||
return {
|
||||
'State': CONNECTIONSTATE['ServerUpdateNeeded'],
|
||||
'Servers': [server]
|
||||
}
|
||||
else:
|
||||
log.debug("calling onSuccessfulConnection with mode %s, "
|
||||
"address %s, settings %s with server %s"
|
||||
% (mode, address, settings, server.get('Name')))
|
||||
return self._onSuccessfulConnection(server,
|
||||
result,
|
||||
mode,
|
||||
settings)
|
||||
|
||||
def _onSuccessfulConnection(self, server, systemInfo, CONNECTIONMODE, options):
|
||||
|
||||
credentials = self.credentialProvider.getCredentials()
|
||||
|
||||
if credentials.get('ConnectAccessToken') and options.get('enableAutoLogin') is not False:
|
||||
|
||||
if self._ensureConnectUser(credentials) is not False:
|
||||
|
||||
if server.get('ExchangeToken'):
|
||||
|
||||
self._addAuthenticationInfoFromConnect(server, CONNECTIONMODE, credentials, options)
|
||||
|
||||
return self._afterConnectValidated(server, credentials, systemInfo, CONNECTIONMODE, True, options)
|
||||
|
||||
def _afterConnectValidated(self, server, credentials, systemInfo, CONNECTIONMODE, verifyLocalAuthentication, options):
|
||||
|
||||
if options.get('enableAutoLogin') is False:
|
||||
server['UserId'] = None
|
||||
server['AccessToken'] = None
|
||||
|
||||
elif (verifyLocalAuthentication and server.get('AccessToken') and
|
||||
options.get('enableAutoLogin') is not False):
|
||||
|
||||
if self._validateAuthentication(server, CONNECTIONMODE, options) is not False:
|
||||
return self._afterConnectValidated(server, credentials, systemInfo, CONNECTIONMODE, False, options)
|
||||
|
||||
return
|
||||
|
||||
self._updateServerInfo(server, systemInfo)
|
||||
server['LastCONNECTIONMODE'] = CONNECTIONMODE
|
||||
|
||||
if options.get('updateDateLastAccessed') is not False:
|
||||
server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
self.credentialProvider.addOrUpdateServer(credentials['Servers'], server)
|
||||
self.credentialProvider.getCredentials(credentials)
|
||||
|
||||
result = {
|
||||
'Servers': [],
|
||||
'ConnectUser': self._connectUser()
|
||||
}
|
||||
result['State'] = CONNECTIONSTATE['SignedIn'] if (server.get('AccessToken') and options.get('enableAutoLogin') is not False) else CONNECTIONSTATE['ServerSignIn']
|
||||
result['Servers'].append(server)
|
||||
|
||||
# Connected
|
||||
return result
|
||||
|
||||
def _validateAuthentication(self, server, CONNECTIONMODE, options={}):
|
||||
|
||||
url = getServerAddress(server, CONNECTIONMODE)
|
||||
request = {
|
||||
|
||||
'type': "GET",
|
||||
'url': self.getEmbyServerUrl(url, "System/Info"),
|
||||
'ssl': options.get('ssl'),
|
||||
'headers': {
|
||||
'X-MediaBrowser-Token': server['AccessToken']
|
||||
}
|
||||
}
|
||||
try:
|
||||
systemInfo = self.requestUrl(request)
|
||||
self._updateServerInfo(server, systemInfo)
|
||||
|
||||
if server.get('UserId'):
|
||||
user = self.requestUrl({
|
||||
|
||||
'type': "GET",
|
||||
'url': self.getEmbyServerUrl(url, "users/%s" % server['UserId']),
|
||||
'ssl': options.get('ssl'),
|
||||
'headers': {
|
||||
'X-MediaBrowser-Token': server['AccessToken']
|
||||
}
|
||||
})
|
||||
|
||||
except Exception:
|
||||
server['UserId'] = None
|
||||
server['AccessToken'] = None
|
||||
return False
|
||||
|
||||
def loginToConnect(self, username, password):
|
||||
|
||||
if not username:
|
||||
raise AttributeError("username cannot be empty")
|
||||
|
||||
if not password:
|
||||
raise AttributeError("password cannot be empty")
|
||||
|
||||
md5 = self._getConnectPasswordHash(password)
|
||||
request = {
|
||||
'type': "POST",
|
||||
'url': self.getConnectUrl("user/authenticate"),
|
||||
'data': {
|
||||
'nameOrEmail': username,
|
||||
'password': md5
|
||||
},
|
||||
}
|
||||
try:
|
||||
result = self.requestUrl(request)
|
||||
except Exception as e: # Failed to login
|
||||
log.error(e)
|
||||
return False
|
||||
else:
|
||||
credentials = self.credentialProvider.getCredentials()
|
||||
credentials['ConnectAccessToken'] = result['AccessToken']
|
||||
credentials['ConnectUserId'] = result['User']['Id']
|
||||
credentials['ConnectUser'] = result['User']['DisplayName']
|
||||
self.credentialProvider.getCredentials(credentials)
|
||||
# Signed in
|
||||
self._onConnectUserSignIn(result['User'])
|
||||
|
||||
return result
|
||||
|
||||
def _onConnectUserSignIn(self, user):
|
||||
|
||||
self.connectUser = user
|
||||
log.info("connectusersignedin %s" % user)
|
||||
|
||||
def _getConnectUser(self, userId, accessToken):
|
||||
|
||||
if not userId:
|
||||
raise AttributeError("null userId")
|
||||
|
||||
if not accessToken:
|
||||
raise AttributeError("null accessToken")
|
||||
|
||||
url = self.getConnectUrl('user?id=%s' % userId)
|
||||
|
||||
return self.requestUrl({
|
||||
|
||||
'type': "GET",
|
||||
'url': url,
|
||||
'headers': {
|
||||
'X-Connect-UserToken': accessToken
|
||||
}
|
||||
})
|
||||
|
||||
def _addAuthenticationInfoFromConnect(self, server, CONNECTIONMODE, credentials, options={}):
|
||||
|
||||
if not server.get('ExchangeToken'):
|
||||
raise KeyError("server['ExchangeToken'] cannot be null")
|
||||
|
||||
if not credentials.get('ConnectUserId'):
|
||||
raise KeyError("credentials['ConnectUserId'] cannot be null")
|
||||
|
||||
url = getServerAddress(server, CONNECTIONMODE)
|
||||
url = self.getEmbyServerUrl(url, "Connect/Exchange?format=json")
|
||||
auth = ('MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"'
|
||||
% (self.appName, self.deviceName, self.deviceId, self.appVersion))
|
||||
try:
|
||||
auth = self.requestUrl({
|
||||
|
||||
'url': url,
|
||||
'type': "GET",
|
||||
'ssl': options.get('ssl'),
|
||||
'params': {
|
||||
'ConnectUserId': credentials['ConnectUserId']
|
||||
},
|
||||
'headers': {
|
||||
'X-MediaBrowser-Token': server['ExchangeToken'],
|
||||
'X-Emby-Authorization': auth
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
server['UserId'] = None
|
||||
server['AccessToken'] = None
|
||||
return False
|
||||
else:
|
||||
server['UserId'] = auth['LocalUserId']
|
||||
server['AccessToken'] = auth['AccessToken']
|
||||
return auth
|
||||
|
||||
def _ensureConnectUser(self, credentials):
|
||||
|
||||
if self.connectUser and self.connectUser['Id'] == credentials['ConnectUserId']:
|
||||
return
|
||||
|
||||
elif credentials.get('ConnectUserId') and credentials.get('ConnectAccessToken'):
|
||||
|
||||
self.connectUser = None
|
||||
|
||||
try:
|
||||
result = self._getConnectUser(credentials['ConnectUserId'], credentials['ConnectAccessToken'])
|
||||
self._onConnectUserSignIn(result)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def connect(self, settings=None):
|
||||
|
||||
log.info("Begin connect")
|
||||
|
||||
servers = self.getAvailableServers()
|
||||
return self._connectToServers(servers, settings)
|
||||
|
||||
def _connectToServers(self, servers, settings):
|
||||
|
||||
log.info("Begin connectToServers, with %s servers" % len(servers))
|
||||
|
||||
if len(servers) == 1:
|
||||
result = self.connectToServer(servers[0], settings)
|
||||
if result and result.get('State') == CONNECTIONSTATE['Unavailable']:
|
||||
result['State'] = CONNECTIONSTATE['ConnectSignIn'] if result['ConnectUser'] == None else CONNECTIONSTATE['ServerSelection']
|
||||
|
||||
log.info("resolving connectToServers with result['State']: %s" % result)
|
||||
return result
|
||||
|
||||
firstServer = self._getLastUsedServer()
|
||||
# See if we have any saved credentials and can auto sign in
|
||||
if firstServer:
|
||||
|
||||
result = self.connectToServer(firstServer, settings)
|
||||
if result and result.get('State') == CONNECTIONSTATE['SignedIn']:
|
||||
return result
|
||||
|
||||
# Return loaded credentials if exists
|
||||
credentials = self.credentialProvider.getCredentials()
|
||||
self._ensureConnectUser(credentials)
|
||||
|
||||
return {
|
||||
'Servers': servers,
|
||||
'State': CONNECTIONSTATE['ConnectSignIn'] if (not len(servers) and not self._connectUser()) else CONNECTIONSTATE['ServerSelection'],
|
||||
'ConnectUser': self._connectUser()
|
||||
}
|
||||
|
||||
def _cleanConnectPassword(self, password):
|
||||
|
||||
password = password or ""
|
||||
|
||||
password = password.replace("&", '&')
|
||||
password = password.replace("/", '\')
|
||||
password = password.replace("!", '!')
|
||||
password = password.replace("$", '$')
|
||||
password = password.replace("\"", '"')
|
||||
password = password.replace("<", '<')
|
||||
password = password.replace(">", '>')
|
||||
password = password.replace("'", ''')
|
||||
|
||||
return password
|
||||
|
||||
def clearData(self):
|
||||
|
||||
log.info("connection manager clearing data")
|
||||
|
||||
self.connectUser = None
|
||||
credentials = self.credentialProvider.getCredentials()
|
||||
credentials['ConnectAccessToken'] = None
|
||||
credentials['ConnectUserId'] = None
|
||||
credentials['Servers'] = []
|
||||
self.credentialProvider.getCredentials(credentials)
|
|
@ -1,149 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
import json
|
||||
from logging import getLogger
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
###############################################################################
|
||||
log = getLogger("PLEX."+__name__)
|
||||
|
||||
# This is a throwaway variable to deal with a python bug
|
||||
_ = datetime.strptime('20110101', '%Y%m%d')
|
||||
###############################################################################
|
||||
|
||||
|
||||
class Credentials(object):
|
||||
# Borg
|
||||
_shared_state = {}
|
||||
credentials = None
|
||||
path = ""
|
||||
|
||||
def __init__(self):
|
||||
# Borg
|
||||
self.__dict__ = self._shared_state
|
||||
|
||||
def setPath(self, path):
|
||||
# Path to save persistant data.txt
|
||||
self.path = path
|
||||
|
||||
def _ensure(self):
|
||||
if self.credentials is None:
|
||||
try:
|
||||
with open(os.path.join(self.path, 'data.txt')) as infile:
|
||||
self.credentials = json.load(infile)
|
||||
|
||||
if not isinstance(self.credentials, dict):
|
||||
raise ValueError("invalid credentials format")
|
||||
|
||||
except Exception as e:
|
||||
# File is either empty or missing
|
||||
log.warn(e)
|
||||
self.credentials = {}
|
||||
|
||||
log.info("credentials initialized with: %s" % self.credentials)
|
||||
self.credentials['Servers'] = self.credentials.setdefault('Servers', [])
|
||||
|
||||
def _get(self):
|
||||
|
||||
self._ensure()
|
||||
return self.credentials
|
||||
|
||||
def _set(self, data):
|
||||
|
||||
if data:
|
||||
self.credentials = data
|
||||
# Set credentials to file
|
||||
with open(os.path.join(self.path, 'data.txt'), 'w') as outfile:
|
||||
for server in data['Servers']:
|
||||
server['Name'] = server['Name'].encode('utf-8')
|
||||
json.dump(data, outfile, ensure_ascii=False)
|
||||
else:
|
||||
self._clear()
|
||||
|
||||
log.info("credentialsupdated")
|
||||
|
||||
def _clear(self):
|
||||
|
||||
self.credentials = None
|
||||
# Remove credentials from file
|
||||
with open(os.path.join(self.path, 'data.txt'), 'w'):
|
||||
pass
|
||||
|
||||
def getCredentials(self, data=None):
|
||||
|
||||
if data is not None:
|
||||
self._set(data)
|
||||
|
||||
return self._get()
|
||||
|
||||
def addOrUpdateServer(self, list_, server):
|
||||
|
||||
if server.get('Id') is None:
|
||||
raise KeyError("Server['Id'] cannot be null or empty")
|
||||
|
||||
# Add default DateLastAccessed if doesn't exist.
|
||||
server.setdefault('DateLastAccessed', "2001-01-01T00:00:00Z")
|
||||
|
||||
for existing in list_:
|
||||
if existing['Id'] == server['Id']:
|
||||
# Merge the data
|
||||
if server.get('DateLastAccessed'):
|
||||
if self._dateObject(server['DateLastAccessed']) > self._dateObject(existing['DateLastAccessed']):
|
||||
existing['DateLastAccessed'] = server['DateLastAccessed']
|
||||
|
||||
if server.get('UserLinkType'):
|
||||
existing['UserLinkType'] = server['UserLinkType']
|
||||
|
||||
if server.get('AccessToken'):
|
||||
existing['AccessToken'] = server['AccessToken']
|
||||
existing['UserId'] = server['UserId']
|
||||
|
||||
if server.get('ExchangeToken'):
|
||||
existing['ExchangeToken'] = server['ExchangeToken']
|
||||
|
||||
if server.get('RemoteAddress'):
|
||||
existing['RemoteAddress'] = server['RemoteAddress']
|
||||
|
||||
if server.get('ManualAddress'):
|
||||
existing['ManualAddress'] = server['ManualAddress']
|
||||
|
||||
if server.get('LocalAddress'):
|
||||
existing['LocalAddress'] = server['LocalAddress']
|
||||
|
||||
if server.get('Name'):
|
||||
existing['Name'] = server['Name']
|
||||
|
||||
if server.get('WakeOnLanInfos'):
|
||||
existing['WakeOnLanInfos'] = server['WakeOnLanInfos']
|
||||
|
||||
if server.get('LastConnectionMode') is not None:
|
||||
existing['LastConnectionMode'] = server['LastConnectionMode']
|
||||
|
||||
if server.get('ConnectServerId'):
|
||||
existing['ConnectServerId'] = server['ConnectServerId']
|
||||
|
||||
return existing
|
||||
else:
|
||||
list_.append(server)
|
||||
return server
|
||||
|
||||
def addOrUpdateUser(self, server, user):
|
||||
|
||||
for existing in server.setdefault('Users', []):
|
||||
if existing['Id'] == user['Id']:
|
||||
# Merge the data
|
||||
existing['IsSignedInOffline'] = True
|
||||
break
|
||||
else:
|
||||
server['Users'].append(user)
|
||||
|
||||
def _dateObject(self, date):
|
||||
# Convert string to date
|
||||
try:
|
||||
date_obj = datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ")
|
||||
except (ImportError, TypeError):
|
||||
# TypeError: attribute of type 'NoneType' is not callable
|
||||
# Known Kodi/python error
|
||||
date_obj = datetime(*(time.strptime(date, "%Y-%m-%dT%H:%M:%SZ")[0:6]))
|
||||
return date_obj
|
|
@ -1,449 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from logging import getLogger
|
||||
|
||||
from xbmc import sleep, executebuiltin
|
||||
|
||||
from utils import window, settings, dialog, language as lang, tryEncode
|
||||
from clientinfo import getXArgsDeviceInfo
|
||||
from downloadutils import DownloadUtils
|
||||
import variables as v
|
||||
import state
|
||||
|
||||
###############################################################################
|
||||
log = getLogger("PLEX."+__name__)
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
def my_plex_sign_in(username, password, options):
|
||||
"""
|
||||
MyPlex Sign In
|
||||
|
||||
parameters:
|
||||
username - Plex forum name, MyPlex login, or email address
|
||||
password
|
||||
options - dict() of PlexConnect-options as received from aTV -
|
||||
necessary: PlexConnectUDID
|
||||
result:
|
||||
username
|
||||
authtoken - token for subsequent communication with MyPlex
|
||||
"""
|
||||
# create POST request
|
||||
xml = DownloadUtils().downloadUrl(
|
||||
'https://plex.tv/users/sign_in.xml',
|
||||
action_type='POST',
|
||||
headerOptions=getXArgsDeviceInfo(options),
|
||||
authenticate=False,
|
||||
auth=(username, password))
|
||||
|
||||
try:
|
||||
xml.attrib
|
||||
except AttributeError:
|
||||
log.error('Could not sign in to plex.tv')
|
||||
return ('', '')
|
||||
|
||||
el_username = xml.find('username')
|
||||
el_authtoken = xml.find('authentication-token')
|
||||
if el_username is None or \
|
||||
el_authtoken is None:
|
||||
username = ''
|
||||
authtoken = ''
|
||||
else:
|
||||
username = el_username.text
|
||||
authtoken = el_authtoken.text
|
||||
return (username, authtoken)
|
||||
|
||||
|
||||
def check_plex_tv_pin(identifier):
|
||||
"""
|
||||
Checks with plex.tv whether user entered the correct PIN on plex.tv/pin
|
||||
|
||||
Returns False if not yet done so, or the XML response file as etree
|
||||
"""
|
||||
# Try to get a temporary token
|
||||
xml = DownloadUtils().downloadUrl(
|
||||
'https://plex.tv/pins/%s.xml' % identifier,
|
||||
authenticate=False)
|
||||
try:
|
||||
temp_token = xml.find('auth_token').text
|
||||
except:
|
||||
log.error("Could not find token in plex.tv answer")
|
||||
return False
|
||||
if not temp_token:
|
||||
return False
|
||||
# Use temp token to get the final plex credentials
|
||||
xml = DownloadUtils().downloadUrl('https://plex.tv/users/account',
|
||||
authenticate=False,
|
||||
parameters={'X-Plex-Token': temp_token})
|
||||
return xml
|
||||
|
||||
|
||||
def get_plex_pin():
|
||||
"""
|
||||
For plex.tv sign-in: returns 4-digit code and identifier as 2 str
|
||||
"""
|
||||
code = None
|
||||
identifier = None
|
||||
# Download
|
||||
xml = DownloadUtils().downloadUrl('https://plex.tv/pins.xml',
|
||||
authenticate=False,
|
||||
action_type="POST")
|
||||
try:
|
||||
xml.attrib
|
||||
except:
|
||||
log.error("Error, no PIN from plex.tv provided")
|
||||
return None, None
|
||||
code = xml.find('code').text
|
||||
identifier = xml.find('id').text
|
||||
log.info('Successfully retrieved code and id from plex.tv')
|
||||
return code, identifier
|
||||
|
||||
|
||||
def get_plex_login_password():
|
||||
"""
|
||||
Signs in to plex.tv.
|
||||
|
||||
plexLogin, authtoken = get_plex_login_password()
|
||||
|
||||
Input: nothing
|
||||
Output:
|
||||
plexLogin plex.tv username
|
||||
authtoken token for plex.tv
|
||||
|
||||
Also writes 'plexLogin' and 'token_plex.tv' to Kodi settings file
|
||||
If not logged in, empty strings are returned for both.
|
||||
"""
|
||||
retrievedPlexLogin = ''
|
||||
plexLogin = 'dummy'
|
||||
authtoken = ''
|
||||
while retrievedPlexLogin == '' and plexLogin != '':
|
||||
# Enter plex.tv username. Or nothing to cancel.
|
||||
plexLogin = dialog('input',
|
||||
lang(29999) + lang(39300),
|
||||
type='{alphanum}')
|
||||
if plexLogin != "":
|
||||
# Enter password for plex.tv user
|
||||
plexPassword = dialog('input',
|
||||
lang(39301) + plexLogin,
|
||||
type='{alphanum}',
|
||||
option='{hide_input}')
|
||||
retrievedPlexLogin, authtoken = my_plex_sign_in(
|
||||
plexLogin,
|
||||
plexPassword,
|
||||
{'X-Plex-Client-Identifier': window('plex_client_Id')})
|
||||
log.debug("plex.tv username and token: %s, %s"
|
||||
% (plexLogin, authtoken))
|
||||
if plexLogin == '':
|
||||
# Could not sign in user
|
||||
dialog('ok', lang(29999), lang(39302) + plexLogin)
|
||||
# Write to Kodi settings file
|
||||
settings('plexLogin', value=retrievedPlexLogin)
|
||||
settings('plexToken', value=authtoken)
|
||||
return (retrievedPlexLogin, authtoken)
|
||||
|
||||
|
||||
def plex_tv_sign_in_with_pin():
|
||||
"""
|
||||
Prompts user to sign in by visiting https://plex.tv/pin
|
||||
|
||||
Writes to Kodi settings file. Also returns:
|
||||
{
|
||||
'plexhome': 'true' if Plex Home, 'false' otherwise
|
||||
'username':
|
||||
'avatar': URL to user avator
|
||||
'token':
|
||||
'plexid': Plex user ID
|
||||
'homesize': Number of Plex home users (defaults to '1')
|
||||
}
|
||||
Returns False if authentication did not work.
|
||||
"""
|
||||
code, identifier = get_plex_pin()
|
||||
if not code:
|
||||
# Problems trying to contact plex.tv. Try again later
|
||||
dialog('ok', lang(29999), lang(39303))
|
||||
return False
|
||||
# Go to https://plex.tv/pin and enter the code:
|
||||
# Or press No to cancel the sign in.
|
||||
answer = dialog('yesno',
|
||||
lang(29999),
|
||||
lang(39304) + "\n\n",
|
||||
code + "\n\n",
|
||||
lang(39311))
|
||||
if not answer:
|
||||
return False
|
||||
count = 0
|
||||
# Wait for approx 30 seconds (since the PIN is not visible anymore :-))
|
||||
while count < 30:
|
||||
xml = check_plex_tv_pin(identifier)
|
||||
if xml is not False:
|
||||
break
|
||||
# Wait for 1 seconds
|
||||
sleep(1000)
|
||||
count += 1
|
||||
if xml is False:
|
||||
# Could not sign in to plex.tv Try again later
|
||||
dialog('ok', lang(29999), lang(39305))
|
||||
return False
|
||||
# Parse xml
|
||||
userid = xml.attrib.get('id')
|
||||
home = xml.get('home', '0')
|
||||
if home == '1':
|
||||
home = 'true'
|
||||
else:
|
||||
home = 'false'
|
||||
username = xml.get('username', '')
|
||||
avatar = xml.get('thumb', '')
|
||||
token = xml.findtext('authentication-token')
|
||||
homeSize = xml.get('homeSize', '1')
|
||||
result = {
|
||||
'plexhome': home,
|
||||
'username': username,
|
||||
'avatar': avatar,
|
||||
'token': token,
|
||||
'plexid': userid,
|
||||
'homesize': homeSize
|
||||
}
|
||||
settings('plexLogin', username)
|
||||
settings('plexToken', token)
|
||||
settings('plexhome', home)
|
||||
settings('plexid', userid)
|
||||
settings('plexAvatar', avatar)
|
||||
settings('plexHomeSize', homeSize)
|
||||
# Let Kodi log into plex.tv on startup from now on
|
||||
settings('myplexlogin', 'true')
|
||||
settings('plex_status', value=lang(39227))
|
||||
return result
|
||||
|
||||
|
||||
def list_plex_home_users(token):
|
||||
"""
|
||||
Returns a list for myPlex home users for the current plex.tv account.
|
||||
|
||||
Input:
|
||||
token for plex.tv
|
||||
Output:
|
||||
List of users, where one entry is of the form:
|
||||
"id": userId,
|
||||
"admin": '1'/'0',
|
||||
"guest": '1'/'0',
|
||||
"restricted": '1'/'0',
|
||||
"protected": '1'/'0',
|
||||
"email": email,
|
||||
"title": title,
|
||||
"username": username,
|
||||
"thumb": thumb_url
|
||||
}
|
||||
If any value is missing, None is returned instead (or "" from plex.tv)
|
||||
If an error is encountered, False is returned
|
||||
"""
|
||||
xml = DownloadUtils().downloadUrl('https://plex.tv/api/home/users/',
|
||||
authenticate=False,
|
||||
headerOptions={'X-Plex-Token': token})
|
||||
try:
|
||||
xml.attrib
|
||||
except:
|
||||
log.error('Download of Plex home users failed.')
|
||||
return False
|
||||
users = []
|
||||
for user in xml:
|
||||
users.append(user.attrib)
|
||||
return users
|
||||
|
||||
|
||||
def switch_home_user(userId, pin, token, machineIdentifier):
|
||||
"""
|
||||
Retrieves Plex home token for a Plex home user.
|
||||
Returns False if unsuccessful
|
||||
|
||||
Input:
|
||||
userId id of the Plex home user
|
||||
pin PIN of the Plex home user, if protected
|
||||
token token for plex.tv
|
||||
|
||||
Output:
|
||||
{
|
||||
'username'
|
||||
'usertoken' Might be empty strings if no token found
|
||||
for the machineIdentifier that was chosen
|
||||
}
|
||||
|
||||
settings('userid') and settings('username') with new plex token
|
||||
"""
|
||||
log.info('Switching to user %s' % userId)
|
||||
url = 'https://plex.tv/api/home/users/' + userId + '/switch'
|
||||
if pin:
|
||||
url += '?pin=' + pin
|
||||
answer = DownloadUtils.downloadUrl(
|
||||
url,
|
||||
authenticate=False,
|
||||
action_type="POST",
|
||||
headerOptions={'X-Plex-Token': token})
|
||||
try:
|
||||
answer.attrib
|
||||
except:
|
||||
log.error('Error: plex.tv switch HomeUser change failed')
|
||||
return False
|
||||
|
||||
username = answer.attrib.get('title', '')
|
||||
token = answer.attrib.get('authenticationToken', '')
|
||||
|
||||
# Write to settings file
|
||||
settings('username', username)
|
||||
settings('accessToken', token)
|
||||
settings('userid', answer.attrib.get('id', ''))
|
||||
settings('plex_restricteduser',
|
||||
'true' if answer.attrib.get('restricted', '0') == '1'
|
||||
else 'false')
|
||||
state.RESTRICTED_USER = True if \
|
||||
answer.attrib.get('restricted', '0') == '1' else False
|
||||
|
||||
# Get final token to the PMS we've chosen
|
||||
url = 'https://plex.tv/api/resources?includeHttps=1'
|
||||
xml = DownloadUtils.downloadUrl(url,
|
||||
authenticate=False,
|
||||
headerOptions={'X-Plex-Token': token})
|
||||
try:
|
||||
xml.attrib
|
||||
except:
|
||||
log.error('Answer from plex.tv not as excepted')
|
||||
# Set to empty iterable list for loop
|
||||
xml = []
|
||||
|
||||
found = 0
|
||||
log.debug('Our machineIdentifier is %s' % machineIdentifier)
|
||||
for device in xml:
|
||||
identifier = device.attrib.get('clientIdentifier')
|
||||
log.debug('Found a Plex machineIdentifier: %s' % identifier)
|
||||
if (identifier in machineIdentifier or
|
||||
machineIdentifier in identifier):
|
||||
found += 1
|
||||
token = device.attrib.get('accessToken')
|
||||
|
||||
result = {
|
||||
'username': username,
|
||||
}
|
||||
if found == 0:
|
||||
log.info('No tokens found for your server! Using empty string')
|
||||
result['usertoken'] = ''
|
||||
else:
|
||||
result['usertoken'] = token
|
||||
log.info('Plex.tv switch HomeUser change successfull for user %s'
|
||||
% username)
|
||||
return result
|
||||
|
||||
|
||||
def ChoosePlexHomeUser(plexToken):
|
||||
"""
|
||||
Let's user choose from a list of Plex home users. Will switch to that
|
||||
user accordingly.
|
||||
|
||||
Returns a dict:
|
||||
{
|
||||
'username': Unicode
|
||||
'userid': '' Plex ID of the user
|
||||
'token': '' User's token
|
||||
'protected': True if PIN is needed, else False
|
||||
}
|
||||
|
||||
Will return False if something went wrong (wrong PIN, no connection)
|
||||
"""
|
||||
# Get list of Plex home users
|
||||
users = list_plex_home_users(plexToken)
|
||||
if not users:
|
||||
log.error("User download failed.")
|
||||
return False
|
||||
|
||||
userlist = []
|
||||
userlistCoded = []
|
||||
for user in users:
|
||||
username = user['title']
|
||||
userlist.append(username)
|
||||
# To take care of non-ASCII usernames
|
||||
userlistCoded.append(tryEncode(username))
|
||||
usernumber = len(userlist)
|
||||
|
||||
username = ''
|
||||
usertoken = ''
|
||||
trials = 0
|
||||
while trials < 3:
|
||||
if usernumber > 1:
|
||||
# Select user
|
||||
user_select = dialog('select',
|
||||
lang(29999) + lang(39306),
|
||||
userlistCoded)
|
||||
if user_select == -1:
|
||||
log.info("No user selected.")
|
||||
settings('username', value='')
|
||||
executebuiltin('Addon.OpenSettings(%s)'
|
||||
% v.ADDON_ID)
|
||||
return False
|
||||
# Only 1 user received, choose that one
|
||||
else:
|
||||
user_select = 0
|
||||
selected_user = userlist[user_select]
|
||||
log.info("Selected user: %s" % selected_user)
|
||||
user = users[user_select]
|
||||
# Ask for PIN, if protected:
|
||||
pin = None
|
||||
if user['protected'] == '1':
|
||||
log.debug('Asking for users PIN')
|
||||
pin = dialog('input',
|
||||
lang(39307) + selected_user,
|
||||
'',
|
||||
type='{numeric}',
|
||||
option='{hide_input}')
|
||||
# User chose to cancel
|
||||
# Plex bug: don't call url for protected user with empty PIN
|
||||
if not pin:
|
||||
trials += 1
|
||||
continue
|
||||
# Switch to this Plex Home user, if applicable
|
||||
result = switch_home_user(
|
||||
user['id'],
|
||||
pin,
|
||||
plexToken,
|
||||
settings('plex_machineIdentifier'))
|
||||
if result:
|
||||
# Successfully retrieved username: break out of while loop
|
||||
username = result['username']
|
||||
usertoken = result['usertoken']
|
||||
break
|
||||
# Couldn't get user auth
|
||||
else:
|
||||
trials += 1
|
||||
# Could not login user, please try again
|
||||
if not dialog('yesno',
|
||||
lang(29999),
|
||||
lang(39308) + selected_user,
|
||||
lang(39309)):
|
||||
# User chose to cancel
|
||||
break
|
||||
if not username:
|
||||
log.error('Failed signing in a user to plex.tv')
|
||||
executebuiltin('Addon.OpenSettings(%s)' % v.ADDON_ID)
|
||||
return False
|
||||
return {
|
||||
'username': username,
|
||||
'userid': user['id'],
|
||||
'protected': True if user['protected'] == '1' else False,
|
||||
'token': usertoken
|
||||
}
|
||||
|
||||
|
||||
def get_user_artwork_url(username):
|
||||
"""
|
||||
Returns the URL for the user's Avatar. Or False if something went
|
||||
wrong.
|
||||
"""
|
||||
plexToken = settings('plexToken')
|
||||
users = list_plex_home_users(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
|
|
@ -1,977 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
from logging import getLogger
|
||||
from copy import deepcopy
|
||||
from os import makedirs
|
||||
|
||||
from xbmc import getIPAddress
|
||||
|
||||
# from connect.connectionmanager import ConnectionManager
|
||||
from downloadutils import DownloadUtils
|
||||
from dialogs.serverconnect import ServerConnect
|
||||
from dialogs.servermanual import ServerManual
|
||||
from connect.plex_tv import plex_tv_sign_in_with_pin
|
||||
import connect.connectionmanager as connectionmanager
|
||||
from userclient import UserClient
|
||||
from utils import window, settings, tryEncode, language as lang, dialog, \
|
||||
exists_dir
|
||||
from PlexFunctions import GetMachineIdentifier, get_pms_settings, \
|
||||
check_connection
|
||||
import variables as v
|
||||
import state
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = getLogger("PLEX."+__name__)
|
||||
|
||||
STATE = connectionmanager.CONNECTIONSTATE
|
||||
XML_PATH = (tryEncode(v.ADDON_PATH), "default", "1080i")
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
def get_plex_login_from_settings():
|
||||
"""
|
||||
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')
|
||||
}
|
||||
|
||||
|
||||
class ConnectManager(object):
|
||||
# Borg
|
||||
__shared_state = {}
|
||||
state = {}
|
||||
|
||||
def __init__(self):
|
||||
# Borg
|
||||
self.__dict__ = self.__shared_state
|
||||
|
||||
log.debug('Instantiating')
|
||||
self.doUtils = DownloadUtils().downloadUrl
|
||||
self.server = UserClient().getServer()
|
||||
self.serverid = settings('plex_machineIdentifier')
|
||||
# Get Plex credentials from settings file, if they exist
|
||||
plexdict = get_plex_login_from_settings()
|
||||
self.myplexlogin = plexdict['myplexlogin'] == 'true'
|
||||
self.plexLogin = plexdict['plexLogin']
|
||||
self.plexid = plexdict['plexid']
|
||||
# Token for the PMS, not plex.tv
|
||||
self.__connect = connectionmanager.ConnectionManager(
|
||||
appName="Kodi",
|
||||
appVersion=v.ADDON_VERSION,
|
||||
deviceName=v.DEVICENAME,
|
||||
deviceId=window('plex_client_Id'))
|
||||
|
||||
self.pms_token = settings('accessToken')
|
||||
self.plexToken = plexdict['plexToken']
|
||||
self.__connect.plexToken = self.plexToken
|
||||
if self.plexToken:
|
||||
log.debug('Found a plex.tv token in the settings')
|
||||
if not exists_dir(v.ADDON_PATH_DATA):
|
||||
makedirs(v.ADDON_PATH_DATA)
|
||||
self.__connect.setFilePath(v.ADDON_PATH_DATA)
|
||||
|
||||
if state.CONNECT_STATE:
|
||||
self.state = state.CONNECT_STATE
|
||||
else:
|
||||
self.state = self.__connect.connect()
|
||||
log.debug("Started with: %s", self.state)
|
||||
state.CONNECT_STATE = deepcopy(self.state)
|
||||
|
||||
def update_state(self):
|
||||
self.state = self.__connect.connect({'updateDateLastAccessed': False})
|
||||
return self.get_state()
|
||||
|
||||
def get_state(self):
|
||||
state.CONNECT_STATE = deepcopy(self.state)
|
||||
return self.state
|
||||
|
||||
def get_server(self, server, options={}):
|
||||
self.state = self.__connect.connectToAddress(server, options)
|
||||
return self.get_state()
|
||||
|
||||
@classmethod
|
||||
def get_address(cls, server):
|
||||
return connectionmanager.getServerAddress(server, server['LastConnectionMode'])
|
||||
|
||||
def clear_data(self):
|
||||
self.__connect.clearData()
|
||||
|
||||
def select_servers(self):
|
||||
"""
|
||||
Will return selected server or raise RuntimeError
|
||||
"""
|
||||
status = self.__connect.connect({'enableAutoLogin': False})
|
||||
dia = ServerConnect("script-plex-connect-server.xml", *XML_PATH)
|
||||
kwargs = {
|
||||
'connect_manager': self.__connect,
|
||||
'username': state.PLEX_USERNAME,
|
||||
'user_image': state.PLEX_USER_IMAGE,
|
||||
'servers': status.get('Servers') or [],
|
||||
'plex_connect': False if status.get('ConnectUser') else True
|
||||
}
|
||||
dia.set_args(**kwargs)
|
||||
dia.doModal()
|
||||
|
||||
if dia.is_server_selected():
|
||||
log.debug("Server selected")
|
||||
return dia.get_server()
|
||||
|
||||
elif dia.is_connect_login():
|
||||
log.debug("Login to plex.tv")
|
||||
self.plex_tv_signin()
|
||||
return self.select_servers()
|
||||
|
||||
elif dia.is_manual_server():
|
||||
log.debug("Add manual server")
|
||||
try:
|
||||
# Add manual server address
|
||||
return self.manual_server()
|
||||
except RuntimeError:
|
||||
return self.select_servers()
|
||||
else:
|
||||
raise RuntimeError("No server selected")
|
||||
|
||||
def manual_server(self):
|
||||
# Return server or raise error
|
||||
dia = ServerManual("script-plex-connect-server-manual.xml", *XML_PATH)
|
||||
dia.set_connect_manager(self.__connect)
|
||||
dia.doModal()
|
||||
|
||||
if dia.is_connected():
|
||||
return dia.get_server()
|
||||
else:
|
||||
raise RuntimeError("Server is not connected")
|
||||
|
||||
def login(self, server=None):
|
||||
# Return user or raise error
|
||||
server = server or self.state['Servers'][0]
|
||||
server_address = connectionmanager.getServerAddress(server, server['LastConnectionMode'])
|
||||
|
||||
users = "";
|
||||
try:
|
||||
users = self.emby.getUsers(server_address)
|
||||
except Exception as error:
|
||||
log.info("Error getting users from server: " + str(error))
|
||||
|
||||
if not users:
|
||||
try:
|
||||
return self.login_manual(server_address)
|
||||
except RuntimeError:
|
||||
raise RuntimeError("No user selected")
|
||||
|
||||
dia = UsersConnect("script-emby-connect-users.xml", *XML_PATH)
|
||||
dia.set_server(server_address)
|
||||
dia.set_users(users)
|
||||
dia.doModal()
|
||||
|
||||
if dia.is_user_selected():
|
||||
|
||||
user = dia.get_user()
|
||||
username = user['Name']
|
||||
|
||||
if user['HasPassword']:
|
||||
log.debug("User has password, present manual login")
|
||||
try:
|
||||
return self.login_manual(server_address, username)
|
||||
except RuntimeError:
|
||||
return self.login(server)
|
||||
else:
|
||||
try:
|
||||
user = self.emby.loginUser(server_address, username)
|
||||
except Exception as error:
|
||||
log.info("Error logging in user: " + str(error))
|
||||
raise
|
||||
|
||||
self.__connect.onAuthenticated(user)
|
||||
return user
|
||||
|
||||
elif dia.is_manual_login():
|
||||
try:
|
||||
return self.login_manual(server_address)
|
||||
except RuntimeError:
|
||||
return self.login(server)
|
||||
else:
|
||||
raise RuntimeError("No user selected")
|
||||
|
||||
def login_manual(self, server, user=None):
|
||||
# Return manual login user authenticated or raise error
|
||||
dia = LoginManual("script-emby-connect-login-manual.xml", *XML_PATH)
|
||||
dia.set_server(server)
|
||||
dia.set_user(user)
|
||||
dia.doModal()
|
||||
|
||||
if dia.is_logged_in():
|
||||
user = dia.get_user()
|
||||
self.__connect.onAuthenticated(user)
|
||||
return user
|
||||
else:
|
||||
raise RuntimeError("User is not authenticated")
|
||||
|
||||
def update_token(self, server):
|
||||
|
||||
credentials = self.__connect.credentialProvider.getCredentials()
|
||||
self.__connect.credentialProvider.addOrUpdateServer(credentials['Servers'], server)
|
||||
|
||||
for server in self.get_state()['Servers']:
|
||||
for cred_server in credentials['Servers']:
|
||||
if server['Id'] == cred_server['Id']:
|
||||
# Update token saved in current state
|
||||
server.update(cred_server)
|
||||
# Update the token in data.txt
|
||||
self.__connect.credentialProvider.getCredentials(credentials)
|
||||
|
||||
def get_connect_servers(self):
|
||||
|
||||
connect_servers = []
|
||||
servers = self.__connect.getAvailableServers()
|
||||
for server in servers:
|
||||
if 'ExchangeToken' in server:
|
||||
result = self.connect_server(server)
|
||||
if result['State'] == STATE['SignedIn']:
|
||||
connect_servers.append(server)
|
||||
|
||||
log.info(connect_servers)
|
||||
return connect_servers
|
||||
|
||||
def connect_server(self, server):
|
||||
return self.__connect.connectToServer(server, {'updateDateLastAccessed': False})
|
||||
|
||||
def pick_pms(self, show_dialog=False):
|
||||
"""
|
||||
Searches for PMS in local Lan and optionally (if self.plexToken set)
|
||||
also on plex.tv
|
||||
show_dialog=True: let the user pick one
|
||||
show_dialog=False: automatically pick PMS based on
|
||||
machineIdentifier
|
||||
|
||||
Returns the picked PMS' detail as a dict:
|
||||
{
|
||||
'name': friendlyName, the Plex server's name
|
||||
'address': ip:port
|
||||
'ip': ip, without http/https
|
||||
'port': port
|
||||
'scheme': 'http'/'https', nice for checking for secure connections
|
||||
'local': '1'/'0', Is the server a local server?
|
||||
'owned': '1'/'0', Is the server owned by the user?
|
||||
'machineIdentifier': id, Plex server machine identifier
|
||||
'accesstoken': token Access token to this server
|
||||
'baseURL': baseURL scheme://ip:port
|
||||
'ownername' Plex username of PMS owner
|
||||
}
|
||||
|
||||
or None if unsuccessful
|
||||
"""
|
||||
server = None
|
||||
# If no server is set, let user choose one
|
||||
if not self.server or not self.serverid:
|
||||
show_dialog = True
|
||||
if show_dialog is True:
|
||||
try:
|
||||
server = self.select_servers()
|
||||
except RuntimeError:
|
||||
pass
|
||||
log.info("Server: %s", server)
|
||||
server = self.__user_pick_pms()
|
||||
else:
|
||||
server = self.__auto_pick_pms()
|
||||
if server is not None:
|
||||
self.write_pms_settings(server['baseURL'], server['accesstoken'])
|
||||
return server
|
||||
|
||||
@staticmethod
|
||||
def write_pms_settings(url, token):
|
||||
"""
|
||||
Sets certain settings for server by asking for the PMS' settings
|
||||
Call with url: scheme://ip:port
|
||||
"""
|
||||
xml = 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'))
|
||||
|
||||
def __auto_pick_pms(self):
|
||||
"""
|
||||
Will try to pick PMS based on machineIdentifier saved in file settings
|
||||
but only once
|
||||
|
||||
Returns server or None if unsuccessful
|
||||
"""
|
||||
httpsUpdated = False
|
||||
checkedPlexTV = False
|
||||
server = None
|
||||
while True:
|
||||
if httpsUpdated is False:
|
||||
serverlist = self.__get_server_list()
|
||||
for item in serverlist:
|
||||
if item.get('machineIdentifier') == self.serverid:
|
||||
server = item
|
||||
if server is None:
|
||||
name = settings('plex_servername')
|
||||
log.warn('The PMS you have used before with a unique '
|
||||
'machineIdentifier of %s and name %s is '
|
||||
'offline' % (self.serverid, name))
|
||||
return
|
||||
chk = self._checkServerCon(server)
|
||||
if chk == 504 and httpsUpdated is False:
|
||||
# Not able to use HTTP, try HTTPs for now
|
||||
server['scheme'] = 'https'
|
||||
httpsUpdated = True
|
||||
continue
|
||||
if chk == 401:
|
||||
log.warn('Not yet authorized for Plex server %s'
|
||||
% server['name'])
|
||||
if self.check_plex_tv_signin() is True:
|
||||
if checkedPlexTV is False:
|
||||
# Try again
|
||||
checkedPlexTV = True
|
||||
httpsUpdated = False
|
||||
continue
|
||||
else:
|
||||
log.warn('Not authorized even though we are signed '
|
||||
' in to plex.tv correctly')
|
||||
dialog('ok',
|
||||
lang(29999), '%s %s'
|
||||
% (lang(39214),
|
||||
tryEncode(server['name'])))
|
||||
return
|
||||
else:
|
||||
return
|
||||
# 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):
|
||||
"""
|
||||
Lets user pick his/her PMS from a list
|
||||
|
||||
Returns server or None if unsuccessful
|
||||
"""
|
||||
httpsUpdated = False
|
||||
while True:
|
||||
if httpsUpdated is False:
|
||||
serverlist = self.__get_server_list()
|
||||
# Exit if no servers found
|
||||
if len(serverlist) == 0:
|
||||
log.warn('No plex media servers found!')
|
||||
dialog('ok', lang(29999), lang(39011))
|
||||
return
|
||||
# Get a nicer list
|
||||
dialoglist = []
|
||||
for server in serverlist:
|
||||
if server['local'] == '1':
|
||||
# server is in the same network as client.
|
||||
# Add"local"
|
||||
msg = lang(39022)
|
||||
else:
|
||||
# Add 'remote'
|
||||
msg = lang(39054)
|
||||
if server.get('ownername'):
|
||||
# Display username if its not our PMS
|
||||
dialoglist.append('%s (%s, %s)'
|
||||
% (server['name'],
|
||||
server['ownername'],
|
||||
msg))
|
||||
else:
|
||||
dialoglist.append('%s (%s)'
|
||||
% (server['name'], msg))
|
||||
# Let user pick server from a list
|
||||
resp = dialog('select', lang(39012), dialoglist)
|
||||
if resp == -1:
|
||||
# User cancelled
|
||||
return
|
||||
|
||||
server = serverlist[resp]
|
||||
chk = self._checkServerCon(server)
|
||||
if chk == 504 and httpsUpdated is False:
|
||||
# Not able to use HTTP, try HTTPs for now
|
||||
serverlist[resp]['scheme'] = 'https'
|
||||
httpsUpdated = True
|
||||
continue
|
||||
httpsUpdated = False
|
||||
if chk == 401:
|
||||
log.warn('Not yet authorized for Plex server %s'
|
||||
% server['name'])
|
||||
# Please sign in to plex.tv
|
||||
dialog('ok',
|
||||
lang(29999),
|
||||
lang(39013) + server['name'],
|
||||
lang(39014))
|
||||
if self.plex_tv_signin() is False:
|
||||
# Exit while loop if user cancels
|
||||
return
|
||||
# Problems connecting
|
||||
elif chk >= 400 or chk is False:
|
||||
# Problems connecting to server. Pick another server?
|
||||
# Exit while loop if user chooses No
|
||||
if not dialog('yesno', lang(29999), lang(39015)):
|
||||
return
|
||||
# Otherwise: connection worked!
|
||||
else:
|
||||
return server
|
||||
|
||||
@staticmethod
|
||||
def write_pms_to_settings(server):
|
||||
"""
|
||||
Saves server to file settings. server is a dict of the form:
|
||||
{
|
||||
'name': friendlyName, the Plex server's name
|
||||
'address': ip:port
|
||||
'ip': ip, without http/https
|
||||
'port': port
|
||||
'scheme': 'http'/'https', nice for checking for secure connections
|
||||
'local': '1'/'0', Is the server a local server?
|
||||
'owned': '1'/'0', Is the server owned by the user?
|
||||
'machineIdentifier': id, Plex server machine identifier
|
||||
'accesstoken': token Access token to this server
|
||||
'baseURL': baseURL scheme://ip:port
|
||||
'ownername' Plex username of PMS owner
|
||||
}
|
||||
"""
|
||||
settings('plex_machineIdentifier', server['machineIdentifier'])
|
||||
settings('plex_servername', server['name'])
|
||||
settings('plex_serverowned',
|
||||
'true' if server['owned'] == '1'
|
||||
else 'false')
|
||||
# Careful to distinguish local from remote PMS
|
||||
if server['local'] == '1':
|
||||
scheme = server['scheme']
|
||||
settings('ipaddress', server['ip'])
|
||||
settings('port', server['port'])
|
||||
log.debug("Setting SSL verify to false, because server is "
|
||||
"local")
|
||||
settings('sslverify', 'false')
|
||||
else:
|
||||
baseURL = server['baseURL'].split(':')
|
||||
scheme = baseURL[0]
|
||||
settings('ipaddress', baseURL[1].replace('//', ''))
|
||||
settings('port', baseURL[2])
|
||||
log.debug("Setting SSL verify to true, because server is not "
|
||||
"local")
|
||||
settings('sslverify', 'true')
|
||||
|
||||
if scheme == 'https':
|
||||
settings('https', 'true')
|
||||
else:
|
||||
settings('https', 'false')
|
||||
# And finally do some logging
|
||||
log.debug("Writing to Kodi user settings file")
|
||||
log.debug("PMS machineIdentifier: %s, ip: %s, port: %s, https: %s "
|
||||
% (server['machineIdentifier'], server['ip'],
|
||||
server['port'], server['scheme']))
|
||||
|
||||
def plex_tv_signin(self):
|
||||
"""
|
||||
Signs (freshly) in to plex.tv (will be saved to file settings)
|
||||
|
||||
Returns True if successful, or False if not
|
||||
"""
|
||||
result = plex_tv_sign_in_with_pin()
|
||||
if result:
|
||||
self.plexLogin = result['username']
|
||||
self.plexToken = result['token']
|
||||
self.plexid = result['plexid']
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_plex_tv_signin(self):
|
||||
"""
|
||||
Checks existing connection to plex.tv. If not, triggers sign in
|
||||
|
||||
Returns True if signed in, False otherwise
|
||||
"""
|
||||
answer = True
|
||||
chk = check_connection('plex.tv', token=self.plexToken)
|
||||
if chk in (401, 403):
|
||||
# HTTP Error: unauthorized. Token is no longer valid
|
||||
log.info('plex.tv connection returned HTTP %s' % str(chk))
|
||||
# Delete token in the settings
|
||||
settings('plexToken', value='')
|
||||
settings('plexLogin', value='')
|
||||
# Could not login, please try again
|
||||
dialog('ok', lang(29999), lang(39009))
|
||||
answer = self.plex_tv_signin()
|
||||
elif chk is False or chk >= 400:
|
||||
# Problems connecting to plex.tv. Network or internet issue?
|
||||
log.info('Problems connecting to plex.tv; connection returned '
|
||||
'HTTP %s' % str(chk))
|
||||
dialog('ok', lang(29999), lang(39010))
|
||||
answer = False
|
||||
else:
|
||||
log.info('plex.tv connection with token successful')
|
||||
settings('plex_status', value=lang(39227))
|
||||
# Refresh the info from Plex.tv
|
||||
xml = self.doUtils('https://plex.tv/users/account',
|
||||
authenticate=False,
|
||||
headerOptions={'X-Plex-Token': self.plexToken})
|
||||
try:
|
||||
self.plexLogin = xml.attrib['title']
|
||||
except (AttributeError, KeyError):
|
||||
log.error('Failed to update Plex info from plex.tv')
|
||||
else:
|
||||
settings('plexLogin', value=self.plexLogin)
|
||||
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')
|
||||
return answer
|
||||
|
||||
def check_pms(self):
|
||||
"""
|
||||
Check the PMS that was set in file settings.
|
||||
Will return False if we need to reconnect, because:
|
||||
PMS could not be reached (no matter the authorization)
|
||||
machineIdentifier did not match
|
||||
|
||||
Will also set the PMS machineIdentifier in the file settings if it was
|
||||
not set before
|
||||
"""
|
||||
answer = True
|
||||
chk = check_connection(self.server, verifySSL=False)
|
||||
if chk is False:
|
||||
log.warn('Could not reach PMS %s' % self.server)
|
||||
answer = False
|
||||
if answer is True and not self.serverid:
|
||||
log.info('No PMS machineIdentifier found for %s. Trying to '
|
||||
'get the PMS unique ID' % self.server)
|
||||
self.serverid = GetMachineIdentifier(self.server)
|
||||
if self.serverid is None:
|
||||
log.warn('Could not retrieve machineIdentifier')
|
||||
answer = False
|
||||
else:
|
||||
settings('plex_machineIdentifier', value=self.serverid)
|
||||
elif answer is True:
|
||||
tempServerid = GetMachineIdentifier(self.server)
|
||||
if tempServerid != self.serverid:
|
||||
log.warn('The current PMS %s was expected to have a '
|
||||
'unique machineIdentifier of %s. But we got '
|
||||
'%s. Pick a new server to be sure'
|
||||
% (self.server, self.serverid, tempServerid))
|
||||
answer = False
|
||||
return answer
|
||||
|
||||
def __get_server_list(self):
|
||||
"""
|
||||
Returns a list of servers from GDM and possibly plex.tv
|
||||
"""
|
||||
self.discoverPMS(getIPAddress(), plexToken=self.plexToken)
|
||||
serverlist = self.plx.returnServerList(self.plx.g_PMS)
|
||||
log.debug('PMS serverlist: %s' % serverlist)
|
||||
return serverlist
|
||||
|
||||
def _checkServerCon(self, server):
|
||||
"""
|
||||
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'] == '1':
|
||||
url = '%s://%s:%s' \
|
||||
% (server['scheme'], server['ip'], server['port'])
|
||||
# Deactive SSL verification if the server is local!
|
||||
verifySSL = False
|
||||
else:
|
||||
url = server['baseURL']
|
||||
verifySSL = True
|
||||
chk = check_connection(url,
|
||||
token=server['accesstoken'],
|
||||
verifySSL=verifySSL)
|
||||
return chk
|
||||
|
||||
def discoverPMS(self, IP_self, plexToken=None):
|
||||
"""
|
||||
parameters:
|
||||
IP_self Own IP
|
||||
optional:
|
||||
plexToken token for plex.tv
|
||||
result:
|
||||
self.g_PMS dict set
|
||||
"""
|
||||
self.g_PMS = {}
|
||||
|
||||
# Look first for local PMS in the LAN
|
||||
pmsList = self.PlexGDM()
|
||||
log.debug('PMS found in the local LAN via GDM: %s' % pmsList)
|
||||
|
||||
# Get PMS from plex.tv
|
||||
if plexToken:
|
||||
log.info('Checking with plex.tv for more PMS to connect to')
|
||||
self.getPMSListFromMyPlex(plexToken)
|
||||
else:
|
||||
log.info('No plex token supplied, only checked LAN for PMS')
|
||||
|
||||
for uuid in pmsList:
|
||||
PMS = pmsList[uuid]
|
||||
if PMS['uuid'] in self.g_PMS:
|
||||
log.debug('We already know of PMS %s from plex.tv'
|
||||
% PMS['serverName'])
|
||||
# Update with GDM data - potentially more reliable than plex.tv
|
||||
self.updatePMSProperty(PMS['uuid'], 'ip', PMS['ip'])
|
||||
self.updatePMSProperty(PMS['uuid'], 'port', PMS['port'])
|
||||
self.updatePMSProperty(PMS['uuid'], 'local', '1')
|
||||
self.updatePMSProperty(PMS['uuid'], 'scheme', 'http')
|
||||
self.updatePMSProperty(PMS['uuid'],
|
||||
'baseURL',
|
||||
'http://%s:%s' % (PMS['ip'],
|
||||
PMS['port']))
|
||||
else:
|
||||
self.declarePMS(PMS['uuid'], PMS['serverName'], 'http',
|
||||
PMS['ip'], PMS['port'])
|
||||
# Ping to check whether we need HTTPs or HTTP
|
||||
https = PMSHttpsEnabled('%s:%s' % (PMS['ip'], PMS['port']))
|
||||
if https is None:
|
||||
# Error contacting url. Skip for now
|
||||
continue
|
||||
elif https is True:
|
||||
self.updatePMSProperty(PMS['uuid'], 'scheme', 'https')
|
||||
self.updatePMSProperty(
|
||||
PMS['uuid'],
|
||||
'baseURL',
|
||||
'https://%s:%s' % (PMS['ip'], PMS['port']))
|
||||
else:
|
||||
# Already declared with http
|
||||
pass
|
||||
|
||||
# install plex.tv "virtual" PMS - for myPlex, PlexHome
|
||||
# self.declarePMS('plex.tv', 'plex.tv', 'https', 'plex.tv', '443')
|
||||
# self.updatePMSProperty('plex.tv', 'local', '-')
|
||||
# self.updatePMSProperty('plex.tv', 'owned', '-')
|
||||
# self.updatePMSProperty(
|
||||
# 'plex.tv', 'accesstoken', plexToken)
|
||||
# (remote and local) servers from plex.tv
|
||||
|
||||
def declarePMS(self, uuid, name, scheme, ip, port):
|
||||
"""
|
||||
Plex Media Server handling
|
||||
|
||||
parameters:
|
||||
uuid - PMS ID
|
||||
name, scheme, ip, port, type, owned, token
|
||||
"""
|
||||
address = ip + ':' + port
|
||||
baseURL = scheme + '://' + ip + ':' + port
|
||||
self.g_PMS[uuid] = {
|
||||
'name': name,
|
||||
'scheme': scheme,
|
||||
'ip': ip,
|
||||
'port': port,
|
||||
'address': address,
|
||||
'baseURL': baseURL,
|
||||
'local': '1',
|
||||
'owned': '1',
|
||||
'accesstoken': '',
|
||||
'enableGzip': False
|
||||
}
|
||||
|
||||
def updatePMSProperty(self, uuid, tag, value):
|
||||
# set property element of PMS by UUID
|
||||
try:
|
||||
self.g_PMS[uuid][tag] = value
|
||||
except:
|
||||
log.error('%s has not yet been declared ' % uuid)
|
||||
return False
|
||||
|
||||
def getPMSProperty(self, uuid, tag):
|
||||
# get name of PMS by UUID
|
||||
try:
|
||||
answ = self.g_PMS[uuid].get(tag, '')
|
||||
except:
|
||||
log.error('%s not found in PMS catalogue' % uuid)
|
||||
answ = False
|
||||
return answ
|
||||
|
||||
def PlexGDM(self):
|
||||
"""
|
||||
PlexGDM
|
||||
|
||||
parameters:
|
||||
none
|
||||
result:
|
||||
PMS_list - dict() of PMSs found
|
||||
"""
|
||||
import struct
|
||||
|
||||
IP_PlexGDM = '239.0.0.250' # multicast to PMS
|
||||
Port_PlexGDM = 32414
|
||||
Msg_PlexGDM = 'M-SEARCH * HTTP/1.0'
|
||||
|
||||
# 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)
|
||||
|
||||
returnData = []
|
||||
try:
|
||||
# Send data to the multicast group
|
||||
GDM.sendto(Msg_PlexGDM, (IP_PlexGDM, Port_PlexGDM))
|
||||
|
||||
# Look for responses from all recipients
|
||||
while True:
|
||||
try:
|
||||
data, server = GDM.recvfrom(1024)
|
||||
returnData.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()
|
||||
|
||||
pmsList = {}
|
||||
for response in returnData:
|
||||
update = {'ip': response.get('from')[0]}
|
||||
# Check if we had a positive HTTP response
|
||||
if "200 OK" not in response.get('data'):
|
||||
continue
|
||||
for each in response.get('data').split('\n'):
|
||||
# decode response data
|
||||
update['discovery'] = "auto"
|
||||
# update['owned']='1'
|
||||
# update['master']= 1
|
||||
# update['role']='master'
|
||||
|
||||
if "Content-Type:" in each:
|
||||
update['content-type'] = each.split(':')[1].strip()
|
||||
elif "Resource-Identifier:" in each:
|
||||
update['uuid'] = each.split(':')[1].strip()
|
||||
elif "Name:" in each:
|
||||
update['serverName'] = tryDecode(
|
||||
each.split(':')[1].strip())
|
||||
elif "Port:" in each:
|
||||
update['port'] = each.split(':')[1].strip()
|
||||
elif "Updated-At:" in each:
|
||||
update['updated'] = each.split(':')[1].strip()
|
||||
elif "Version:" in each:
|
||||
update['version'] = each.split(':')[1].strip()
|
||||
pmsList[update['uuid']] = update
|
||||
return pmsList
|
||||
|
||||
def getPMSListFromMyPlex(self, token):
|
||||
"""
|
||||
getPMSListFromMyPlex
|
||||
|
||||
get Plex media Server List from plex.tv/pms/resources
|
||||
"""
|
||||
xml = self.doUtils('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
|
||||
|
||||
import Queue
|
||||
queue = Queue.Queue()
|
||||
threadQueue = []
|
||||
|
||||
maxAgeSeconds = 2*60*60*24
|
||||
for Dir in xml.findall('Device'):
|
||||
if 'server' not in Dir.get('provides'):
|
||||
# No PMS - skip
|
||||
continue
|
||||
if Dir.find('Connection') is None:
|
||||
# no valid connection - skip
|
||||
continue
|
||||
|
||||
# check MyPlex data age - skip if >2 days
|
||||
PMS = {}
|
||||
PMS['name'] = Dir.get('name')
|
||||
infoAge = time() - int(Dir.get('lastSeenAt'))
|
||||
if infoAge > maxAgeSeconds:
|
||||
log.debug("Server %s not seen for 2 days - skipping."
|
||||
% PMS['name'])
|
||||
continue
|
||||
|
||||
PMS['uuid'] = Dir.get('clientIdentifier')
|
||||
PMS['token'] = Dir.get('accessToken', token)
|
||||
PMS['owned'] = Dir.get('owned', '1')
|
||||
PMS['local'] = Dir.get('publicAddressMatches')
|
||||
PMS['ownername'] = Dir.get('sourceTitle', '')
|
||||
PMS['path'] = '/'
|
||||
PMS['options'] = None
|
||||
|
||||
# Try a local connection first
|
||||
# Backup to remote connection, if that failes
|
||||
PMS['connections'] = []
|
||||
for Con in Dir.findall('Connection'):
|
||||
if Con.get('local') == '1':
|
||||
PMS['connections'].append(Con)
|
||||
# Append non-local
|
||||
for Con in Dir.findall('Connection'):
|
||||
if Con.get('local') != '1':
|
||||
PMS['connections'].append(Con)
|
||||
|
||||
t = Thread(target=self.pokePMS,
|
||||
args=(PMS, queue))
|
||||
threadQueue.append(t)
|
||||
|
||||
maxThreads = 5
|
||||
threads = []
|
||||
# poke PMS, own thread for each PMS
|
||||
while True:
|
||||
# Remove finished threads
|
||||
for t in threads:
|
||||
if not t.isAlive():
|
||||
threads.remove(t)
|
||||
if len(threads) < maxThreads:
|
||||
try:
|
||||
t = threadQueue.pop()
|
||||
except IndexError:
|
||||
# We have done our work
|
||||
break
|
||||
else:
|
||||
t.start()
|
||||
threads.append(t)
|
||||
else:
|
||||
sleep(50)
|
||||
|
||||
# wait for requests being answered
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# declare new PMSs
|
||||
while not queue.empty():
|
||||
PMS = queue.get()
|
||||
self.declarePMS(PMS['uuid'], PMS['name'],
|
||||
PMS['protocol'], PMS['ip'], PMS['port'])
|
||||
self.updatePMSProperty(
|
||||
PMS['uuid'], 'accesstoken', PMS['token'])
|
||||
self.updatePMSProperty(
|
||||
PMS['uuid'], 'owned', PMS['owned'])
|
||||
self.updatePMSProperty(
|
||||
PMS['uuid'], 'local', PMS['local'])
|
||||
# set in declarePMS, overwrite for https encryption
|
||||
self.updatePMSProperty(
|
||||
PMS['uuid'], 'baseURL', PMS['baseURL'])
|
||||
self.updatePMSProperty(
|
||||
PMS['uuid'], 'ownername', PMS['ownername'])
|
||||
log.debug('Found PMS %s: %s'
|
||||
% (PMS['uuid'], self.g_PMS[PMS['uuid']]))
|
||||
queue.task_done()
|
||||
|
||||
def pokePMS(self, PMS, queue):
|
||||
data = PMS['connections'][0].attrib
|
||||
if data['local'] == '1':
|
||||
protocol = data['protocol']
|
||||
address = data['address']
|
||||
port = data['port']
|
||||
url = '%s://%s:%s' % (protocol, address, port)
|
||||
else:
|
||||
url = data['uri']
|
||||
if url.count(':') == 1:
|
||||
url = '%s:%s' % (url, data['port'])
|
||||
protocol, address, port = url.split(':', 2)
|
||||
address = address.replace('/', '')
|
||||
|
||||
xml = self.doUtils('%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 len(PMS['connections']) > 0:
|
||||
# Still got connections left, try them
|
||||
return self.pokePMS(PMS, queue)
|
||||
return
|
||||
else:
|
||||
# Connection successful - correct PMS?
|
||||
if xml.get('machineIdentifier') == PMS['uuid']:
|
||||
# process later
|
||||
PMS['baseURL'] = url
|
||||
PMS['protocol'] = 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 returnServerList(self, data):
|
||||
"""
|
||||
Returns a nicer list of all servers found in data, where data is in
|
||||
g_PMS format, for the client device with unique ID ATV_udid
|
||||
|
||||
Input:
|
||||
data e.g. self.g_PMS
|
||||
|
||||
Output: List of all servers, with an entry of the form:
|
||||
{
|
||||
'name': friendlyName, the Plex server's name
|
||||
'address': ip:port
|
||||
'ip': ip, without http/https
|
||||
'port': port
|
||||
'scheme': 'http'/'https', nice for checking for secure connections
|
||||
'local': '1'/'0', Is the server a local server?
|
||||
'owned': '1'/'0', Is the server owned by the user?
|
||||
'machineIdentifier': id, Plex server machine identifier
|
||||
'accesstoken': token Access token to this server
|
||||
'baseURL': baseURL scheme://ip:port
|
||||
'ownername' Plex username of PMS owner
|
||||
}
|
||||
"""
|
||||
serverlist = []
|
||||
for key, value in data.items():
|
||||
serverlist.append({
|
||||
'name': value.get('name'),
|
||||
'address': value.get('address'),
|
||||
'ip': value.get('ip'),
|
||||
'port': value.get('port'),
|
||||
'scheme': value.get('scheme'),
|
||||
'local': value.get('local'),
|
||||
'owned': value.get('owned'),
|
||||
'machineIdentifier': key,
|
||||
'accesstoken': value.get('accesstoken'),
|
||||
'baseURL': value.get('baseURL'),
|
||||
'ownername': value.get('ownername')
|
||||
})
|
||||
return serverlist
|
|
@ -1,19 +1,16 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
###############################################################################
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
import xbmcgui
|
||||
import xbmcaddon
|
||||
|
||||
from utils import window
|
||||
from . import utils
|
||||
from . import path_ops
|
||||
from . import variables as v
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = logging.getLogger("PLEX."+__name__)
|
||||
addon = xbmcaddon.Addon('plugin.video.plexkodiconnect')
|
||||
LOG = getLogger('PLEX.context')
|
||||
|
||||
ACTION_PARENT_DIR = 9
|
||||
ACTION_PREVIOUS_MENU = 10
|
||||
|
@ -27,16 +24,16 @@ USER_IMAGE = 150
|
|||
|
||||
|
||||
class ContextMenu(xbmcgui.WindowXMLDialog):
|
||||
|
||||
_options = []
|
||||
selected_option = None
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
self._options = []
|
||||
self.selected_option = None
|
||||
self.list_ = None
|
||||
self.background = None
|
||||
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
|
||||
|
||||
def set_options(self, options=[]):
|
||||
def set_options(self, options=None):
|
||||
if not options:
|
||||
options = []
|
||||
self._options = options
|
||||
|
||||
def is_selected(self):
|
||||
|
@ -46,17 +43,13 @@ class ContextMenu(xbmcgui.WindowXMLDialog):
|
|||
return self.selected_option
|
||||
|
||||
def onInit(self):
|
||||
|
||||
if window('PlexUserImage'):
|
||||
self.getControl(USER_IMAGE).setImage(window('PlexUserImage'))
|
||||
|
||||
if utils.window('plexAvatar'):
|
||||
self.getControl(USER_IMAGE).setImage(utils.window('plexAvatar'))
|
||||
height = 479 + (len(self._options) * 55)
|
||||
log.info("options: %s", self._options)
|
||||
LOG.debug("options: %s", self._options)
|
||||
self.list_ = self.getControl(LIST)
|
||||
|
||||
for option in self._options:
|
||||
self.list_.addItem(self._add_listitem(option))
|
||||
|
||||
self.background = self._add_editcontrol(730, height, 30, 450)
|
||||
self.setFocus(self.list_)
|
||||
|
||||
|
@ -64,27 +57,24 @@ class ContextMenu(xbmcgui.WindowXMLDialog):
|
|||
|
||||
if action in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU):
|
||||
self.close()
|
||||
|
||||
if action in (ACTION_SELECT_ITEM, ACTION_MOUSE_LEFT_CLICK):
|
||||
|
||||
if self.getFocusId() == LIST:
|
||||
option = self.list_.getSelectedItem()
|
||||
self.selected_option = option.getLabel()
|
||||
log.info('option selected: %s', self.selected_option)
|
||||
|
||||
self.selected_option = option.getLabel().decode('utf-8')
|
||||
LOG.info('option selected: %s', self.selected_option)
|
||||
self.close()
|
||||
|
||||
def _add_editcontrol(self, x, y, height, width, password=0):
|
||||
|
||||
media = os.path.join(addon.getAddonInfo('path'), 'resources', 'skins', 'default', 'media')
|
||||
def _add_editcontrol(self, x, y, height, width, password=None):
|
||||
media = path_ops.path.join(
|
||||
v.ADDON_PATH, 'resources', 'skins', 'default', 'media')
|
||||
filename = utils.try_encode(path_ops.path.join(media, 'white.png'))
|
||||
control = xbmcgui.ControlImage(0, 0, 0, 0,
|
||||
filename=os.path.join(media, "white.png"),
|
||||
filename=filename,
|
||||
aspectRatio=0,
|
||||
colorDiffuse="ff111111")
|
||||
control.setPosition(x, y)
|
||||
control.setHeight(height)
|
||||
control.setWidth(width)
|
||||
|
||||
self.addControl(control)
|
||||
return control
|
||||
|
|
@ -1,217 +1,159 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
###############################################################################
|
||||
|
||||
import logging
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
import xbmc
|
||||
import xbmcaddon
|
||||
import xbmcgui
|
||||
|
||||
import plexdb_functions as plexdb
|
||||
from utils import window, settings, dialog, language as lang, kodiSQL
|
||||
from dialogs import context
|
||||
from PlexFunctions import delete_item_from_pms
|
||||
import variables as v
|
||||
from .plex_api import API
|
||||
from .plex_db import PlexDB
|
||||
from . import context, plex_functions as PF, playqueue as PQ
|
||||
from . import utils, variables as v, app
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = logging.getLogger("PLEX."+__name__)
|
||||
LOG = getLogger('PLEX.context_entry')
|
||||
|
||||
OPTIONS = {
|
||||
'Refresh': lang(30410),
|
||||
'Delete': lang(30409),
|
||||
'Addon': lang(30408),
|
||||
# 'AddFav': lang(30405),
|
||||
# 'RemoveFav': lang(30406),
|
||||
# 'RateSong': lang(30407),
|
||||
'Transcode': lang(30412),
|
||||
'PMS_Play': lang(30415) # Use PMS to start playback
|
||||
'Refresh': utils.lang(30410),
|
||||
'Delete': utils.lang(30409),
|
||||
'Addon': utils.lang(30408),
|
||||
# 'AddFav': utils.lang(30405),
|
||||
# 'RemoveFav': utils.lang(30406),
|
||||
# 'RateSong': utils.lang(30407),
|
||||
'Transcode': utils.lang(30412),
|
||||
'PMS_Play': utils.lang(30415), # Use PMS to start playback
|
||||
'Extras': utils.lang(30235)
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
class ContextMenu(object):
|
||||
|
||||
"""
|
||||
Class initiated if user opens "Plex options" on a PLEX item using the Kodi
|
||||
context menu
|
||||
"""
|
||||
_selected_option = None
|
||||
|
||||
def __init__(self):
|
||||
self.kodi_id = xbmc.getInfoLabel('ListItem.DBID').decode('utf-8')
|
||||
self.item_type = self._get_item_type()
|
||||
self.item_id = self._get_item_id(self.kodi_id, self.item_type)
|
||||
|
||||
log.info("Found item_id: %s item_type: %s"
|
||||
% (self.item_id, self.item_type))
|
||||
|
||||
if not self.item_id:
|
||||
def __init__(self, kodi_id=None, kodi_type=None):
|
||||
"""
|
||||
Simply instantiate with ContextMenu() - no need to call any methods
|
||||
"""
|
||||
self.kodi_id = kodi_id
|
||||
self.kodi_type = kodi_type
|
||||
self.plex_id = self._get_plex_id(self.kodi_id, self.kodi_type)
|
||||
if self.kodi_type:
|
||||
self.plex_type = v.PLEX_TYPE_FROM_KODI_TYPE[self.kodi_type]
|
||||
else:
|
||||
self.plex_type = None
|
||||
LOG.debug("Found plex_id: %s plex_type: %s",
|
||||
self.plex_id, self.plex_type)
|
||||
if not self.plex_id:
|
||||
return
|
||||
|
||||
xml = PF.GetPlexMetadata(self.plex_id)
|
||||
try:
|
||||
xml[0].attrib
|
||||
except (TypeError, IndexError, KeyError):
|
||||
self.api = None
|
||||
else:
|
||||
self.api = API(xml[0])
|
||||
if self._select_menu():
|
||||
self._action_menu()
|
||||
|
||||
if self._selected_option in (OPTIONS['Delete'],
|
||||
OPTIONS['Refresh']):
|
||||
log.info("refreshing container")
|
||||
xbmc.sleep(500)
|
||||
xbmc.executebuiltin('Container.Refresh')
|
||||
|
||||
@classmethod
|
||||
def _get_item_type(cls):
|
||||
item_type = xbmc.getInfoLabel('ListItem.DBTYPE').decode('utf-8')
|
||||
if not item_type:
|
||||
if xbmc.getCondVisibility('Container.Content(albums)'):
|
||||
item_type = "album"
|
||||
elif xbmc.getCondVisibility('Container.Content(artists)'):
|
||||
item_type = "artist"
|
||||
elif xbmc.getCondVisibility('Container.Content(songs)'):
|
||||
item_type = "song"
|
||||
elif xbmc.getCondVisibility('Container.Content(pictures)'):
|
||||
item_type = "picture"
|
||||
else:
|
||||
log.info("item_type is unknown")
|
||||
return item_type
|
||||
|
||||
@classmethod
|
||||
def _get_item_id(cls, kodi_id, item_type):
|
||||
item_id = xbmc.getInfoLabel('ListItem.Property(plexid)')
|
||||
if not item_id and kodi_id and item_type:
|
||||
with plexdb.Get_Plex_DB() as plexcursor:
|
||||
item = plexcursor.getItem_byKodiId(kodi_id, item_type)
|
||||
try:
|
||||
item_id = item[0]
|
||||
except TypeError:
|
||||
log.error('Could not get the Plex id for context menu')
|
||||
return item_id
|
||||
@staticmethod
|
||||
def _get_plex_id(kodi_id, kodi_type):
|
||||
plex_id = xbmc.getInfoLabel('ListItem.Property(plexid)') or None
|
||||
if not plex_id and kodi_id and kodi_type:
|
||||
with PlexDB() as plexdb:
|
||||
item = plexdb.item_by_kodi_id(kodi_id, kodi_type)
|
||||
if item:
|
||||
plex_id = item['plex_id']
|
||||
return plex_id
|
||||
|
||||
def _select_menu(self):
|
||||
# Display select dialog
|
||||
"""
|
||||
Display select dialog
|
||||
"""
|
||||
options = []
|
||||
|
||||
# if user uses direct paths, give option to initiate playback via PMS
|
||||
if (window('useDirectPaths') == 'true' and
|
||||
self.item_type in v.KODI_VIDEOTYPES):
|
||||
if self.api and self.api.extras():
|
||||
options.append(OPTIONS['Extras'])
|
||||
if app.SYNC.direct_paths and self.kodi_type in v.KODI_VIDEOTYPES:
|
||||
options.append(OPTIONS['PMS_Play'])
|
||||
|
||||
if self.item_type in v.KODI_VIDEOTYPES:
|
||||
if self.kodi_type in v.KODI_VIDEOTYPES:
|
||||
options.append(OPTIONS['Transcode'])
|
||||
|
||||
# userdata = self.api.getUserData()
|
||||
# if userdata['Favorite']:
|
||||
# # Remove from emby favourites
|
||||
# options.append(OPTIONS['RemoveFav'])
|
||||
# else:
|
||||
# # Add to emby favourites
|
||||
# options.append(OPTIONS['AddFav'])
|
||||
|
||||
# if self.item_type == "song":
|
||||
# # Set custom song rating
|
||||
# options.append(OPTIONS['RateSong'])
|
||||
|
||||
# Refresh item
|
||||
# options.append(OPTIONS['Refresh'])
|
||||
# Delete item, only if the Plex Home main user is logged in
|
||||
if (window('plex_restricteduser') != 'true' and
|
||||
window('plex_allows_mediaDeletion') == 'true'):
|
||||
if (utils.window('plex_restricteduser') != 'true' and
|
||||
utils.window('plex_allows_mediaDeletion') == 'true'):
|
||||
options.append(OPTIONS['Delete'])
|
||||
# Addon settings
|
||||
options.append(OPTIONS['Addon'])
|
||||
|
||||
context_menu = context.ContextMenu(
|
||||
"script-emby-context.xml",
|
||||
xbmcaddon.Addon(
|
||||
'plugin.video.plexkodiconnect').getAddonInfo('path'),
|
||||
"default", "1080i")
|
||||
"script-plex-context.xml",
|
||||
utils.try_encode(v.ADDON_PATH),
|
||||
"default",
|
||||
"1080i")
|
||||
context_menu.set_options(options)
|
||||
context_menu.doModal()
|
||||
|
||||
if context_menu.is_selected():
|
||||
self._selected_option = context_menu.get_selected()
|
||||
|
||||
return self._selected_option
|
||||
|
||||
def _action_menu(self):
|
||||
|
||||
"""
|
||||
Do whatever the user selected to do
|
||||
"""
|
||||
selected = self._selected_option
|
||||
|
||||
if selected == OPTIONS['Transcode']:
|
||||
window('plex_forcetranscode', value='true')
|
||||
app.PLAYSTATE.force_transcode = True
|
||||
self._PMS_play()
|
||||
|
||||
elif selected == OPTIONS['PMS_Play']:
|
||||
self._PMS_play()
|
||||
|
||||
# elif selected == OPTIONS['Refresh']:
|
||||
# self.emby.refreshItem(self.item_id)
|
||||
|
||||
# elif selected == OPTIONS['AddFav']:
|
||||
# self.emby.updateUserRating(self.item_id, favourite=True)
|
||||
|
||||
# elif selected == OPTIONS['RemoveFav']:
|
||||
# self.emby.updateUserRating(self.item_id, favourite=False)
|
||||
|
||||
# elif selected == OPTIONS['RateSong']:
|
||||
# self._rate_song()
|
||||
elif selected == OPTIONS['Extras']:
|
||||
self._extras()
|
||||
|
||||
elif selected == OPTIONS['Addon']:
|
||||
xbmc.executebuiltin('Addon.OpenSettings(plugin.video.plexkodiconnect)')
|
||||
|
||||
xbmc.executebuiltin(
|
||||
'Addon.OpenSettings(plugin.video.plexkodiconnect)')
|
||||
elif selected == OPTIONS['Delete']:
|
||||
self._delete_item()
|
||||
|
||||
def _rate_song(self):
|
||||
|
||||
conn = kodiSQL('music')
|
||||
cursor = conn.cursor()
|
||||
query = "SELECT rating FROM song WHERE idSong = ?"
|
||||
cursor.execute(query, (self.kodi_id,))
|
||||
try:
|
||||
value = cursor.fetchone()[0]
|
||||
current_value = int(round(float(value), 0))
|
||||
except TypeError:
|
||||
pass
|
||||
else:
|
||||
new_value = dialog("numeric", 0, lang(30411), str(current_value))
|
||||
if new_value > -1:
|
||||
|
||||
new_value = int(new_value)
|
||||
if new_value > 5:
|
||||
new_value = 5
|
||||
|
||||
if settings('enableUpdateSongRating') == "true":
|
||||
musicutils.updateRatingToFile(new_value, self.api.get_file_path())
|
||||
|
||||
query = "UPDATE song SET rating = ? WHERE idSong = ?"
|
||||
cursor.execute(query, (new_value, self.kodi_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
def _delete_item(self):
|
||||
|
||||
"""
|
||||
Delete item on PMS
|
||||
"""
|
||||
delete = True
|
||||
if settings('skipContextMenu') != "true":
|
||||
|
||||
if not dialog("yesno", heading=lang(29999), line1=lang(33041)):
|
||||
log.info("User skipped deletion for: %s", self.item_id)
|
||||
if utils.settings('skipContextMenu') != "true":
|
||||
if not utils.dialog("yesno", heading="{plex}", line1=utils.lang(33041)):
|
||||
LOG.info("User skipped deletion for: %s", self.plex_id)
|
||||
delete = False
|
||||
|
||||
if delete:
|
||||
log.info("Deleting Plex item with id %s", self.item_id)
|
||||
if delete_item_from_pms(self.item_id) is False:
|
||||
dialog("ok", heading="{plex}", line1=lang(30414))
|
||||
LOG.info("Deleting Plex item with id %s", self.plex_id)
|
||||
if PF.delete_item_from_pms(self.plex_id) is False:
|
||||
utils.dialog("ok", heading="{plex}", line1=utils.lang(30414))
|
||||
|
||||
def _PMS_play(self):
|
||||
"""
|
||||
For using direct paths: Initiates playback using the PMS
|
||||
"""
|
||||
window('plex_contextplay', value='true')
|
||||
params = {
|
||||
'filename': '/library/metadata/%s' % self.item_id,
|
||||
'id': self.item_id,
|
||||
'dbid': self.kodi_id,
|
||||
'mode': "play"
|
||||
}
|
||||
from urllib import urlencode
|
||||
handle = ("plugin://plugin.video.plexkodiconnect/movies?%s"
|
||||
% urlencode(params))
|
||||
xbmc.executebuiltin('RunPlugin(%s)' % handle)
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_KODI_TYPE[self.kodi_type])
|
||||
playqueue.clear()
|
||||
app.PLAYSTATE.context_menu_play = True
|
||||
handle = self.api.fullpath(force_addon=True)[0]
|
||||
handle = 'RunPlugin(%s)' % handle
|
||||
xbmc.executebuiltin(handle.encode('utf-8'))
|
||||
|
||||
def _extras(self):
|
||||
"""
|
||||
Displays a list of elements for all the extras of the Plex element
|
||||
"""
|
||||
handle = ('plugin://plugin.video.plexkodiconnect?mode=extras&plex_id=%s'
|
||||
% self.plex_id)
|
||||
if xbmcgui.getCurrentWindowId() == 10025:
|
||||
# Video Window
|
||||
xbmc.executebuiltin('Container.Update(\"%s\")' % handle)
|
||||
else:
|
||||
xbmc.executebuiltin('ActivateWindow(videos, \"%s\")' % handle)
|
||||
|
|
97
resources/lib/db.py
Normal file
97
resources/lib/db.py
Normal file
|
@ -0,0 +1,97 @@
|
|||
#!/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
|
39
resources/lib/defused_etree.py
Normal file
39
resources/lib/defused_etree.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
#!/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,6 +0,0 @@
|
|||
# Dummy file to make this directory a package.
|
||||
# from serverconnect import ServerConnect
|
||||
# from usersconnect import UsersConnect
|
||||
# from loginconnect import LoginConnect
|
||||
# from loginmanual import LoginManual
|
||||
# from servermanual import ServerManual
|
|
@ -1,145 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
##################################################################################################
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import xbmcgui
|
||||
import xbmcaddon
|
||||
|
||||
import read_embyserver as embyserver
|
||||
from utils import language as lang
|
||||
|
||||
##################################################################################################
|
||||
|
||||
log = logging.getLogger("EMBY."+__name__)
|
||||
addon = xbmcaddon.Addon('plugin.video.emby')
|
||||
|
||||
ACTION_PARENT_DIR = 9
|
||||
ACTION_PREVIOUS_MENU = 10
|
||||
ACTION_BACK = 92
|
||||
SIGN_IN = 200
|
||||
CANCEL = 201
|
||||
ERROR_TOGGLE = 202
|
||||
ERROR_MSG = 203
|
||||
ERROR = {
|
||||
'Invalid': 1,
|
||||
'Empty': 2
|
||||
}
|
||||
|
||||
##################################################################################################
|
||||
|
||||
|
||||
class LoginManual(xbmcgui.WindowXMLDialog):
|
||||
|
||||
_user = None
|
||||
error = None
|
||||
username = None
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
self.emby = embyserver.Read_EmbyServer()
|
||||
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
|
||||
|
||||
def is_logged_in(self):
|
||||
return True if self._user else False
|
||||
|
||||
def set_server(self, server):
|
||||
self.server = server
|
||||
|
||||
def set_user(self, user):
|
||||
self.username = user or {}
|
||||
|
||||
def get_user(self):
|
||||
return self._user
|
||||
|
||||
def onInit(self):
|
||||
|
||||
self.signin_button = self.getControl(SIGN_IN)
|
||||
self.cancel_button = self.getControl(CANCEL)
|
||||
self.error_toggle = self.getControl(ERROR_TOGGLE)
|
||||
self.error_msg = self.getControl(ERROR_MSG)
|
||||
self.user_field = self._add_editcontrol(725, 400, 40, 500)
|
||||
self.password_field = self._add_editcontrol(725, 475, 40, 500, password=1)
|
||||
|
||||
if self.username:
|
||||
self.user_field.setText(self.username)
|
||||
self.setFocus(self.password_field)
|
||||
else:
|
||||
self.setFocus(self.user_field)
|
||||
|
||||
self.user_field.controlUp(self.cancel_button)
|
||||
self.user_field.controlDown(self.password_field)
|
||||
self.password_field.controlUp(self.user_field)
|
||||
self.password_field.controlDown(self.signin_button)
|
||||
self.signin_button.controlUp(self.password_field)
|
||||
self.cancel_button.controlDown(self.user_field)
|
||||
|
||||
def onClick(self, control):
|
||||
|
||||
if control == SIGN_IN:
|
||||
# Sign in to emby connect
|
||||
self._disable_error()
|
||||
|
||||
user = self.user_field.getText()
|
||||
password = self.password_field.getText()
|
||||
|
||||
if not user:
|
||||
# Display error
|
||||
self._error(ERROR['Empty'], lang(30613))
|
||||
log.error("Username cannot be null")
|
||||
|
||||
elif self._login(user, password):
|
||||
self.close()
|
||||
|
||||
elif control == CANCEL:
|
||||
# Remind me later
|
||||
self.close()
|
||||
|
||||
def onAction(self, action):
|
||||
|
||||
if self.error == ERROR['Empty'] and self.user_field.getText():
|
||||
self._disable_error()
|
||||
|
||||
if action in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU):
|
||||
self.close()
|
||||
|
||||
def _add_editcontrol(self, x, y, height, width, password=0):
|
||||
|
||||
media = os.path.join(addon.getAddonInfo('path'), 'resources', 'skins', 'default', 'media')
|
||||
control = xbmcgui.ControlEdit(0, 0, 0, 0,
|
||||
label="User",
|
||||
font="font10",
|
||||
textColor="ff525252",
|
||||
focusTexture=os.path.join(media, "button-focus.png"),
|
||||
noFocusTexture=os.path.join(media, "button-focus.png"),
|
||||
isPassword=password)
|
||||
control.setPosition(x, y)
|
||||
control.setHeight(height)
|
||||
control.setWidth(width)
|
||||
|
||||
self.addControl(control)
|
||||
return control
|
||||
|
||||
def _login(self, username, password):
|
||||
|
||||
result = self.emby.loginUser(self.server, username, password)
|
||||
if not result:
|
||||
self._error(ERROR['Invalid'], lang(33009))
|
||||
return False
|
||||
else:
|
||||
self._user = result
|
||||
return True
|
||||
|
||||
def _error(self, state, message):
|
||||
|
||||
self.error = state
|
||||
self.error_msg.setLabel(message)
|
||||
self.error_toggle.setVisibleCondition('True')
|
||||
|
||||
def _disable_error(self):
|
||||
|
||||
self.error = None
|
||||
self.error_toggle.setVisibleCondition('False')
|
|
@ -1,139 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
###############################################################################
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
import xbmc
|
||||
import xbmcgui
|
||||
|
||||
import connect.connectionmanager as connectionmanager
|
||||
from utils import language as lang
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = getLogger("PLEX."+__name__)
|
||||
|
||||
CONN_STATE = connectionmanager.CONNECTIONSTATE
|
||||
ACTION_PARENT_DIR = 9
|
||||
ACTION_PREVIOUS_MENU = 10
|
||||
ACTION_BACK = 92
|
||||
ACTION_SELECT_ITEM = 7
|
||||
ACTION_MOUSE_LEFT_CLICK = 100
|
||||
USER_IMAGE = 150
|
||||
USER_NAME = 151
|
||||
LIST = 155
|
||||
CANCEL = 201
|
||||
MESSAGE_BOX = 202
|
||||
MESSAGE = 203
|
||||
BUSY = 204
|
||||
PLEX_CONNECT = 205
|
||||
MANUAL_SERVER = 206
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
class ServerConnect(xbmcgui.WindowXMLDialog):
|
||||
|
||||
username = ""
|
||||
user_image = None
|
||||
servers = []
|
||||
|
||||
_selected_server = None
|
||||
_connect_login = False
|
||||
_manual_server = False
|
||||
|
||||
def set_args(self, **kwargs):
|
||||
# connect_manager, username, user_image, servers, plex_connect
|
||||
for key, value in kwargs.iteritems():
|
||||
setattr(self, key, value)
|
||||
|
||||
def is_server_selected(self):
|
||||
return True if self._selected_server else False
|
||||
|
||||
def get_server(self):
|
||||
return self._selected_server
|
||||
|
||||
def is_connect_login(self):
|
||||
return self._connect_login
|
||||
|
||||
def is_manual_server(self):
|
||||
return self._manual_server
|
||||
|
||||
def onInit(self):
|
||||
|
||||
self.message = self.getControl(MESSAGE)
|
||||
self.message_box = self.getControl(MESSAGE_BOX)
|
||||
self.busy = self.getControl(BUSY)
|
||||
self.list_ = self.getControl(LIST)
|
||||
|
||||
for server in self.servers:
|
||||
server_type = "wifi" if server.get('local') == '0' else "network"
|
||||
self.list_.addItem(self._add_listitem(server['Name'], server['Id'], server_type))
|
||||
|
||||
self.getControl(USER_NAME).setLabel("%s %s" % (lang(33000), self.username.decode('utf-8')))
|
||||
|
||||
if self.user_image is not None:
|
||||
self.getControl(USER_IMAGE).setImage(self.user_image)
|
||||
|
||||
if not self.plex_connect: # Change connect user
|
||||
self.getControl(PLEX_CONNECT).setLabel("[UPPERCASE][B]"+'plex.tv user change'+"[/B][/UPPERCASE]")
|
||||
|
||||
if self.servers:
|
||||
self.setFocus(self.list_)
|
||||
|
||||
@classmethod
|
||||
def _add_listitem(cls, label, server_id, server_type):
|
||||
|
||||
item = xbmcgui.ListItem(label)
|
||||
item.setProperty('id', server_id)
|
||||
item.setProperty('server_type', server_type)
|
||||
|
||||
return item
|
||||
|
||||
def onAction(self, action):
|
||||
|
||||
if action in (ACTION_BACK, ACTION_PREVIOUS_MENU, ACTION_PARENT_DIR):
|
||||
self.close()
|
||||
|
||||
if action in (ACTION_SELECT_ITEM, ACTION_MOUSE_LEFT_CLICK):
|
||||
|
||||
if self.getFocusId() == LIST:
|
||||
server = self.list_.getSelectedItem()
|
||||
selected_id = server.getProperty('id')
|
||||
log.info('Server Id selected: %s', selected_id)
|
||||
|
||||
if self._connect_server(selected_id):
|
||||
self.message_box.setVisibleCondition('False')
|
||||
self.close()
|
||||
|
||||
def onClick(self, control):
|
||||
|
||||
if control == PLEX_CONNECT:
|
||||
self.connect_manager.clearData()
|
||||
self._connect_login = True
|
||||
self.close()
|
||||
|
||||
elif control == MANUAL_SERVER:
|
||||
self._manual_server = True
|
||||
self.close()
|
||||
|
||||
elif control == CANCEL:
|
||||
self.close()
|
||||
|
||||
def _connect_server(self, server_id):
|
||||
|
||||
server = self.connect_manager.getServerInfo(server_id)
|
||||
self.message.setLabel("%s %s..." % (lang(30610), server['Name']))
|
||||
self.message_box.setVisibleCondition('True')
|
||||
self.busy.setVisibleCondition('True')
|
||||
result = self.connect_manager.connectToServer(server)
|
||||
|
||||
if result['State'] == CONN_STATE['Unavailable']:
|
||||
self.busy.setVisibleCondition('False')
|
||||
self.message.setLabel(lang(30609))
|
||||
return False
|
||||
else:
|
||||
xbmc.sleep(1000)
|
||||
self._selected_server = result['Servers'][0]
|
||||
return True
|
|
@ -1,146 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
from logging import getLogger
|
||||
|
||||
import xbmcgui
|
||||
|
||||
import connect.connectionmanager as connectionmanager
|
||||
from utils import language as lang, tryDecode
|
||||
|
||||
###############################################################################
|
||||
log = getLogger("PLEX."+__name__)
|
||||
|
||||
CONN_STATE = connectionmanager.CONNECTIONSTATE
|
||||
ACTION_PARENT_DIR = 9
|
||||
ACTION_PREVIOUS_MENU = 10
|
||||
ACTION_BACK = 92
|
||||
CONNECT = 200
|
||||
CANCEL = 201
|
||||
ERROR_TOGGLE = 202
|
||||
ERROR_MSG = 203
|
||||
VERIFY_SSL = 204
|
||||
HOST_SSL_PATH = 205
|
||||
PMS_IP = 208
|
||||
PMS_PORT = 209
|
||||
ERROR = {
|
||||
'Invalid': 1,
|
||||
'Empty': 2
|
||||
}
|
||||
###############################################################################
|
||||
|
||||
|
||||
class ServerManual(xbmcgui.WindowXMLDialog):
|
||||
_server = None
|
||||
error = None
|
||||
|
||||
def onInit(self):
|
||||
self.connect_button = self.getControl(CONNECT)
|
||||
self.cancel_button = self.getControl(CANCEL)
|
||||
self.error_toggle = self.getControl(ERROR_TOGGLE)
|
||||
self.error_msg = self.getControl(ERROR_MSG)
|
||||
|
||||
self.host_field = self.getControl(PMS_IP)
|
||||
self.port_field = self.getControl(PMS_PORT)
|
||||
self.verify_ssl_radio = self.getControl(VERIFY_SSL)
|
||||
self.host_ssl_path_radio = self.getControl(HOST_SSL_PATH)
|
||||
|
||||
self.port_field.setText('32400')
|
||||
self.setFocus(self.host_field)
|
||||
self.verify_ssl_radio.setSelected(True)
|
||||
self.host_ssl_path_radio.setSelected(False)
|
||||
self.host_ssl_path = None
|
||||
|
||||
self.host_field.controlUp(self.cancel_button)
|
||||
self.host_field.controlDown(self.port_field)
|
||||
self.port_field.controlUp(self.host_field)
|
||||
self.port_field.controlDown(self.verify_ssl_radio)
|
||||
self.verify_ssl_radio.controlUp(self.port_field)
|
||||
self.verify_ssl_radio.controlDown(self.host_ssl_path_radio)
|
||||
self.host_ssl_path_radio.controlUp(self.verify_ssl_radio)
|
||||
self.host_ssl_path_radio.controlDown(self.connect_button)
|
||||
self.connect_button.controlUp(self.host_ssl_path_radio)
|
||||
self.connect_button.controlDown(self.cancel_button)
|
||||
self.cancel_button.controlUp(self.connect_button)
|
||||
self.cancel_button.controlDown(self.host_field)
|
||||
|
||||
def set_connect_manager(self, connect_manager):
|
||||
self.connect_manager = connect_manager
|
||||
|
||||
def is_connected(self):
|
||||
return True if self._server else False
|
||||
|
||||
def get_server(self):
|
||||
return self._server
|
||||
|
||||
def onClick(self, control):
|
||||
if control == CONNECT:
|
||||
self._disable_error()
|
||||
|
||||
server = self.host_field.getText()
|
||||
port = self.port_field.getText()
|
||||
|
||||
if not server or not port:
|
||||
# Display error
|
||||
self._error(ERROR['Empty'], lang(30021))
|
||||
log.error("Server or port cannot be null")
|
||||
|
||||
elif self._connect_to_server(server, port):
|
||||
self.close()
|
||||
|
||||
elif control == CANCEL:
|
||||
self.close()
|
||||
elif control == HOST_SSL_PATH:
|
||||
if self.host_ssl_path_radio.isSelected():
|
||||
# Let the user choose path to the certificate (=file)
|
||||
self.host_ssl_path = xbmcgui.Dialog().browse(
|
||||
1, lang(29999), 'files', '', False, False, '', False)
|
||||
log.debug('Host SSL file path chosen: %s' % self.host_ssl_path)
|
||||
if not self.host_ssl_path:
|
||||
self.host_ssl_path_radio.setSelected(False)
|
||||
else:
|
||||
self.host_ssl_path = tryDecode(self.host_ssl_path)
|
||||
else:
|
||||
# User disabled
|
||||
# Ensure that we don't have a host certificate set
|
||||
self.host_ssl_path = None
|
||||
|
||||
def onAction(self, action):
|
||||
if (self.error == ERROR['Empty'] and
|
||||
self.host_field.getText() and self.port_field.getText()):
|
||||
self._disable_error()
|
||||
if action in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU):
|
||||
self.close()
|
||||
|
||||
def _connect_to_server(self, server, port):
|
||||
"""Returns True if we could connect, False otherwise"""
|
||||
url = "%s:%s" % (server, port)
|
||||
self._message("%s %s..." % (lang(30023), url))
|
||||
options = {
|
||||
'verify': True if self.verify_ssl_radio.isSelected() else False
|
||||
}
|
||||
if self.host_ssl_path:
|
||||
options['cert'] = self.host_ssl_path
|
||||
result = self.connect_manager.connectToAddress(url, options)
|
||||
log.debug('Received the following results: %s' % result)
|
||||
if result['State'] == CONN_STATE['Unavailable']:
|
||||
self._message(lang(30204))
|
||||
return False
|
||||
else:
|
||||
self._server = result['Servers'][0]
|
||||
return True
|
||||
|
||||
def _message(self, message):
|
||||
"""Displays a message popup just underneath the dialog"""
|
||||
self.error_msg.setLabel(message)
|
||||
self.error_toggle.setVisibleCondition('True')
|
||||
|
||||
def _error(self, state, message):
|
||||
"""Displays an error message just underneath the dialog"""
|
||||
self.error = state
|
||||
self.error_msg.setLabel(message)
|
||||
self.error_toggle.setVisibleCondition('True')
|
||||
|
||||
def _disable_error(self):
|
||||
"""Disables the message popup just underneath the dialog"""
|
||||
self.error = None
|
||||
self.error_toggle.setVisibleCondition('False')
|
|
@ -1,104 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
##################################################################################################
|
||||
|
||||
import logging
|
||||
|
||||
import xbmc
|
||||
import xbmcgui
|
||||
|
||||
##################################################################################################
|
||||
|
||||
log = logging.getLogger("EMBY."+__name__)
|
||||
|
||||
ACTION_PARENT_DIR = 9
|
||||
ACTION_PREVIOUS_MENU = 10
|
||||
ACTION_BACK = 92
|
||||
ACTION_SELECT_ITEM = 7
|
||||
ACTION_MOUSE_LEFT_CLICK = 100
|
||||
LIST = 155
|
||||
MANUAL = 200
|
||||
CANCEL = 201
|
||||
|
||||
##################################################################################################
|
||||
|
||||
|
||||
class UsersConnect(xbmcgui.WindowXMLDialog):
|
||||
|
||||
_user = None
|
||||
_manual_login = False
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
self.kodi_version = int(xbmc.getInfoLabel('System.BuildVersion')[:2])
|
||||
xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs)
|
||||
|
||||
def set_server(self, server):
|
||||
self.server = server
|
||||
|
||||
def set_users(self, users):
|
||||
self.users = users
|
||||
|
||||
def is_user_selected(self):
|
||||
return True if self._user else False
|
||||
|
||||
def get_user(self):
|
||||
return self._user
|
||||
|
||||
def is_manual_login(self):
|
||||
return self._manual_login
|
||||
|
||||
|
||||
def onInit(self):
|
||||
|
||||
self.list_ = self.getControl(LIST)
|
||||
for user in self.users:
|
||||
user_image = ("userflyoutdefault2.png" if 'PrimaryImageTag' not in user
|
||||
else self._get_user_artwork(user['Id'], 'Primary'))
|
||||
self.list_.addItem(self._add_listitem(user['Name'], user['Id'], user_image))
|
||||
|
||||
self.setFocus(self.list_)
|
||||
|
||||
def _add_listitem(self, label, user_id, user_image):
|
||||
|
||||
item = xbmcgui.ListItem(label)
|
||||
item.setProperty('id', user_id)
|
||||
if self.kodi_version > 15:
|
||||
item.setArt({'Icon': user_image})
|
||||
else:
|
||||
item.setArt({'icon': user_image})
|
||||
|
||||
return item
|
||||
|
||||
def onAction(self, action):
|
||||
|
||||
if action in (ACTION_BACK, ACTION_PREVIOUS_MENU, ACTION_PARENT_DIR):
|
||||
self.close()
|
||||
|
||||
if action in (ACTION_SELECT_ITEM, ACTION_MOUSE_LEFT_CLICK):
|
||||
|
||||
if self.getFocusId() == LIST:
|
||||
user = self.list_.getSelectedItem()
|
||||
selected_id = user.getProperty('id')
|
||||
log.info('User Id selected: %s', selected_id)
|
||||
|
||||
for user in self.users:
|
||||
if user['Id'] == selected_id:
|
||||
self._user = user
|
||||
break
|
||||
|
||||
self.close()
|
||||
|
||||
def onClick(self, control):
|
||||
|
||||
if control == MANUAL:
|
||||
self._manual_login = True
|
||||
self.close()
|
||||
|
||||
elif control == CANCEL:
|
||||
self.close()
|
||||
|
||||
def _get_user_artwork(self, user_id, item_type):
|
||||
# Load user information set by UserClient
|
||||
return "%s/emby/Users/%s/Images/%s?Format=original" % (self.server, user_id, item_type)
|
|
@ -1,15 +1,11 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
###############################################################################
|
||||
|
||||
import logging
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
import requests
|
||||
import xml.etree.ElementTree as etree
|
||||
import requests.exceptions as exceptions
|
||||
|
||||
from utils import settings, window, language as lang, dialog
|
||||
import clientinfo as client
|
||||
|
||||
import state
|
||||
from . import utils, clientinfo, app
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
@ -17,7 +13,7 @@ import state
|
|||
import requests.packages.urllib3
|
||||
requests.packages.urllib3.disable_warnings()
|
||||
|
||||
log = logging.getLogger("PLEX."+__name__)
|
||||
LOG = getLogger('PLEX.download')
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
@ -33,101 +29,75 @@ class DownloadUtils():
|
|||
_shared_state = {}
|
||||
|
||||
# How many failed attempts before declaring PMS dead?
|
||||
connectionAttempts = 2
|
||||
connection_attempts = 1
|
||||
count_error = 0
|
||||
# How many 401 returns before declaring unauthorized?
|
||||
unauthorizedAttempts = 2
|
||||
unauthorized_attempts = 2
|
||||
count_unauthorized = 0
|
||||
# How long should we wait for an answer from the
|
||||
timeout = 30.0
|
||||
|
||||
def __init__(self):
|
||||
self.__dict__ = self._shared_state
|
||||
|
||||
def setServer(self, server):
|
||||
def setSSL(self):
|
||||
"""
|
||||
Reserved for userclient only
|
||||
"""
|
||||
self.server = server
|
||||
log.debug("Set server: %s" % server)
|
||||
|
||||
def setToken(self, token):
|
||||
"""
|
||||
Reserved for userclient only
|
||||
"""
|
||||
self.token = token
|
||||
if token == '':
|
||||
log.debug('Set token: empty token!')
|
||||
else:
|
||||
log.debug("Set token: xxxxxxx")
|
||||
|
||||
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'
|
||||
"""
|
||||
if verifySSL is None:
|
||||
verifySSL = settings('sslverify')
|
||||
if certificate is None:
|
||||
certificate = settings('sslcert')
|
||||
log.debug("Verify SSL certificates set to: %s" % verifySSL)
|
||||
log.debug("SSL client side certificate set to: %s" % certificate)
|
||||
if verifySSL != 'true':
|
||||
self.s.verify = False
|
||||
if certificate != 'None':
|
||||
verifySSL = app.CONN.verify_ssl_cert
|
||||
certificate = app.CONN.ssl_cert_path
|
||||
# Set the session's parameters
|
||||
self.s.verify = verifySSL
|
||||
if certificate:
|
||||
self.s.cert = certificate
|
||||
LOG.debug("Verify SSL certificates set to: %s", verifySSL)
|
||||
LOG.debug("SSL client side certificate set to: %s", certificate)
|
||||
|
||||
def startSession(self, reset=False):
|
||||
"""
|
||||
User should be authenticated when this method is called (via
|
||||
userclient)
|
||||
User should be authenticated when this method is called
|
||||
"""
|
||||
# Start session
|
||||
self.s = requests.Session()
|
||||
|
||||
self.deviceId = client.getDeviceId()
|
||||
self.deviceId = clientinfo.getDeviceId()
|
||||
# Attach authenticated header to the session
|
||||
self.s.headers = client.getXArgsDeviceInfo()
|
||||
self.s.headers = clientinfo.getXArgsDeviceInfo()
|
||||
self.s.encoding = 'utf-8'
|
||||
# Set SSL settings
|
||||
self.setSSL()
|
||||
|
||||
# Set other stuff
|
||||
self.setServer(window('pms_server'))
|
||||
self.setToken(window('pms_token'))
|
||||
|
||||
# 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:
|
||||
window('countUnauthorized', value='0')
|
||||
window('countError', value='0')
|
||||
self.count_error = 0
|
||||
self.count_unauthorized = 0
|
||||
|
||||
# Retry connections to the server
|
||||
self.s.mount("http://", requests.adapters.HTTPAdapter(max_retries=1))
|
||||
self.s.mount("https://", requests.adapters.HTTPAdapter(max_retries=1))
|
||||
|
||||
log.info("Requests session started on: %s" % self.server)
|
||||
LOG.debug("Requests session started on: %s", app.CONN.server)
|
||||
|
||||
def stopSession(self):
|
||||
try:
|
||||
self.s.close()
|
||||
except:
|
||||
log.info("Requests session already closed")
|
||||
except Exception:
|
||||
LOG.info("Requests session already closed")
|
||||
try:
|
||||
del self.s
|
||||
except:
|
||||
except AttributeError:
|
||||
pass
|
||||
log.info('Request session stopped')
|
||||
LOG.info('Request session stopped')
|
||||
|
||||
def getHeader(self, options=None):
|
||||
header = client.getXArgsDeviceInfo()
|
||||
@staticmethod
|
||||
def getHeader(options=None):
|
||||
header = clientinfo.getXArgsDeviceInfo()
|
||||
if options is not None:
|
||||
header.update(options)
|
||||
return header
|
||||
|
||||
def _doDownload(self, s, action_type, **kwargs):
|
||||
@staticmethod
|
||||
def _doDownload(s, action_type, **kwargs):
|
||||
if action_type == "GET":
|
||||
r = s.get(**kwargs)
|
||||
elif action_type == "POST":
|
||||
|
@ -143,15 +113,13 @@ class DownloadUtils():
|
|||
def downloadUrl(self, url, action_type="GET", postBody=None,
|
||||
parameters=None, authenticate=True, headerOptions=None,
|
||||
verifySSL=True, timeout=None, return_response=False,
|
||||
auth=None):
|
||||
headerOverride=None, reraise=False):
|
||||
"""
|
||||
Override SSL check with verifySSL=False
|
||||
|
||||
If authenticate=True, existing request session will be used/started
|
||||
Otherwise, 'empty' request will be made
|
||||
|
||||
auth=None or auth=('user', 'password')
|
||||
|
||||
Returns:
|
||||
None If an error occured
|
||||
True If connection worked but no body was received
|
||||
|
@ -167,18 +135,22 @@ class DownloadUtils():
|
|||
try:
|
||||
s = self.s
|
||||
except AttributeError:
|
||||
log.info("Request session does not exist: start one")
|
||||
LOG.info("Request session does not exist: start one")
|
||||
self.startSession()
|
||||
s = self.s
|
||||
# Replace for the real values
|
||||
url = url.replace("{server}", self.server)
|
||||
url = url.replace("{server}", app.CONN.server)
|
||||
else:
|
||||
# User is not (yet) authenticated. Used to communicate with
|
||||
# plex.tv and to check for PMS servers
|
||||
s = requests
|
||||
if not headerOverride:
|
||||
headerOptions = self.getHeader(options=headerOptions)
|
||||
if settings('sslcert') != 'None':
|
||||
kwargs['cert'] = settings('sslcert')
|
||||
else:
|
||||
headerOptions = headerOverride
|
||||
kwargs['verify'] = app.CONN.verify_ssl_cert
|
||||
if app.CONN.ssl_cert_path:
|
||||
kwargs['cert'] = app.CONN.ssl_cert_path
|
||||
|
||||
# Set the variables we were passed (fallback to request session
|
||||
# otherwise - faster)
|
||||
|
@ -193,57 +165,70 @@ class DownloadUtils():
|
|||
kwargs['params'] = parameters
|
||||
if timeout is not None:
|
||||
kwargs['timeout'] = timeout
|
||||
if auth is not None:
|
||||
kwargs['auth'] = auth
|
||||
|
||||
# ACTUAL DOWNLOAD HAPPENING HERE
|
||||
success = False
|
||||
try:
|
||||
r = self._doDownload(s, action_type, **kwargs)
|
||||
|
||||
# THE EXCEPTIONS
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
except exceptions.SSLError as e:
|
||||
LOG.warn("Invalid SSL certificate for: %s", url)
|
||||
LOG.warn(e)
|
||||
if reraise:
|
||||
raise
|
||||
except exceptions.ConnectionError as e:
|
||||
# Connection error
|
||||
log.debug("Server unreachable at: %s" % url)
|
||||
log.debug(e)
|
||||
|
||||
except requests.exceptions.Timeout as e:
|
||||
log.debug("Server timeout at: %s" % url)
|
||||
log.debug(e)
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
log.warn('HTTP Error at %s' % url)
|
||||
log.warn(e)
|
||||
|
||||
except requests.exceptions.SSLError as e:
|
||||
log.warn("Invalid SSL certificate for: %s" % url)
|
||||
log.warn(e)
|
||||
|
||||
except requests.exceptions.TooManyRedirects as e:
|
||||
log.warn("Too many redirects connecting to: %s" % url)
|
||||
log.warn(e)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
log.warn("Unknown error connecting to: %s" % url)
|
||||
log.warn(e)
|
||||
|
||||
LOG.warn("Server unreachable at: %s", url)
|
||||
LOG.warn(e)
|
||||
if reraise:
|
||||
raise
|
||||
except exceptions.Timeout as e:
|
||||
LOG.warn("Server timeout at: %s", url)
|
||||
LOG.warn(e)
|
||||
if reraise:
|
||||
raise
|
||||
except exceptions.HTTPError as e:
|
||||
LOG.warn('HTTP Error at %s', url)
|
||||
LOG.warn(e)
|
||||
if reraise:
|
||||
raise
|
||||
except exceptions.TooManyRedirects as e:
|
||||
LOG.warn("Too many redirects connecting to: %s", url)
|
||||
LOG.warn(e)
|
||||
if reraise:
|
||||
raise
|
||||
except exceptions.RequestException as e:
|
||||
LOG.warn("Unknown error connecting to: %s", url)
|
||||
LOG.warn(e)
|
||||
if reraise:
|
||||
raise
|
||||
except SystemExit:
|
||||
log.info('SystemExit detected, aborting download')
|
||||
LOG.info('SystemExit detected, aborting download')
|
||||
self.stopSession()
|
||||
|
||||
except:
|
||||
log.warn('Unknown error while downloading. Traceback:')
|
||||
if reraise:
|
||||
raise
|
||||
except Exception:
|
||||
LOG.warn('Unknown error while downloading. Traceback:')
|
||||
import traceback
|
||||
log.warn(traceback.format_exc())
|
||||
LOG.warn(traceback.format_exc())
|
||||
if reraise:
|
||||
raise
|
||||
|
||||
# THE RESPONSE #####
|
||||
else:
|
||||
success = True
|
||||
# We COULD contact the PMS, hence it ain't dead
|
||||
if authenticate is True:
|
||||
window('countError', value='0')
|
||||
self.count_error = 0
|
||||
if r.status_code != 401:
|
||||
window('countUnauthorized', value='0')
|
||||
self.count_unauthorized = 0
|
||||
|
||||
if r.status_code == 204:
|
||||
if return_response is True:
|
||||
# return the entire response object
|
||||
return r
|
||||
|
||||
elif r.status_code == 204:
|
||||
# No body in the response
|
||||
# But read (empty) content to release connection back to pool
|
||||
# (see requests: keep-alive documentation)
|
||||
|
@ -255,42 +240,33 @@ class DownloadUtils():
|
|||
# Called when checking a connect - no need for rash action
|
||||
return 401
|
||||
r.encoding = 'utf-8'
|
||||
log.warn('HTTP error 401 from PMS %s' % url)
|
||||
log.info(r.text)
|
||||
LOG.warn('HTTP error 401 from PMS %s', url)
|
||||
LOG.info(r.text)
|
||||
if '401 Unauthorized' in r.text:
|
||||
# Truly unauthorized
|
||||
window('countUnauthorized',
|
||||
value=str(int(window('countUnauthorized')) + 1))
|
||||
if (int(window('countUnauthorized')) >=
|
||||
self.unauthorizedAttempts):
|
||||
log.warn('We seem to be truly unauthorized for PMS'
|
||||
' %s ' % url)
|
||||
if state.PMS_STATUS not in ('401', 'Auth'):
|
||||
# Tell userclient token has been revoked.
|
||||
log.debug('Setting PMS server status to '
|
||||
'unauthorized')
|
||||
state.PMS_STATUS = '401'
|
||||
window('plex_serverStatus', value="401")
|
||||
dialog('notification',
|
||||
lang(29999),
|
||||
lang(30017),
|
||||
self.count_unauthorized += 1
|
||||
if self.count_unauthorized >= self.unauthorized_attempts:
|
||||
LOG.warn('We seem to be truly unauthorized for PMS'
|
||||
' %s ', url)
|
||||
# Unauthorized access, user no longer has access
|
||||
app.ACCOUNT.log_out()
|
||||
utils.dialog('notification',
|
||||
utils.lang(29999),
|
||||
utils.lang(30017),
|
||||
icon='{error}')
|
||||
else:
|
||||
# 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')
|
||||
return 401
|
||||
|
||||
elif r.status_code in (200, 201):
|
||||
# 200: OK
|
||||
# 201: Created
|
||||
if return_response is True:
|
||||
# return the entire response object
|
||||
return r
|
||||
try:
|
||||
# xml response
|
||||
r = etree.fromstring(r.content)
|
||||
r = utils.defused_etree.fromstring(r.content)
|
||||
return r
|
||||
except:
|
||||
except Exception:
|
||||
r.encoding = 'utf-8'
|
||||
if r.text == '':
|
||||
# Answer does not contain a body
|
||||
|
@ -299,40 +275,33 @@ class DownloadUtils():
|
|||
# UNICODE - JSON object
|
||||
r = r.json()
|
||||
return r
|
||||
except:
|
||||
except Exception:
|
||||
if '200 OK' in r.text:
|
||||
# Received fucked up OK from PMS on playstate
|
||||
# update
|
||||
pass
|
||||
else:
|
||||
log.error("Unable to convert the response for: "
|
||||
"%s" % url)
|
||||
log.info("Received headers were: %s" % r.headers)
|
||||
log.info('Received text:')
|
||||
log.info(r.text)
|
||||
LOG.warn("Unable to convert the response for: "
|
||||
"%s", url)
|
||||
LOG.warn("Received headers were: %s", r.headers)
|
||||
LOG.warn('Received text: %s', r.text)
|
||||
return True
|
||||
elif r.status_code == 403:
|
||||
# E.g. deleting a PMS item
|
||||
log.error('PMS sent 403: Forbidden error for url %s' % url)
|
||||
return None
|
||||
LOG.warn('PMS sent 403: Forbidden error for url %s', url)
|
||||
return
|
||||
else:
|
||||
log.error('Unknown answer from PMS %s with status code %s. '
|
||||
'Message:' % (url, r.status_code))
|
||||
r.encoding = 'utf-8'
|
||||
log.info(r.text)
|
||||
LOG.warn('Unknown answer from PMS %s with status code %s: %s',
|
||||
url, r.status_code, r.text)
|
||||
return True
|
||||
|
||||
# And now deal with the consequences of the exceptions
|
||||
if authenticate is True:
|
||||
finally:
|
||||
if not success and authenticate:
|
||||
# Deal with the consequences of the exceptions
|
||||
# Make the addon aware of status
|
||||
try:
|
||||
window('countError',
|
||||
value=str(int(window('countError')) + 1))
|
||||
if int(window('countError')) >= self.connectionAttempts:
|
||||
log.warn('Failed to connect to %s too many times. '
|
||||
'Declare PMS dead' % url)
|
||||
window('plex_online', value="false")
|
||||
except:
|
||||
# 'countError' not yet set
|
||||
pass
|
||||
return None
|
||||
self.count_error += 1
|
||||
if self.count_error >= self.connection_attempts:
|
||||
LOG.warn('Failed to connect to %s too many times. '
|
||||
'Declare PMS dead', url)
|
||||
app.CONN.online = False
|
||||
|
|
File diff suppressed because it is too large
Load diff
32
resources/lib/exceptions.py
Normal file
32
resources/lib/exceptions.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
#!/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,139 +1,709 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
|
||||
from xbmc import executebuiltin
|
||||
|
||||
from utils import settings, language as lang, advancedsettings_xml, dialog
|
||||
from connectmanager import ConnectManager
|
||||
|
||||
import state
|
||||
from migration import check_migration
|
||||
|
||||
###############################################################################
|
||||
log = getLogger("PLEX."+__name__)
|
||||
from . import utils
|
||||
from .utils import etree
|
||||
from . import path_ops
|
||||
from . import migration
|
||||
from .downloadutils import DownloadUtils as DU, exceptions
|
||||
from . import plex_functions as PF
|
||||
from . import plex_tv
|
||||
from . import json_rpc as js
|
||||
from . import app
|
||||
from . import variables as v
|
||||
|
||||
###############################################################################
|
||||
|
||||
LOG = getLogger('PLEX.initialsetup')
|
||||
|
||||
def setup():
|
||||
###############################################################################
|
||||
|
||||
if not path_ops.exists(v.EXTERNAL_SUBTITLE_TEMP_PATH):
|
||||
path_ops.makedirs(v.EXTERNAL_SUBTITLE_TEMP_PATH)
|
||||
|
||||
|
||||
class InitialSetup(object):
|
||||
"""
|
||||
Will load Plex PMS settings (e.g. address) and token
|
||||
Will ask the user initial questions on first PKC boot
|
||||
"""
|
||||
def __init__(self):
|
||||
LOG.debug('Entering initialsetup class')
|
||||
# Get Plex credentials from settings file, if they exist
|
||||
plexdict = PF.GetPlexLoginFromSettings()
|
||||
self.plex_login = plexdict['plexLogin']
|
||||
self.plex_login_id = plexdict['plexid']
|
||||
self.plex_token = plexdict['plexToken']
|
||||
# Token for the PMS, not plex.tv
|
||||
self.pms_token = utils.settings('accessToken')
|
||||
if self.plex_token:
|
||||
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):
|
||||
"""
|
||||
Signs (freshly) in to plex.tv (will be saved to file settings)
|
||||
|
||||
Returns True if successful, or False if not
|
||||
"""
|
||||
user = plex_tv.sign_in_with_pin()
|
||||
if user:
|
||||
self.plex_login = user.username
|
||||
self.plex_token = user.authToken
|
||||
self.plex_login_id = user.id
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_plex_tv_sign_in(self):
|
||||
"""
|
||||
Checks existing connection to plex.tv. If not, triggers sign in
|
||||
|
||||
Returns True if signed in, False otherwise
|
||||
"""
|
||||
answer = True
|
||||
chk = PF.check_connection('plex.tv', token=self.plex_token)
|
||||
if chk in (401, 403):
|
||||
# HTTP Error: unauthorized. Token is no longer valid
|
||||
LOG.info('plex.tv connection returned HTTP %s', str(chk))
|
||||
# Delete token in the settings
|
||||
utils.settings('plexToken', value='')
|
||||
utils.settings('plexLogin', value='')
|
||||
# Could not login, please try again
|
||||
utils.messageDialog(utils.lang(29999), utils.lang(39009))
|
||||
answer = self.plex_tv_sign_in()
|
||||
elif chk is False or chk >= 400:
|
||||
# Problems connecting to plex.tv. Network or internet issue?
|
||||
LOG.info('Problems connecting to plex.tv; connection returned '
|
||||
'HTTP %s', str(chk))
|
||||
utils.messageDialog(utils.lang(29999), utils.lang(39010))
|
||||
answer = False
|
||||
else:
|
||||
LOG.info('plex.tv connection with token successful')
|
||||
utils.settings('plex_status', value=utils.lang(39227))
|
||||
# Refresh the info from Plex.tv
|
||||
xml = DU().downloadUrl('https://plex.tv/users/account',
|
||||
authenticate=False,
|
||||
headerOptions={'X-Plex-Token': self.plex_token})
|
||||
try:
|
||||
self.plex_login = xml.attrib['title']
|
||||
except (AttributeError, KeyError):
|
||||
LOG.error('Failed to update Plex info from plex.tv')
|
||||
else:
|
||||
utils.settings('plexLogin', value=self.plex_login)
|
||||
utils.settings('plexAvatar', value=xml.attrib.get('thumb'))
|
||||
LOG.info('Updated Plex info from plex.tv')
|
||||
return answer
|
||||
|
||||
@staticmethod
|
||||
def check_existing_pms():
|
||||
"""
|
||||
Check the PMS that was set in file settings.
|
||||
Will return False if we need to reconnect, because:
|
||||
PMS could not be reached (no matter the authorization)
|
||||
machineIdentifier did not match
|
||||
|
||||
Will also set the PMS machineIdentifier in the file settings if it was
|
||||
not set before
|
||||
"""
|
||||
answer = True
|
||||
chk = PF.check_connection(app.CONN.server,
|
||||
verifySSL=True if v.KODIVERSION >= 18 else False)
|
||||
if chk is False:
|
||||
LOG.warn('Could not reach PMS %s', app.CONN.server)
|
||||
answer = False
|
||||
if answer is True and not app.CONN.machine_identifier:
|
||||
LOG.info('No PMS machineIdentifier found for %s. Trying to '
|
||||
'get the PMS unique ID', app.CONN.server)
|
||||
app.CONN.machine_identifier = PF.GetMachineIdentifier(app.CONN.server)
|
||||
if app.CONN.machine_identifier is None:
|
||||
LOG.warn('Could not retrieve machineIdentifier')
|
||||
answer = False
|
||||
else:
|
||||
utils.settings('plex_machineIdentifier', value=app.CONN.machine_identifier)
|
||||
elif answer is True:
|
||||
temp_server_id = PF.GetMachineIdentifier(app.CONN.server)
|
||||
if temp_server_id != app.CONN.machine_identifier:
|
||||
LOG.warn('The current PMS %s was expected to have a '
|
||||
'unique machineIdentifier of %s. But we got '
|
||||
'%s. Pick a new server to be sure',
|
||||
app.CONN.server, app.CONN.machine_identifier, temp_server_id)
|
||||
answer = False
|
||||
return answer
|
||||
|
||||
@staticmethod
|
||||
def _check_pms_connectivity(server):
|
||||
"""
|
||||
Checks for server's connectivity. Returns check_connection result
|
||||
"""
|
||||
if server['local']:
|
||||
# Deactive SSL verification if the server is local for Kodi 17
|
||||
verifySSL = True if v.KODIVERSION >= 18 else False
|
||||
else:
|
||||
verifySSL = True
|
||||
if not server['token']:
|
||||
# Plex GDM: we only get the token from plex.tv after
|
||||
# Sign-in to plex.tv
|
||||
server['token'] = utils.settings('plexToken') or None
|
||||
return PF.check_connection(server['baseURL'],
|
||||
token=server['token'],
|
||||
verifySSL=verifySSL)
|
||||
|
||||
def pick_pms(self, showDialog=False, inform_of_search=False):
|
||||
"""
|
||||
Searches for PMS in local Lan and optionally (if self.plex_token set)
|
||||
also on plex.tv
|
||||
showDialog=True: let the user pick one
|
||||
showDialog=False: automatically pick PMS based on machineIdentifier
|
||||
|
||||
Returns the picked PMS' detail as a 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
|
||||
}
|
||||
or None if unsuccessful
|
||||
"""
|
||||
# If no server is set, let user choose one
|
||||
if not app.CONN.server or not app.CONN.machine_identifier:
|
||||
showDialog = True
|
||||
if showDialog is True:
|
||||
server = self._user_pick_pms()
|
||||
else:
|
||||
server = self._auto_pick_pms(show_dialog=inform_of_search)
|
||||
return server
|
||||
|
||||
def _auto_pick_pms(self, show_dialog=False):
|
||||
"""
|
||||
Will try to pick PMS based on machineIdentifier saved in file settings
|
||||
but only once
|
||||
|
||||
Returns server or None if unsuccessful
|
||||
"""
|
||||
https_updated = False
|
||||
server = None
|
||||
if show_dialog:
|
||||
# Searching for PMS
|
||||
utils.dialog('notification',
|
||||
heading='{plex}',
|
||||
message=utils.lang(30001),
|
||||
icon='{plex}',
|
||||
time=60000)
|
||||
try:
|
||||
while True:
|
||||
if https_updated is False:
|
||||
serverlist = PF.discover_pms(self.plex_token)
|
||||
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
|
||||
LOG.info('We found a server to automatically connect to: %s',
|
||||
server['name'])
|
||||
return server
|
||||
finally:
|
||||
if show_dialog:
|
||||
executebuiltin("Dialog.Close(all, true)")
|
||||
|
||||
|
||||
def _user_pick_pms(self):
|
||||
"""
|
||||
Lets user pick his/her PMS from a list
|
||||
|
||||
Returns server or None if unsuccessful
|
||||
"""
|
||||
https_updated = False
|
||||
# Searching for PMS
|
||||
utils.dialog('notification',
|
||||
heading='{plex}',
|
||||
message=utils.lang(30001),
|
||||
icon='{plex}',
|
||||
time=60000)
|
||||
while True:
|
||||
if https_updated is False:
|
||||
serverlist = PF.discover_pms(self.plex_token)
|
||||
# Exit if no servers found
|
||||
if not serverlist:
|
||||
LOG.warn('No plex media servers found!')
|
||||
utils.messageDialog(utils.lang(29999), utils.lang(39011))
|
||||
return
|
||||
# Get a nicer list
|
||||
dialoglist = []
|
||||
for server in serverlist:
|
||||
if server['local']:
|
||||
# server is in the same network as client.
|
||||
# Add"local"
|
||||
msg = utils.lang(39022)
|
||||
else:
|
||||
# Add 'remote'
|
||||
msg = utils.lang(39054)
|
||||
if server.get('ownername'):
|
||||
# Display username if its not our PMS
|
||||
dialoglist.append('%s (%s, %s)'
|
||||
% (server['name'],
|
||||
server['ownername'],
|
||||
msg))
|
||||
else:
|
||||
dialoglist.append('%s (%s)'
|
||||
% (server['name'], msg))
|
||||
# Let user pick server from a list
|
||||
# Close the PKC info "Searching for PMS"
|
||||
executebuiltin("Dialog.Close(all, true)")
|
||||
resp = utils.dialog('select', utils.lang(39012), dialoglist)
|
||||
if resp == -1:
|
||||
# User cancelled
|
||||
return
|
||||
|
||||
server = serverlist[resp]
|
||||
chk = self._check_pms_connectivity(server)
|
||||
if chk == 504 and https_updated is False:
|
||||
# Not able to use HTTP, try HTTPs for now
|
||||
serverlist[resp]['scheme'] = 'https'
|
||||
https_updated = True
|
||||
continue
|
||||
https_updated = False
|
||||
if chk == 401:
|
||||
LOG.warn('Not yet authorized for Plex server %s',
|
||||
server['name'])
|
||||
# Not yet authorized for Plex server %s
|
||||
utils.messageDialog(
|
||||
utils.lang(29999),
|
||||
'%s %s\n%s' % (utils.lang(39013),
|
||||
server['name'].decode('utf-8'),
|
||||
utils.lang(39014)))
|
||||
if self.plex_tv_sign_in() is False:
|
||||
# Exit while loop if user cancels
|
||||
return
|
||||
# Problems connecting
|
||||
elif chk >= 400 or chk is False:
|
||||
# Problems connecting to server. Pick another server?
|
||||
if not utils.yesno_dialog(utils.lang(29999), utils.lang(39015)):
|
||||
# Exit while loop if user chooses No
|
||||
return
|
||||
# Otherwise: connection worked!
|
||||
else:
|
||||
return server
|
||||
|
||||
@staticmethod
|
||||
def write_pms_to_settings(server):
|
||||
"""
|
||||
Saves server to file settings
|
||||
"""
|
||||
utils.settings('plex_machineIdentifier', server['machineIdentifier'])
|
||||
utils.settings('plex_servername', server['name'])
|
||||
utils.settings('plex_serverowned',
|
||||
'true' if server['owned'] else 'false')
|
||||
# Careful to distinguish local from remote PMS
|
||||
if server['local']:
|
||||
scheme = server['scheme']
|
||||
utils.settings('ipaddress', server['ip'])
|
||||
utils.settings('port', server['port'])
|
||||
LOG.debug("Setting SSL verify to false, because server is "
|
||||
"local")
|
||||
utils.settings('sslverify', 'false')
|
||||
else:
|
||||
baseURL = server['baseURL'].split(':')
|
||||
scheme = baseURL[0]
|
||||
utils.settings('ipaddress', baseURL[1].replace('//', ''))
|
||||
utils.settings('port', baseURL[2])
|
||||
LOG.debug("Setting SSL verify to true, because server is not "
|
||||
"local")
|
||||
utils.settings('sslverify', 'true')
|
||||
|
||||
if scheme == 'https':
|
||||
utils.settings('https', 'true')
|
||||
else:
|
||||
utils.settings('https', 'false')
|
||||
# And finally do some logging
|
||||
LOG.debug("Writing to Kodi user settings file")
|
||||
LOG.debug("PMS machineIdentifier: %s, ip: %s, port: %s, https: %s ",
|
||||
server['machineIdentifier'], server['ip'], server['port'],
|
||||
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):
|
||||
"""
|
||||
Initial setup. Run once upon startup.
|
||||
|
||||
Check server, user, direct paths, music, direct stream if not direct
|
||||
path.
|
||||
"""
|
||||
log.info("Initial setup called")
|
||||
connectmanager = ConnectManager()
|
||||
|
||||
LOG.info("Initial setup called.")
|
||||
try:
|
||||
with utils.XmlKodiSetting('advancedsettings.xml',
|
||||
force_create=True,
|
||||
top_element='advancedsettings') as xml:
|
||||
# Get current Kodi video cache setting
|
||||
cache, _ = advancedsettings_xml(['cache', 'memorysize'])
|
||||
if cache is None:
|
||||
# Kodi default cache
|
||||
cache = '20971520'
|
||||
else:
|
||||
cache = str(cache.text)
|
||||
log.info('Current Kodi video memory cache in bytes: %s' % cache)
|
||||
settings('kodi_video_cache', value=cache)
|
||||
cache = xml.get_setting(['cache', 'memorysize'])
|
||||
# Disable foreground "Loading media information from files"
|
||||
# (still used by Kodi, even though the Wiki says otherwise)
|
||||
xml.set_setting(['musiclibrary', 'backgroundupdate'],
|
||||
value='true')
|
||||
cleanonupdate = xml.get_setting(
|
||||
['videolibrary', 'cleanonupdate']) == 'true'
|
||||
if utils.settings('useDirectPaths') != '1':
|
||||
# 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%)
|
||||
xml.set_setting(['video', 'ignorepercentatend'], value='10')
|
||||
xml.set_setting(['video', 'playcountminimumpercent'],
|
||||
value='90')
|
||||
xml.set_setting(['video', 'ignoresecondsatstart'],
|
||||
value='60')
|
||||
reboot = xml.write_xml
|
||||
except utils.ParseError:
|
||||
cache = None
|
||||
reboot = False
|
||||
cleanonupdate = False
|
||||
# Kodi default cache if no setting is set
|
||||
cache = str(cache.text) if cache is not None else '20971520'
|
||||
LOG.info('Current Kodi video memory cache in bytes: %s', cache)
|
||||
utils.settings('kodi_video_cache', value=cache)
|
||||
|
||||
# Hack to make PKC Kodi master lock compatible
|
||||
try:
|
||||
with utils.XmlKodiSetting('sources.xml',
|
||||
force_create=True,
|
||||
top_element='sources') as xml:
|
||||
changed = False
|
||||
for extension in ('smb://', 'nfs://'):
|
||||
root = xml.set_setting(['video'])
|
||||
changed = self._add_sources(root, extension) or changed
|
||||
if changed:
|
||||
xml.write_xml = True
|
||||
reboot = True
|
||||
except utils.ParseError:
|
||||
pass
|
||||
|
||||
# Do we need to migrate stuff?
|
||||
check_migration()
|
||||
migration.check_migration()
|
||||
# Reload the server IP cause we might've deleted it during migration
|
||||
app.CONN.load()
|
||||
|
||||
# Optionally sign into plex.tv. Will not be called on very first run
|
||||
# as plexToken will be ''
|
||||
settings('plex_status', value=lang(39226))
|
||||
if connectmanager.plexToken and connectmanager.myplexlogin:
|
||||
connectmanager.check_plex_tv_signin()
|
||||
# Display a warning if Kodi puts ALL movies into the queue, basically
|
||||
# breaking playback reporting for PKC
|
||||
warn = False
|
||||
settings = js.settings_getsettingvalue('videoplayer.autoplaynextitem')
|
||||
if v.KODIVERSION >= 18:
|
||||
# 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
|
||||
utils.settings('warned_setting_videoplayer.autoplaynextitem',
|
||||
value='true')
|
||||
# Warning: Kodi setting "Play next video automatically" is
|
||||
# enabled. This could break PKC. Deactivate?
|
||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(30003)):
|
||||
if v.KODIVERSION >= 18:
|
||||
for i in (1, 2, 3):
|
||||
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
|
||||
# hide "Compressing database"
|
||||
js.settings_setsettingvalue('videolibrary.backgroundupdate', True)
|
||||
|
||||
# If a Plex server IP has already been set
|
||||
# return only if the right machine identifier is found
|
||||
if connectmanager.server:
|
||||
log.info("PMS is already set: %s. Checking now..."
|
||||
% connectmanager.server)
|
||||
if connectmanager.check_pms():
|
||||
log.info("Using PMS %s with machineIdentifier %s"
|
||||
% (connectmanager.server, connectmanager.serverid))
|
||||
connectmanager.write_pms_settings(connectmanager.server,
|
||||
connectmanager.pms_token)
|
||||
connectmanager.pick_pms(show_dialog=True)
|
||||
if app.CONN.server:
|
||||
LOG.info("PMS is already set: %s. Checking now...", app.CONN.server)
|
||||
if self.check_existing_pms():
|
||||
LOG.info("Using PMS %s with machineIdentifier %s",
|
||||
app.CONN.server, app.CONN.machine_identifier)
|
||||
self.save_pms_settings(app.CONN.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:
|
||||
utils.reboot_kodi()
|
||||
return
|
||||
else:
|
||||
LOG.info('No PMS set yet')
|
||||
|
||||
# If not already retrieved myplex info, optionally let user sign in
|
||||
# to plex.tv. This DOES get called on very first install run
|
||||
if not connectmanager.plexToken and connectmanager.myplexlogin:
|
||||
connectmanager.plex_tv_signin()
|
||||
if not self.plex_token and app.ACCOUNT.myplexlogin:
|
||||
self.plex_tv_sign_in()
|
||||
|
||||
server = connectmanager.connectmanager.pick_pms()
|
||||
server = self.pick_pms(inform_of_search=True)
|
||||
if server is not None:
|
||||
# Write our chosen server to Kodi settings file
|
||||
connectmanager.write_pms_to_settings(server)
|
||||
self.save_pms_settings(server['baseURL'], server['token'])
|
||||
self.write_pms_to_settings(server)
|
||||
|
||||
# User already answered the installation questions
|
||||
if settings('InstallQuestionsAnswered') == 'true':
|
||||
if utils.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:
|
||||
utils.reboot_kodi()
|
||||
# Reload relevant settings
|
||||
app.CONN.load()
|
||||
app.ACCOUNT.load()
|
||||
app.SYNC.load()
|
||||
return
|
||||
|
||||
LOG.info('Showing install questions')
|
||||
# Additional settings where the user needs to choose
|
||||
# Direct paths (\\NAS\mymovie.mkv) or addon (http)?
|
||||
goToSettings = False
|
||||
if dialog('yesno',
|
||||
lang(29999),
|
||||
lang(39027),
|
||||
lang(39028),
|
||||
nolabel="Addon (Default)",
|
||||
yeslabel="Native (Direct Paths)"):
|
||||
log.debug("User opted to use direct paths.")
|
||||
settings('useDirectPaths', value="1")
|
||||
state.DIRECT_PATHS = True
|
||||
goto_settings = False
|
||||
from .windows import optionsdialog
|
||||
# Use Add-on Paths (default, easy) or Direct Paths? PKC will not work
|
||||
# if your Direct Paths setup is wrong!
|
||||
# Buttons: Add-on Paths // Direct Paths
|
||||
if optionsdialog.show(utils.lang(29999), utils.lang(39080),
|
||||
utils.lang(39081), utils.lang(39082)) == 1:
|
||||
LOG.debug("User opted to use direct paths.")
|
||||
utils.settings('useDirectPaths', value="1")
|
||||
if cleanonupdate:
|
||||
# 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
|
||||
# \\NAS\mymovie.mkv with smb://NAS/mymovie.mkv? (e.g. Windows)
|
||||
if dialog('yesno', heading=lang(29999), line1=lang(39033)):
|
||||
log.debug("User chose to replace paths with smb")
|
||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(39033)):
|
||||
LOG.debug("User chose to replace paths with smb")
|
||||
else:
|
||||
settings('replaceSMB', value="false")
|
||||
utils.settings('replaceSMB', value="false")
|
||||
|
||||
# complete replace all original Plex library paths with custom SMB
|
||||
if dialog('yesno', heading=lang(29999), line1=lang(39043)):
|
||||
log.debug("User chose custom smb paths")
|
||||
settings('remapSMB', value="true")
|
||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(39043)):
|
||||
LOG.debug("User chose custom smb paths")
|
||||
utils.settings('remapSMB', value="true")
|
||||
# Please enter your custom smb paths in the settings under
|
||||
# "Sync Options" and then restart Kodi
|
||||
dialog('ok', heading=lang(29999), line1=lang(39044))
|
||||
goToSettings = True
|
||||
utils.messageDialog(utils.lang(29999), utils.lang(39044))
|
||||
goto_settings = True
|
||||
|
||||
# Go to network credentials?
|
||||
if dialog('yesno',
|
||||
heading=lang(29999),
|
||||
line1=lang(39029),
|
||||
line2=lang(39030)):
|
||||
log.debug("Presenting network credentials dialog.")
|
||||
from utils import passwordsXML
|
||||
passwordsXML()
|
||||
if utils.yesno_dialog(utils.lang(39029), utils.lang(39030)):
|
||||
LOG.debug("Presenting network credentials dialog.")
|
||||
from .windows import direct_path_sources
|
||||
direct_path_sources.start()
|
||||
# Disable Plex music?
|
||||
if dialog('yesno', heading=lang(29999), line1=lang(39016)):
|
||||
log.debug("User opted to disable Plex music library.")
|
||||
settings('enableMusic', value="false")
|
||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(39016)):
|
||||
LOG.debug("User opted to disable Plex music library.")
|
||||
utils.settings('enableMusic', value="false")
|
||||
|
||||
# Download additional art from FanArtTV
|
||||
if dialog('yesno', heading=lang(29999), line1=lang(39061)):
|
||||
log.debug("User opted to use FanArtTV")
|
||||
settings('FanartTV', value="true")
|
||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(39061)):
|
||||
LOG.debug("User opted to use FanArtTV")
|
||||
utils.settings('FanartTV', value="true")
|
||||
# Do you want to replace your custom user ratings with an indicator of
|
||||
# how many versions of a media item you posses?
|
||||
if dialog('yesno', heading=lang(29999), line1=lang(39718)):
|
||||
log.debug("User opted to replace user ratings with version number")
|
||||
settings('indicate_media_versions', value="true")
|
||||
if utils.yesno_dialog(utils.lang(29999), utils.lang(39718)):
|
||||
LOG.debug("User opted to replace user ratings with version number")
|
||||
utils.settings('indicate_media_versions', value="true")
|
||||
|
||||
# If you use several Plex libraries of one kind, e.g. "Kids Movies" and
|
||||
# "Parents Movies", be sure to check https://goo.gl/JFtQV9
|
||||
dialog('ok', heading=lang(29999), line1=lang(39076))
|
||||
# dialog.ok(heading=utils.lang(29999), line1=utils.lang(39076))
|
||||
|
||||
# Need to tell about our image source for collections: themoviedb.org
|
||||
dialog('ok', heading=lang(29999), line1=lang(39717))
|
||||
# dialog.ok(heading=utils.lang(29999), line1=utils.lang(39717))
|
||||
# Make sure that we only ask these questions upon first installation
|
||||
settings('InstallQuestionsAnswered', value='true')
|
||||
utils.settings('InstallQuestionsAnswered', value='true')
|
||||
|
||||
if goToSettings is False:
|
||||
if goto_settings is False:
|
||||
# Open Settings page now? You will need to restart!
|
||||
goToSettings = dialog('yesno', heading=lang(29999), line1=lang(39017))
|
||||
if goToSettings:
|
||||
state.PMS_STATUS = 'Stop'
|
||||
executebuiltin('Addon.OpenSettings(plugin.video.plexkodiconnect)')
|
||||
goto_settings = utils.yesno_dialog(utils.lang(29999),
|
||||
utils.lang(39017))
|
||||
# New installation - make sure we start with a clean slate
|
||||
utils.wipe_database(reboot=False)
|
||||
if goto_settings:
|
||||
LOG.info('User chose to go to the PKC settings - suspending PKC')
|
||||
app.APP.stop_pkc = True
|
||||
executebuiltin(
|
||||
'Addon.OpenSettings(plugin.video.plexkodiconnect)')
|
||||
return
|
||||
utils.reboot_kodi()
|
||||
|
|
File diff suppressed because it is too large
Load diff
30
resources/lib/itemtypes/__init__.py
Normal file
30
resources/lib/itemtypes/__init__.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
#!/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
|
||||
}
|
171
resources/lib/itemtypes/common.py
Normal file
171
resources/lib/itemtypes/common.py
Normal file
|
@ -0,0 +1,171 @@
|
|||
#!/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
|
245
resources/lib/itemtypes/movies.py
Normal file
245
resources/lib/itemtypes/movies.py
Normal file
|
@ -0,0 +1,245 @@
|
|||
#!/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')))
|
635
resources/lib/itemtypes/music.py
Normal file
635
resources/lib/itemtypes/music.py
Normal file
|
@ -0,0 +1,635 @@
|
|||
#!/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)
|
594
resources/lib/itemtypes/tvshows.py
Normal file
594
resources/lib/itemtypes/tvshows.py
Normal file
|
@ -0,0 +1,594 @@
|
|||
#!/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')))
|
626
resources/lib/json_rpc.py
Normal file
626
resources/lib/json_rpc.py
Normal file
|
@ -0,0 +1,626 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Collection of functions using the Kodi JSON RPC interface.
|
||||
See http://kodi.wiki/view/JSON-RPC_API
|
||||
"""
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from json import loads, dumps
|
||||
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):
|
||||
"""
|
||||
Used for all Kodi JSON RPC calls.
|
||||
"""
|
||||
id_ = 1
|
||||
version = "2.0"
|
||||
|
||||
def __init__(self, method, **kwargs):
|
||||
"""
|
||||
Initialize with the Kodi method, e.g. 'Player.GetActivePlayers'
|
||||
"""
|
||||
self.method = method
|
||||
self.params = None
|
||||
for arg in kwargs:
|
||||
self.arg = arg
|
||||
|
||||
def _query(self):
|
||||
query = {
|
||||
'jsonrpc': self.version,
|
||||
'id': self.id_,
|
||||
'method': self.method,
|
||||
}
|
||||
if self.params is not None:
|
||||
query['params'] = self.params
|
||||
return dumps(query)
|
||||
|
||||
def execute(self, params=None):
|
||||
"""
|
||||
Pass any params as a dict. Will return Kodi's answer as a dict.
|
||||
"""
|
||||
self.params = params
|
||||
return loads(executeJSONRPC(self._query()))
|
||||
|
||||
|
||||
def get_players():
|
||||
"""
|
||||
Returns all the active Kodi players (usually 3) in a dict:
|
||||
{
|
||||
'video': {'playerid': int, 'type': 'video'}
|
||||
'audio': ...
|
||||
'picture': ...
|
||||
}
|
||||
"""
|
||||
ret = {}
|
||||
for player in JsonRPC("Player.GetActivePlayers").execute()['result']:
|
||||
player['playerid'] = int(player['playerid'])
|
||||
ret[player['type']] = player
|
||||
return ret
|
||||
|
||||
|
||||
def get_player_ids():
|
||||
"""
|
||||
Returns a list of all the active Kodi player ids (usually 3) as int
|
||||
"""
|
||||
ret = []
|
||||
for player in get_players().values():
|
||||
ret.append(player['playerid'])
|
||||
return ret
|
||||
|
||||
|
||||
def get_playlist_id(typus):
|
||||
"""
|
||||
Returns the corresponding Kodi playlist id as an int
|
||||
typus: Kodi playlist types: 'video', 'audio' or 'picture'
|
||||
|
||||
Returns None if nothing was found
|
||||
"""
|
||||
for playlist in get_playlists():
|
||||
if playlist.get('type') == typus:
|
||||
return playlist.get('playlistid')
|
||||
|
||||
|
||||
def get_playlists():
|
||||
"""
|
||||
Returns a list of all the Kodi playlists, e.g.
|
||||
[
|
||||
{u'playlistid': 0, u'type': u'audio'},
|
||||
{u'playlistid': 1, u'type': u'video'},
|
||||
{u'playlistid': 2, u'type': u'picture'}
|
||||
]
|
||||
"""
|
||||
try:
|
||||
ret = JsonRPC('Playlist.GetPlaylists').execute()['result']
|
||||
except KeyError:
|
||||
ret = []
|
||||
return ret
|
||||
|
||||
|
||||
def get_volume():
|
||||
"""
|
||||
Returns the Kodi volume as an int between 0 (min) and 100 (max)
|
||||
"""
|
||||
return JsonRPC('Application.GetProperties').execute(
|
||||
{"properties": ['volume']})['result']['volume']
|
||||
|
||||
|
||||
def set_volume(volume):
|
||||
"""
|
||||
Set's the volume (for Kodi overall, not only a player).
|
||||
Feed with an int
|
||||
"""
|
||||
return JsonRPC('Application.SetVolume').execute({"volume": volume})
|
||||
|
||||
|
||||
def get_muted():
|
||||
"""
|
||||
Returns True if Kodi is muted, False otherwise
|
||||
"""
|
||||
return JsonRPC('Application.GetProperties').execute(
|
||||
{"properties": ['muted']})['result']['muted']
|
||||
|
||||
|
||||
def play():
|
||||
"""
|
||||
Toggles all Kodi players to play
|
||||
"""
|
||||
for playerid in get_player_ids():
|
||||
JsonRPC("Player.PlayPause").execute({"playerid": playerid,
|
||||
"play": True})
|
||||
|
||||
|
||||
def pause():
|
||||
"""
|
||||
Pauses playback for all Kodi players
|
||||
"""
|
||||
for playerid in get_player_ids():
|
||||
JsonRPC("Player.PlayPause").execute({"playerid": playerid,
|
||||
"play": False})
|
||||
|
||||
|
||||
def stop():
|
||||
"""
|
||||
Stops playback for all Kodi players
|
||||
"""
|
||||
for playerid in get_player_ids():
|
||||
JsonRPC("Player.Stop").execute({"playerid": playerid})
|
||||
|
||||
|
||||
def seek_to(offset):
|
||||
"""
|
||||
Seeks all Kodi players to offset [int] in milliseconds
|
||||
"""
|
||||
for playerid in get_player_ids():
|
||||
return JsonRPC("Player.Seek").execute(
|
||||
{"playerid": playerid,
|
||||
"value": timing.millis_to_kodi_time(offset)})
|
||||
|
||||
|
||||
def smallforward():
|
||||
"""
|
||||
Small step forward for all Kodi players
|
||||
"""
|
||||
for playerid in get_player_ids():
|
||||
JsonRPC("Player.Seek").execute({"playerid": playerid,
|
||||
"value": "smallforward"})
|
||||
|
||||
|
||||
def smallbackward():
|
||||
"""
|
||||
Small step backward for all Kodi players
|
||||
"""
|
||||
for playerid in get_player_ids():
|
||||
JsonRPC("Player.Seek").execute({"playerid": playerid,
|
||||
"value": "smallbackward"})
|
||||
|
||||
|
||||
def skipnext():
|
||||
"""
|
||||
Skips to the next item to play for all Kodi players
|
||||
"""
|
||||
for playerid in get_player_ids():
|
||||
JsonRPC("Player.GoTo").execute({"playerid": playerid,
|
||||
"to": "next"})
|
||||
|
||||
|
||||
def skipprevious():
|
||||
"""
|
||||
Skips to the previous item to play for all Kodi players
|
||||
Using a HACK to make sure we're not just starting same item over again
|
||||
"""
|
||||
for playerid in get_player_ids():
|
||||
try:
|
||||
skipto(get_position(playerid) - 1)
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def wont_work_skipprevious():
|
||||
"""
|
||||
Skips to the previous item to play for all Kodi players
|
||||
"""
|
||||
for playerid in get_player_ids():
|
||||
JsonRPC("Player.GoTo").execute({"playerid": playerid,
|
||||
"to": "previous"})
|
||||
|
||||
|
||||
def skipto(position):
|
||||
"""
|
||||
Skips to the position [int] of the current playlist
|
||||
"""
|
||||
for playerid in get_player_ids():
|
||||
JsonRPC("Player.GoTo").execute({"playerid": playerid,
|
||||
"to": position})
|
||||
|
||||
|
||||
def input_up():
|
||||
"""
|
||||
Tells Kodi the user pushed up
|
||||
"""
|
||||
return JsonRPC("Input.Up").execute()
|
||||
|
||||
|
||||
def input_down():
|
||||
"""
|
||||
Tells Kodi the user pushed down
|
||||
"""
|
||||
return JsonRPC("Input.Down").execute()
|
||||
|
||||
|
||||
def input_left():
|
||||
"""
|
||||
Tells Kodi the user pushed left
|
||||
"""
|
||||
return JsonRPC("Input.Left").execute()
|
||||
|
||||
|
||||
def input_right():
|
||||
"""
|
||||
Tells Kodi the user pushed left
|
||||
"""
|
||||
return JsonRPC("Input.Right").execute()
|
||||
|
||||
|
||||
def input_select():
|
||||
"""
|
||||
Tells Kodi the user pushed select
|
||||
"""
|
||||
return JsonRPC("Input.Select").execute()
|
||||
|
||||
|
||||
def input_home():
|
||||
"""
|
||||
Tells Kodi the user pushed home
|
||||
"""
|
||||
return JsonRPC("Input.Home").execute()
|
||||
|
||||
|
||||
def input_back():
|
||||
"""
|
||||
Tells Kodi the user pushed back
|
||||
"""
|
||||
return JsonRPC("Input.Back").execute()
|
||||
|
||||
|
||||
def input_sendtext(text):
|
||||
"""
|
||||
Tells Kodi the user sent text [unicode]
|
||||
"""
|
||||
return JsonRPC("Input.SendText").execute({'test': text, 'done': False})
|
||||
|
||||
|
||||
def playlist_get_items(playlistid):
|
||||
"""
|
||||
playlistid: [int] id of the Kodi playlist
|
||||
|
||||
Returns a list of Kodi playlist items as dicts with the keys specified in
|
||||
properties. Or an empty list if unsuccessful. Example:
|
||||
[
|
||||
{
|
||||
u'file':u'smb://nas/PlexMovies/3 Idiots 2009 pt1.mkv',
|
||||
u'title': u'3 Idiots',
|
||||
u'type': u'movie', # IF possible! Else key missing
|
||||
u'id': 3, # IF possible! Else key missing
|
||||
u'label': u'3 Idiots'}]
|
||||
"""
|
||||
reply = JsonRPC('Playlist.GetItems').execute({
|
||||
'playlistid': playlistid,
|
||||
'properties': ['title', 'file']
|
||||
})
|
||||
try:
|
||||
reply = reply['result']['items']
|
||||
except KeyError:
|
||||
reply = []
|
||||
return reply
|
||||
|
||||
|
||||
def playlist_add(playlistid, item):
|
||||
"""
|
||||
Adds an item to the Kodi playlist with id playlistid. item is either the
|
||||
dict
|
||||
{'file': filepath as string}
|
||||
or
|
||||
{kodi_type: kodi_id}
|
||||
|
||||
Returns a dict with the key 'error' if unsuccessful.
|
||||
"""
|
||||
return JsonRPC('Playlist.Add').execute({'playlistid': playlistid,
|
||||
'item': item})
|
||||
|
||||
|
||||
def playlist_insert(params):
|
||||
"""
|
||||
Insert item(s) into playlist. Does not work for picture playlists (aka
|
||||
slideshows). params is the dict
|
||||
{
|
||||
'playlistid': [int]
|
||||
'position': [int]
|
||||
'item': <item>
|
||||
}
|
||||
item is either the dict
|
||||
{'file': filepath as string}
|
||||
or
|
||||
{kodi_type: kodi_id}
|
||||
Returns a dict with the key 'error' if something went wrong.
|
||||
"""
|
||||
return JsonRPC('Playlist.Insert').execute(params)
|
||||
|
||||
|
||||
def playlist_remove(playlistid, position):
|
||||
"""
|
||||
Removes the playlist item at position from the playlist
|
||||
position: [int]
|
||||
|
||||
Returns a dict with the key 'error' if something went wrong.
|
||||
"""
|
||||
return JsonRPC('Playlist.Remove').execute({'playlistid': playlistid,
|
||||
'position': position})
|
||||
|
||||
|
||||
def get_setting(setting):
|
||||
"""
|
||||
Returns the Kodi setting (GetSettingValue), a [str], or None if not
|
||||
possible
|
||||
"""
|
||||
try:
|
||||
ret = JsonRPC('Settings.GetSettingValue').execute(
|
||||
{'setting': setting})['result']['value']
|
||||
except (KeyError, TypeError):
|
||||
ret = None
|
||||
return ret
|
||||
|
||||
|
||||
def set_setting(setting, value):
|
||||
"""
|
||||
Sets the Kodi setting, a [str], to value
|
||||
"""
|
||||
return JsonRPC('Settings.SetSettingValue').execute(
|
||||
{'setting': setting, 'value': value})
|
||||
|
||||
|
||||
def get_tv_shows(params):
|
||||
"""
|
||||
Returns a list of tv shows for params (check the Kodi wiki)
|
||||
"""
|
||||
ret = JsonRPC('VideoLibrary.GetTVShows').execute(params)
|
||||
try:
|
||||
ret = ret['result']['tvshows']
|
||||
except (KeyError, TypeError):
|
||||
ret = []
|
||||
return ret
|
||||
|
||||
|
||||
def get_episodes(params):
|
||||
"""
|
||||
Returns a list of tv show episodes for params (check the Kodi wiki)
|
||||
"""
|
||||
ret = JsonRPC('VideoLibrary.GetEpisodes').execute(params)
|
||||
try:
|
||||
ret = ret['result']['episodes']
|
||||
except (KeyError, TypeError):
|
||||
ret = []
|
||||
return ret
|
||||
|
||||
|
||||
def get_item(playerid):
|
||||
"""
|
||||
UNRELIABLE on playback startup! (as other JSON and Python Kodi functions)
|
||||
Returns the following for the currently playing item:
|
||||
{
|
||||
u'title': u'Okja',
|
||||
u'type': u'movie',
|
||||
u'id': 258,
|
||||
u'file': u'smb://...movie.mkv',
|
||||
u'label': u'Okja'
|
||||
}
|
||||
"""
|
||||
return JsonRPC('Player.GetItem').execute({
|
||||
'playerid': playerid,
|
||||
'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):
|
||||
"""
|
||||
Returns a dict for the active Kodi player with the following values:
|
||||
{
|
||||
'type' [str] the Kodi player type, e.g. 'video'
|
||||
'time' The current item's time in Kodi time
|
||||
'totaltime' The current item's total length in Kodi time
|
||||
'speed' [int] playback speed, 0 is paused, 1 is playing
|
||||
'shuffled' [bool] True if shuffled
|
||||
'repeat' [str] 'off', 'one', 'all'
|
||||
'position' [int] position in playlist (or -1)
|
||||
'playlistid' [int] the Kodi playlist id (or -1)
|
||||
}
|
||||
"""
|
||||
return JsonRPC('Player.GetProperties').execute({
|
||||
'playerid': playerid,
|
||||
'properties': ['type',
|
||||
'time',
|
||||
'totaltime',
|
||||
'speed',
|
||||
'shuffled',
|
||||
'repeat',
|
||||
'position',
|
||||
'playlistid',
|
||||
'currentvideostream',
|
||||
'currentaudiostream',
|
||||
'subtitleenabled',
|
||||
'currentsubtitle']})['result']
|
||||
|
||||
|
||||
def get_position(playerid):
|
||||
"""
|
||||
Returns the currently playing item's position [int] within the playlist
|
||||
"""
|
||||
return JsonRPC('Player.GetProperties').execute({
|
||||
'playerid': playerid,
|
||||
'properties': ['position']})['result']['position']
|
||||
|
||||
|
||||
def current_audiostream(playerid):
|
||||
"""
|
||||
Returns a dict of the active audiostream for playerid [int]:
|
||||
{
|
||||
'index': [int], audiostream index
|
||||
'language': [str]
|
||||
'name': [str]
|
||||
'codec': [str]
|
||||
'bitrate': [int]
|
||||
'channels': [int]
|
||||
}
|
||||
or an empty dict if unsuccessful
|
||||
"""
|
||||
ret = JsonRPC('Player.GetProperties').execute(
|
||||
{'properties': ['currentaudiostream'], 'playerid': playerid})
|
||||
try:
|
||||
ret = ret['result']['currentaudiostream']
|
||||
except (KeyError, TypeError):
|
||||
ret = {}
|
||||
return ret
|
||||
|
||||
|
||||
def current_subtitle(playerid):
|
||||
"""
|
||||
Returns a dict of the active subtitle for playerid [int]:
|
||||
{
|
||||
'index': [int], subtitle index
|
||||
'language': [str]
|
||||
'name': [str]
|
||||
}
|
||||
or an empty dict if unsuccessful
|
||||
"""
|
||||
ret = JsonRPC('Player.GetProperties').execute(
|
||||
{'properties': ['currentsubtitle'], 'playerid': playerid})
|
||||
try:
|
||||
ret = ret['result']['currentsubtitle']
|
||||
except (KeyError, TypeError):
|
||||
ret = {}
|
||||
return ret
|
||||
|
||||
|
||||
def subtitle_enabled(playerid):
|
||||
"""
|
||||
Returns True if a subtitle is enabled, False otherwise
|
||||
"""
|
||||
ret = JsonRPC('Player.GetProperties').execute(
|
||||
{'properties': ['subtitleenabled'], 'playerid': playerid})
|
||||
try:
|
||||
ret = ret['result']['subtitleenabled']
|
||||
except (KeyError, TypeError):
|
||||
ret = False
|
||||
return ret
|
||||
|
||||
|
||||
def ping():
|
||||
"""
|
||||
Pings the JSON RPC interface
|
||||
"""
|
||||
return JsonRPC('JSONRPC.Ping').execute()
|
||||
|
||||
|
||||
def activate_window(window, parameters):
|
||||
"""
|
||||
Pass the parameters as str/unicode to open the corresponding window
|
||||
"""
|
||||
return JsonRPC('GUI.ActivateWindow').execute({'window': window,
|
||||
'parameters': [parameters]})
|
||||
|
||||
|
||||
def settings_getsections():
|
||||
'''
|
||||
Retrieve all Kodi settings sections
|
||||
'''
|
||||
return JsonRPC('Settings.GetSections').execute({'level': 'expert'})
|
||||
|
||||
|
||||
def settings_getcategories():
|
||||
'''
|
||||
Retrieve all Kodi settings categories (one level below sections)
|
||||
'''
|
||||
return JsonRPC('Settings.GetCategories').execute({'level': 'expert'})
|
||||
|
||||
|
||||
def settings_getsettings(filter_params):
|
||||
'''
|
||||
Get all the settings for
|
||||
filter_params = {'category': <str>, 'section': <str>}
|
||||
e.g. = {'category':'videoplayer', 'section':'player'}
|
||||
'''
|
||||
return JsonRPC('Settings.GetSettings').execute({
|
||||
'level': 'expert',
|
||||
'filter': filter_params
|
||||
})
|
||||
|
||||
|
||||
def settings_getsettingvalue(setting):
|
||||
'''
|
||||
Pass in the setting id as a string (as retrieved from settings_getsettings),
|
||||
e.g. 'videoplayer.autoplaynextitem' or None is something went wrong
|
||||
'''
|
||||
ret = JsonRPC('Settings.GetSettingValue').execute({'setting': setting})
|
||||
try:
|
||||
ret = ret['result']['value']
|
||||
except (TypeError, KeyError):
|
||||
ret = None
|
||||
return ret
|
||||
|
||||
|
||||
def settings_setsettingvalue(setting, value):
|
||||
'''
|
||||
Set the Kodi setting (str) to value (type depends, see JSON wiki)
|
||||
'''
|
||||
return JsonRPC('Settings.SetSettingValue').execute({
|
||||
'setting': setting,
|
||||
'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 {}
|
92
resources/lib/kodi_constants.py
Normal file
92
resources/lib/kodi_constants.py
Normal file
|
@ -0,0 +1,92 @@
|
|||
#!/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'
|
||||
}
|
126
resources/lib/kodi_db/__init__.py
Normal file
126
resources/lib/kodi_db/__init__.py
Normal file
|
@ -0,0 +1,126 @@
|
|||
#!/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
|
||||
}
|
155
resources/lib/kodi_db/common.py
Normal file
155
resources/lib/kodi_db/common.py
Normal file
|
@ -0,0 +1,155 @@
|
|||
#!/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)
|
628
resources/lib/kodi_db/music.py
Normal file
628
resources/lib/kodi_db/music.py
Normal file
|
@ -0,0 +1,628 @@
|
|||
#!/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, ))
|
17
resources/lib/kodi_db/texture.py
Normal file
17
resources/lib/kodi_db/texture.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
#!/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
|
1019
resources/lib/kodi_db/video.py
Normal file
1019
resources/lib/kodi_db/video.py
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -1,255 +1,685 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
###############################################################################
|
||||
|
||||
import logging
|
||||
"""
|
||||
PKC Kodi Monitoring implementation
|
||||
"""
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
from json import loads
|
||||
import copy
|
||||
import json
|
||||
import binascii
|
||||
|
||||
from xbmc import Monitor, Player, sleep
|
||||
import xbmc
|
||||
|
||||
import downloadutils
|
||||
import plexdb_functions as plexdb
|
||||
from utils import window, settings, CatchExceptions, tryDecode, tryEncode
|
||||
from PlexFunctions import scrobble
|
||||
from kodidb_functions import get_kodiid_from_filename
|
||||
from PlexAPI import API
|
||||
from variables import REMAP_TYPE_FROM_PLEXTYPE
|
||||
import state
|
||||
from .plex_api import API
|
||||
from .plex_db import PlexDB
|
||||
from .kodi_db import KodiVideoDB
|
||||
from . import kodi_db
|
||||
from .downloadutils import DownloadUtils as DU
|
||||
from . import utils, timing, plex_functions as PF
|
||||
from . import json_rpc as js, playqueue as PQ, playlist_func as PL
|
||||
from . import backgroundthread, app, variables as v
|
||||
from . import exceptions
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = logging.getLogger("PLEX."+__name__)
|
||||
|
||||
###############################################################################
|
||||
LOG = getLogger('PLEX.kodimonitor')
|
||||
|
||||
|
||||
class KodiMonitor(Monitor):
|
||||
class KodiMonitor(xbmc.Monitor):
|
||||
"""
|
||||
PKC implementation of the Kodi Monitor class. Invoke only once.
|
||||
"""
|
||||
|
||||
def __init__(self, callback):
|
||||
self.mgr = callback
|
||||
self.doUtils = downloadutils.DownloadUtils().downloadUrl
|
||||
self.xbmcplayer = Player()
|
||||
self.playqueue = self.mgr.playqueue
|
||||
Monitor.__init__(self)
|
||||
log.info("Kodi monitor started.")
|
||||
def __init__(self):
|
||||
self._already_slept = False
|
||||
self._switched_to_plex_streams = True
|
||||
xbmc.Monitor.__init__(self)
|
||||
for playerid in app.PLAYSTATE.player_states:
|
||||
app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
|
||||
app.PLAYSTATE.old_player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
|
||||
LOG.info("Kodi monitor started.")
|
||||
|
||||
def onScanStarted(self, library):
|
||||
log.debug("Kodi library scan %s running." % library)
|
||||
if library == "video":
|
||||
window('plex_kodiScan', value="true")
|
||||
"""
|
||||
Will be called when Kodi starts scanning the library
|
||||
"""
|
||||
LOG.debug("Kodi library scan %s running.", library)
|
||||
|
||||
def onScanFinished(self, library):
|
||||
log.debug("Kodi library scan %s finished." % library)
|
||||
if library == "video":
|
||||
window('plex_kodiScan', clear=True)
|
||||
"""
|
||||
Will be called when Kodi finished scanning the library
|
||||
"""
|
||||
LOG.debug("Kodi library scan %s finished.", library)
|
||||
|
||||
def onSettingsChanged(self):
|
||||
"""
|
||||
Monitor the PKC settings for changes made by the user
|
||||
"""
|
||||
# settings: window-variable
|
||||
items = {
|
||||
'logLevel': 'plex_logLevel',
|
||||
'enableContext': 'plex_context',
|
||||
'plex_restricteduser': 'plex_restricteduser',
|
||||
'dbSyncIndicator': 'dbSyncIndicator',
|
||||
'remapSMB': 'remapSMB',
|
||||
'replaceSMB': 'replaceSMB',
|
||||
'force_transcode_pix': 'plex_force_transcode_pix',
|
||||
'fetch_pms_item_number': 'fetch_pms_item_number'
|
||||
}
|
||||
# Path replacement
|
||||
for typus in REMAP_TYPE_FROM_PLEXTYPE.values():
|
||||
for arg in ('Org', 'New'):
|
||||
key = 'remapSMB%s%s' % (typus, arg)
|
||||
items[key] = key
|
||||
# Reset the window variables from the settings variables
|
||||
for settings_value, window_value in items.iteritems():
|
||||
if window(window_value) != settings(settings_value):
|
||||
log.debug('PKC settings changed: %s is now %s'
|
||||
% (settings_value, settings(settings_value)))
|
||||
window(window_value, value=settings(settings_value))
|
||||
if settings_value == 'fetch_pms_item_number':
|
||||
log.info('Requesting playlist/nodes refresh')
|
||||
window('plex_runLibScan', value="views")
|
||||
LOG.debug('PKC settings change detected')
|
||||
|
||||
@CatchExceptions(warnuser=False)
|
||||
def onNotification(self, sender, method, data):
|
||||
|
||||
"""
|
||||
Called when a bunch of different stuff happens on the Kodi side
|
||||
"""
|
||||
if data:
|
||||
data = loads(data, 'utf-8')
|
||||
log.debug("Method: %s Data: %s" % (method, data))
|
||||
LOG.debug("Method: %s Data: %s", method, data)
|
||||
|
||||
if method == "Player.OnPlay":
|
||||
with app.APP.lock_playqueues:
|
||||
self.PlayBackStart(data)
|
||||
|
||||
elif method == 'Player.OnAVChange':
|
||||
with app.APP.lock_playqueues:
|
||||
self._on_av_change(data)
|
||||
elif method == "Player.OnStop":
|
||||
# Should refresh our video nodes, e.g. on deck
|
||||
# xbmc.executebuiltin('ReloadSkin()')
|
||||
pass
|
||||
|
||||
with app.APP.lock_playqueues:
|
||||
_playback_cleanup(ended=data.get('end'))
|
||||
elif method == 'Playlist.OnAdd':
|
||||
if 'item' in data and data['item'].get('type') == v.KODI_TYPE_SHOW:
|
||||
# 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':
|
||||
self._playlist_onremove(data)
|
||||
elif method == 'Playlist.OnClear':
|
||||
with app.APP.lock_playqueues:
|
||||
self._playlist_onclear(data)
|
||||
elif method == "VideoLibrary.OnUpdate":
|
||||
# Manually marking as watched/unwatched
|
||||
playcount = data.get('playcount')
|
||||
item = data.get('item')
|
||||
|
||||
try:
|
||||
kodiid = item['id']
|
||||
item_type = item['type']
|
||||
except (KeyError, TypeError):
|
||||
log.info("Item is invalid for playstate update.")
|
||||
else:
|
||||
# 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:
|
||||
# Stop from manually marking as watched unwatched, with
|
||||
# actual playback.
|
||||
if window('plex_skipWatched%s' % itemid) == "true":
|
||||
# property is set in player.py
|
||||
window('plex_skipWatched%s' % itemid, clear=True)
|
||||
else:
|
||||
# notify the server
|
||||
if playcount > 0:
|
||||
scrobble(itemid, 'watched')
|
||||
else:
|
||||
scrobble(itemid, 'unwatched')
|
||||
|
||||
with app.APP.lock_playqueues:
|
||||
_videolibrary_onupdate(data)
|
||||
elif method == "VideoLibrary.OnRemove":
|
||||
pass
|
||||
|
||||
elif method == "System.OnSleep":
|
||||
# Connection is going to sleep
|
||||
log.info("Marking the server as offline. SystemOnSleep activated.")
|
||||
window('plex_online', value="sleep")
|
||||
|
||||
LOG.info("Marking the server as offline. SystemOnSleep activated.")
|
||||
elif method == "System.OnWake":
|
||||
# Allow network to wake up
|
||||
sleep(10000)
|
||||
window('plex_onWake', value="true")
|
||||
window('plex_online', value="false")
|
||||
|
||||
self.waitForAbort(10)
|
||||
app.CONN.online = False
|
||||
elif method == "GUI.OnScreensaverDeactivated":
|
||||
if settings('dbSyncScreensaver') == "true":
|
||||
sleep(5000)
|
||||
window('plex_runLibScan', value="full")
|
||||
|
||||
if utils.settings('dbSyncScreensaver') == "true":
|
||||
self.waitForAbort(5)
|
||||
app.SYNC.run_lib_scan = 'full'
|
||||
elif method == "System.OnQuit":
|
||||
log.info('Kodi OnQuit detected - shutting down')
|
||||
state.STOP_PKC = True
|
||||
LOG.info('Kodi OnQuit detected - shutting down')
|
||||
app.APP.stop_pkc = True
|
||||
elif method == 'Other.plugin.video.plexkodiconnect_play_action':
|
||||
self._start_next_episode(data)
|
||||
|
||||
def _playlist_onadd(self, data):
|
||||
"""
|
||||
Called if an item is added to a Kodi playlist. Example data dict:
|
||||
{
|
||||
u'item': {
|
||||
u'type': u'movie',
|
||||
u'id': 2},
|
||||
u'playlistid': 1,
|
||||
u'position': 0
|
||||
}
|
||||
Will NOT be called if playback initiated by Kodi widgets
|
||||
"""
|
||||
pass
|
||||
|
||||
def _playlist_onremove(self, data):
|
||||
"""
|
||||
Called if an item is removed from a Kodi playlist. Example data dict:
|
||||
{
|
||||
u'playlistid': 1,
|
||||
u'position': 0
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _playlist_onclear(data):
|
||||
"""
|
||||
Called if a Kodi playlist is cleared. Example data dict:
|
||||
{
|
||||
u'playlistid': 1,
|
||||
}
|
||||
"""
|
||||
playqueue = PQ.PLAYQUEUES[data['playlistid']]
|
||||
if not playqueue.is_pkc_clear():
|
||||
playqueue.pkc_edit = True
|
||||
playqueue.clear(kodi=False)
|
||||
else:
|
||||
LOG.debug('Detected PKC clear - ignoring')
|
||||
|
||||
@staticmethod
|
||||
def _get_ids(kodi_id, kodi_type, path):
|
||||
"""
|
||||
Returns the tuple (plex_id, plex_type) or (None, None)
|
||||
"""
|
||||
# No Kodi id returned by Kodi, even if there is one. Ex: Widgets
|
||||
plex_id = None
|
||||
plex_type = None
|
||||
# If using direct paths and starting playback from a widget
|
||||
if not kodi_id and kodi_type and path:
|
||||
kodi_id, _ = kodi_db.kodiid_from_filename(path, kodi_type)
|
||||
if kodi_id:
|
||||
with PlexDB() as plexdb:
|
||||
db_item = plexdb.item_by_kodi_id(kodi_id, kodi_type)
|
||||
if db_item:
|
||||
plex_id = db_item['plex_id']
|
||||
plex_type = db_item['plex_type']
|
||||
return plex_id, plex_type
|
||||
|
||||
@staticmethod
|
||||
def _add_remaining_items_to_playlist(playqueue):
|
||||
"""
|
||||
Adds all but the very first item of the Kodi playlist to the Plex
|
||||
playqueue
|
||||
"""
|
||||
items = js.playlist_get_items(playqueue.playlistid)
|
||||
if not items:
|
||||
LOG.error('Could not retrieve Kodi playlist items')
|
||||
return
|
||||
# Remove first item
|
||||
items.pop(0)
|
||||
try:
|
||||
for i, item in enumerate(items):
|
||||
PL.add_item_to_plex_playqueue(playqueue, i + 1, kodi_item=item)
|
||||
except exceptions.PlaylistError:
|
||||
LOG.info('Could not build Plex playlist for: %s', items)
|
||||
|
||||
def _json_item(self, playerid):
|
||||
"""
|
||||
Uses JSON RPC to get the playing item's info and returns the tuple
|
||||
kodi_id, kodi_type, path
|
||||
or None each time if not found.
|
||||
"""
|
||||
if not self._already_slept:
|
||||
# SLEEP before calling this for the first time just after playback
|
||||
# start as Kodi updates this info very late!! Might get previous
|
||||
# element otherwise
|
||||
self._already_slept = True
|
||||
self.waitForAbort(1)
|
||||
try:
|
||||
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)
|
||||
return (json_item.get('id'),
|
||||
json_item.get('type'),
|
||||
json_item.get('file'))
|
||||
|
||||
@staticmethod
|
||||
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):
|
||||
"""
|
||||
Called whenever a playback is started
|
||||
Called whenever playback is started. Example data:
|
||||
{
|
||||
u'item': {u'type': u'movie', u'title': u''},
|
||||
u'player': {u'playerid': 1, u'speed': 1}
|
||||
}
|
||||
Unfortunately when using Widgets, Kodi doesn't tell us shit
|
||||
"""
|
||||
# Get currently playing file - can take a while. Will be utf-8!
|
||||
try:
|
||||
currentFile = self.xbmcplayer.getPlayingFile()
|
||||
except:
|
||||
currentFile = None
|
||||
count = 0
|
||||
while currentFile is None:
|
||||
sleep(100)
|
||||
try:
|
||||
currentFile = self.xbmcplayer.getPlayingFile()
|
||||
except:
|
||||
pass
|
||||
if count == 50:
|
||||
log.info("No current File, cancel OnPlayBackStart...")
|
||||
return
|
||||
else:
|
||||
count += 1
|
||||
# Just to be on the safe side
|
||||
currentFile = tryDecode(currentFile)
|
||||
log.debug("Currently playing file is: %s" % currentFile)
|
||||
|
||||
self._already_slept = False
|
||||
# Get the type of media we're playing
|
||||
try:
|
||||
typus = data['item']['type']
|
||||
playerid = data['player']['playerid']
|
||||
except (TypeError, KeyError):
|
||||
log.info("Item is invalid for PMS playstate update.")
|
||||
LOG.info('Aborting playback report - item invalid for updates %s',
|
||||
data)
|
||||
return
|
||||
log.debug("Playing itemtype is (or appears to be): %s" % typus)
|
||||
|
||||
# Try to get a Kodi ID
|
||||
# If PKC was used - native paths, not direct paths
|
||||
plex_id = window('plex_%s.itemid' % tryEncode(currentFile))
|
||||
# Get rid of the '' if the window property was not set
|
||||
plex_id = None if not plex_id else plex_id
|
||||
kodiid = None
|
||||
if plex_id is None:
|
||||
log.debug('Did not get Plex id from window properties')
|
||||
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:
|
||||
# Kodi might return -1 for "last player"
|
||||
# Getting the playerid is really a PITA
|
||||
try:
|
||||
kodiid = data['item']['id']
|
||||
except (TypeError, KeyError):
|
||||
log.debug('Did not get a Kodi id from Kodi, darn')
|
||||
# For direct paths, if we're not streaming something
|
||||
# When using Widgets, Kodi doesn't tell us shit so we need this hack
|
||||
if (kodiid is None and plex_id is None and typus != 'song'
|
||||
and not currentFile.startswith('http')):
|
||||
(kodiid, typus) = get_kodiid_from_filename(currentFile)
|
||||
if kodiid is None:
|
||||
return
|
||||
|
||||
if plex_id is None:
|
||||
# Get Plex' item id
|
||||
with plexdb.Get_Plex_DB() as plexcursor:
|
||||
plex_dbitem = plexcursor.getItem_byKodiId(kodiid, typus)
|
||||
try:
|
||||
plex_id = plex_dbitem[0]
|
||||
except TypeError:
|
||||
log.info("No Plex id returned for kodiid %s. Aborting playback"
|
||||
" report" % kodiid)
|
||||
return
|
||||
log.debug("Found Plex id %s for Kodi id %s for type %s"
|
||||
% (plex_id, kodiid, typus))
|
||||
|
||||
# Switch subtitle tracks if applicable
|
||||
subtitle = window('plex_%s.subtitle' % tryEncode(currentFile))
|
||||
if window(tryEncode('plex_%s.playmethod' % currentFile)) \
|
||||
== 'Transcode' and subtitle:
|
||||
if window('plex_%s.subtitle' % currentFile) == 'None':
|
||||
self.xbmcplayer.showSubtitles(False)
|
||||
playerid = js.get_player_ids()[0]
|
||||
except IndexError:
|
||||
# E.g. Kodi 18 doesn't tell us anything useful
|
||||
if kodi_type in v.KODI_VIDEOTYPES:
|
||||
playlist_type = v.KODI_TYPE_VIDEO_PLAYLIST
|
||||
elif kodi_type in v.KODI_AUDIOTYPES:
|
||||
playlist_type = v.KODI_TYPE_AUDIO_PLAYLIST
|
||||
else:
|
||||
self.xbmcplayer.setSubtitleStream(int(subtitle))
|
||||
|
||||
# Set some stuff if Kodi initiated playback
|
||||
if ((settings('useDirectPaths') == "1" and not typus == "song")
|
||||
or
|
||||
(typus == "song" and settings('enableMusic') == "true")):
|
||||
if self.StartDirectPath(plex_id,
|
||||
typus,
|
||||
tryEncode(currentFile)) is False:
|
||||
log.error('Could not initiate monitoring; aborting')
|
||||
LOG.error('Unexpected type %s, data %s', kodi_type, data)
|
||||
return
|
||||
|
||||
# Save currentFile for cleanup later and to be able to access refs
|
||||
window('plex_lastPlayedFiled', value=currentFile)
|
||||
window('plex_currently_playing_itemid', value=plex_id)
|
||||
window("plex_%s.itemid" % tryEncode(currentFile), value=plex_id)
|
||||
log.info('Finish playback startup')
|
||||
|
||||
def StartDirectPath(self, plex_id, type, currentFile):
|
||||
"""
|
||||
Set some additional stuff if playback was initiated by Kodi, not PKC
|
||||
"""
|
||||
xml = self.doUtils('{server}/library/metadata/%s' % plex_id)
|
||||
try:
|
||||
xml[0].attrib
|
||||
except:
|
||||
log.error('Did not receive a valid XML for plex_id %s.' % plex_id)
|
||||
return False
|
||||
# Setup stuff, because playback was started by Kodi, not PKC
|
||||
api = API(xml[0])
|
||||
listitem = api.CreateListItemFromPlexItem()
|
||||
api.set_playback_win_props(currentFile, listitem)
|
||||
if type == "song" and settings('streamMusic') == "true":
|
||||
window('plex_%s.playmethod' % currentFile, value="DirectStream")
|
||||
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]
|
||||
info = js.get_player_props(playerid)
|
||||
if playqueue.kodi_playlist_playback:
|
||||
# Kodi will tell us the wrong position - of the playlist, not the
|
||||
# playqueue, when user starts playing from a playlist :-(
|
||||
pos = 0
|
||||
LOG.debug('Detected playback from a Kodi playlist')
|
||||
else:
|
||||
window('plex_%s.playmethod' % currentFile, value="DirectPlay")
|
||||
log.debug('Window properties set for direct paths!')
|
||||
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:
|
||||
item = playqueue.items[pos]
|
||||
LOG.debug('PKC playqueue item is: %s', item)
|
||||
except IndexError:
|
||||
# PKC playqueue not yet initialized
|
||||
LOG.debug('Position %s not in PKC playqueue yet', pos)
|
||||
initialize = True
|
||||
else:
|
||||
if not kodi_id:
|
||||
kodi_id, kodi_type, path = self._json_item(playerid)
|
||||
if kodi_id and item.kodi_id:
|
||||
if item.kodi_id != kodi_id or item.kodi_type != kodi_type:
|
||||
LOG.debug('Detected different Kodi id')
|
||||
initialize = True
|
||||
else:
|
||||
initialize = False
|
||||
else:
|
||||
# E.g. clips set-up previously with no Kodi DB entry
|
||||
if not path:
|
||||
kodi_id, kodi_type, path = self._json_item(playerid)
|
||||
if path == '':
|
||||
LOG.debug('Detected empty path: aborting playback report')
|
||||
return
|
||||
if item.file != path:
|
||||
# Clips will get a new path
|
||||
LOG.debug('Detected different path')
|
||||
try:
|
||||
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:
|
||||
initialize = False
|
||||
if initialize:
|
||||
LOG.debug('Need to initialize Plex and PKC playqueue')
|
||||
if not kodi_id or not kodi_type:
|
||||
kodi_id, kodi_type, path = self._json_item(playerid)
|
||||
plex_id, plex_type = self._get_ids(kodi_id, kodi_type, path)
|
||||
if not plex_id:
|
||||
LOG.debug('No Plex id obtained - aborting playback report')
|
||||
app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
|
||||
return
|
||||
try:
|
||||
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)
|
||||
container_key = None
|
||||
if info['playlistid'] != -1:
|
||||
# -1 is Kodi's answer if there is no playlist
|
||||
container_key = PQ.PLAYQUEUES[playerid].id
|
||||
if container_key is not None:
|
||||
container_key = '/playQueues/%s' % container_key
|
||||
elif plex_id is not None:
|
||||
container_key = '/library/metadata/%s' % plex_id
|
||||
else:
|
||||
LOG.debug('No need to initialize playqueues')
|
||||
kodi_id = item.kodi_id
|
||||
kodi_type = item.kodi_type
|
||||
plex_id = item.plex_id
|
||||
plex_type = item.plex_type
|
||||
if playqueue.id:
|
||||
container_key = '/playQueues/%s' % playqueue.id
|
||||
else:
|
||||
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
|
||||
app.PLAYSTATE.active_players.add(playerid)
|
||||
status.update(info)
|
||||
LOG.debug('Set the Plex container_key to: %s', container_key)
|
||||
status['container_key'] = container_key
|
||||
status['file'] = path
|
||||
status['kodi_id'] = kodi_id
|
||||
status['kodi_type'] = kodi_type
|
||||
status['plex_id'] = plex_id
|
||||
status['plex_type'] = plex_type
|
||||
status['playmethod'] = item.playmethod
|
||||
status['playcount'] = item.playcount
|
||||
status['external_player'] = app.APP.player.isExternalPlayer() == 1
|
||||
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):
|
||||
"""
|
||||
Will be called when Kodi has a video, audio or subtitle stream. Also
|
||||
happens when the stream changes.
|
||||
|
||||
Example data as returned by Kodi:
|
||||
{'item': {'id': 5, 'type': 'movie'},
|
||||
'player': {'playerid': 1, 'speed': 1}}
|
||||
|
||||
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE!
|
||||
Kodi subs will never change. Also see json_rpc.py
|
||||
"""
|
||||
playerid = data['player']['playerid']
|
||||
if not playerid == v.KODI_VIDEO_PLAYER_ID:
|
||||
# We're just messing with Kodi's videoplayer
|
||||
return
|
||||
item = app.PLAYSTATE.item
|
||||
if item is None:
|
||||
# Player might've quit
|
||||
return
|
||||
if not self._switched_to_plex_streams:
|
||||
# We need to switch to the Plex streams ONCE upon playback start
|
||||
# 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)
|
||||
|
||||
|
||||
def _playback_cleanup(ended=False):
|
||||
"""
|
||||
PKC cleanup after playback ends/is stopped. Pass ended=True if Kodi
|
||||
completely finished playing an item (because we will get and use wrong
|
||||
timing data otherwise)
|
||||
"""
|
||||
LOG.debug('playback_cleanup called. Active players: %s',
|
||||
app.PLAYSTATE.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
|
||||
# Companion (if we could not use the playqueue to store the token)
|
||||
app.CONN.plex_transient_token = None
|
||||
for playerid in app.PLAYSTATE.active_players:
|
||||
status = app.PLAYSTATE.player_states[playerid]
|
||||
# Remember the last played item later
|
||||
app.PLAYSTATE.old_player_states[playerid] = copy.deepcopy(status)
|
||||
# Stop transcoding
|
||||
if status['playmethod'] == v.PLAYBACK_METHOD_TRANSCODE:
|
||||
LOG.debug('Tell the PMS to stop transcoding')
|
||||
DU().downloadUrl(
|
||||
'{server}/video/:/transcode/universal/stop',
|
||||
parameters={'session': v.PKC_MACHINE_IDENTIFIER})
|
||||
if playerid == 1:
|
||||
# Bookmarks might not be pickup up correctly, so let's do them
|
||||
# manually. Applies to addon paths, but direct paths might have
|
||||
# started playback via PMS
|
||||
_record_playstate(status, ended)
|
||||
# Reset the player's status
|
||||
app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
|
||||
# As all playback has halted, reset the players that have been active
|
||||
app.PLAYSTATE.active_players = set()
|
||||
app.PLAYSTATE.item = None
|
||||
utils.delete_temporary_subtitles()
|
||||
LOG.debug('Finished PKC playback cleanup')
|
||||
|
||||
|
||||
def _record_playstate(status, ended):
|
||||
if not status['plex_id']:
|
||||
LOG.debug('No Plex id found to record playstate for status %s', status)
|
||||
return
|
||||
if status['plex_type'] not in v.PLEX_VIDEOTYPES:
|
||||
LOG.debug('Not messing with non-video entries')
|
||||
return
|
||||
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
|
||||
LOG.debug('No playstate update due to Plex id not found: %s', status)
|
||||
return
|
||||
totaltime = float(timing.kodi_time_to_millis(status['totaltime'])) / 1000
|
||||
if status['external_player']:
|
||||
# video has either been entirely watched - or not.
|
||||
# "ended" won't work, need a workaround
|
||||
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:
|
||||
if ended:
|
||||
progress = 0.99
|
||||
time = v.IGNORE_SECONDS_AT_START + 1
|
||||
else:
|
||||
time = float(timing.kodi_time_to_millis(status['time'])) / 1000
|
||||
try:
|
||||
progress = time / totaltime
|
||||
except ZeroDivisionError:
|
||||
progress = 0.0
|
||||
LOG.debug('Playback progress %s (%s of %s seconds)',
|
||||
progress, time, totaltime)
|
||||
playcount = status['playcount']
|
||||
last_played = timing.kodi_now()
|
||||
if playcount is None:
|
||||
LOG.debug('playcount not found, looking it up in the Kodi DB')
|
||||
with kodi_db.KodiVideoDB() as kodidb:
|
||||
playcount = kodidb.get_playcount(db_item['kodi_fileid'])
|
||||
playcount = 0 if playcount is None else playcount
|
||||
if time < v.IGNORE_SECONDS_AT_START:
|
||||
LOG.debug('Ignoring playback less than %s seconds',
|
||||
v.IGNORE_SECONDS_AT_START)
|
||||
# Annoying Plex bug - it'll reset an already watched video to unwatched
|
||||
playcount = None
|
||||
last_played = None
|
||||
time = 0
|
||||
elif progress >= v.MARK_PLAYED_AT:
|
||||
LOG.debug('Recording entirely played video since progress > %s',
|
||||
v.MARK_PLAYED_AT)
|
||||
playcount += 1
|
||||
time = 0
|
||||
with kodi_db.KodiVideoDB() as kodidb:
|
||||
kodidb.set_resume(db_item['kodi_fileid'],
|
||||
time,
|
||||
totaltime,
|
||||
playcount,
|
||||
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
|
||||
if (app.APP.force_reload_skin and
|
||||
xbmc.getCondVisibility('Window.IsVisible(Home.xml)')):
|
||||
LOG.debug('Refreshing skin to update widgets')
|
||||
xbmc.executebuiltin('ReloadSkin()')
|
||||
task = backgroundthread.FunctionAsTask(_clean_file_table, None)
|
||||
backgroundthread.BGThreader.addTasksToFront([task])
|
||||
|
||||
|
||||
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():
|
||||
"""
|
||||
If we associate a playing video e.g. pointing to plugin://... to an existing
|
||||
Kodi library item, Kodi will add an additional entry for this (additional)
|
||||
path plugin:// in the file table. This leads to all sorts of wierd behavior.
|
||||
This function tries for at most 5 seconds to clean the file table.
|
||||
"""
|
||||
LOG.debug('Start cleaning Kodi files table')
|
||||
if app.APP.monitor.waitForAbort(2):
|
||||
# PKC should exit
|
||||
return
|
||||
try:
|
||||
with kodi_db.KodiVideoDB() as kodidb:
|
||||
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
|
||||
else:
|
||||
LOG.error('Did not find the episode with Plex id %s for show %s: %s',
|
||||
current_api.plex_id, current_api.grandparent_id(),
|
||||
current_api.grandparent_title())
|
||||
return
|
||||
try:
|
||||
return API(xml[counter + 1])
|
||||
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 +1,9 @@
|
|||
# Dummy file to make this directory a package.
|
||||
# -*- coding: utf-8 -*-
|
||||
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
|
||||
|
|
73
resources/lib/library_sync/common.py
Normal file
73
resources/lib/library_sync/common.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
#!/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,86 +1,154 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
from threading import Thread
|
||||
from Queue import Empty
|
||||
|
||||
from xbmc import sleep
|
||||
|
||||
from utils import thread_methods
|
||||
import plexdb_functions as plexdb
|
||||
import itemtypes
|
||||
import variables as v
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = getLogger("PLEX."+__name__)
|
||||
|
||||
###############################################################################
|
||||
from ..plex_api import API
|
||||
from ..plex_db import PlexDB
|
||||
from ..kodi_db import KodiVideoDB
|
||||
from .. import backgroundthread, utils
|
||||
from .. import itemtypes, plex_functions as PF, variables as v, app
|
||||
|
||||
|
||||
@thread_methods(add_suspends=['SUSPEND_LIBRARY_THREAD', 'DB_SCAN'],
|
||||
add_stops=['STOP_SYNC'])
|
||||
class Process_Fanart_Thread(Thread):
|
||||
LOG = getLogger('PLEX.sync.fanart')
|
||||
|
||||
SUPPORTED_TYPES = (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)
|
||||
SYNC_FANART = (utils.settings('FanartTV') == 'true' and
|
||||
utils.settings('usePlexArtwork') == 'true')
|
||||
PREFER_KODI_COLLECTION_ART = utils.settings('PreferKodiCollectionArt') == 'false'
|
||||
BATCH_SIZE = 500
|
||||
|
||||
|
||||
class FanartThread(backgroundthread.KillableThread):
|
||||
"""
|
||||
Threaded download of additional fanart in the background
|
||||
|
||||
Input:
|
||||
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
|
||||
}
|
||||
This will potentially take hours!
|
||||
"""
|
||||
def __init__(self, queue):
|
||||
self.queue = queue
|
||||
Thread.__init__(self)
|
||||
def __init__(self, callback, refresh=False):
|
||||
self.callback = callback
|
||||
self.refresh = refresh
|
||||
super(FanartThread, self).__init__()
|
||||
|
||||
def should_suspend(self):
|
||||
return self._suspended or app.APP.is_playing_video
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Catch all exceptions and log them
|
||||
"""
|
||||
LOG.info('Starting FanartThread')
|
||||
app.APP.register_fanart_thread(self)
|
||||
try:
|
||||
self.__run()
|
||||
except Exception as e:
|
||||
log.error('Exception %s' % e)
|
||||
import traceback
|
||||
log.error("Traceback:\n%s" % traceback.format_exc())
|
||||
self._run()
|
||||
except Exception:
|
||||
utils.ERROR(notify=True)
|
||||
finally:
|
||||
app.APP.deregister_fanart_thread(self)
|
||||
|
||||
def __run(self):
|
||||
def _loop(self):
|
||||
for typus in SUPPORTED_TYPES:
|
||||
offset = 0
|
||||
while True:
|
||||
with PlexDB() as plexdb:
|
||||
# Keep DB connection open only for a short period of time!
|
||||
if self.refresh:
|
||||
batch = list(plexdb.every_plex_id(typus,
|
||||
offset,
|
||||
BATCH_SIZE))
|
||||
else:
|
||||
batch = list(plexdb.missing_fanart(typus,
|
||||
offset,
|
||||
BATCH_SIZE))
|
||||
for plex_id in batch:
|
||||
# Do the actual, time-consuming processing
|
||||
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):
|
||||
"""
|
||||
Do the work
|
||||
This task will also be executed while library sync is suspended!
|
||||
"""
|
||||
log.debug("---===### Starting FanartSync ###===---")
|
||||
thread_stopped = self.thread_stopped
|
||||
thread_suspended = self.thread_suspended
|
||||
queue = self.queue
|
||||
while not thread_stopped():
|
||||
# In the event the server goes offline
|
||||
while thread_suspended():
|
||||
# Set in service.py
|
||||
if thread_stopped():
|
||||
# Abort was requested while waiting. We should exit
|
||||
log.info("---===### Stopped FanartSync ###===---")
|
||||
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
|
||||
sleep(1000)
|
||||
# grabs Plex item from queue
|
||||
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:
|
||||
item = queue.get(block=False)
|
||||
except Empty:
|
||||
sleep(200)
|
||||
continue
|
||||
|
||||
log.debug('Get additional fanart for Plex id %s' % item['plex_id'])
|
||||
with getattr(itemtypes,
|
||||
v.ITEMTYPE_FROM_PLEXTYPE[item['plex_type']])() as cls:
|
||||
result = cls.getfanart(item['plex_id'],
|
||||
refresh=item['refresh'])
|
||||
if result is True:
|
||||
log.debug('Done getting fanart for Plex id %s'
|
||||
% item['plex_id'])
|
||||
with plexdb.Get_Plex_DB() as plex_db:
|
||||
plex_db.set_fanart_synched(item['plex_id'])
|
||||
queue.task_done()
|
||||
log.debug("---===### Stopped FanartSync ###===---")
|
||||
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)
|
||||
|
|
80
resources/lib/library_sync/fill_metadata_queue.py
Normal file
80
resources/lib/library_sync/fill_metadata_queue.py
Normal file
|
@ -0,0 +1,80 @@
|
|||
# -*- 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())
|
325
resources/lib/library_sync/full_sync.py
Normal file
325
resources/lib/library_sync/full_sync.py
Normal file
|
@ -0,0 +1,325 @@
|
|||
#!/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,139 +1,123 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
from threading import Thread
|
||||
from Queue import Empty
|
||||
|
||||
from xbmc import sleep
|
||||
from . import common
|
||||
from ..plex_api import API
|
||||
from .. import backgroundthread, plex_functions as PF, utils, variables as v
|
||||
|
||||
from utils import thread_methods, window
|
||||
from PlexFunctions import GetPlexMetadata, GetAllPlexChildren
|
||||
import sync_info
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = getLogger("PLEX."+__name__)
|
||||
|
||||
###############################################################################
|
||||
LOG = getLogger('PLEX.sync.get_metadata')
|
||||
LOCK = backgroundthread.threading.Lock()
|
||||
|
||||
|
||||
@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD'])
|
||||
class Threaded_Get_Metadata(Thread):
|
||||
class GetMetadataThread(common.LibrarySyncMixin,
|
||||
backgroundthread.KillableThread):
|
||||
"""
|
||||
Threaded download of Plex XML metadata for a certain library item.
|
||||
Fills the out_queue with the downloaded etree XML objects
|
||||
Fills the queue with the downloaded etree XML objects
|
||||
"""
|
||||
def __init__(self, get_metadata_queue, processing_queue):
|
||||
self.get_metadata_queue = get_metadata_queue
|
||||
self.processing_queue = processing_queue
|
||||
super(GetMetadataThread, self).__init__()
|
||||
|
||||
Input:
|
||||
queue Queue.Queue() object that you'll need to fill up
|
||||
with Plex itemIds
|
||||
out_queue Queue() object where this thread will store
|
||||
the downloaded metadata XMLs as etree objects
|
||||
"""
|
||||
def __init__(self, queue, out_queue):
|
||||
self.queue = queue
|
||||
self.out_queue = out_queue
|
||||
Thread.__init__(self)
|
||||
|
||||
def terminate_now(self):
|
||||
"""
|
||||
Needed to terminate this thread, because there might be items left in
|
||||
the queue which could cause other threads to hang
|
||||
"""
|
||||
while not self.queue.empty():
|
||||
# Still try because remaining item might have been taken
|
||||
def _collections(self, item):
|
||||
api = API(item['xml'][0])
|
||||
collection_match = item['section'].collection_match
|
||||
collection_xmls = item['section'].collection_xmls
|
||||
if collection_match is None:
|
||||
collection_match = PF.collections(api.library_section_id())
|
||||
if collection_match is None:
|
||||
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:
|
||||
self.queue.get(block=False)
|
||||
except Empty:
|
||||
sleep(10)
|
||||
collection_xml[0].attrib
|
||||
except (TypeError, IndexError, AttributeError):
|
||||
LOG.error('Could not get collection %s %s',
|
||||
collection_plex_id, set_name)
|
||||
continue
|
||||
else:
|
||||
self.queue.task_done()
|
||||
if self.thread_stopped():
|
||||
# Shutdown from outside requested; purge out_queue as well
|
||||
while not self.out_queue.empty():
|
||||
# Still try because remaining item might have been taken
|
||||
try:
|
||||
self.out_queue.get(block=False)
|
||||
except Empty:
|
||||
sleep(10)
|
||||
continue
|
||||
else:
|
||||
self.out_queue.task_done()
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Catch all exceptions and log them
|
||||
"""
|
||||
try:
|
||||
self.__run()
|
||||
except Exception as e:
|
||||
log.error('Exception %s' % e)
|
||||
import traceback
|
||||
log.error("Traceback:\n%s" % traceback.format_exc())
|
||||
|
||||
def __run(self):
|
||||
"""
|
||||
Do the work
|
||||
"""
|
||||
log.debug('Starting get metadata thread')
|
||||
# cache local variables because it's faster
|
||||
queue = self.queue
|
||||
out_queue = self.out_queue
|
||||
thread_stopped = self.thread_stopped
|
||||
while thread_stopped() is False:
|
||||
# grabs Plex item from queue
|
||||
try:
|
||||
item = queue.get(block=False)
|
||||
# Empty queue
|
||||
except Empty:
|
||||
sleep(20)
|
||||
continue
|
||||
# Download Metadata
|
||||
xml = GetPlexMetadata(item['itemId'])
|
||||
if xml is None:
|
||||
# Did not receive a valid XML - skip that item for now
|
||||
log.error("Could not get metadata for %s. Skipping that item "
|
||||
"for now" % item['itemId'])
|
||||
# Increase BOTH counters - since metadata won't be processed
|
||||
with sync_info.LOCK:
|
||||
sync_info.GET_METADATA_COUNT += 1
|
||||
sync_info.PROCESS_METADATA_COUNT += 1
|
||||
queue.task_done()
|
||||
continue
|
||||
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()
|
||||
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]
|
||||
|
||||
item['XML'] = xml
|
||||
if item.get('get_children') is True:
|
||||
children_xml = GetAllPlexChildren(item['itemId'])
|
||||
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:
|
||||
if item is None or self.should_cancel():
|
||||
self._process_abort(item[0] if item else None,
|
||||
item[2] if item else None)
|
||||
break
|
||||
count, plex_id, section = item
|
||||
item = {
|
||||
'xml': PF.GetPlexMetadata(plex_id), # This will block
|
||||
'children': None,
|
||||
'section': section
|
||||
}
|
||||
if item['xml'] is None:
|
||||
# Did not receive a valid XML - skip that item for now
|
||||
LOG.error("Could not get metadata for %s. Skipping item "
|
||||
"for now", plex_id)
|
||||
self._process_skipped_item(count, section)
|
||||
continue
|
||||
elif item['xml'] == 401:
|
||||
LOG.error('HTTP 401 returned by PMS. Too much strain? '
|
||||
'Cancelling sync for now')
|
||||
utils.window('plex_scancrashed', value='401')
|
||||
self._process_abort(count, section)
|
||||
break
|
||||
if section.plex_type == v.PLEX_TYPE_MOVIE:
|
||||
# Check for collections/sets
|
||||
collections = False
|
||||
for child in item['xml'][0]:
|
||||
if child.tag == 'Collection':
|
||||
collections = True
|
||||
break
|
||||
if collections:
|
||||
with LOCK:
|
||||
self._collections(item)
|
||||
if section.get_children:
|
||||
if self.should_cancel():
|
||||
self._process_abort(count, section)
|
||||
break
|
||||
children_xml = PF.GetAllPlexChildren(plex_id) # Will block
|
||||
try:
|
||||
children_xml[0].attrib
|
||||
except (TypeError, IndexError, AttributeError):
|
||||
log.error('Could not get children for Plex id %s'
|
||||
% item['itemId'])
|
||||
LOG.error('Could not get children for Plex id %s',
|
||||
plex_id)
|
||||
self._process_skipped_item(count, section)
|
||||
continue
|
||||
else:
|
||||
item['children'] = []
|
||||
for child in children_xml:
|
||||
child_xml = GetPlexMetadata(child.attrib['ratingKey'])
|
||||
try:
|
||||
child_xml[0].attrib
|
||||
except (TypeError, IndexError, AttributeError):
|
||||
log.error('Could not get child for Plex id %s'
|
||||
% child.attrib['ratingKey'])
|
||||
else:
|
||||
item['children'].append(child_xml[0])
|
||||
|
||||
# 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')
|
||||
item['children'] = children_xml
|
||||
self.processing_queue.put((count, item))
|
||||
finally:
|
||||
self.get_metadata_queue.task_done()
|
||||
|
|
412
resources/lib/library_sync/nodes.py
Normal file
412
resources/lib/library_sync/nodes.py
Normal file
|
@ -0,0 +1,412 @@
|
|||
#!/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,102 +1,92 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
from threading import Thread
|
||||
from Queue import Empty
|
||||
|
||||
from xbmc import sleep
|
||||
from . import common, sections
|
||||
from ..plex_db import PlexDB
|
||||
from .. import backgroundthread, app
|
||||
|
||||
from utils import thread_methods
|
||||
import itemtypes
|
||||
import sync_info
|
||||
LOG = getLogger('PLEX.sync.process_metadata')
|
||||
|
||||
###############################################################################
|
||||
log = getLogger("PLEX."+__name__)
|
||||
|
||||
###############################################################################
|
||||
COMMIT_TO_DB_EVERY_X_ITEMS = 500
|
||||
|
||||
|
||||
@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD'])
|
||||
class Threaded_Process_Metadata(Thread):
|
||||
class ProcessMetadataThread(common.LibrarySyncMixin,
|
||||
backgroundthread.KillableThread):
|
||||
"""
|
||||
Not yet implemented for more than 1 thread - if ever. Only to be called by
|
||||
ONE thread!
|
||||
Processes the XML metadata in the queue
|
||||
Invoke once in order to process the received PMS metadata xmls
|
||||
"""
|
||||
def __init__(self, current_time, processing_queue, update_progressbar):
|
||||
self.current_time = current_time
|
||||
self.processing_queue = processing_queue
|
||||
self.update_progressbar = update_progressbar
|
||||
self.last_section = sections.Section()
|
||||
self.successful = True
|
||||
super(ProcessMetadataThread, self).__init__()
|
||||
|
||||
Input:
|
||||
queue: Queue.Queue() object that you'll need to fill up with
|
||||
the downloaded XML eTree objects
|
||||
item_type: as used to call functions in itemtypes.py e.g. 'Movies' =>
|
||||
itemtypes.Movies()
|
||||
"""
|
||||
def __init__(self, queue, item_type):
|
||||
self.queue = queue
|
||||
self.item_type = item_type
|
||||
Thread.__init__(self)
|
||||
|
||||
def terminate_now(self):
|
||||
"""
|
||||
Needed to terminate this thread, because there might be items left in
|
||||
the queue which could cause other threads to hang
|
||||
"""
|
||||
while not self.queue.empty():
|
||||
# Still try because remaining item might have been taken
|
||||
try:
|
||||
self.queue.get(block=False)
|
||||
except Empty:
|
||||
sleep(10)
|
||||
continue
|
||||
def start_section(self, section):
|
||||
if section != self.last_section:
|
||||
if self.last_section:
|
||||
self.finish_last_section()
|
||||
LOG.debug('Start or continue processing section %s', section)
|
||||
self.last_section = section
|
||||
# Warn the user for this new section if we cannot access a file
|
||||
app.SYNC.path_verified = False
|
||||
else:
|
||||
self.queue.task_done()
|
||||
LOG.debug('Resume processing section %s', section)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Catch all exceptions and log them
|
||||
"""
|
||||
try:
|
||||
self.__run()
|
||||
except Exception as e:
|
||||
log.error('Exception %s' % e)
|
||||
import traceback
|
||||
log.error("Traceback:\n%s" % traceback.format_exc())
|
||||
def finish_last_section(self):
|
||||
if (not self.should_cancel() and self.last_section and
|
||||
self.last_section.sync_successful):
|
||||
# Check for should_cancel() because we cannot be sure that we
|
||||
# processed every item of the section
|
||||
with PlexDB() as plexdb:
|
||||
# Set the new time mark for the next delta sync
|
||||
plexdb.update_section_last_sync(self.last_section.section_id,
|
||||
self.current_time)
|
||||
LOG.info('Finished processing section successfully: %s',
|
||||
self.last_section)
|
||||
elif self.last_section and not self.last_section.sync_successful:
|
||||
LOG.warn('Sync not successful for section %s', self.last_section)
|
||||
self.successful = False
|
||||
|
||||
def __run(self):
|
||||
"""
|
||||
Do the work
|
||||
"""
|
||||
log.debug('Processing thread started')
|
||||
# Constructs the method name, e.g. itemtypes.Movies
|
||||
item_fct = getattr(itemtypes, self.item_type)
|
||||
# cache local variables because it's faster
|
||||
queue = self.queue
|
||||
thread_stopped = self.thread_stopped
|
||||
with item_fct() as item_class:
|
||||
while thread_stopped() is False:
|
||||
# grabs item from queue
|
||||
try:
|
||||
item = queue.get(block=False)
|
||||
except Empty:
|
||||
sleep(20)
|
||||
continue
|
||||
# Do the work
|
||||
item_method = getattr(item_class, item['method'])
|
||||
if item.get('children') is not None:
|
||||
item_method(item['XML'][0],
|
||||
viewtag=item['viewName'],
|
||||
viewid=item['viewId'],
|
||||
def _get(self):
|
||||
item = {'xml': None}
|
||||
while item and item['xml'] is None:
|
||||
item = self.processing_queue.get()
|
||||
self.processing_queue.task_done()
|
||||
return item
|
||||
|
||||
def _run(self):
|
||||
# There are 2 sentinels: None for aborting/ending this thread, the dict
|
||||
# {'section': section, 'xml': None} for skipped/invalid items
|
||||
item = self._get()
|
||||
if item:
|
||||
section = item['section']
|
||||
processed = 0
|
||||
self.start_section(section)
|
||||
while not self.should_cancel():
|
||||
if item is None:
|
||||
break
|
||||
elif item['section'] != section:
|
||||
# We received an entirely new section
|
||||
self.start_section(item['section'])
|
||||
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'])
|
||||
else:
|
||||
item_method(item['XML'][0],
|
||||
viewtag=item['viewName'],
|
||||
viewid=item['viewId'])
|
||||
# Keep track of where we are at
|
||||
try:
|
||||
log.debug('found child: %s'
|
||||
% item['children'].attrib)
|
||||
except:
|
||||
pass
|
||||
with sync_info.LOCK:
|
||||
sync_info.PROCESS_METADATA_COUNT += 1
|
||||
sync_info.PROCESSING_VIEW_NAME = item['title']
|
||||
queue.task_done()
|
||||
self.terminate_now()
|
||||
log.debug('Processing thread terminated')
|
||||
processed += 1
|
||||
section.count += 1
|
||||
if processed == COMMIT_TO_DB_EVERY_X_ITEMS:
|
||||
processed = 0
|
||||
context.commit()
|
||||
item = self._get()
|
||||
self.finish_last_section()
|
||||
|
|
745
resources/lib/library_sync/sections.py
Normal file
745
resources/lib/library_sync/sections.py
Normal file
|
@ -0,0 +1,745 @@
|
|||
#!/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
|
|
@ -1,81 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from logging import getLogger
|
||||
from threading import Thread, Lock
|
||||
|
||||
from xbmc import sleep
|
||||
|
||||
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'])
|
||||
class Threaded_Show_Sync_Info(Thread):
|
||||
"""
|
||||
Threaded class to show the Kodi statusbar of the metadata download.
|
||||
|
||||
Input:
|
||||
dialog xbmcgui.DialogProgressBG() object to show progress
|
||||
total: Total number of items to get
|
||||
"""
|
||||
def __init__(self, dialog, total, item_type):
|
||||
self.total = total
|
||||
self.dialog = dialog
|
||||
self.item_type = item_type
|
||||
Thread.__init__(self)
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Catch all exceptions and log them
|
||||
"""
|
||||
try:
|
||||
self.__run()
|
||||
except Exception as e:
|
||||
log.error('Exception %s' % e)
|
||||
import traceback
|
||||
log.error("Traceback:\n%s" % traceback.format_exc())
|
||||
|
||||
def __run(self):
|
||||
"""
|
||||
Do the work
|
||||
"""
|
||||
log.debug('Show sync info thread started')
|
||||
# cache local variables because it's faster
|
||||
total = self.total
|
||||
dialog = self.dialog
|
||||
thread_stopped = self.thread_stopped
|
||||
dialog.create("%s %s: %s %s"
|
||||
% (lang(39714), self.item_type, str(total), lang(39715)))
|
||||
|
||||
total = 2 * total
|
||||
totalProgress = 0
|
||||
while thread_stopped() is False:
|
||||
with LOCK:
|
||||
get_progress = GET_METADATA_COUNT
|
||||
process_progress = PROCESS_METADATA_COUNT
|
||||
viewName = PROCESSING_VIEW_NAME
|
||||
totalProgress = get_progress + process_progress
|
||||
try:
|
||||
percentage = int(float(totalProgress) / 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),
|
||||
viewName))
|
||||
# Sleep for x milliseconds
|
||||
sleep(200)
|
||||
dialog.close()
|
||||
log.debug('Show sync info thread terminated')
|
372
resources/lib/library_sync/websocket.py
Normal file
372
resources/lib/library_sync/websocket.py
Normal file
|
@ -0,0 +1,372 @@
|
|||
#!/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)
|
File diff suppressed because it is too large
Load diff
|
@ -1,74 +1,50 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##################################################################################################
|
||||
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
import logging
|
||||
import xbmc
|
||||
###############################################################################
|
||||
LEVELS = {
|
||||
logging.ERROR: xbmc.LOGERROR,
|
||||
logging.WARNING: xbmc.LOGWARNING,
|
||||
logging.INFO: xbmc.LOGNOTICE,
|
||||
logging.DEBUG: xbmc.LOGDEBUG
|
||||
}
|
||||
###############################################################################
|
||||
|
||||
from utils import window, tryEncode
|
||||
|
||||
##################################################################################################
|
||||
def try_encode(uniString, encoding='utf-8'):
|
||||
"""
|
||||
Will try to encode uniString (in unicode) to encoding. This possibly
|
||||
fails with e.g. Android TV's Python, which does not accept arguments for
|
||||
string.encode()
|
||||
"""
|
||||
if isinstance(uniString, str):
|
||||
# already encoded
|
||||
return uniString
|
||||
try:
|
||||
uniString = uniString.encode(encoding, "ignore")
|
||||
except TypeError:
|
||||
uniString = uniString.encode()
|
||||
return uniString
|
||||
|
||||
|
||||
def config():
|
||||
|
||||
logger = logging.getLogger('PLEX')
|
||||
logger.addHandler(LogHandler())
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class LogHandler(logging.StreamHandler):
|
||||
|
||||
def __init__(self):
|
||||
|
||||
logging.StreamHandler.__init__(self)
|
||||
self.setFormatter(MyFormatter())
|
||||
self.setFormatter(logging.Formatter(fmt=b"%(name)s: %(message)s"))
|
||||
|
||||
def emit(self, record):
|
||||
|
||||
if self._get_log_level(record.levelno):
|
||||
if isinstance(record.msg, unicode):
|
||||
record.msg = record.msg.encode('utf-8')
|
||||
try:
|
||||
xbmc.log(self.format(record), level=xbmc.LOGNOTICE)
|
||||
xbmc.log(self.format(record), level=LEVELS[record.levelno])
|
||||
except UnicodeEncodeError:
|
||||
xbmc.log(tryEncode(self.format(record)), level=xbmc.LOGNOTICE)
|
||||
|
||||
@classmethod
|
||||
def _get_log_level(cls, level):
|
||||
|
||||
levels = {
|
||||
logging.ERROR: 0,
|
||||
logging.WARNING: 0,
|
||||
logging.INFO: 1,
|
||||
logging.DEBUG: 2
|
||||
}
|
||||
try:
|
||||
log_level = int(window('plex_logLevel'))
|
||||
except ValueError:
|
||||
log_level = 0
|
||||
|
||||
return log_level >= levels[level]
|
||||
|
||||
|
||||
class MyFormatter(logging.Formatter):
|
||||
|
||||
def __init__(self, fmt="%(name)s -> %(message)s"):
|
||||
|
||||
logging.Formatter.__init__(self, fmt)
|
||||
|
||||
def format(self, record):
|
||||
|
||||
# Save the original format configured by the user
|
||||
# when the logger formatter was instantiated
|
||||
format_orig = self._fmt
|
||||
|
||||
# Replace the original format with one customized by logging level
|
||||
if record.levelno in (logging.DEBUG, logging.ERROR):
|
||||
self._fmt = '%(name)s -> %(levelname)s: %(message)s'
|
||||
|
||||
# Call the original formatter class to do the grunt work
|
||||
result = logging.Formatter.format(self, record)
|
||||
|
||||
# Restore the original format configured by the user
|
||||
self._fmt = format_orig
|
||||
|
||||
return result
|
||||
xbmc.log(try_encode(self.format(record)),
|
||||
level=LEVELS[record.levelno])
|
||||
|
|
|
@ -1,24 +1,102 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
import variables as v
|
||||
from utils import compare_version, settings
|
||||
|
||||
from . import variables as v
|
||||
from . import utils
|
||||
###############################################################################
|
||||
|
||||
log = getLogger("PLEX."+__name__)
|
||||
LOG = getLogger('PLEX.migration')
|
||||
|
||||
|
||||
def check_migration():
|
||||
log.info('Checking whether we need to migrate something')
|
||||
last_migration = settings('last_migrated_PKC_version')
|
||||
if last_migration == v.ADDON_VERSION:
|
||||
log.info('Already migrated to PKC version %s' % v.ADDON_VERSION)
|
||||
LOG.info('Checking whether we need to migrate something')
|
||||
last_migration = utils.settings('last_migrated_PKC_version')
|
||||
# Ensure later migration if user downgraded PKC!
|
||||
utils.settings('last_migrated_PKC_version', value=v.ADDON_VERSION)
|
||||
|
||||
if last_migration == '':
|
||||
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
|
||||
if not last_migration:
|
||||
log.info('Never migrated, so checking everything')
|
||||
last_migration = '1.0.0'
|
||||
|
||||
if not compare_version(v.ADDON_VERSION, '1.8.2'):
|
||||
log.info('Migrating to version 1.8.1')
|
||||
if not utils.compare_version(last_migration, '1.8.2'):
|
||||
LOG.info('Migrating to version 1.8.1')
|
||||
# Set the new PKC theMovieDB key
|
||||
settings('themoviedbAPIKey', value='19c90103adb9e98f2172c6a6a3d85dc4')
|
||||
utils.settings('themoviedbAPIKey',
|
||||
value='19c90103adb9e98f2172c6a6a3d85dc4')
|
||||
|
||||
settings('last_migrated_PKC_version', value=v.ADDON_VERSION)
|
||||
if not utils.compare_version(last_migration, '2.0.25'):
|
||||
LOG.info('Migrating to version 2.0.24')
|
||||
# Need to re-connect with PMS to pick up on plex.direct URIs
|
||||
utils.settings('ipaddress', value='')
|
||||
utils.settings('port', value='')
|
||||
|
||||
if not utils.compare_version(last_migration, '2.7.6'):
|
||||
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,107 +1,81 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from logging import getLogger
|
||||
from re import compile as re_compile
|
||||
import xml.etree.ElementTree as etree
|
||||
import re
|
||||
|
||||
from utils import advancedsettings_xml, indent, tryEncode
|
||||
from PlexFunctions import get_plex_sections
|
||||
from PlexAPI import API
|
||||
import variables as v
|
||||
from .plex_api.media import Media
|
||||
from . import utils
|
||||
from . import variables as v
|
||||
|
||||
###############################################################################
|
||||
log = getLogger("PLEX."+__name__)
|
||||
|
||||
REGEX_MUSICPATH = re_compile(r'''^\^(.+)\$$''')
|
||||
LOG = getLogger('PLEX.music.py')
|
||||
###############################################################################
|
||||
|
||||
|
||||
def get_current_music_folders():
|
||||
"""
|
||||
Returns a list of encoded strings as paths to the currently "blacklisted"
|
||||
excludefromscan music folders in the advancedsettings.xml
|
||||
"""
|
||||
paths = []
|
||||
root, _ = advancedsettings_xml(['audio', 'excludefromscan'])
|
||||
if root is None:
|
||||
return paths
|
||||
|
||||
for element in root:
|
||||
try:
|
||||
path = REGEX_MUSICPATH.findall(element.text)[0]
|
||||
except IndexError:
|
||||
log.error('Could not parse %s of xml element %s'
|
||||
% (element.text, element.tag))
|
||||
continue
|
||||
else:
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
def set_excludefromscan_music_folders():
|
||||
def excludefromscan_music_folders(sections):
|
||||
"""
|
||||
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.
|
||||
Existing keys will be replaced
|
||||
xml: etree XML PMS answer containing all library sections
|
||||
|
||||
Returns False if no new Plex libraries needed to be exluded, True otherwise
|
||||
Reboots Kodi if new library detected
|
||||
"""
|
||||
changed = False
|
||||
write_xml = False
|
||||
xml = get_plex_sections()
|
||||
try:
|
||||
xml[0].attrib
|
||||
except (TypeError, IndexError, AttributeError):
|
||||
log.error('Could not get Plex sections')
|
||||
return
|
||||
# Build paths
|
||||
paths = []
|
||||
api = API(item=None)
|
||||
for library in xml:
|
||||
if library.attrib['type'] != v.PLEX_TYPE_ARTIST:
|
||||
reboot = False
|
||||
api = Media()
|
||||
for section in sections:
|
||||
if section.section_type != v.PLEX_TYPE_ARTIST:
|
||||
# Only look at music libraries
|
||||
continue
|
||||
for location in library:
|
||||
if location.tag == 'Location':
|
||||
path = api.validatePlayurl(location.attrib['path'],
|
||||
if not section.sync_to_kodi:
|
||||
continue
|
||||
for location in section.xml.findall('Location'):
|
||||
path = api.validate_playurl(location.attrib['path'],
|
||||
typus=v.PLEX_TYPE_ARTIST,
|
||||
omitCheck=True)
|
||||
paths.append(__turn_to_regex(path))
|
||||
# Get existing advancedsettings
|
||||
root, tree = advancedsettings_xml(['audio', 'excludefromscan'],
|
||||
force_create=True)
|
||||
|
||||
omit_check=True)
|
||||
paths.append(_turn_to_regex(path))
|
||||
try:
|
||||
with utils.XmlKodiSetting(
|
||||
'advancedsettings.xml',
|
||||
force_create=True,
|
||||
top_element='advancedsettings') as xml_file:
|
||||
parent = xml_file.set_setting(['audio', 'excludefromscan'])
|
||||
for path in paths:
|
||||
for element in root:
|
||||
for element in parent:
|
||||
if element.text == path:
|
||||
# Path already excluded
|
||||
break
|
||||
else:
|
||||
changed = True
|
||||
write_xml = True
|
||||
log.info('New Plex music library detected: %s' % path)
|
||||
element = etree.Element(tag='regexp')
|
||||
element.text = path
|
||||
root.append(element)
|
||||
|
||||
# Delete obsolete entries (unlike above, we don't change 'changed' to not
|
||||
# enforce a restart)
|
||||
for element in root:
|
||||
LOG.info('New Plex music library detected: %s', path)
|
||||
xml_file.set_setting(['audio', 'excludefromscan', 'regexp'],
|
||||
value=path,
|
||||
append=True)
|
||||
if paths:
|
||||
# We only need to reboot if we ADD new paths!
|
||||
reboot = xml_file.write_xml
|
||||
# Delete obsolete entries
|
||||
# Make sure we're not saving an empty audio-excludefromscan
|
||||
xml_file.write_xml = reboot
|
||||
for element in parent:
|
||||
for path in paths:
|
||||
if element.text == path:
|
||||
break
|
||||
else:
|
||||
log.info('Deleting Plex music library from advancedsettings: %s'
|
||||
% element.text)
|
||||
root.remove(element)
|
||||
write_xml = True
|
||||
|
||||
if write_xml is True:
|
||||
indent(tree.getroot())
|
||||
tree.write('%sadvancedsettings.xml' % v.KODI_PROFILE, encoding="UTF-8")
|
||||
return changed
|
||||
LOG.info('Deleting music library from advancedsettings: %s',
|
||||
element.text)
|
||||
parent.remove(element)
|
||||
xml_file.write_xml = True
|
||||
except (utils.ParseError, IOError):
|
||||
LOG.error('Could not adjust advancedsettings.xml')
|
||||
if reboot is True:
|
||||
# 'New Plex music library detected. Sorry, but we need to
|
||||
# restart Kodi now due to the changes made.'
|
||||
utils.reboot_kodi(utils.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
|
||||
"""
|
||||
|
@ -112,7 +86,7 @@ def __turn_to_regex(path):
|
|||
else:
|
||||
if not path.endswith('\\'):
|
||||
path = '%s\\' % path
|
||||
# Need to escape backslashes
|
||||
path = path.replace('\\', '\\\\')
|
||||
# Escape all characters that could cause problems
|
||||
path = re.escape(path)
|
||||
# Beginning of path only needs to be similar
|
||||
return '^%s' % path
|
||||
|
|
243
resources/lib/path_ops.py
Normal file
243
resources/lib/path_ops.py
Normal file
|
@ -0,0 +1,243 @@
|
|||
#!/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
|
21
resources/lib/pathtools/__init__.py
Normal file
21
resources/lib/pathtools/__init__.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
# -*- 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.
|
206
resources/lib/pathtools/path.py
Normal file
206
resources/lib/pathtools/path.py
Normal file
|
@ -0,0 +1,206 @@
|
|||
#!/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))
|
265
resources/lib/pathtools/patterns.py
Normal file
265
resources/lib/pathtools/patterns.py
Normal file
|
@ -0,0 +1,265 @@
|
|||
#!/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
|
31
resources/lib/pathtools/version.py
Normal file
31
resources/lib/pathtools/version.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
# -*- 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
|
|
@ -1,44 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
###############################################################################
|
||||
import logging
|
||||
import cPickle as Pickle
|
||||
|
||||
from utils import pickl_window
|
||||
###############################################################################
|
||||
log = logging.getLogger("PLEX."+__name__)
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
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.debug('Start pickling: %s' % obj)
|
||||
pickl_window(window_var, value=Pickle.dumps(obj))
|
||||
log.debug('Successfully pickled')
|
||||
|
||||
|
||||
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.debug('Start unpickling')
|
||||
obj = Pickle.loads(result)
|
||||
log.debug('Successfully unpickled: %s' % obj)
|
||||
return obj
|
||||
|
||||
|
||||
class Playback_Successful(object):
|
||||
"""
|
||||
Used to communicate with another PKC Python instance
|
||||
"""
|
||||
listitem = None
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue