Compare commits

..

799 commits

Author SHA1 Message Date
croneter
fdffc53d75 Pull translations from transifex 2021-10-09 18:03:03 +02:00
croneter
27c2aa4d08 Update addon.xml 2021-10-09 17:58:19 +02:00
croneter
c0a600ef46 Bump English strings 2021-10-09 17:45:11 +02:00
croneter
e225be2d40 Update English master 2021-06-05 15:36:13 +02:00
croneter
8f30688e18 Pull translations from Transifex 2021-03-20 14:34:08 +01:00
croneter
16ad89f7d4 Pull languages that haven't been completely translated 2021-03-20 14:33:55 +01:00
croneter
790add3911 Fix encoding 2021-03-20 14:02:42 +01:00
croneter
4c03ee943f Fix indentation 2021-03-20 13:58:05 +01:00
croneter
9dc9c9c15f Pull translations from transifex 2021-03-20 13:53:29 +01:00
croneter
d118ef1d04 Add support for new languages 2021-03-20 13:51:46 +01:00
croneter
66fe5e2ea5 Pull newest English strings 2021-03-20 10:43:24 +01:00
croneter
323f9c27bc Get new PKC strings 2021-03-14 14:44:29 +01:00
croneter
693bb5f152 Pull translations from Transifex 2021-02-13 18:03:44 +01:00
croneter
fcec9683f7 Pull correct original English file 2021-02-13 18:00:39 +01:00
croneter
e79f9fcc3c Pull Translations from Transifex 2021-02-13 17:56:55 +01:00
croneter
a1c916089f Pull English original strings 2021-02-13 12:31:59 +01:00
croneter
b161aca55f Pull translations from Transifex 2021-01-02 13:09:23 +01:00
croneter
49fce53bfc Update English 2020-06-09 15:24:14 +02:00
croneter
09bd646dc1 Get translations from Transifex 2020-05-02 13:15:05 +02:00
croneter
f61c5d16ee Pull translations from Transifex 2020-02-23 15:49:00 +01:00
croneter
947bf57b91 Update English 2020-02-23 15:40:54 +01:00
croneter
8f5786a044 Update translations from transifex 2019-11-07 07:12:52 +01:00
croneter
c10d556d12 Update English 2019-11-07 07:07:40 +01:00
croneter
9147cd5453 Fixup 2019-09-22 13:43:45 +02:00
croneter
d89ff61c1b Pulled Transifex translations 2019-09-22 13:42:27 +02:00
croneter
011134d0ca Update original English strings 2019-09-22 13:33:37 +02:00
croneter
5cdcbae830 Add Lithuanian language 2019-07-21 12:35:09 +02:00
croneter
1488276269 Pull translations from Transifex 2019-06-22 18:42:21 +02:00
croneter
b0eb7d88f1 Update translations 2019-06-14 20:37:05 +02:00
croneter
08cc184772 Bump English 2019-06-14 20:05:59 +02:00
croneter
46db59ebd4 Update translations 2019-06-12 12:31:58 +02:00
croneter
a947997f8f Update English 2019-06-12 12:27:59 +02:00
croneter
d743ebd39c Update English strings from beta branch 2019-04-14 15:07:32 +02:00
croneter
825ef6cab4 Pull transifex translations 2019-03-29 14:22:49 +01:00
croneter
74bc85ba47 Update translated languages 2019-02-16 19:02:24 +01:00
croneter
d050b5b04d Pull Transifex translations 2019-02-16 19:02:12 +01:00
croneter
eaf2445e1e Pull transifex translations 2019-02-16 18:45:57 +01:00
croneter
47f6ac1133 Get latest English strings 2019-02-08 15:44:29 +01:00
croneter
59e3937e4a Pull Transifex strings 2019-02-04 16:20:38 +01:00
croneter
dcdf9c615d Get German strings from Transifex 2019-02-03 20:48:42 +01:00
croneter
06de0c7994 Update English strings 2019-02-03 20:32:55 +01:00
croneter
253eb2474b Update translations 2019-01-30 17:56:04 +01:00
croneter
af57dedc80 Pull translations from transifex 2019-01-29 08:14:49 +01:00
croneter
dbfb0e9c63 Pull translations from transifex 2019-01-28 19:40:05 +01:00
croneter
af0ffc79c2 Update English strings 2019-01-28 15:28:05 +01:00
croneter
8e5dc549c9 Update English strings 2019-01-26 11:58:24 +01:00
croneter
ecbf5172e1 Get English strings 2019-01-26 11:38:50 +01:00
croneter
b987de646d Revert "Update English strings"
This reverts commit f0f86a516c.
2019-01-26 11:38:29 +01:00
croneter
f0f86a516c Update English strings 2019-01-26 11:36:28 +01:00
croneter
75c851ba03 Pull Transifex translations 2018-08-29 16:54:06 +02:00
croneter
5ec86bd0f6 Merge branch 'translations' of https://github.com/croneter/PlexKodiConnect into translations 2018-08-29 16:48:00 +02:00
croneter
654d424486 Pull English 2018-08-29 16:47:42 +02:00
croneter
60bef060be Updating translations for resources/language/resource.language.es_MX/strings.po 2018-08-29 16:45:13 +02:00
croneter
f54418d08c Updating translations for resources/language/resource.language.es_AR/strings.po 2018-08-29 16:44:42 +02:00
croneter
5ab52e6347 Pull languages from Transifex 2018-08-10 08:48:07 +02:00
croneter
e7f75bee03 Pull English strings 2018-08-10 08:06:09 +02:00
Croneter
7aa9b6c4a1 Fix IOError 2018-07-04 16:19:39 +02:00
Croneter
4a6fe4ba53 Add import_strings_to_addon_xml.py 2018-07-04 14:52:53 +02:00
Croneter
3cceb7ec48 Pull translations 2018-07-04 14:47:38 +02:00
Croneter
01c932b792 Include Ukrainian 2018-07-04 14:45:56 +02:00
Croneter
8deed5179e Update English 2018-07-04 14:35:22 +02:00
Croneter
b9d53ff94c Fetch translations 2018-06-15 10:24:40 +02:00
croneter
7638564e13 Pull translations 2018-06-01 20:43:34 +02:00
croneter
c68710c650 Delete .orig files 2018-05-18 19:53:28 +02:00
croneter
fcc981c216 Pull translations 2018-05-18 19:50:28 +02:00
croneter
15ac259eec Merge branch 'hotfixes' into translations 2018-05-18 19:47:59 +02:00
croneter
e57f1661bc Merge branch 'hotfixes' into translations 2018-05-15 20:49:39 +02:00
croneter
2e1b9f4e0c Updating translations for resources/language/resource.language.ru_RU/strings.po 2018-05-13 20:07:40 +02:00
croneter
58fdcc00fe Updating translations for resources/language/resource.language.cs_CZ/strings.po 2018-05-13 18:40:00 +02:00
croneter
0a2a1c4041 Pull translations 2018-05-13 16:32:26 +02:00
croneter
a7a3cb0ace Updating translations for resources/language/resource.language.de_DE/strings.po 2018-05-13 16:25:55 +02:00
croneter
a2867d2806 Merge branch 'hotfixes' into translations 2018-05-13 16:16:27 +02:00
Croneter
8ae7eac24a Pull Transifex languages 2018-04-29 14:54:18 +02:00
croneter
12b6fd8efd Updating translations for resources/language/resource.language.de_DE/strings.po 2018-04-29 14:50:34 +02:00
Croneter
fae3647407 Merge branch 'hotfixes' into translations 2018-04-29 14:40:04 +02:00
croneter
056285f7ae Merge branch 'master' into translations 2018-04-17 21:05:06 +02:00
Croneter
bc36231454 Merge branch 'master' into translations 2018-04-10 19:31:10 +02:00
Croneter
8f7cfb8178 Update languages 2018-04-06 15:34:46 +02:00
Croneter
848e155122 Add Hungarian 2018-04-06 15:32:20 +02:00
croneter
9727899d11 Updating translations for resources/language/resource.language.ru_RU/strings.po 2018-04-06 12:13:34 +02:00
croneter
571289b880 Updating translations for resources/language/resource.language.cs_CZ/strings.po 2018-04-05 19:08:38 +02:00
croneter
b10309837a Updating translations for resources/language/resource.language.nl_NL/strings.po 2018-04-05 17:28:05 +02:00
croneter
70052d77b5 Updating translations for resources/language/resource.language.fr_FR/strings.po 2018-04-05 17:08:19 +02:00
Croneter
62423838b8 Pull translations from transifex 2018-04-05 16:55:37 +02:00
Croneter
0fd65b7ee6 Merge branch 'hotfixes' into translations 2018-04-05 15:31:40 +02:00
croneter
2e9935dbd7 Updating translations for resources/language/resource.language.de_DE/strings.po 2018-04-03 17:40:52 +02:00
croneter
4360548e5e Update 2018-04-03 17:33:59 +02:00
croneter
fe952afa3e Merge branch 'hotfixes' into translations 2018-04-03 17:20:28 +02:00
croneter
d7178e278a Pull Transifex languages 2017-10-09 22:24:21 +02:00
croneter
361f38f334 Updating translations for resources/language/resource.language.ru_RU/strings.po 2017-10-08 13:53:33 +02:00
croneter
eb4d6b000d Pull transifex translations 2017-09-17 14:05:55 +02:00
croneter
0407a75c79 Merge changelog 2017-09-17 13:45:08 +02:00
croneter
b29879006e Updating translations for resources/language/resource.language.es_MX/strings.po 2017-09-17 13:39:10 +02:00
croneter
10b7f33078 Updating translations for resources/language/resource.language.es_AR/strings.po 2017-09-17 13:38:39 +02:00
croneter
fa6d95aa61 Merge master 2017-09-17 13:36:59 +02:00
croneter
c33565af4c Updating translations for resources/language/resource.language.es_ES/strings.po 2017-08-25 17:51:25 +02:00
croneter
80fdc999a9 Updating translations for resources/language/resource.language.fr_FR/strings.po 2017-08-17 10:53:02 +02:00
croneter
7bc1fea65b Updating translations for resources/language/resource.language.fr_CA/strings.po 2017-08-17 10:52:54 +02:00
croneter
a2bc3fe502 Updating translations for resources/language/resource.language.cs_CZ/strings.po 2017-08-03 12:25:17 +02:00
croneter
ac758c730d Updating translations for resources/language/resource.language.de_DE/strings.po 2017-08-02 20:04:16 +02:00
tomkat83
4eec33993c Merge branch 'hotfixes' into translations 2017-08-02 20:01:41 +02:00
tomkat83
8cf6a7ddf7 Merge branch 'hotfixes' into translations 2017-08-02 19:15:42 +02:00
tomkat83
183807c755 Update translations from transifex 2017-07-25 20:13:57 +02:00
tomkat83
251554182e Update translations 2017-07-01 14:28:28 +02:00
tomkat83
8bd51b68a4 Merge branch 'translations' of https://github.com/croneter/PlexKodiConnect into translations 2017-05-31 14:35:27 +02:00
tomkat83
5b2cab5b48 Update translations 2017-05-31 14:33:41 +02:00
croneter
26931a92ea Updating translations for resources/language/resource.language.de_DE/strings.po 2017-05-31 14:20:31 +02:00
tomkat83
0f292c2799 Merge branch 'develop' into translations 2017-05-31 14:14:32 +02:00
croneter
849530de4f Updating translations for resources/language/resource.language.es_ES/strings.po 2017-05-30 17:41:09 +02:00
croneter
5915bbdb83 Updating translations for resources/language/resource.language.de_DE/strings.po 2017-05-29 17:31:35 +02:00
tomkat83
3c4865c425 Merge branch 'develop' into translations 2017-05-29 17:30:02 +02:00
croneter
b421b21375 Updating translations for resources/language/resource.language.de_DE/strings.po 2017-05-29 15:45:54 +02:00
tomkat83
64c6afad92 Merge branch 'develop' into translations 2017-05-29 15:43:10 +02:00
croneter
5e9cc78d71 Updating translations for resources/language/resource.language.cs_CZ/strings.po 2017-05-27 13:39:33 +02:00
croneter
e6fe9a9fb4 Updating translations for resources/language/resource.language.de_DE/strings.po 2017-05-22 21:44:24 +02:00
tomkat83
9fdab58cdb Merge branch 'develop' into translations 2017-05-22 21:42:28 +02:00
croneter
9d2c8bbb10 Updating translations for resources/language/resource.language.cs_CZ/strings.po 2017-05-12 14:50:29 +02:00
croneter
6983c89e5f Updating translations for resources/language/resource.language.de_DE/strings.po 2017-05-11 20:18:00 +02:00
tomkat83
4cbb1dc7bc Merge branch 'develop' into translations 2017-05-11 20:16:39 +02:00
croneter
1dd201e46e Updating translations for resources/language/resource.language.cs_CZ/strings.po 2017-05-08 18:11:16 +02:00
croneter
4ff15db2af Updating translations for resources/language/resource.language.de_DE/strings.po 2017-05-06 19:04:26 +02:00
tomkat83
fcfb18226b Merge branch 'develop' into translations 2017-05-06 19:02:16 +02:00
croneter
9a178d9545 Updating translations for resources/language/resource.language.cs_CZ/strings.po 2017-05-02 09:42:24 +02:00
croneter
06db33c349 Updating translations for resources/language/resource.language.de_DE/strings.po 2017-05-01 20:51:59 +02:00
tomkat83
4dc42ce7cf Merge branch 'develop' into translations 2017-05-01 20:50:03 +02:00
croneter
4d930b16e2 Updating translations for resources/language/resource.language.de_DE/strings.po 2017-05-01 09:12:18 +02:00
tomkat83
1808d0b609 Merge branch 'develop' into translations 2017-05-01 09:05:07 +02:00
croneter
2443bbdd50 Updating translations for resources/language/resource.language.es_ES/strings.po 2017-04-30 18:02:15 +02:00
tomkat83
df89419a25 Spanish translations 2017-04-30 12:37:07 +02:00
tomkat83
92576875e4 it_IT translation 2017-04-30 12:36:31 +02:00
tomkat83
96df618447 zh_TW translation 2017-04-30 12:35:59 +02:00
tomkat83
bce36b10ee zh_CN translation 2017-04-30 12:35:41 +02:00
tomkat83
a577850cbd fr_CA translation 2017-04-30 12:34:58 +02:00
tomkat83
8619e87220 fr_FR translation 2017-04-30 12:34:31 +02:00
tomkat83
6c3a7e5204 Transifex configuration 2017-04-30 12:33:27 +02:00
croneter
7b9d35e028 Updating translations for resources/language/resource.language.da_DK/strings.po 2017-04-30 12:12:18 +02:00
croneter
6dc08d6e6e Updating translations for resources/language/resource.language.cs_CZ/strings.po 2017-04-30 12:10:22 +02:00
croneter
1fc99a3996 Updating translations for resources/language/resource.language.nl_NL/strings.po 2017-04-30 12:08:05 +02:00
croneter
5c986652e0 Updating translations for resources/language/resource.language.de_DE/strings.po 2017-04-30 11:54:50 +02:00
tomkat83
6fa0d51354 Update English 2017-04-30 11:38:42 +02:00
tomkat83
d8af31e519 Update English 2017-04-30 11:31:45 +02:00
tomkat83
674151bd2a Remove obsolete strings 2017-04-30 11:28:35 +02:00
tomkat83
f16e77afa9 Update English 2017-04-30 11:24:09 +02:00
tomkat83
203a55e0df Update English 2017-04-30 11:22:52 +02:00
tomkat83
763cc8ebd3 Update English translation 2017-04-30 11:18:14 +02:00
tomkat83
1f17a67724 Update English translation 2017-04-30 11:13:41 +02:00
tomkat83
1fd00fc9b4 Update English translation 2017-04-30 11:11:10 +02:00
tomkat83
2267256b5d Update English translation 2017-04-30 11:08:52 +02:00
tomkat83
a3ff330988 Merge branch 'develop' into translations 2017-04-30 11:07:27 +02:00
croneter
d08a410d36 Merge pull request #280 from croneter/l10n_translations
New Crowdin translations
2017-04-24 15:29:38 +02:00
croneter
6dc5cf6155 New translations strings.xml (Portuguese) 2017-04-22 19:31:41 +02:00
croneter
4c1534d2fa New translations strings.xml (Portuguese) 2017-04-22 19:21:47 +02:00
croneter
a2203e11c1 New translations strings.xml (Portuguese) 2017-04-22 19:11:50 +02:00
croneter
14ff8c587f New translations strings.xml (Portuguese) 2017-04-22 19:02:06 +02:00
croneter
fb0358ae09 New translations strings.xml (Portuguese) 2017-04-22 18:51:56 +02:00
croneter
a6bb30a790 New translations strings.xml (Portuguese) 2017-04-22 18:47:59 +02:00
croneter
ca09247c80 Merge pull request #276 from croneter/l10n_translations
New Crowdin translations
2017-04-15 18:29:00 +02:00
croneter
f90821ddd7 New translations strings.xml (French) 2017-04-15 17:30:57 +02:00
croneter
c606595816 Merge pull request #275 from croneter/l10n_translations
New Crowdin translations
2017-04-15 17:05:57 +02:00
croneter
278c547df0 New translations strings.xml (French) 2017-04-15 16:40:22 +02:00
croneter
3aa4d47726 New translations strings.xml (French) 2017-04-15 16:30:23 +02:00
croneter
6b8fd018f3 New translations strings.xml (French) 2017-04-15 16:22:15 +02:00
croneter
209c2fc232 New translations strings.xml (French) 2017-04-15 15:41:46 +02:00
croneter
d3b7297010 Merge pull request #271 from croneter/l10n_translations
New Crowdin translations
2017-04-14 14:57:21 +02:00
croneter
93bbc7c26b New translations strings.xml (Russian) 2017-04-13 15:03:00 +02:00
croneter
151f48417c New translations strings.xml (Russian) 2017-04-13 14:52:13 +02:00
croneter
4ae0501a89 New translations strings.xml (Greek) 2017-04-11 22:20:30 +02:00
croneter
50ab018da2 New translations strings.xml (French) 2017-04-11 21:33:42 +02:00
croneter
f905376a7f New translations strings.xml (French) 2017-04-11 21:20:55 +02:00
croneter
3532953b81 New translations strings.xml (French) 2017-04-11 21:10:53 +02:00
croneter
80ae07dbc6 New translations strings.xml (French) 2017-04-11 21:04:45 +02:00
croneter
021b235211 New translations strings.xml (Greek) 2017-04-11 21:04:41 +02:00
croneter
e1c663949d New translations strings.xml (Greek) 2017-04-11 20:50:32 +02:00
croneter
48c46d6a47 New translations addon.xml (Chinese Simplified) 2017-04-10 07:10:40 +02:00
croneter
0982c6e381 New translations addon.xml (Chinese Simplified) 2017-04-10 06:04:48 +02:00
croneter
af18885254 New translations addon.xml (Chinese Traditional) 2017-04-07 17:40:37 +02:00
croneter
8060eccc9f New translations addon.xml (Chinese Traditional) 2017-04-07 17:30:47 +02:00
croneter
abeddbf4c1 New translations strings.xml (Chinese Traditional) 2017-04-07 17:20:55 +02:00
croneter
814bc38fd5 New translations addon.xml (Chinese Traditional) 2017-04-07 17:20:51 +02:00
croneter
d407a4818c New translations strings.xml (Chinese Traditional) 2017-04-07 17:11:32 +02:00
croneter
b6f13482fd New translations strings.xml (Chinese Traditional) 2017-04-07 17:01:43 +02:00
croneter
afa2463a95 New translations strings.xml (Chinese Traditional) 2017-04-07 16:52:35 +02:00
croneter
7ea89a39d5 New translations strings.xml (Chinese Traditional) 2017-04-07 16:41:18 +02:00
croneter
3b38ebef19 New translations strings.xml (Chinese Traditional) 2017-04-07 16:27:04 +02:00
croneter
1d257a78d7 New translations strings.xml (Chinese Traditional) 2017-04-07 16:19:44 +02:00
croneter
cf2cdb0de5 New translations strings.xml (Chinese Traditional) 2017-04-07 16:15:38 +02:00
tomkat83
8c667885dd Merge branch 'l10n_translations' into translations 2017-04-07 11:45:01 +02:00
tomkat83
318c01deb9 Merge branch 'translations' into l10n_translations 2017-04-07 11:44:48 +02:00
tomkat83
f8fe6b6659 Merge branch 'develop' into translations 2017-04-07 11:41:24 +02:00
tomkat83
ccfb32bc0d Merge branch 'master' into translations 2017-04-07 11:31:23 +02:00
croneter
d344a36b6e New translations strings.xml (Chinese Traditional) 2017-04-06 21:01:34 +02:00
croneter
277774f73f New translations strings.xml (Chinese Traditional) 2017-04-06 20:50:42 +02:00
croneter
b5965c3ac5 New translations strings.xml (Chinese Traditional) 2017-04-06 20:40:54 +02:00
croneter
ffeac69718 New translations strings.xml (Chinese Traditional) 2017-04-06 20:30:50 +02:00
croneter
b229f4d2a9 New translations strings.xml (Chinese Traditional) 2017-04-06 20:20:43 +02:00
croneter
f4ab7f8fd8 New translations strings.xml (Chinese Traditional) 2017-04-06 20:10:40 +02:00
croneter
e07f5ffa53 New translations strings.xml (Chinese Traditional) 2017-04-06 20:00:57 +02:00
croneter
a487ed2697 New translations strings.xml (Chinese Traditional) 2017-04-06 19:54:24 +02:00
croneter
ab53fdba94 New translations strings.xml (Chinese Traditional) 2017-04-06 19:41:16 +02:00
croneter
54566f9763 New translations strings.xml (Chinese Traditional) 2017-04-06 19:31:04 +02:00
croneter
02a13a80e8 New translations strings.xml (Chinese Traditional) 2017-04-06 19:21:07 +02:00
croneter
ef0dbfdc49 New translations strings.xml (Chinese Traditional) 2017-04-06 19:10:52 +02:00
croneter
9e74600acb New translations strings.xml (Chinese Traditional) 2017-04-06 19:00:39 +02:00
croneter
f733c677d5 New translations strings.xml (Chinese Traditional) 2017-04-06 18:50:46 +02:00
croneter
a62fedcb71 New translations strings.xml (Chinese Traditional) 2017-04-06 18:40:36 +02:00
croneter
797b6f51a7 New translations strings.xml (Chinese Traditional) 2017-04-06 18:30:59 +02:00
croneter
a5af348b1c New translations strings.xml (Chinese Traditional) 2017-04-06 18:21:02 +02:00
croneter
c0ae00ac9a New translations strings.xml (Chinese Traditional) 2017-04-06 18:10:56 +02:00
croneter
c57d28c483 New translations strings.xml (Chinese Traditional) 2017-04-06 18:02:05 +02:00
croneter
bf65de2806 New translations strings.xml (Chinese Traditional) 2017-04-06 17:51:10 +02:00
croneter
7472d1e5df New translations strings.xml (Chinese Traditional) 2017-04-06 17:40:43 +02:00
croneter
281145dc68 New translations strings.xml (Chinese Traditional) 2017-04-06 17:31:14 +02:00
croneter
940816b327 New translations strings.xml (Chinese Traditional) 2017-04-05 20:31:32 +02:00
croneter
aca3b25ed9 New translations addon.xml (French) 2017-04-05 20:31:25 +02:00
croneter
d337cf7fca New translations addon.xml (Norwegian) 2017-04-05 20:31:23 +02:00
croneter
956e445441 New translations addon.xml (Afrikaans) 2017-04-05 20:31:22 +02:00
croneter
648090a516 New translations addon.xml (Catalan) 2017-04-05 20:31:20 +02:00
croneter
4b27509102 New translations addon.xml (Hebrew) 2017-04-05 20:31:19 +02:00
croneter
7d65e3d7fc New translations addon.xml (Romanian) 2017-04-05 20:31:18 +02:00
croneter
984d4baace New translations addon.xml (Hungarian) 2017-04-05 20:31:16 +02:00
croneter
03137c8d9e New translations addon.xml (Finnish) 2017-04-05 20:31:14 +02:00
croneter
5132042498 New translations addon.xml (Greek) 2017-04-05 20:31:13 +02:00
croneter
8e9be8d7f5 New translations addon.xml (Serbian (Cyrillic)) 2017-04-05 20:31:11 +02:00
croneter
ff1366a916 New translations addon.xml (Indonesian) 2017-04-05 20:31:10 +02:00
croneter
b0779499ab New translations addon.xml (Albanian) 2017-04-05 20:31:08 +02:00
croneter
6bcfb528f0 New translations addon.xml (Croatian) 2017-04-05 20:31:07 +02:00
croneter
27a0a50c72 New translations addon.xml (Slovenian) 2017-04-05 20:31:05 +02:00
croneter
2736979856 New translations addon.xml (Bulgarian) 2017-04-05 20:31:04 +02:00
croneter
6da8e913c2 New translations addon.xml (Slovak) 2017-04-05 20:31:01 +02:00
croneter
bce06583f3 New translations addon.xml (Thai) 2017-04-05 20:30:59 +02:00
croneter
c1b7babe34 New translations addon.xml (Persian) 2017-04-05 20:30:58 +02:00
croneter
990e466a22 New translations addon.xml (Hindi) 2017-04-05 20:30:57 +02:00
croneter
74373d5e88 New translations addon.xml (Vietnamese) 2017-04-05 20:30:56 +02:00
croneter
e63dba2aa5 New translations addon.xml (Danish) 2017-04-05 20:30:54 +02:00
croneter
3ac93455ea New translations addon.xml (Chinese Simplified) 2017-04-05 20:30:53 +02:00
croneter
f5cf8c51b9 New translations addon.xml (Portuguese, Brazilian) 2017-04-05 20:30:51 +02:00
croneter
77ff5e0ae6 New translations addon.xml (Japanese) 2017-04-05 20:30:50 +02:00
croneter
c3e15e83c0 New translations addon.xml (Italian) 2017-04-05 20:30:48 +02:00
croneter
c7ef5bb6a6 New translations addon.xml (Russian) 2017-04-05 20:30:47 +02:00
croneter
6dd0a448b3 New translations addon.xml (German) 2017-04-05 20:30:44 +02:00
croneter
b6b9798c26 New translations addon.xml (Spanish) 2017-04-05 20:30:43 +02:00
croneter
890bf1f0a2 New translations addon.xml (English) 2017-04-05 20:30:42 +02:00
croneter
523a29be5e New translations addon.xml (Arabic) 2017-04-05 20:30:40 +02:00
croneter
d58dec404f New translations addon.xml (Dutch) 2017-04-05 20:30:39 +02:00
croneter
1f5fa804e5 New translations addon.xml (Swedish) 2017-04-05 20:30:36 +02:00
croneter
5856c11438 New translations addon.xml (Ukrainian) 2017-04-05 20:30:34 +02:00
croneter
d606d80052 New translations addon.xml (Czech) 2017-04-05 20:30:32 +02:00
croneter
0a1727496e New translations addon.xml (Chinese Traditional) 2017-04-05 20:30:31 +02:00
croneter
bef1339cf3 New translations addon.xml (Korean) 2017-04-05 20:30:29 +02:00
croneter
b6bde683c3 New translations addon.xml (Polish) 2017-04-05 20:30:25 +02:00
croneter
940f077cb7 New translations addon.xml (Portuguese) 2017-04-05 20:30:23 +02:00
croneter
c51aaa4267 New translations addon.xml (Turkish) 2017-04-05 20:30:22 +02:00
croneter
9a70416ba8 New translations strings.xml (Chinese Traditional) 2017-04-05 20:21:23 +02:00
tomkat83
0e5ebf45de Merge branch 'l10n_translations' into translations 2017-04-05 20:15:27 +02:00
tomkat83
145bb743e3 Merge branch 'translations' into l10n_translations 2017-04-05 20:14:58 +02:00
croneter
37e7c22309 New translations strings.xml (Chinese Traditional) 2017-04-05 20:10:31 +02:00
croneter
e77532ac28 New translations strings.xml (Chinese Traditional) 2017-04-05 20:00:42 +02:00
croneter
52af6f09b7 New translations strings.xml (Chinese Traditional) 2017-04-05 19:50:25 +02:00
croneter
663cb17bb6 New translations strings.xml (Chinese Traditional) 2017-04-05 19:40:42 +02:00
croneter
fefd47d8a8 New translations strings.xml (Chinese Traditional) 2017-04-05 19:30:32 +02:00
croneter
9615feb135 New translations strings.xml (Chinese Traditional) 2017-04-05 19:20:49 +02:00
croneter
b30e7ae9fa New translations strings.xml (Chinese Traditional) 2017-04-05 19:11:05 +02:00
croneter
5311884c73 New translations strings.xml (Chinese Traditional) 2017-04-05 19:00:51 +02:00
croneter
4fbe690993 New translations strings.xml (Chinese Traditional) 2017-04-05 18:52:28 +02:00
croneter
08d4eb63cb New translations strings.xml (Chinese Traditional) 2017-04-05 18:41:00 +02:00
croneter
8276f2897a New translations strings.xml (Chinese Traditional) 2017-04-05 18:30:32 +02:00
croneter
6768f57f9e New translations strings.xml (Chinese Traditional) 2017-04-05 18:20:35 +02:00
croneter
32db8224a6 New translations strings.xml (Chinese Traditional) 2017-04-05 18:11:44 +02:00
croneter
c58394d894 New translations strings.xml (Chinese Traditional) 2017-04-05 18:01:06 +02:00
croneter
bd4ea41af3 New translations strings.xml (Chinese Traditional) 2017-04-05 17:50:30 +02:00
croneter
5585ac6a24 New translations strings.xml (Chinese Traditional) 2017-04-05 17:41:56 +02:00
croneter
210c7c5d87 New translations strings.xml (Chinese Simplified) 2017-04-05 17:41:52 +02:00
croneter
4bb02f7afd New translations strings.xml (Chinese Simplified) 2017-04-05 17:30:33 +02:00
croneter
9b1730ff4b New translations strings.xml (Chinese Simplified) 2017-04-05 17:23:23 +02:00
croneter
6cfe6842e7 New translations strings.xml (Chinese Simplified) 2017-04-05 17:11:42 +02:00
croneter
c68571db4f New translations strings.xml (Chinese Simplified) 2017-04-05 17:02:15 +02:00
croneter
2df8ae75c8 New translations strings.xml (Chinese Simplified) 2017-04-05 16:50:47 +02:00
croneter
572bb4cb97 New translations strings.xml (Chinese Simplified) 2017-04-05 16:42:03 +02:00
croneter
a4ef15df45 New translations strings.xml (Chinese Simplified) 2017-04-05 16:32:10 +02:00
croneter
38223df931 New translations strings.xml (Chinese Simplified) 2017-04-05 16:24:24 +02:00
croneter
c13eb94075 New translations strings.xml (Chinese Simplified) 2017-04-05 16:13:29 +02:00
croneter
3124de6a46 New translations strings.xml (Chinese Simplified) 2017-04-05 16:01:16 +02:00
croneter
48b2294cf2 New translations strings.xml (Chinese Simplified) 2017-04-05 15:52:14 +02:00
croneter
585c95d781 New translations strings.xml (Chinese Simplified) 2017-04-05 15:40:54 +02:00
croneter
7d14fe8e93 New translations strings.xml (Chinese Simplified) 2017-04-05 15:31:09 +02:00
croneter
a88c925b6e New translations strings.xml (Chinese Simplified) 2017-04-05 15:21:26 +02:00
croneter
04be54c428 New translations strings.xml (Chinese Simplified) 2017-04-05 15:11:48 +02:00
croneter
a17b2b8f81 New translations strings.xml (Chinese Simplified) 2017-04-05 15:01:33 +02:00
croneter
92d3ac2394 New translations strings.xml (Chinese Simplified) 2017-04-05 14:50:36 +02:00
croneter
ddc27de1a9 New translations strings.xml (Chinese Simplified) 2017-04-05 14:41:20 +02:00
croneter
3b3e7db05b New translations strings.xml (Chinese Simplified) 2017-04-05 14:36:42 +02:00
croneter
bdb9cdceb1 New translations strings.xml (Chinese Simplified) 2017-04-05 12:30:54 +02:00
croneter
09b279ea2a New translations strings.xml (Hungarian) 2017-04-05 12:21:57 +02:00
croneter
791f711e57 New translations strings.xml (Chinese Simplified) 2017-04-05 12:21:53 +02:00
croneter
db8335e400 New translations strings.xml (Chinese Simplified) 2017-04-05 12:12:19 +02:00
croneter
53a2f491e3 New translations strings.xml (Chinese Simplified) 2017-04-05 12:03:13 +02:00
croneter
eb92e8ae65 New translations strings.xml (Chinese Simplified) 2017-04-05 11:32:33 +02:00
croneter
e47e4ce618 New translations strings.xml (Chinese Simplified) 2017-04-05 11:22:51 +02:00
croneter
e50c1a49fe New translations strings.xml (Hungarian) 2017-04-05 11:12:09 +02:00
croneter
059c1f0169 New translations strings.xml (Chinese Simplified) 2017-04-05 11:12:02 +02:00
croneter
774e9cbf02 New translations strings.xml (Hungarian) 2017-04-05 11:00:52 +02:00
croneter
49971417c4 New translations strings.xml (Chinese Simplified) 2017-04-05 11:00:49 +02:00
croneter
1b9cc47781 New translations strings.xml (Chinese Simplified) 2017-04-05 10:40:30 +02:00
croneter
ec44ef82b7 New translations strings.xml (Chinese Simplified) 2017-04-05 10:30:56 +02:00
croneter
036abbc14c New translations strings.xml (Chinese Simplified) 2017-04-05 10:22:07 +02:00
croneter
4a3aecdc03 New translations strings.xml (Chinese Simplified) 2017-04-05 10:11:37 +02:00
croneter
72dfa7bb2d New translations strings.xml (Dutch) 2017-04-03 14:24:52 +02:00
croneter
2960a64ead New translations strings.xml (Dutch) 2017-04-03 11:31:50 +02:00
croneter
99748c5905 New translations strings.xml (Dutch) 2017-04-03 11:20:36 +02:00
croneter
0849f8413b New translations strings.xml (Dutch) 2017-04-03 11:10:59 +02:00
croneter
73f12d725b New translations strings.xml (German) 2017-04-01 17:00:22 +02:00
croneter
650b53a5cb New translations strings.xml (Polish) 2017-04-01 12:00:28 +02:00
croneter
38a5840221 New translations strings.xml (Polish) 2017-04-01 11:50:18 +02:00
croneter
8d311866d8 New translations strings.xml (Polish) 2017-04-01 11:40:19 +02:00
croneter
fecb532f92 Merge pull request #260 from croneter/l10n_translations
New Crowdin translations
2017-03-29 21:12:47 +02:00
croneter
7227963c83 New translations strings.xml (French) 2017-03-23 19:04:11 +01:00
croneter
b009d8661b New translations strings.xml (French) 2017-03-23 18:50:36 +01:00
croneter
11e5e0e2ee New translations strings.xml (Czech) 2017-03-19 19:00:38 +01:00
croneter
ba955cde70 New translations strings.xml (Czech) 2017-03-19 18:50:16 +01:00
croneter
b517c706f5 New translations strings.xml (Dutch) 2017-03-19 17:20:41 +01:00
croneter
54b9982ea0 New translations strings.xml (Dutch) 2017-03-19 17:10:37 +01:00
croneter
b7ff805bb0 New translations strings.xml (Dutch) 2017-03-19 17:01:10 +01:00
croneter
4a3af53507 New translations strings.xml (French) 2017-03-19 14:51:38 +01:00
croneter
763567277a New translations strings.xml (Ukrainian) 2017-03-19 14:51:36 +01:00
croneter
bf7fc7583a New translations strings.xml (Swedish) 2017-03-19 14:51:34 +01:00
croneter
3d9f486ae2 New translations strings.xml (Chinese Traditional) 2017-03-19 14:51:32 +01:00
croneter
0721aa9062 New translations strings.xml (Vietnamese) 2017-03-19 14:51:30 +01:00
croneter
103082dc1d New translations strings.xml (Hungarian) 2017-03-19 14:51:28 +01:00
croneter
f0bf8d122c New translations strings.xml (Romanian) 2017-03-19 14:51:26 +01:00
croneter
bc0aa70e40 New translations strings.xml (Greek) 2017-03-19 14:51:24 +01:00
croneter
ff5b360d2b New translations strings.xml (Finnish) 2017-03-19 14:51:22 +01:00
croneter
5c9be50c5e New translations strings.xml (Korean) 2017-03-19 14:51:19 +01:00
croneter
e60effa948 New translations strings.xml (Turkish) 2017-03-19 14:51:17 +01:00
croneter
713804cca6 New translations strings.xml (Portuguese, Brazilian) 2017-03-19 14:51:15 +01:00
croneter
21705fe3b0 New translations strings.xml (Chinese Simplified) 2017-03-19 14:51:13 +01:00
croneter
414b700267 New translations strings.xml (Russian) 2017-03-19 14:51:11 +01:00
croneter
e4ca6535e0 New translations strings.xml (Japanese) 2017-03-19 14:51:09 +01:00
croneter
5777bb8073 New translations strings.xml (Arabic) 2017-03-19 14:51:07 +01:00
croneter
9f1fe23687 New translations strings.xml (Portuguese) 2017-03-19 14:51:05 +01:00
croneter
f5b70b1f9f New translations strings.xml (Polish) 2017-03-19 14:51:03 +01:00
croneter
a0f2334e7b New translations strings.xml (Dutch) 2017-03-19 14:51:00 +01:00
croneter
312e1e667c New translations strings.xml (Hebrew) 2017-03-19 14:50:58 +01:00
croneter
69ff8ac530 New translations strings.xml (Norwegian) 2017-03-19 14:50:56 +01:00
croneter
7f3e787652 New translations strings.xml (German) 2017-03-19 14:50:54 +01:00
croneter
b04437a274 New translations strings.xml (Slovenian) 2017-03-19 14:50:52 +01:00
croneter
53a344bb91 New translations strings.xml (Croatian) 2017-03-19 14:50:49 +01:00
croneter
5f300de112 New translations strings.xml (Czech) 2017-03-19 14:50:47 +01:00
croneter
b92dd90921 New translations strings.xml (Danish) 2017-03-19 14:50:45 +01:00
croneter
bcc2cc59ef New translations strings.xml (Italian) 2017-03-19 14:50:42 +01:00
croneter
f36a2ad85b New translations strings.xml (Spanish) 2017-03-19 14:50:40 +01:00
croneter
817e1eda84 New translations strings.xml (Albanian) 2017-03-19 14:50:38 +01:00
croneter
89df1e9190 New translations strings.xml (Bulgarian) 2017-03-19 14:50:36 +01:00
croneter
2c06f1b087 New translations strings.xml (Serbian (Cyrillic)) 2017-03-19 14:50:34 +01:00
croneter
282787aedf New translations strings.xml (Catalan) 2017-03-19 14:50:32 +01:00
croneter
e742090b04 New translations strings.xml (Afrikaans) 2017-03-19 14:50:30 +01:00
croneter
b47f8f4798 New translations strings.xml (Indonesian) 2017-03-19 14:50:27 +01:00
croneter
8a93d43ac1 New translations strings.xml (Thai) 2017-03-19 14:50:25 +01:00
croneter
98781f05e9 New translations strings.xml (Slovak) 2017-03-19 14:50:23 +01:00
croneter
40dc343fbc New translations strings.xml (Hindi) 2017-03-19 14:50:21 +01:00
croneter
38408446a7 New translations strings.xml (Persian) 2017-03-19 14:50:19 +01:00
croneter
f6099573be Merge pull request #255 from croneter/l10n_translations
New Crowdin translations
2017-03-19 14:43:48 +01:00
croneter
d9d9623189 New translations strings.xml (Dutch) 2017-03-18 10:20:19 +01:00
croneter
015b7c2a4c New translations strings.xml (Dutch) 2017-03-17 22:00:24 +01:00
croneter
5e7bd364cc New translations strings.xml (Dutch) 2017-03-17 21:31:52 +01:00
croneter
f54b71375b New translations strings.xml (Dutch) 2017-03-17 21:22:07 +01:00
croneter
139bb77ef5 New translations strings.xml (Dutch) 2017-03-17 21:12:08 +01:00
croneter
68da99c5f1 New translations strings.xml (Dutch) 2017-03-17 21:02:21 +01:00
croneter
25211be097 New translations strings.xml (English) 2017-03-16 13:20:45 +01:00
croneter
b3270bbfaa New translations strings.xml (English) 2017-03-16 13:10:52 +01:00
croneter
efc0a87cce New translations strings.xml (Dutch) 2017-03-16 01:20:22 +01:00
croneter
2f87c9705a New translations strings.xml (Dutch) 2017-03-16 01:10:18 +01:00
croneter
6e538eb00c New translations strings.xml (Dutch) 2017-03-15 21:00:31 +01:00
croneter
53c57af08b New translations strings.xml (Italian) 2017-03-14 14:11:22 +01:00
croneter
a911617273 New translations strings.xml (Italian) 2017-03-14 14:01:46 +01:00
croneter
0c6ae7f368 New translations strings.xml (Italian) 2017-03-14 13:50:55 +01:00
croneter
0ab114e5d4 New translations strings.xml (Italian) 2017-03-14 13:42:48 +01:00
croneter
dff4c218d2 New translations strings.xml (Danish) 2017-03-14 12:12:02 +01:00
croneter
4fb6f9bc7a New translations strings.xml (Danish) 2017-03-14 12:01:36 +01:00
croneter
6073b9db09 New translations strings.xml (Danish) 2017-03-14 11:54:58 +01:00
croneter
82325e3827 Merge pull request #251 from croneter/l10n_translations
New Crowdin translations
2017-03-12 19:02:20 +01:00
croneter
c59720f104 New translations strings.xml (Czech) 2017-03-09 16:51:58 +01:00
tomkat83
90a6c4a497 Update addon.xml 2017-03-09 16:49:42 +01:00
tomkat83
28cfddc5cb Merge branch 'master' into translations 2017-03-09 16:48:48 +01:00
croneter
2d86383c5a Merge pull request #250 from croneter/l10n_translations
New Crowdin translations
2017-03-09 16:42:23 +01:00
croneter
3d3255d365 New translations strings.xml (Danish) 2017-03-09 13:08:27 +01:00
tomkat83
09237a2c2f Merge branch 'l10n_translations' into translations 2017-03-09 13:06:44 +01:00
tomkat83
1d424cf547 Merge branch 'translations' into l10n_translations 2017-03-09 13:04:43 +01:00
croneter
fad5b69551 New translations strings.xml (German) 2017-03-09 12:45:52 +01:00
tomkat83
91e3d24658 Merge branch 'develop' into translations 2017-03-09 12:43:59 +01:00
croneter
d8cc3acf36 New translations strings.xml (French) 2017-03-08 19:17:37 +01:00
croneter
1e90a3d888 New translations strings.xml (Serbian (Cyrillic)) 2017-03-08 19:17:35 +01:00
croneter
bd4e97a5de New translations strings.xml (Indonesian) 2017-03-08 19:17:33 +01:00
croneter
542bc98802 New translations strings.xml (Catalan) 2017-03-08 19:17:30 +01:00
croneter
ef0c4f2ee3 New translations strings.xml (Afrikaans) 2017-03-08 19:17:28 +01:00
croneter
f839d0aee6 New translations strings.xml (Romanian) 2017-03-08 19:17:26 +01:00
croneter
7dade0429e New translations strings.xml (Hebrew) 2017-03-08 19:17:24 +01:00
croneter
9b220e4b17 New translations strings.xml (Norwegian) 2017-03-08 19:17:21 +01:00
croneter
3667b1dc62 New translations strings.xml (Thai) 2017-03-08 19:17:19 +01:00
croneter
2601ef29d7 New translations strings.xml (Persian) 2017-03-08 19:17:17 +01:00
croneter
e6ab88a323 New translations strings.xml (Slovenian) 2017-03-08 19:17:15 +01:00
croneter
cd7bbf6949 New translations strings.xml (German) 2017-03-08 19:17:13 +01:00
croneter
7e4a15bd0f New translations strings.xml (Czech) 2017-03-08 19:17:10 +01:00
croneter
9c4820ab51 New translations strings.xml (Croatian) 2017-03-08 19:17:08 +01:00
croneter
07955b784a New translations strings.xml (Albanian) 2017-03-08 19:17:06 +01:00
croneter
e207c9b48f New translations strings.xml (Hindi) 2017-03-08 19:17:03 +01:00
croneter
076264e1c0 New translations strings.xml (Slovak) 2017-03-08 19:17:01 +01:00
croneter
8ba867762b New translations strings.xml (Bulgarian) 2017-03-08 19:16:59 +01:00
croneter
c43f6dad26 New translations strings.xml (Greek) 2017-03-08 19:16:56 +01:00
croneter
305f0d44ba New translations strings.xml (Finnish) 2017-03-08 19:16:54 +01:00
croneter
95350af7eb New translations strings.xml (Japanese) 2017-03-08 19:16:52 +01:00
croneter
e707bea310 New translations strings.xml (Arabic) 2017-03-08 19:16:49 +01:00
croneter
b54f87c01f New translations strings.xml (Dutch) 2017-03-08 19:16:47 +01:00
croneter
3adf890b96 New translations strings.xml (Portuguese, Brazilian) 2017-03-08 19:16:45 +01:00
croneter
d32307d264 New translations strings.xml (Chinese Simplified) 2017-03-08 19:16:42 +01:00
croneter
e2bad22b0e New translations strings.xml (English) 2017-03-08 19:16:40 +01:00
croneter
bf15602569 New translations strings.xml (Russian) 2017-03-08 19:16:38 +01:00
croneter
d9b3bdf140 New translations strings.xml (Italian) 2017-03-08 19:16:36 +01:00
croneter
b656c0da2b New translations strings.xml (Polish) 2017-03-08 19:16:33 +01:00
croneter
423305700a New translations strings.xml (Portuguese) 2017-03-08 19:16:31 +01:00
croneter
19bf9b63a8 New translations strings.xml (Danish) 2017-03-08 19:16:29 +01:00
croneter
db82920db3 New translations strings.xml (Vietnamese) 2017-03-08 19:16:27 +01:00
croneter
999611285d New translations strings.xml (Hungarian) 2017-03-08 19:16:24 +01:00
croneter
3c4c12cddd New translations strings.xml (Ukrainian) 2017-03-08 19:16:22 +01:00
croneter
11d89c588a New translations strings.xml (Swedish) 2017-03-08 19:16:20 +01:00
croneter
f3fe69aca8 New translations strings.xml (Turkish) 2017-03-08 19:16:18 +01:00
croneter
f45fec36ce New translations strings.xml (Korean) 2017-03-08 19:16:16 +01:00
croneter
ed77081a81 New translations strings.xml (Chinese Traditional) 2017-03-08 19:16:14 +01:00
croneter
bd1ceb1804 New translations strings.xml (Spanish) 2017-03-08 19:16:04 +01:00
tomkat83
d57d55d070 Merge branch 'master' into translations 2017-03-08 19:11:16 +01:00
croneter
98a5357bda Translated 2017-03-07 23:21:48 +01:00
croneter
7e507da1b4 Merge pull request #246 from croneter/l10n_translations
New Crowdin translations
2017-03-07 20:13:48 +01:00
croneter
2f92e1bc45 Translated 2017-03-07 14:52:28 +01:00
croneter
100e8d3099 New translations 2017-03-07 14:43:18 +01:00
croneter
f4b5e0a449 New translations 2017-03-07 14:43:15 +01:00
croneter
d3b1a65a7d New translations 2017-03-07 14:43:13 +01:00
croneter
66670fdcb5 New translations 2017-03-07 14:43:10 +01:00
croneter
0c9967f75a New translations 2017-03-07 14:43:06 +01:00
croneter
140693b288 New translations 2017-03-07 14:43:03 +01:00
croneter
339a0f0e60 New translations 2017-03-07 14:42:58 +01:00
croneter
f2a16cba02 New translations 2017-03-07 14:42:54 +01:00
croneter
f73596c704 New translations 2017-03-07 14:42:51 +01:00
croneter
1cf8e6e0ac New translations 2017-03-07 14:42:47 +01:00
croneter
38f03fd98c New translations 2017-03-07 14:42:43 +01:00
croneter
902556f40f Translated 2017-03-07 14:42:39 +01:00
croneter
2646ca32df New translations 2017-03-07 14:42:35 +01:00
croneter
d115d69a5e New translations 2017-03-07 14:42:31 +01:00
croneter
a5e1704fe4 New translations 2017-03-07 14:42:28 +01:00
croneter
cbf14b8724 New translations 2017-03-07 14:42:23 +01:00
croneter
688b625e13 New translations 2017-03-07 14:42:18 +01:00
croneter
a30b29a7ed New translations 2017-03-07 14:42:13 +01:00
croneter
8868fdebb8 New translations 2017-03-07 14:42:09 +01:00
croneter
556dbcaf1f New translations 2017-03-07 14:42:04 +01:00
croneter
57d4d175e0 New translations 2017-03-07 14:42:00 +01:00
croneter
bd8b238001 New translations 2017-03-07 14:41:58 +01:00
croneter
cb6715ad43 New translations 2017-03-07 14:41:55 +01:00
croneter
d535043559 New translations 2017-03-07 14:41:52 +01:00
croneter
8b5b79331a New translations 2017-03-07 14:41:49 +01:00
croneter
73cde8e262 New translations 2017-03-07 14:41:46 +01:00
croneter
28d114cb14 New translations 2017-03-07 14:41:43 +01:00
croneter
5bd0dfeea8 New translations 2017-03-07 14:41:41 +01:00
croneter
bb864dd6c8 New translations 2017-03-07 14:41:38 +01:00
croneter
46a95217ee New translations 2017-03-07 14:41:36 +01:00
croneter
19bbb0c0a6 New translations 2017-03-07 14:41:33 +01:00
croneter
bc77e56f31 New translations 2017-03-07 14:41:30 +01:00
croneter
a8ce822da6 New translations 2017-03-07 14:41:28 +01:00
croneter
a28c0121cf New translations 2017-03-07 14:41:25 +01:00
croneter
baf4bc43d9 New translations 2017-03-07 14:41:22 +01:00
croneter
d603f4ee3b New translations 2017-03-07 14:41:19 +01:00
croneter
f3b6c1cda6 New translations 2017-03-07 14:41:16 +01:00
croneter
9e112a79b0 New translations 2017-03-07 14:41:05 +01:00
croneter
968de605c2 New translations 2017-03-07 14:41:01 +01:00
tomkat83
54f9d5ece9 Merge branch 'l10n_translations' into translations 2017-03-07 14:39:02 +01:00
tomkat83
70034af9df Merge branch 'translations' into l10n_translations 2017-03-07 14:38:21 +01:00
croneter
f06c9725e6 Translated 2017-03-07 14:32:32 +01:00
tomkat83
29b6fe6667 Merge branch 'develop' into translations 2017-03-07 14:30:31 +01:00
croneter
75d930d21b New translations 2017-03-07 14:27:41 +01:00
croneter
0097aa7acc New translations 2017-03-07 14:27:39 +01:00
croneter
69d29725b7 New translations 2017-03-07 14:27:36 +01:00
croneter
461e67d071 New translations 2017-03-07 14:27:34 +01:00
croneter
40a49340b3 New translations 2017-03-07 14:27:31 +01:00
croneter
04585ad9b1 New translations 2017-03-07 14:27:28 +01:00
croneter
b8294dc794 New translations 2017-03-07 14:27:26 +01:00
croneter
3a3979ecd1 New translations 2017-03-07 14:27:23 +01:00
croneter
085272c196 New translations 2017-03-07 14:27:20 +01:00
croneter
535e4e054a New translations 2017-03-07 14:27:18 +01:00
croneter
7f397006ec Translated 2017-03-07 14:27:15 +01:00
croneter
bd7358144b Translated 2017-03-07 14:27:12 +01:00
croneter
064f885272 New translations 2017-03-07 14:27:08 +01:00
croneter
1f3900bae9 New translations 2017-03-07 14:27:05 +01:00
croneter
104688cbfe New translations 2017-03-07 14:27:02 +01:00
croneter
4822f520e4 New translations 2017-03-07 14:26:58 +01:00
croneter
a80a679d26 New translations 2017-03-07 14:26:56 +01:00
croneter
0e6a99991a New translations 2017-03-07 14:26:53 +01:00
croneter
8468a5329a New translations 2017-03-07 14:26:50 +01:00
croneter
f9e3a1d6a8 New translations 2017-03-07 14:26:47 +01:00
croneter
4268ab46bb New translations 2017-03-07 14:26:44 +01:00
croneter
d809d87cfc New translations 2017-03-07 14:26:42 +01:00
croneter
7183444e1a New translations 2017-03-07 14:26:39 +01:00
croneter
9d8d6af58b New translations 2017-03-07 14:26:36 +01:00
croneter
c878c9df17 New translations 2017-03-07 14:26:34 +01:00
croneter
9e925d9900 New translations 2017-03-07 14:26:31 +01:00
croneter
84dc60b377 New translations 2017-03-07 14:26:28 +01:00
croneter
d3adddfaed New translations 2017-03-07 14:26:25 +01:00
croneter
499a5625e8 New translations 2017-03-07 14:26:22 +01:00
croneter
7b001aea78 New translations 2017-03-07 14:26:19 +01:00
croneter
29d4389b69 New translations 2017-03-07 14:26:17 +01:00
croneter
a0fb597f3a New translations 2017-03-07 14:26:14 +01:00
croneter
29b0601ca1 New translations 2017-03-07 14:26:11 +01:00
croneter
c245a2fe30 New translations 2017-03-07 14:26:08 +01:00
croneter
6bf5e894b4 New translations 2017-03-07 14:26:06 +01:00
croneter
f2abe84fce New translations 2017-03-07 14:26:03 +01:00
croneter
68741c13c8 New translations 2017-03-07 14:26:00 +01:00
croneter
79100c2698 Translated 2017-03-07 14:25:50 +01:00
croneter
c663779fde Translated 2017-03-07 14:25:47 +01:00
tomkat83
5f91580e76 Merge branch 'develop' into translations 2017-03-07 14:24:02 +01:00
croneter
ab9fc288e2 New translations 2017-03-06 13:40:44 +01:00
croneter
972aae552f Translated 2017-03-06 11:31:37 +01:00
croneter
3b05d9bc46 New translations 2017-03-06 00:41:00 +01:00
croneter
03b855515e New translations 2017-03-06 00:30:51 +01:00
croneter
50d02d05bb New translations 2017-03-06 00:21:47 +01:00
croneter
759be8bf8b New translations 2017-03-06 00:11:49 +01:00
croneter
d1399e91ef New translations 2017-03-06 00:02:39 +01:00
croneter
90b943ca9a New translations 2017-03-06 00:00:10 +01:00
croneter
2ebb07bf11 New translations 2017-03-06 00:00:04 +01:00
croneter
6fa129ce03 New translations 2017-03-05 23:41:40 +01:00
croneter
7f0be4f94e New translations 2017-03-05 23:41:37 +01:00
croneter
719b5ffcad New translations 2017-03-05 23:30:21 +01:00
croneter
4cac355d94 New translations 2017-03-05 23:20:29 +01:00
croneter
d9d3f5040b New translations 2017-03-05 23:20:26 +01:00
croneter
d0f00ff279 New translations 2017-03-05 23:11:46 +01:00
croneter
b12bc88f27 New translations 2017-03-05 23:11:43 +01:00
croneter
a713acf2e8 New translations 2017-03-05 23:01:20 +01:00
croneter
aab8aaeca5 New translations 2017-03-05 23:01:16 +01:00
croneter
cfe03fa147 New translations 2017-03-05 23:01:13 +01:00
croneter
2c14379764 New translations 2017-03-05 22:51:01 +01:00
croneter
942b2bf4b7 New translations 2017-03-05 22:50:57 +01:00
croneter
1e7d0a114e New translations 2017-03-05 22:50:55 +01:00
croneter
0177154323 New translations 2017-03-05 22:40:36 +01:00
croneter
459410dc28 New translations 2017-03-05 22:40:34 +01:00
croneter
7c37bd639f New translations 2017-03-05 22:31:38 +01:00
croneter
d90c9ab113 New translations 2017-03-05 22:31:35 +01:00
croneter
54532169cd New translations 2017-03-05 22:31:32 +01:00
croneter
e40e3fbc3a New translations 2017-03-05 22:31:27 +01:00
croneter
93d7b4fde4 New translations 2017-03-05 22:20:32 +01:00
croneter
1b20c13ac8 New translations 2017-03-05 22:20:28 +01:00
croneter
867592a0ac New translations 2017-03-05 21:40:20 +01:00
croneter
ce9a7a7931 New translations 2017-03-05 21:30:40 +01:00
croneter
e769999060 New translations 2017-03-05 21:20:49 +01:00
croneter
dfdf50457b New translations 2017-03-05 21:10:26 +01:00
croneter
1c1a49ff73 New translations 2017-03-05 21:02:40 +01:00
croneter
eacb00aa01 New translations 2017-03-05 20:50:29 +01:00
croneter
f34c360d6b New translations 2017-03-05 20:21:41 +01:00
croneter
73cd7a533e New translations 2017-03-05 20:10:28 +01:00
croneter
9163330986 New translations 2017-03-05 16:00:26 +01:00
croneter
b5d2318ed9 New translations 2017-03-05 15:50:20 +01:00
croneter
b19cc6bbdc New translations 2017-03-05 15:40:19 +01:00
croneter
5476965d25 New translations 2017-03-05 15:30:20 +01:00
croneter
cf180c66ac Merge pull request #240 from croneter/l10n_translations
New Crowdin translations
2017-03-01 19:14:17 +01:00
croneter
95901f5559 Translated 2017-02-28 20:20:31 +01:00
croneter
199c21aeb6 Translated 2017-02-28 20:08:20 +01:00
croneter
5a57e6201d New translations 2017-02-27 20:30:23 +01:00
croneter
678dc0630a New translations 2017-02-27 20:20:31 +01:00
croneter
600c7e59ef New translations 2017-02-27 20:10:33 +01:00
croneter
58d0e2eccc New translations 2017-02-27 20:02:14 +01:00
croneter
281275cb19 New translations 2017-02-27 19:53:42 +01:00
croneter
72167df167 New translations 2017-02-27 19:53:35 +01:00
croneter
a415596bde Merge pull request #227 from croneter/l10n_translations
New Crowdin translations
2017-02-26 18:08:44 +01:00
croneter
27d3818186 New translations 2017-02-24 22:04:56 +01:00
croneter
1ace6c62ec New translations 2017-02-24 21:53:25 +01:00
croneter
fb2e220557 New translations 2017-02-24 21:53:21 +01:00
croneter
3fba8fc6ee New translations 2017-02-24 21:43:32 +01:00
croneter
2b3938ed96 New translations 2017-02-24 08:31:10 +01:00
croneter
fbe1fde2cb New translations 2017-02-21 22:00:35 +01:00
croneter
2bfa238903 Translated 2017-02-21 21:04:12 +01:00
croneter
15a62eca9b Translated 2017-02-21 21:04:11 +01:00
croneter
227d53828d New translations 2017-02-21 20:56:10 +01:00
croneter
02c7983829 New translations 2017-02-21 20:50:39 +01:00
croneter
c72c42d987 New translations 2017-02-21 16:05:19 +01:00
croneter
cb41add911 New translations 2017-02-21 15:52:58 +01:00
croneter
dd7b7afd3f New translations 2017-02-21 15:42:18 +01:00
croneter
05cb53ab18 New translations 2017-02-21 15:32:50 +01:00
croneter
a5e43f439f New translations 2017-02-21 15:22:58 +01:00
croneter
7111725bef New translations 2017-02-21 15:02:49 +01:00
croneter
d5edee1e28 New translations 2017-02-21 14:52:28 +01:00
croneter
5a08c1c4a2 New translations 2017-02-21 14:41:21 +01:00
croneter
0e95fcb78f New translations 2017-02-21 14:33:51 +01:00
croneter
e3f5888651 New translations 2017-02-21 14:22:52 +01:00
croneter
6871a09ba3 New translations 2017-02-21 14:12:41 +01:00
croneter
23b391c19c New translations 2017-02-21 13:43:08 +01:00
croneter
d0f47d04eb New translations 2017-02-21 13:32:36 +01:00
croneter
9ba8ec1284 New translations 2017-02-21 13:24:36 +01:00
croneter
8019ff652d New translations 2017-02-21 13:13:29 +01:00
croneter
7629409562 New translations 2017-02-21 13:03:25 +01:00
croneter
78be780faf New translations 2017-02-21 12:50:47 +01:00
croneter
542bcd96ef New translations 2017-02-21 12:07:27 +01:00
croneter
eb643627b6 New translations 2017-02-21 11:52:13 +01:00
croneter
f1750718a0 New translations 2017-02-21 11:41:10 +01:00
croneter
c58dfbca14 New translations 2017-02-21 11:31:00 +01:00
croneter
56b758883e New translations 2017-02-21 11:22:31 +01:00
croneter
0269e6bc02 New translations 2017-02-19 21:51:54 +01:00
croneter
1f5112cebb New translations 2017-02-19 21:42:20 +01:00
croneter
cb76c22ed2 New translations 2017-02-19 21:32:51 +01:00
croneter
7bffca3c69 New translations 2017-02-19 19:11:48 +01:00
croneter
8e76e4bcfc New translations 2017-02-19 19:00:28 +01:00
croneter
2db77e46e2 New translations 2017-02-19 18:51:39 +01:00
croneter
d242e66991 New translations 2017-02-19 18:41:35 +01:00
tomkat83
43ede54afe Merge branch 'master' into translations 2017-02-18 17:58:56 +01:00
croneter
c3b87b1d04 Merge pull request #215 from croneter/l10n_translations
New Crowdin translations
2017-02-18 17:20:33 +01:00
croneter
56fa706b1c New translations 2017-02-18 15:42:00 +01:00
croneter
182c0cd97a New translations 2017-02-18 15:32:03 +01:00
croneter
8510713721 New translations 2017-02-18 10:20:48 +01:00
croneter
8876000bc7 New translations 2017-02-18 10:11:11 +01:00
croneter
d3d1017391 New translations 2017-02-10 09:53:52 +01:00
croneter
baf4e5f220 New translations 2017-02-10 09:42:54 +01:00
croneter
a103d91d18 New translations 2017-02-10 09:34:12 +01:00
croneter
c2c44b3edd New translations 2017-02-07 18:31:03 +01:00
croneter
a9bd260295 New translations 2017-02-07 18:23:31 +01:00
croneter
9a60904b75 New translations 2017-02-07 10:38:58 +01:00
croneter
c87264e63d New translations 2017-02-07 10:21:07 +01:00
croneter
d99437b3fb New translations 2017-02-07 10:16:52 +01:00
croneter
a5ee18c840 Translated 2017-02-07 10:16:48 +01:00
croneter
f86e718c29 New translations 2017-02-07 10:03:56 +01:00
croneter
e74ea431ce New translations 2017-02-07 10:03:51 +01:00
croneter
e59556a7cb New translations 2017-02-07 09:54:07 +01:00
croneter
3b43f7048a New translations 2017-02-07 09:45:52 +01:00
croneter
a374efb40b New translations 2017-02-07 09:35:40 +01:00
croneter
438659a6ea New translations 2017-02-05 22:41:22 +01:00
tomkat83
614f9d4a99 Merge branch 'master' into translations 2017-02-05 17:17:49 +01:00
tomkat83
626e67cab1 Merge branch 'develop' into translations 2017-02-05 16:57:44 +01:00
croneter
24ebc1bf04 Merge pull request #214 from croneter/l10n_translations
New Crowdin translations
2017-02-05 13:30:22 +01:00
croneter
caff6730bd Translated 2017-02-05 13:29:25 +01:00
croneter
6710781cb7 Translated 2017-02-05 13:29:23 +01:00
croneter
20d9f9f7d5 Translated 2017-02-05 13:28:44 +01:00
croneter
4cccca0b23 Merge pull request #213 from croneter/l10n_translations
New Crowdin translations
2017-02-05 13:20:44 +01:00
croneter
ac9c44c2bb New translations 2017-02-05 13:20:18 +01:00
croneter
f6a3204aaa New translations 2017-02-05 13:20:16 +01:00
croneter
6316b2ce44 New translations 2017-02-05 13:20:14 +01:00
croneter
567213d515 New translations 2017-02-05 13:20:11 +01:00
croneter
0fdc9964b8 New translations 2017-02-05 13:20:09 +01:00
croneter
08a78b86d1 New translations 2017-02-05 13:20:06 +01:00
croneter
82a2f38f72 New translations 2017-02-05 13:20:03 +01:00
croneter
150f4f5bf2 New translations 2017-02-05 13:20:00 +01:00
croneter
767569df08 New translations 2017-02-05 13:19:56 +01:00
croneter
948cef3358 New translations 2017-02-05 13:19:54 +01:00
croneter
6d009028fc New translations 2017-02-05 13:19:51 +01:00
croneter
e5217e1e9f New translations 2017-02-05 13:19:49 +01:00
croneter
b0a4ec4688 Translated 2017-02-05 13:19:45 +01:00
croneter
9c2927f684 New translations 2017-02-05 13:19:43 +01:00
croneter
c781d6b17c New translations 2017-02-05 13:19:40 +01:00
croneter
16aee4be21 New translations 2017-02-05 13:19:38 +01:00
croneter
41a95112e5 New translations 2017-02-05 13:19:35 +01:00
croneter
5f7eb0bd74 New translations 2017-02-05 13:19:33 +01:00
croneter
abca0f1502 New translations 2017-02-05 13:19:30 +01:00
croneter
d4877a79f2 New translations 2017-02-05 13:19:26 +01:00
croneter
ed628da200 New translations 2017-02-05 13:19:24 +01:00
croneter
50d4937ddf New translations 2017-02-05 13:19:22 +01:00
croneter
b5477d7141 New translations 2017-02-05 13:19:19 +01:00
croneter
cadeb1e814 New translations 2017-02-05 13:19:16 +01:00
croneter
2fea039b15 New translations 2017-02-05 13:19:14 +01:00
croneter
6cb5828954 New translations 2017-02-05 13:19:12 +01:00
croneter
ce7afa7b24 New translations 2017-02-05 13:19:09 +01:00
croneter
53978e33f6 New translations 2017-02-05 13:19:06 +01:00
croneter
c168bad50b New translations 2017-02-05 13:19:03 +01:00
croneter
5f0349ddf6 New translations 2017-02-05 13:19:00 +01:00
croneter
4c2c081173 New translations 2017-02-05 13:18:57 +01:00
croneter
cfc9af1745 New translations 2017-02-05 13:18:54 +01:00
croneter
c73ff1ffdc New translations 2017-02-05 13:18:52 +01:00
croneter
03edfc79e1 New translations 2017-02-05 13:18:49 +01:00
croneter
eef7c29d56 New translations 2017-02-05 13:18:47 +01:00
croneter
60ec330234 New translations 2017-02-05 13:18:44 +01:00
croneter
4b7bfa22ee New translations 2017-02-05 13:18:42 +01:00
croneter
3df8edb6f7 New translations 2017-02-05 13:18:39 +01:00
croneter
479ba036e7 Translated 2017-02-05 13:18:35 +01:00
tomkat83
3a8d7b56ef Merge branch 'develop' into translations 2017-02-05 13:17:40 +01:00
croneter
312688fbec Merge pull request #208 from croneter/l10n_translations
New Crowdin translations
2017-02-05 12:33:07 +01:00
croneter
d7374a7490 New translations 2017-02-05 12:01:27 +01:00
croneter
2c97f6a383 Translated 2017-02-04 18:02:51 +01:00
croneter
2db99e2b94 New translations 2017-02-04 18:02:49 +01:00
croneter
65566937f4 New translations 2017-02-04 18:02:48 +01:00
croneter
3024ccf39e New translations 2017-02-04 18:02:46 +01:00
croneter
a52de0f449 New translations 2017-02-04 18:02:45 +01:00
croneter
814d18c125 New translations 2017-02-04 18:02:44 +01:00
croneter
7169d6e58a New translations 2017-02-04 18:02:42 +01:00
croneter
25f810923f New translations 2017-02-04 18:02:41 +01:00
croneter
56c8e89eb9 New translations 2017-02-04 18:02:39 +01:00
croneter
c5701a5dd6 New translations 2017-02-04 18:02:37 +01:00
croneter
060e94d432 New translations 2017-02-04 18:02:36 +01:00
croneter
c44bcf62b9 New translations 2017-02-04 18:02:34 +01:00
croneter
1c0386bc60 New translations 2017-02-04 18:02:32 +01:00
croneter
5f88210839 New translations 2017-02-04 18:02:31 +01:00
croneter
2e509c6bcb New translations 2017-02-04 18:02:29 +01:00
croneter
c5274cbf95 New translations 2017-02-04 18:02:28 +01:00
croneter
aa304108ea New translations 2017-02-04 18:02:26 +01:00
croneter
2f79b339d8 New translations 2017-02-04 18:02:25 +01:00
croneter
3094ed3fc2 New translations 2017-02-04 18:02:24 +01:00
croneter
d1a1b491d4 New translations 2017-02-04 18:02:21 +01:00
croneter
f5044fcb55 New translations 2017-02-04 18:02:20 +01:00
croneter
0b2678be13 New translations 2017-02-04 18:02:17 +01:00
croneter
5868567265 New translations 2017-02-04 18:02:16 +01:00
croneter
1448407d03 New translations 2017-02-04 18:02:14 +01:00
croneter
e1a65a0af1 New translations 2017-02-04 18:02:12 +01:00
croneter
ff741dbb1e New translations 2017-02-04 18:02:11 +01:00
croneter
e621daaf51 New translations 2017-02-04 18:02:09 +01:00
croneter
44b7eb26ab New translations 2017-02-04 18:02:08 +01:00
croneter
fa3feebded New translations 2017-02-04 18:02:06 +01:00
croneter
12f41160d7 New translations 2017-02-04 18:02:05 +01:00
croneter
617bcf2c3b New translations 2017-02-04 18:02:03 +01:00
croneter
e740d2b0e8 New translations 2017-02-04 18:02:02 +01:00
croneter
072097be3e New translations 2017-02-04 18:02:00 +01:00
croneter
e1eccb2b70 New translations 2017-02-04 18:01:59 +01:00
croneter
f6b722c803 New translations 2017-02-04 18:01:58 +01:00
croneter
ff3581dd4d New translations 2017-02-04 18:01:56 +01:00
croneter
75b59e4fdc New translations 2017-02-04 17:31:51 +01:00
croneter
d895891da5 New translations 2017-02-04 17:31:50 +01:00
tomkat83
cea4c8cc33 Merge branch 'develop' into translations 2017-02-04 17:15:37 +01:00
croneter
be17a47dc7 Translated 2017-02-04 12:50:16 +01:00
croneter
587f2ea832 New translations 2017-02-04 12:10:28 +01:00
croneter
20b5dc1ca6 New translations 2017-02-04 11:10:29 +01:00
croneter
8da3d07789 New translations 2017-02-04 11:00:34 +01:00
croneter
1c8c94bc82 New translations 2017-02-04 10:40:25 +01:00
croneter
705dee8414 New translations 2017-02-04 10:20:37 +01:00
croneter
1849764412 New translations 2017-02-04 10:11:34 +01:00
croneter
f16fcc1d7d New translations 2017-02-04 10:00:32 +01:00
croneter
78dbbed0d3 New translations 2017-02-04 00:53:21 +01:00
croneter
f61bcc1009 New translations 2017-02-04 00:40:23 +01:00
croneter
22f4e0660f New translations 2017-02-04 00:30:21 +01:00
croneter
c8f084be13 New translations 2017-02-04 00:20:24 +01:00
croneter
ce70dca3e1 New translations 2017-02-04 00:10:34 +01:00
croneter
303b484c41 New translations 2017-02-04 00:00:52 +01:00
croneter
cbf29007fe New translations 2017-02-03 23:55:38 +01:00
croneter
cb0f5d7577 New translations 2017-02-03 23:43:16 +01:00
croneter
3938497a8f New translations 2017-02-03 23:40:23 +01:00
croneter
52bc9b7d92 New translations 2017-02-03 23:24:09 +01:00
croneter
4aabe9ca6e New translations 2017-02-03 21:41:01 +01:00
croneter
1f646dd92f New translations 2017-02-03 21:31:48 +01:00
croneter
79391373c3 New translations 2017-02-03 21:31:45 +01:00
croneter
0bb96d9939 New translations 2017-02-03 21:21:12 +01:00
croneter
4956ee4139 New translations 2017-02-03 21:21:08 +01:00
croneter
402ed1d35c New translations 2017-02-03 21:10:34 +01:00
croneter
c2bbcb22ef New translations 2017-02-03 21:10:30 +01:00
croneter
29ff4dc06c New translations 2017-02-03 21:01:51 +01:00
croneter
e72ff31ed2 New translations 2017-02-03 21:01:48 +01:00
croneter
18dba99649 New translations 2017-02-03 20:41:20 +01:00
croneter
e71a9094a1 New translations 2017-02-03 20:41:16 +01:00
croneter
a54257e6ba New translations 2017-02-03 20:34:09 +01:00
croneter
4daa71e598 Merge pull request #205 from croneter/l10n_translations
New Crowdin translations
2017-02-03 18:28:37 +01:00
croneter
3c09846790 Translated 2017-02-03 18:24:03 +01:00
croneter
a7704b3cfd New translations 2017-02-03 18:12:03 +01:00
croneter
021885d60a New translations 2017-02-03 18:02:50 +01:00
croneter
905fb2cf63 New translations 2017-02-03 17:32:03 +01:00
croneter
4817d4b9bf New translations 2017-02-03 17:22:09 +01:00
croneter
5490352e70 New translations 2017-02-03 17:13:38 +01:00
croneter
feb3b1a490 New translations 2017-02-03 17:07:17 +01:00
croneter
2dd4282337 New translations 2017-02-03 16:56:25 +01:00
croneter
b89100fe8e New translations 2017-02-03 16:47:33 +01:00
croneter
6c01952c7f New translations 2017-02-03 16:35:26 +01:00
croneter
472e5b4a2a New translations 2017-02-03 16:23:11 +01:00
croneter
346d0859b7 New translations 2017-02-03 16:16:26 +01:00
croneter
28478697db New translations 2017-02-03 16:13:00 +01:00
croneter
33f4f81dd4 New translations 2017-02-03 15:00:45 +01:00
croneter
e251687db3 New translations 2017-02-03 15:00:38 +01:00
croneter
f5240cc67a New translations 2017-02-03 15:00:33 +01:00
croneter
2ebdd203c9 New translations 2017-02-03 15:00:28 +01:00
croneter
7f8e1ded47 New translations 2017-02-03 15:00:24 +01:00
croneter
61cadf911f New translations 2017-02-03 15:00:17 +01:00
croneter
5f363f1bd2 New translations 2017-02-03 15:00:09 +01:00
croneter
d34ef6d381 New translations 2017-02-03 15:00:03 +01:00
croneter
b0afdb241e New translations 2017-02-03 14:59:58 +01:00
croneter
83a739d36a New translations 2017-02-03 14:59:53 +01:00
croneter
376fb3b579 New translations 2017-02-03 14:59:49 +01:00
croneter
e86c4c8d26 New translations 2017-02-03 14:59:44 +01:00
croneter
d3cf4574e8 New translations 2017-02-03 14:59:40 +01:00
croneter
6273f7967f New translations 2017-02-03 14:59:36 +01:00
croneter
a9ba52d707 New translations 2017-02-03 14:59:32 +01:00
croneter
3a045601c9 New translations 2017-02-03 14:59:27 +01:00
croneter
0ca17efd4b New translations 2017-02-03 14:59:21 +01:00
croneter
33d939af07 New translations 2017-02-03 14:59:17 +01:00
croneter
de73bfb760 New translations 2017-02-03 14:59:13 +01:00
croneter
bb96998de9 New translations 2017-02-03 14:59:10 +01:00
croneter
74fbe511b2 New translations 2017-02-03 14:59:07 +01:00
croneter
7876b30183 New translations 2017-02-03 14:59:03 +01:00
croneter
6d93d67604 New translations 2017-02-03 14:58:59 +01:00
croneter
b675d37ae3 New translations 2017-02-03 14:58:55 +01:00
croneter
3ca1d2a9a1 New translations 2017-02-03 14:58:50 +01:00
croneter
c10c194626 New translations 2017-02-03 14:58:46 +01:00
croneter
b89daa25bc New translations 2017-02-03 14:58:42 +01:00
croneter
3f3ae9f6e5 New translations 2017-02-03 14:58:38 +01:00
croneter
591c342049 New translations 2017-02-03 14:58:35 +01:00
croneter
dd53c3bd0c New translations 2017-02-03 14:58:32 +01:00
croneter
3d597ab24c New translations 2017-02-03 14:58:27 +01:00
croneter
4cc12f1ac8 New translations 2017-02-03 14:58:24 +01:00
croneter
199ae3b89e New translations 2017-02-03 14:58:19 +01:00
croneter
42ecdab712 New translations 2017-02-03 14:58:16 +01:00
croneter
30dc011d7e New translations 2017-02-03 14:58:12 +01:00
croneter
d168f6729e New translations 2017-02-03 14:57:26 +01:00
croneter
41976ed5c6 New translations 2017-02-03 14:57:23 +01:00
croneter
adb908570b New translations 2017-02-03 14:57:19 +01:00
croneter
d35dd29928 New translations 2017-02-03 14:57:15 +01:00
croneter
a6a975223e Update Crowdin configuration file 2017-02-03 14:55:39 +01:00
496 changed files with 15481 additions and 70168 deletions

View file

@ -1,5 +0,0 @@
exclude_paths:
- 'resources/lib/watchdog/**'
- 'resources/lib/pathtools/**'
- 'resources/lib/pathtools/**'
- 'resources/lib/defused_etree.py'

1
.github/FUNDING.yml vendored
View file

@ -1 +0,0 @@
ko_fi: A8182EB

View file

@ -1,41 +0,0 @@
---
name: Bug report
about: Create a report to help us improve. Please read the instructions carefully.
title: ''
labels: ''
assignees: ''
---
## Help yourself
* I did try to restart Kodi :-)
* I checked the [PKC Frequently Asked Questions on the PKC wiki](https://github.com/croneter/PlexKodiConnect/wiki/faq)
* I did try to reset the Kodi database by going to `PKC Settings -> Advanced -> "Reset the database and optionally reset PlexKodiConnect"` and then hitting YES, NO
* I did check the [existing issues on Github](https://github.com/croneter/PlexKodiConnect/issues)
## Describe the bug
A clear and concise description of what the bug is.
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
## Expected behavior
A clear and concise description of what you expected to happen.
## You need to attach a KODI LOG FILE!
A Kodi debug log file is needed that you recorded while you reproduced the bug. **Do clean your log of all Plex tokens (="Plex passwords")!!!** .
1. Activate Kodi's debug logging by going to the Kodi `Settings` -> `System` -> `Logging`. Then toggle the `Enable debug logging` setting.
2. Restart Kodi to start with a "fresh" log file.
3. Reproduce the bug.
4. Follow the [Kodi instructions](http://kodi.wiki/view/Log_file/Easy) to grab/share the Kodi log file. Usually only `kodi.log` is needed
* You can [find the log file here](http://kodi.wiki/view/Log_file/Advanced#Location)
5. **Delete all references to any of your Plex tokens** by searching for `X-Plex-Token` and `accesstoken` and replacing the strings just after that!
* It's easiest if you copy your token, then use Search&Replace for the entire log file
* You don't want others to have access to your Plex installation....
6. Drop your log file here in this issue. Or use a free pasting-service like https://pastebin.com and include the link to it here
I am aware that I can delete Plex tokens that I accidentially posted by following the [instructions on the PKC wiki](https://github.com/croneter/PlexKodiConnect/wiki/How-to-Report-A-Bug#i-published-my-plex-token-to-some-forum-or-github-anyone-can-now-access-my-plex-server)

10
.tx/config Normal file
View file

@ -0,0 +1,10 @@
[main]
host = https://www.transifex.com
[pkc.stringspo]
file_filter = resources/language/resource.language.<lang>/strings.po
minimum_perc = 60
source_file = resources/language/resource.language.en_gb/strings.po
source_lang = en_gb
type = PO

View file

@ -0,0 +1,94 @@
import os
import xml.etree.ElementTree as etree
languages = [
'nl_NL',
'fr_CA',
'fr_FR',
'de_DE',
'pt_PT',
'pt_BR',
'es_ES',
'es_AR',
'es_MX',
'cs_CZ',
'zh_CN',
'zh_TW',
'da_DK',
'it_IT',
'no_NO',
'el_GR',
'pl_PL',
# 'sv_SE',
'hu_HU',
'ru_RU',
'uk_UA',
'lv_LV',
'sv_SE',
'lt_LT',
'ko_KR'
]
tmp_file = r'C:\Users\Kat\Desktop\addon.xml'
PKC_dir = r'C:\Users\Kat\Documents\GitHub\PlexKodiConnect'
addon = {
'msgctxt "#39703"': 'summary',
'msgctxt "#39704"': 'description',
'msgctxt "#39705"': 'disclaimer'
}
def indent(elem, level=0):
"""
Prettifies xml trees. Pass the etree root in
"""
i = "\n" + level*" "
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + " "
if not elem.tail or not elem.tail.strip():
elem.tail = i
for elem in elem:
indent(elem, level+1)
if not elem.tail or not elem.tail.strip():
elem.tail = i
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
root = etree.Element('addon')
for lang in languages:
try:
with open(os.path.join(PKC_dir,
'resources',
'language',
'resource.language.%s' % lang,
'strings.po'), 'r', encoding='utf-8') as f:
for line in f:
if line.strip() in addon:
msg = ''
key = line.strip()
# Advance to the line msgstr ""
part = ''
while not part.startswith('msgstr'):
part = next(f)
msg += part.replace('msgstr', '').replace('"', '').strip()
part = None
while part != '':
part = next(f).strip()
msg += part
msg = msg.replace('"', '').replace('\r', '').replace('\n', '')
print(msg)
etree.SubElement(root,
addon[key],
attrib={'lang': lang}).text = msg
except IOError:
print('Missing file %s' % os.path.join(PKC_dir,
'resources',
'language',
'resource.language.%s' % lang,
'strings.po'))
indent(root)
etree.ElementTree(root).write(tmp_file, encoding="UTF-8")

36
.tx/pull_languages.py Normal file
View file

@ -0,0 +1,36 @@
import os
path = "C:\\Users\\kat\\AppData\\Local\\Continuum\\anaconda3\\envs\\kodi\\Scripts"
command = os.path.join(path, "tx.exe")
languages = [
'nl_NL',
'fr_CA',
'fr_FR',
'de_DE',
'pt_PT',
'pt_BR',
'es_ES',
'es_AR',
'es_MX',
'cs_CZ',
'zh_CN',
'zh_TW',
'da_DK',
'it_IT',
'no_NO',
'el_GR',
'pl_PL',
# 'sv_SE',
'hu_HU',
'ru_RU',
'uk_UA',
'lv_LV',
'sv_SE',
'lt_LT',
'ko_KR'
]
os.system("cd ..")
for lang in languages:
os.system(command + " pull --minimum-perc=10 -f -l %s" % lang)

View file

@ -1,12 +1,9 @@
[![Kodi Leia stable version](https://img.shields.io/badge/Kodi_Leia_STABLE-latest-blue.svg?maxAge=60&style=flat) ](https://croneter.github.io/pkc-source/repository.plexkodiconnect.Kodi-Leia.STABLE.zip)
[![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)
[![stable version](https://img.shields.io/badge/stable_version-1.8.18-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip)
[![beta version](https://img.shields.io/badge/beta_version-2.0.26-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip)
[![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&amp;utm_medium=referral&amp;utm_content=croneter/PlexKodiConnect&amp;utm_campaign=Badge_Grade)
@ -14,12 +11,7 @@
# PlexKodiConnect (PKC)
**Combine the best frontend media player Kodi with the best multimedia backend server Plex**
PKC synchronizes your media from your Plex server to the native Kodi database. Hence:
- 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
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.
Have a look at [some screenshots](https://github.com/croneter/PlexKodiConnect/wiki/Some-PKC-Screenshots) to see what's possible.
@ -29,6 +21,7 @@ Unfortunately, the PKC Kodi repository had to move because it stopped working (t
### Content
* [**Download and Installation**](#download-and-installation)
* [**What does PKC do?**](#what-does-pkc-do)
* [**Warning**](#warning)
* [**PKC Features**](#pkc-features)
* [**Additional Artwork**](#additional-artwork)
@ -39,7 +32,19 @@ Unfortunately, the PKC Kodi repository had to move because it stopped working (t
### Download and Installation
Using the Kodi file manager, add [https://croneter.github.io/pkc-source](https://croneter.github.io/pkc-source) as a new Kodi `Web server directory (HTTPS)` source, then install the PlexKodiConnect repository from this new source "from ZIP file". See the [github wiki installation manual](https://github.com/croneter/PlexKodiConnect/wiki/Installation) for a detailed guide. Kodi will update PKC automatically.
Install PKC via the PlexKodiConnect Kodi repository download button just below (do NOT use the standard GitHub download!). See the [github wiki installation manual](https://github.com/croneter/PlexKodiConnect/wiki/Installation) for a detailed guide. Please use the stable version except if you really know what you're doing. Kodi will update PKC automatically.
| Stable version | Beta version |
|----------------|--------------|
| [![stable version](https://img.shields.io/badge/stable_version-latest-blue.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/stable/repository.plexkodiconnect/repository.plexkodiconnect-1.0.2.zip) | [![beta version](https://img.shields.io/badge/beta_version-latest-red.svg?maxAge=60&style=flat) ](https://github.com/croneter/binary_repo/raw/master/beta/repository.plexkodiconnectbeta/repository.plexkodiconnectbeta-1.0.2.zip) |
### What does PKC do?
PKC synchronizes your media from your Plex server to the native Kodi database. Hence:
- Use virtually any other Kodi add-on
- Use any Kodi skin, completely customize Kodi's look
- Browse your media at full speed (cached artwork)
- Automatically get additional artwork (more than Plex offers)
- Enjoy Plex features using the Kodi interface
### Warning
Use at your own risk! This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases as this plugin directly changes them. Don't worry if you want Plex to manage all your media (like you should ;-)).
@ -48,20 +53,17 @@ Some people argue that PKC is 'hacky' because of the way it directly accesses th
### PKC Features
- 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/)
- Support of Kodi 18 Leia (and Kodi 17 Krypton)
- [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. [Please help and easily translate PKC!](https://www.transifex.com/croneter/pkc)
- PKC is available in the following languages:
+ English
+ German
+ Czech, thanks @Pavuucek
@ -76,9 +78,7 @@ Some people argue that PKC is 'hacky' because of the way it directly accesses th
+ Portuguese, thanks @goncalo532
+ Russian, thanks @UncleStark
+ Hungarian, thanks @savage93
+ Ukrainian, thanks @uniss
+ Lithuanian, thanks @egidusm
+ Korean, thanks @so-o-bima
+ [You can easily help to translate PKC!](https://www.transifex.com/croneter/pkc)
### Additional Artwork
PKC uses additional artwork for free from [TheMovieDB](https://www.themoviedb.org). Many thanks for lettings us use the API, guys!
@ -90,10 +90,12 @@ I'm not in any way affiliated with Plex. Thank you very much for a small donatio
[![Donations](https://az743702.vo.msecnd.net/cdn/kofi1.png?v=a)](https://ko-fi.com/A8182EB)
**Ethereum address for donations:
![ETH-Donations](https://chart.googleapis.com/chart?chs=150x150&cht=qr&chld=L0&chl=0x0f57D98E08e617292D8bC0B3448dd79BF4Cf8e7F)
**Ethereum address:
0x0f57D98E08e617292D8bC0B3448dd79BF4Cf8e7F**
**Bitcoin address for donations:
![BTX-Donations](https://chart.googleapis.com/chart?chs=150x150&cht=qr&chld=L0&chl=3BhwvUsqAGtAZodGUx4mTP7pTECjf1AejT)
**Bitcoin address:
3BhwvUsqAGtAZodGUx4mTP7pTECjf1AejT**

564
addon.xml
View file

@ -1,12 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="2.15.0" provider-name="croneter">
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="2.0.26" provider-name="croneter">
<requires>
<import addon="xbmc.python" version="2.1.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" />
<import addon="plugin.video.plexkodiconnect.movies" version="2.0.4" />
<import addon="plugin.video.plexkodiconnect.tvshows" version="2.0.4" />
</requires>
<extension point="xbmc.python.pluginsource" library="default.py">
<provides>video audio image</provides>
@ -21,9 +19,6 @@
</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>
@ -78,6 +73,8 @@
<summary lang="uk_UA">Нативна інтеграція Plex в Kodi</summary>
<description lang="uk_UA">Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик!</description>
<disclaimer lang="uk_UA">Використовуйте на свій ризик</disclaimer>
<summary lang="lv_LV" />
<description lang="lv_LV" />
<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>
@ -88,193 +85,436 @@
<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 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
<news>version 2.0.26 (beta only):
- Reduce CPU strain for artwork caching progress
- Fallback connection if plex.direct does not resolve
- Prettify Plex context menu, thanks @dazedcrazy
- Default to not show image caching notifications
version 2.14.3 (beta only):
- Implement "Reset resume position" from the Kodi context menu
version 2.0.25 (beta only):
- Fix migration not working correctly for re-connecting PMS
- Fix PMS showing up twice
- Improve artwork caching counter in PKC settings
version 2.14.2:
- version 2.14.1 for everyone
version 2.0.24 (beta only):
- WARNING: You will need to reset the Kodi database! Sorry for that...
- PKC will force you to re-connect with your PMS
- Use plex.direct url instead of local ip to use correct SSL certificate; thus fix artwork caching
- Revert "Increase timeout between syncing images"
- Don't ask user for DB reset if forced by PKC
- Ensure movies and tv shows are synced before music
- Ensure a later migration if user downgraded PKC
version 2.14.1 (beta only):
- 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.0.23 (beta only):
- WARNING: You will need to reset the Kodi database!
- Finally support for Extras!
- Fix context menu not working for shows in library view
- Fix Plex Companion music playstate status for iOS
- Show caching progress and FanartTV lookup progress in PKC settings
- Fix rare library sync errors
- Fix ValueError for third party add-ons calling PKC
- Tweak PKC settings
version 2.14.0:
- Fix PlexKodiConnect changing or removing subtitles for every video on the PMS
- version 2.13.1-2.13.2 for everyone
version 2.0.22 (beta only):
- Fix Recently Added for tv shows not working
- Fix PKC crashing on startup
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.0.21 (beta only):
- Fix TV show artwork Kodi native library (reset Kodi DB!)
- Cache missing posters and backgrounds/fanart on Kodi startup
- Add toggle to deactivate image caching during playback
- Increase timeout between syncing images
- Fix music database if new music is added in the background
version 2.13.1 (beta only):
- Fix a racing condition that could lead to the sync process getting stuck
- Fix likelyhood of `database is locked` error occuring
version 2.0.20 (beta only):
- Fix missing episode poster in certain views. You will have to manually reset your Kodi database to benefit
- Fix episode artwork sometimes not being complete
- Fix IndexError for certain Plex channels
- Kodi Leia: Fix playback failing
- Fix TV On Deck direct paths asking to choose between different media
version 2.13.0:
- 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.0.19 (beta only):
- Fix PKC playback startup getting caught in infinity loop
- Rewire library sync, suspend sync during playback
- Fix playback failing in certain cases
- Fix PKC not working anymore after using context menu on songs
- Fix deletion of Plex music items
- Code cleanup
version 2.12.26 (beta only):
- 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
version 2.0.18 (beta only):
- Fix some playqueue inconsistencies using Plex Companion
- Direct paths: fix replaying item where playback was started via PMS
- Fix Plex trailers screwing up playqueue
- Fix IndexError when emptying Kodi playqueue
- Incorporate PKC player in kodimonitor module
- Fix pretty printing of PKC playqueues not working
- Code cleanups
version 2.0.17 (beta only):
- Finally make PKC compatible with Kodi 18 Leia Alpha 1
- Fix information screen and Plex option not working
- Activate Kodi background updates to hide "Compressing Database"
- Update translations
- Remove most strings not being used by PKC
- Remove some legacy settings
version 2.0.16 (beta only):
- Do NOT delete playstates before getting new ones from the PMS
version 2.0.15 (beta only):
- Fix Plex Companion thinking video is playing again
- Warn if "Play next video automatically" is enabled, cause it breaks PKC playback report
- Don't clean the Kodi file table
- Only remember which player has been active if we got a Plex id
- Hopefully fix ValueError for datetime.utcnow()
version 2.0.14 (beta only):
- Fix resetting PKC player state - should fix PKC telling the PMS that an old, just-played item is playing
- Play the selected element first, then add the Kodi playqueue to the Plex playqueue
- Ensure that playstate for ended (not stopped) video is recorded correctly
- Make sure that LOCK is released after adding one element
version 2.0.13 (beta only):
- Fix resume for On Deck and browse by folder
- Fix PKC sometimes telling wrong item being played
- Don't tell PMS last item is playing if non-Plex item is played
- Fix rare KeyError for playback including trailers
- Use an empty video file to "fail" playback
- Use identical add-on paths for On Deck and browsing folders
version 2.0.12 (beta only):
- Fix resume not working for some Kodi interface languages
- Fix widget navigating to entire TV show not working
- Fix library sync crash TypeError
- Revert "Revert "Fix for "In Progress" not appearing""
- Simplify error message
version 2.0.11 (beta only):
- WARNING: You will need to reset the Kodi database!
- Fix playback for add-on paths
- Fix artwork for episodes for add-on paths
- Revert "Fix for "In Progress" not appearing"
- Fix playback resuming potentially too often
version 2.0.10 (beta only):
- Fix wrong item being reported using direct paths
- Direct paths: correctly clean up after context menu play
- Always resume playback if playback initiated via context menu
- Do not play trailers for resumable movies using playback via PMS
- Fix for "In Progress" widget not appearing
- Fix correctly recording ended (not stopped!) video
- Don't record last played date if state unwatched
- Clean Kodi DB more thoroughly after playback start via PMS
version 2.0.9 (beta only):
- Fix AttributeError on playback start
version 2.0.8 (beta only):
- Fix videos not being correctly marked as played
- Improve playback startup resiliance
- Fix playerstates not being copied/reset correctly
- Fix tv shows not being correctly deleted
- Fix episode rating not being correct
- Make generally sure that we're correctly deleting videos from the Kodi DB
- Fix disabling of background sync (websockets)
version 2.0.7 (beta only):
- Fix another UnicodeDecodeError for playlists
- Hardcode plugin-calls instead of using urlencode
- Fix Kodi 18 log warnings by declaring all settings variables
version 2.0.6 (beta only):
- Addon paths playback was basically broken - hope it works again!
- Fixes to add-on paths playback startup
- Fix UnicodeDecodeError for playqueue logging
version 2.0.5 (beta only):
- WARNING: You will need to reset the Kodi database!
- Fix art and show info not showing for addon paths
- Fix episode information not working
- Big Kodi DB overhaul - ensure video metadata updates/deletes correctly
- Artwork code overhaul
- Greatly speed up switch of PMS
- And a lot of other stuff
version 2.0.4 (beta only):
- WARNING: You will need to reset the Kodi database!
- Many improvements to the Kodi database handling which should get rid of some weird bugs
- Many improvements to playback startup
- Fix info screen and actors not working
- Fix Companion displaying and selecting wrong subtitle
- Don't cache subtitles if direct playing
- Wipe all existing resume point, e.g. on user switch
- Don't mess with Kodi's screensaver settings
- Inhibit idle shutdown only during initial sync
- Fix KeyError for server discovery
- Increase Python requests dependency to version 2.9.1
- Re-introduce PlexKodiConnect dependency add-ons for movies and tv shows
- And a lot of other stuff
version 2.0.3 (beta only):
- Fix Alexa playback
- Fix Kodi boot loop
- Fix playback being reported to the wrong Plex user
- Fix GB/BBFC content ratings
- Fix KeyError when browsing On Deck
- Make sure that empty XML elements get deleted
- Code optimizations
version 2.0.2 (beta only):
- Fix playback reporting not starting up correctly
- Fix playback cleanup if PKC causes stop
- Always detect if user resumes playback
- Enable resume within a playqueue
- Compare playqueue items more reliably
version 2.0.1 (beta only):
- Fix empty On Deck for tv shows
- Fix trailers not playing
version 2.0.0 (beta only):
- HUGE code overhaul - Remember that you can go back to earlier version ;-)
- Completely rewritten Plex Companion
- Completely rewritten playback startup
- Tons of fixes, see the Github changelog for more details
- WARNING: You will need to reset the Kodi database!
version 1.8.18:
- Russian translation, thanks @UncleStark, @xom2000, @AlexFreit
- Fix Plex context menu not showing up
- Deal better with missing stream info (e.g. channels)
- Fix AttributeError if Plex key is missing
version 1.8.17:
- Hopefully fix stable repo
- Fix subtitles not working or showing up as Unknown
- Enable channels for Plex home users
- Remove obsolete PKC settings show contextmenu
version 1.8.16:
- Add premiere dates for movies, thanks @dazedcrazy
- Fix items not getting marked as fully watched
version 1.8.15:
- version 1.8.14 for everyone
- Update translations
version 2.12.25:
- Update websocket client to 0.59.0. Fix threading issues and AttributeErrors
version 1.8.14 (beta only):
- Greatly speed up displaying context menu
- Fix IndexError e.g. for channels if stream info missing
- Sleep a bit before marking item as fully watched
- Don't sleep before updating playstate to fully watched (if you watch on another Plex client)
- Fix KeyError for TV live channels for getGeople
version 2.12.24:
- version 2.12.23 for everyone
version 1.8.13 (beta only):
- Background sync now picks up more PMS changes
- Detect Plex item deletion more reliably
- Fix changed Plex metadata not synced repeatedly
- Detect (some, not all) changes to PKC settings and apply them on-the-fly
- Fix resuming interrupted sync
- PKC logging now uses Kodi log levels
- Further code optimizations
version 2.12.23 (beta only):
- Fix Alexa and RuntimeError: dictionary keys changed during iteration
- Fix a rare AttributeError when using playlists
version 1.8.12:
- Fix library sync crashing trying to display an error
version 2.12.22:
- version 2.12.20 and 2.12.21 for everyone
version 1.8.11:
- version 1.8.10 for everybody
version 2.12.21 (beta only):
- Switch to new websocket implementation
- Hopefully fix RuntimeError: no add-on id "plugin.video.plexkodiconnect"
version 1.8.10 (beta only):
- Vastly improve sync speed for music
- Never show library sync dialog if media is playing
- Improvements to sync dialog
- Fix stop synching if path not found
- Resume aborted sync on PKC settings change
- Don't quit library sync if failed repeatedly
- Verify path for every Plex library on install sync
- More descriptive downloadable subtitles
- More code fixes and optimization
version 1.8.9
- Fix playback not starting in some circumstances
- Deactivate some annoying popups on install
version 1.8.8
- Fix playback not starting in some circumstances
- Fix first artist "missing" tag (Reset your DB!)
- Update Czech translation
version 1.8.7 (beta only):
- Some fixes to playstate reporting, thanks @RickDB
- Add Kodi info screen for episodes in context menu
- Fix PKC asking for trailers not working
- Fix PKC not automatically updating
version 1.8.6:
- Portuguese translation, thanks @goncalo532
- Updated other translations
version 1.8.5:
- version 1.8.4 for everyone
version 1.8.5:
- version 1.8.4 for everyone
version 1.8.4 (beta only):
- Plex cloud should now work: Request pictures with transcoding API
- Fix Plex companion feedback for Android
- Update translations
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.3:
- Fix Kodi playlists being empty
version 2.12.19:
- 2.12.17 and 2.12.18 for everyone
- Rename skip intro skin file
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 2.12.17 (beta only):
- Sync name and user rating of a TV show season to Kodi
- Fix rare TypeError: expected string or buffer on playback start
version 2.12.16:
- versions 2.12.14 and 2.12.15 for everyone
version 2.12.15 (beta only):
- Fix skip intros sometimes not working due to a RuntimeError
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
- Update translations
version 2.12.14:
- Add skip intro functionality
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.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.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.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.22 (beta only)
- Fix playback stop not being recognized by the PMS
- Better way to sync progress to another account
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 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.10:
- Fix pictures from Plex picture libraries not working/displaying
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.9:
- Fix Local variable 'user' referenced before assignement
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 2.12.8:
- version 2.12.7 for everyone
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 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 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 2.12.6:
- Fix rare KeyError when using PKC widgets
- Fix suspension of artwork caching and PKC becoming unresponsive
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
- Update translations
- Versions 2.12.4 and 2.12.5 for everyone
- Rewire Kodi ListItem stuff
- Fix TypeError for setting ListItem streams
- Fix Kodi setContent for images
- Fix AttributeError due to missing Kodi sort methods
version 2.12.5 (beta only):
- 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 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.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 1.7.2
- Fix for some channels not starting playback
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 1.7.1
- Fix Alexa not doing anything
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
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
- 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>
- Code optimization</news>
</extension>
</addon>

View file

@ -1,996 +1,7 @@
version 2.15.0:
- versions 2.14.3-2.14.4 for everyone
- Direct Paths: Fix TypeError: "element indices must be integers" on playback startup [backport]
- Refactor stream code and fix Kodi not activating subtitle when it should [backport]
- Add playback settings to let the user choose whether Plex or Kodi provides the default audio or subtitle stream on playback start [backport]
- Update translations from Transifex [backport]
version 2.14.4 (beta only):
- 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 2.14.3 (beta only):
- Implement "Reset resume position" from the Kodi context menu
version 2.14.2:
- version 2.14.1 for everyone
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 2.12.25:
- Update websocket client to 0.59.0. Fix threading issues and AttributeErrors
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 2.12.20 (beta only):
- Add information to PKC settings for background sync and Alexa whether a connection has been successfully made
version 2.12.19:
- 2.12.17 and 2.12.18 for everyone
- Rename skip intro skin file
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 2.12.17 (beta only):
- Sync name and user rating of a TV show season to Kodi
- Fix rare TypeError: expected string or buffer on playback start
version 2.12.16:
- versions 2.12.14 and 2.12.15 for everyone
version 2.12.15 (beta only):
- Fix skip intros sometimes not working due to a RuntimeError
- Update translations
version 2.12.14 (beta only):
- Add skip intro functionality
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 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 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
- 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
version 2.10.12:
- versions 2.10.5-11 for everyone
version 2.10.11 (beta only):
- Fix yet another rare but annoying bug where PKC becomes unresponsive during sync
version 2.10.10 (beta only):
- Fix rare but annoying bug where PKC becomes unresponsive during sync
- Fix PKC background sync not working in some cases
version 2.10.9 (beta only):
- Other Kodi add-ons can now search for Plex items using plugin://plugin.video.plexkodiconnect?mode=search&query=YOUR SEARCH STRING HERE
version 2.10.8 (beta only):
- Improve thread pool management to render PKC snappier
- Attempt to fix broken pipe error
- Fix DirectPaths when a video's folder name is identical to a video's filename (you will need to manually reset the Kodi database)
version 2.10.7 (beta only):
- Fix PKC not starting up on iOS
- Optimize the new sync process and fix some bugs that were introduced
- Fix PKC becoming unresponsive e.g. when switching the PMS
version 2.10.6 (beta only):
- Fix AttributeError if user enters an invalid pin code
- Fix OperationalError when starting with a fresh PKC installation
- Fix IndexError
version 2.10.5 (beta only):
- Rewire library sync to speed it up and fix sync getting stuck in rare cases
- Optimize threads by using events instead of a polling mechanism. Fixes PKC becoming unresponsive, e.g. when switching users
- Optimize adding values to Kodi databases by not using sqlite COALESCE command
- Fix OperationalError when resetting PKC
- Improve sync resiliance when certain items are not to be synced to Kodi or PKC skipped an item in the past
- Make sure bool is returned instead of an int
- Don't use WAL mode for sqlite connections, it is not making any difference
version 2.10.4:
- version 2.10.3 for everyone
- Fix to correctly wipe Kodi databases
version 2.10.3 (beta only):
- Fix a couple of issues with music when using direct paths: correctly escape music paths for Kodi regex matching
- Fix Recently Added Albums sort order (you will have to reset the Kodi database manually)
- Fix database being locked in rare cases
- Increase batch size for library sync from 500 to 2000 to increase sync speed
- Optimize some code
- Fix KeyError when using Plex search capabilities
- Check faster for available Plex Media Server to connect to
version 2.10.2:
- Fix Kodi playback jumping to the beginning of a video that just started
- Fix transcoding quality degenerating quickly while playing with a new setting to deactivate auto quality for transcoding (applicable e.g. for Chromecast)
- Update translations
version 2.10.1:
- Fix resume for Kodi on low powered devices, e.g. Raspberry Pi
- Fix resume when using an external player
- Fix UnicodeWarning: Unicode equal comparison failed to convert both arguments to Unicode - interpreting them as being unequal
version 2.10.0:
- version 2.9.12 - 2.9.14 for everyone
- Get rid of some obsolete code for the ContextMonitor we dropped
version 2.9.14 (beta only):
- Fix resume when starting playback via PMS or when force transcoding
- Get rid of ContextMonitor and the dedicated Python thread - with new resume mechanics, this is not needed anymore
- Optimize clean-up of file table in the Kodi video database after stopping playback
- Get rid of some obsolete imports
version 2.9.13 (beta only):
- Fix PKC resuming instead of playing from the beginning
version 2.9.12 (beta only):
- Fix resume not working in some cases
- Support Plex search across all media and Plex Media Servers: Navigate to the PlexKodiConnect Add-on, then "Search"
- Always use the current Kodi language when communicating with the PMS (restart Kodi when changing the language!)
- Fix Kodi crashing when casting from e.g. Plex Web or Plex for Windows
- Fix PKC throwing error if m3u playlist contains resume information
version 2.9.11:
- version 2.9.10 for everyone
version 2.9.10 (beta only):
- Add tmdb provider sync
- Fix external subtitles not being available
- Fix PKC increasing the Plex watch count by 2 instead of 1
- Improve subtitle naming
- Delete temporary subtitles on playback stop
- Fix a missleading string
version 2.9.9:
- Versions 2.9.6 - 2.9.8 for everyone
version 2.9.8 (beta only):
- Fix Play Error in scenarios (older PMS version?) where posting playqueues using an uri `server://` is not possible and `library://` is necessary
- Fix rare AttributeError on PKC startup when modifying advancedsettings.xml
- Update translations
version 2.9.7 (beta only):
- Correctly escape URLs for Direct Paths
- Update settings to inform user that reboot is necessary
- Optimize code
- Don't migrate PKC settings if we're dealing with a clean new PKC installation
- Force-scan every single item in the library - seems like we could lose some recently added items otherwise when updating PKC
version 2.9.6 (beta only):
- Rework logic for using direct paths, direct play, direct streaming and transcoding, using the PMS StreamingBrain: Let PMS StreamingBrain decide on whether we need to force-transcode, New setting to choose "Direct Streaming", Allow for 4k transcoding and direct streaming, New setting to force transcode only 4K and above
- Fix PKC background sync synching items to Kodi even though entire section should not be synched
- Force a full sync of all items after choosing a new PMS, changing a PMS' address and changing which Plex libraries to sync
- Only enforce advancedsettings.xml 'cleanonupdate' to be false for PKC add-on paths
- Never give up trying to connect to the PMS or Alexa using websockets
- Fix resume when force-transcoding
version 2.9.5:
- Version 2.9.4 for everyone
version 2.9.4 (beta only):
- Fix extras not playing when path substitution is enabled
- Fix Plex Companion device restarting playback when reconnecting to PKC
- Fix playback report not working after having played a non-Plex video file
- Change how items are added to Plex playqueues by using PMS machine identifier
- Optimize code for playqueue items
- Fix rare AttributeError when shutting down Kodi
version 2.9.3:
- version 2.9.2 for everyone
version 2.9.2 (beta only):
- Fix Plex Companion casting from iOS and Android
- Faster sync of playlists
- Sync playlists immediately after synching new/changed items and show an info dialog
- Fix potential playlist sync issues if there is a dot in the playlist name
- Correctly detect whether we already synched a Kodi playlist
- Remove obsolete check if path is indeed in unicode
- Add unicode representation to Playlist() class
- Separate function to wipe all synched Plex playlists
- Less logging when comparing PKC versions
version 2.9.1:
- Fix On Deck and Recently Added Episodes for shows not appending showname and season and episode number
version 2.9.0:
WARNING: You might have to manually select your PKC widgets again
- versions 2.8.8 - 2.8.11 for everyone
- Fix AttributeError: 'NoneType' object has no attribute 'attrib' on playback startup
- Add new Lithuanian translations (thanks @egidusm)
version 2.8.11 (beta only):
- Support for the Up Next Kodi add-on
- Fix casting to PlexKodiConnect always starting the first episode
- Rename video nodes for ondeck
version 2.8.10 (beta only):
- Fix broken PKC update
version 2.8.9 (beta only):
- Fix sections that are not synced not displaying menu but entire library
- Provide more metadata for unsynced directory-like items like a tv show
- Fix 'Plex.nodes.<id>.path' not linking directly to entire library
version 2.8.8 (beta only):
WARNING: You might have to manually select your PKC widgets again
- Ensure correct Kodi Container.Type is set for PKC widgets
- Fix missing cast artwork if an actor also acted as director or writer for another movie. You will have to manually reset the Kodi DB.
version 2.8.7:
- Fix PKC potentially marking a video as watched on startup; don't sync time by toggling a video watch status but use PMS epoch time
version 2.8.6:
- Fix PKC creating thousands of playlists if a single Kodi playlist wasn't unique
- Fix FutureWarning
version 2.8.5:
- Fix Trakt add-on not recognizing id of tv shows (you will need to manually reset the Kodi database in the PKC settings under Advanced)
- Update translations
version 2.8.4:
- Fix for Kodi 17 Krypton TypeError on playback start: 'offscreen' is an invalid keyword argument for this function
- Fix widgets not being populated after very first PlexKodiConnect library sync without a restart of Kodi
- Don't restart Kodi if user chose to enter PKC settings on install
version 2.8.3:
- Versions 2.8.1-2.8.2 for everyone
version 2.8.2 (beta only):
- Add an additional, faster On Deck node for movies (for tv shows, this is impossible, unfortunately)
- Introduce limits to the number of videos shown in PKC widgets to speed them up
- Fix TypeError for Direct Paths: init() got an unexpected keyword argument item
- Fix In Progress widgets being broken and tv shows showing up as completely watched
- Update translations
version 2.8.1 (beta only):
- Fix playback startup and RuntimeError: Unknown exception thrown from the call "XBMCAddon::xbmcplugin::setResolvedUrl"
- Refactor Plex API
- Fix TV Show clearlogo not displaying during playback
- Fix rare UnicodeDecodeError on library sync
- Add additional info dialog for PKC synching playlists
- Update translations
version 2.8.0:
- Finally fix Kodi crashing on playback startup for add-on paths!
- All the good stuff from 2.7.15-2.7.18 for everyone
version 2.7.18 (beta only):
- Fix Kodi always playing the same file version of a video if several are present
- Also play trailers if user chose to resume movie from the beginning
- Ask user whether to resume if using Direct Paths and user initiated playback via PMS
- Fix video thrown by Plex Companion not resuming
version 2.7.17 (beta only):
- Another attempt to keep Kodi from crashing on playback startup
version 2.7.16 (beta only):
- Hopefully fix Kodi crashing on playback startup for good
version 2.7.15 (beta only):
- Hopefully fix Kodi crashing on playback startup
- Refresh widgets only on homescreen to prevent cursor from jumping within libraries
- Don't refresh container when user chose to delete or refresh an item from the context menu
version 2.7.14:
- Correctly clear window variables e.g. on user switch
- Reload skin on resetting PKC video nodes
- Fix last-played node value to ensure a playcount greater than zero
- 2.7.11-2.7.13 for everyone
version 2.7.13 (beta only):
- Fix transcoding not working
- Fix 4k H265 not being transcoded
- Fix some appearance tweak settings
- Fix music and picture nodes pointing to video library
- Fix unequality when comparing sections
- Fix Plex Companion logging error messages
version 2.7.12 (beta only):
- Fix UnicodeEncodeError on playback startup for direct paths
- Attempt to fix rare Kodi crash on PKC exit
version 2.7.11 (beta only):
- Fixes to unicode
- Cleanup code, remove some obsolet methods and functions
- Fix FutureWarning
version 2.7.10:
- Fix duplicate music entries for direct paths (you will need to manually reset the Kodi database)
- Fix UnicodeEncodeError for Direct Paths and some PKC video nodes
- Fix playback sometimes not being reported for direct paths
- Update translations
version 2.7.9:
- Wait for PKC to authorize before loading widgets
- Fix UnicodeDecodeError for libraries with non-ASCII paths
- Fix TypeError on Kodi start
- Fix Kodi Masterlock for nfs paths (requires restart)
version 2.7.8:
- Fix widgets not working in some cases like NVidia Shield
- Fix appending of show title, season and episode number
- Fix node paths for skins
version 2.7.7:
- Fix sync not working due to non-ASCII Plex library names
- Fix PKC synching playstate to wrong user on profile switch. Be aware that Kodi profile switches are error-prone
- Fix playback sometimes not being reported for direct paths
- Fix float() argument must be a string or a number
- Fix nodes for skin use
- Fix 'NoneType' object has no attribute 'kodi_path'
version 2.7.6:
- Make 2.7.5 available for everyone
version 2.7.5 (beta only):
- Giant overhaul of widgets
- Fix some KeyErrors when playing songs
- Fix rare cases where playlists were being created
version 2.7.4:
- Fix PKC not synching new items if an older Kodi db is present
version 2.7.3:
- Fix PKC trying to initialize playqueues over and over again
- Fix PKC not starting due to a higher version Kodi database
version 2.7.2:
- Fix Kodi profile switch not working correctly and PKC not exiting cleanly
version 2.7.1:
- Fix playback not starting at all
- Fix rare TypeError: unsupported operand type(s) for /: 'NoneType' and 'int' on playback startup
- Improve plex db lookups by creating better db indicees
- Fix background sync crashing in rare cases
- Update translations
- Add Ko-fi donate button
version 2.7.0:
- WARNING: You will need to reset the Kodi database if you're using the stable version of PKC!
- Version 2.6.6-9 for everyone
- Choose which Plex libraries get synched to Kodi
version 2.6.9 (beta only):
- Fix PKC crashing on resetting the database
version 2.6.8 (beta only):
- Choose which Plex libraries get synched to Kodi
- Fix PKC becoming unresponsive
- Fix rare case where thousands of identical playlists could be generated
- Fix movies or shows disappearing in fringe cases
- Fix processing of collections in special cases
- Implement Codacy suggestions
version 2.6.7 (beta only):
- Fix "Unauthorized for PMS" e.g. on switching Plex users
- Improve error messages when playback failes
version 2.6.6 (beta only):
- WARNING: You will need to reset the Kodi database!
- Greatly speed up sync for episodes, especially for large libraries
- Allow websocket redirects. Never allow insecure HTTPs connections for Kodi Leia
- Optimize headers for communication with PMS to appear like a Plex Media Player
- Fix PMS log entries 'Unable to find client profile for device'
- Improve sync dialog
version 2.6.5:
- Fix extras not playing
- Hide "Verify SSL certificate" setting for Kodi 18 Krypton
- Improve logging
- Update translations
version 2.6.4:
- Fix music items getting deleted on startup
- Never ignore SSL certificate errors for Kodi >= 18 - just like Kodi
- Fix playback not starting at the beginning
- Improve dialog to manually enter PMS IP and port
- Show logged in Plex home user in the settings and allow changing it
- Update German strings
- Implement Codacy suggestions
version 2.6.3:
- Fix PKC crashing on Xbox
version 2.6.2:
- Fix playlist sync: sequence item 0: expected string or unicode
- Fix PKC not deleting all the items it should
- Fix keyError 'sessionKey' for weird PMS messages
- Fix artwork caching AttributeError: 'ImageCachingThread' object has no attribute 'cancel'
- Improve pop-up "Searching for PMS"
- Fix FutureWarning
version 2.6.1:
- WARNING: You will need to reset the Kodi database!
- Fix TV sections not being deleted e.g. after user switch
- Don't show a library sync error pop-up when full sync is interrupted
- Fix to correctly escape paths
- Update translations
version 2.6.0:
- Support for Kodi 18 Leia
- Big overhaul of the synching process, it's now much faster
- PKC now supports really big Plex and Kodi libraries
- Too many other improvements to recount. See the changelog for the 2.5.x versions
Furthermore:
- Don't lock Plex DB when processing websocket messages
- Fix KeyError: u'kodi_fileid' for some Plex websocket messages
- Update translations
version 2.5.23 (beta only):
- Hopefully fix slow playback startup just after Kodi startup
- Better, safer way to enter network credentials for Direct Paths
- Fix check whether a direct path is accessible
- Fix OperationalError: no such table on database reset
- Fix widgets not displaying correct playstate after PKC startup
- Fix 'NoneType' object has no attribute 'execute' when Plex artwork is not synced and an item is deleted
- Update translations
- Log whether Plex artwork is synced to Kodi
version 2.5.22 (beta only):
- Fix rare EOFError and PKC starting wrong video as a consequence
version 2.5.21 (beta only):
- Fix KodiVideoDB object has no attribute kodiconn
- Fix local variable 'set_api' referenced before assignment
version 2.5.20 (beta only):
- Begin a new transaction when database was locked
- Fix browsing to show from info dialog
- Fix rare KeyError if user is playing something somewhere else
version 2.5.19 (beta only):
- Fix crash on startup-sync due to missing albums
- Fix browsing to show from info dialog
version 2.5.18 (beta only):
- Fix playback start: Don't lock databases when starting playback
- Refresh Kodi view only once on full syncs
- Ignore playstate updates for full sync time stamps croneter committed
- Try even longer to write to Kodi database
- Fix some items rarely not being synced
version 2.5.17 (beta only):
- Fix playback not starting for really large libraries
version 2.5.16 (beta only):
- Fix KeyError due to malformed PMS messages
- Fix to database parameter must be string
version 2.5.15 (beta only):
- Make PKC potentially compatible with several database schemas
- Support for Kodi 18 Leia RC 5.2
- Increase number of attempts to write to Kodi DB
- Further increase database sync resiliance
version 2.5.14 (beta only):
Fix rare OperationalError: Locked Database
version 2.5.13 (beta only):
- Fix playback not starting up
- Fix Plex channels and watch later not working
- Hopefully fix playstate not being synced to PMS
version 2.5.12 (beta only):
- WARNING: You will need to reset the Kodi database!
- New option to not use Plex artwork
- Add-on paths: Fix resume if playback not initiated with PKC
- Increase database resiliance with sqlite WAL mode
version 2.5.11 (beta only):
- Direct Paths: Fix AttributeError for widgets
version 2.5.10 (beta only):
- Enable Plex Hub listings to be used for widgets
- Finally fix deleteting of items from PMS not working
- Catch sqlite OperationalError for websocket messages
- Revert "Increase database timeouts"
version 2.5.9 (beta only):
- Compatibility with Kodi 18 RC 4
- New setting to escape paths e.g. for HTTP direct paths
- Ensure path replacement never contains trailing (back)slash
- Leia: fix resetting of videoplayer autoplay next item
- Don't store identical show artwork for seasons
- Close DB connections while caching images
- Increase database timeouts
- Improve logging for seasons
version 2.5.8 (beta only):
- Hopefully fix Kodi crashing on playback start
- Fix video resuming from old resume point
- Fix database is locked
- Faster way to initialize playlists on the Plex side
- Fix PKC recreating playlists too often
- Shutdown playlist sync if necessary
version 2.5.7 (beta only):
- WARNING: You will need to reset the Kodi database!
- Increase timeout for database connections
- Fix music DB not being wiped on database reset
- Improve Plex playQueue resiliance
version 2.5.6 (beta only):
- Fix many items not getting synced
- Fix episodes not being synced to due a missing season
- Fix some very few items not being synced
- Fix ValueError during sync due to missing Plex timestamp
- Fix resume for episodes for add-on paths
- Fix movies not showing up on switching PMS
- Finish full syncs during playbacks, don't start new ones
- Fix AttributeError when a playlist disappeared
- Close sync dialog if video playback starts
- Don't show sync messages while Kodi is playing something
- Only marking full sync as successful if that is indeed the case
- Optimize code
version 2.5.5 (beta only):
- Fix OperationalError and PKC not starting up
version 2.5.4 (beta only):
- Fix a couple of issues related to episodes
- Fix permanent missing library items if PMS failed to send a single response
- Fix OperationalError: enforce Kodi restart with clean DB once
- Fix switching PMS not recognizing when old PMS is selected
- Fix PKC not automatically connecting to changed PMS IP on startup
- Remove message "Full library sync finished"
- Fix PKC not automatically connecting to changed PMS IP on startup
- Remove cProfile program metrics measurements
version 2.5.3 (beta only):
- Fix Plex sections not showing up or disappearing
version 2.5.2 (beta only):
- Rewire library sync
- Optimize sqlite transactions
- Replace annoying sync message with PKC settings info
- Add PKC settings status indication for caching
- Fix KeyError when synching playlists
- Fix ImportError for Plex Companion gdm issues
- Increase database connection cache size
- Force-Reboot Kodi immediately if sqlite PRAGMA WAL causes errors
- Force a full sync on switching Plex username
- Fix wierd behavior upon switching to another PMS
- More bugfixes and code optimizations
version 2.5.1 (beta only):
- Fix OSError on resetting the database
version 2.5.0 (beta only):
- Huge rewrite of the sync mechanism - it should now be faster and more stable
- Sync huge Plex libraries now: the sync will load all data bit by bit
- Rewrote code for the main program loop, reducing the need for separate Python threads
- Rewrote and sped up code to access and change Kodi and Plex databases
- Fixes to Kodi 18 Leia music library
- Tons of other small fixes I can't remember
version 2.4.10 (beta only):
- Use xml.etree.cElementTree whenever possible to avoid memory leaks
version 2.4.9:
- Fix Kodi crashing due to PKC memory leak
version 2.4.8:
- Make 2.4.4-2.4.7 available for everyone
version 2.4.7 (beta only):
- Try to fix PKC for Enigma 2
- Fix Kodi 18 wanting to scan tags for songs all the time (you will need to reset the database in the PKC settings)
- Optimize resetting of Kodi and Plex databases
version 2.4.6 (beta only):
- Fix PKC not starting up on Enigma
- Fix sync issues if video lies in root of file system
- Make sure we retain a dummy first music artist entry
- Increase logging
version 2.4.5 (beta only):
- Fix playback not starting up at all
- Rewire Kodi library refreshs
- Wipe Kodi database on first PKC run to more reliably install PKC
version 2.4.4 (beta only):
- Fix rare case when playback would not start-up
- Increase logging
version 2.4.3:
- Fix Kodi addons throwing jsonrpc errors (database reset needed)
version 2.4.2:
- Make version 2.4.1 available for everyone
version 2.4.1 (beta only):
- Hopefully fix endless playlist sync loops
- Ensure shows are deleted before seasons before episodes
- Fix library sync crash on deleting episode with missing season
- Fix numbering of already existing playlist files
- Optimize logging
version 2.4.0:
- Use pretty Plex dialogs for everyone!
version 2.3.14 (beta only):
- Fix AttributeError on forcing texture caching
- Switch to Plex style dialogs
- Include PKC info in plex.tv dialogs
- Include PKC info in user selection dialog
version 2.3.13 (beta only):
- Pretty Plex dialogs for plex.tv sign-in and user selection
- Fix UnicodeDecodeError for PMS with non ASCII chars on local LAN discovery
- Fix add-on settings not opening on installation
- Greatly speed up deleting of items on the Kodi side
- Safely parse XMLs using defusedxml
- Fix PKC trying to sync audio playlists even when audio sync disabled
- Some code cleanup
version 2.3.12:
- Fix Kodi hanging if media stream selection is aborted
- Fix potential sync crash
- Revert "Fix Kodi crash by committing to DB frequently"
version 2.3.11 (beta only):
- Fix Kodi crash by committing to DB frequently
version 2.3.10:
- Compatibility with Kodi 18 Leia Beta 1
- Update translations
- Make version 2.3.9 available for everyone
version 2.3.9 (beta only):
- Fix playback not resuming (Kodi 18 ignores listitem "StartOffset")
- Fix playerid not being retrieved for Kodi 18
- Prefer local trailers; new setting to list extras instead of playing trailer
version 2.3.8:
- Fix typo
- Make version 2.3.4-2.3.7 available for everyone
version 2.3.7 (beta only):
- Fix library sync crash due to exotic playlist characters
- Force-deactivate playlist sync for Microsoft UWP for Kodi 18
version 2.3.6 (beta only):
- Fix PKC not starting by decoupling watchdog/subprocess modules
version 2.3.5 (beta only):
- Fix PKC not starting by importing playlist module only when sync enabled
version 2.3.4 (beta only):
- Fix playback sometimes not starting and UnicodeEncodeError for logging
version 2.3.3:
- Choose trailer if several are present (DB reset required)
version 2.3.2:
- Fix casting to PKC failing
version 2.3.1:
- Fix library sync crashing due to Plex photo albums
version 2.3.0:
Major stable version bump. Highlights:
- Sync Plex playlists to Kodi and Kodi playlists to Plex!
- Support for Plex collection/set artwork
- Many bug fixes, especially Plex Companion
- Tons of code improvements in the hope that someone else will help with developing PKC
Warning: the 2 helper add-ons for movies and tv shows also received an upgrade from 2.0.4 to 2.0.5. If you want to downgrade PKC, be sure to downgrade these add-ons as well!
version 2.2.18 (beta only):
- Fix PKC tv show node "all"
- Move PKC playlist shortcut
version 2.2.17 (beta only):
- Access Plex Hubs. Listing will be different depending on Kodi section!
- Fix year for songs missing
- Fix Plex extras not playing
- Fix rare library sync crash
version 2.2.16 (beta only):
- Enable Kodi libraries for Plex Music libraries
- New Playlists menu item for video libraries
- Only show Plex libraries in the applicable Kodi media category
- Optimize code
version 2.2.15 (beta only):
- Fix ImportError on first PKC run
version 2.2.14 (beta only):
- Hopefully fix playlist sync loops
version 2.2.13 (beta only):
- Fix library sync crash
- Fix switching to __future__ module
- Fix "Prefer Kodi Artwork" toggle doing the exact opposite
- Fix "Prefer Kodi artwork" setting not being visible
version 2.2.12 (beta only):
- Fix slow sync. Fix endless sync of corrupted PMS elements
- Refactor playlist code
- Fix FutureWarning
version 2.2.11 (beta only):
- Fix OnDeck widget for Direct Paths
- Fix Plex Companion crashing when connected to Plex Web
- Fix Plex Companion crash when connected to Plex Web playing playlist music
- Improve Plex playback report when playing music playlist
- Improve reliability in Kodi song playback
- Catch some errors if user mixes audio and video in Kodi playqueue
version 2.2.10 (beta only):
- Fix playlists getting recreated and deleted in an endless loop
- Add some safety nets for playlist sync
- Fix FutureWarning
- Fix playlist sync settings not disappearing
- Optimize code
version 2.2.9 (beta only):
- Hopefully fix Kodi and Plex playlists getting out of sync
- Fix and optimize startup of playlist sync
- Hide certain playlist settings under certain conditions
- Fix errors in Kodi log
version 2.2.8 (beta only):
- Support for Plex collection artwork (PKC settings toggle under Artwork)
- Fix hard PKC reset not working (OSError: no such file)
- Deduplication
- Catch exception
- Update translations
- Extend Kodi metadata
- Update readme
version 2.2.7 (beta only):
- Allow to only sync specific Plex or Kodi playlists
- Don't show artwork sync progress, reduce setting-writes
- Fix playback sometimes not starting up
- Use __future__ for contextmenu.py
- Fix imports
version 2.2.6 (beta only):
- Fix default settings string, only show in English, hopefully fixes PKC loosing its settings
version 2.2.5 (beta only):
- Fix AttributeError and add_update has crashed
version 2.2.4 (beta only):
- Fix LibrarySync crashing due to Plex Companion messages
version 2.2.3 (beta only):
- Compatibility with Kodi Krypton Alpha 2
- Append tv show and SxxExx to episode playlist entries
version 2.2.2 (beta only):
- Fixes to locking mechanisms which resulted in weird behavior in some cases
- Switch to Python __future__ unicode_literals and absolute paths
- Fix UnboundLocalError for playlists
- Check all Kodi database versions before starting PKC
- Fix KeyError on non-PKC playback startup
- Speed up subtitle download to Kodi
- Update translations
- PEP-8 stuff
version 2.2.1 (beta only):
- Fix library sync crash due to PMS sending string, not unicode
- Fix playback from playlists for add-on paths
- Detect playback from a Kodi playlist for add-on paths - because we need some hacks due to Kodi bugs
- Fix add-on paths playstate and Plex Companion for playlists
- Fix Kodi telling Plex companion false playqueue position
- Don't try to get a Kodi library items for Plex clips
- Update translations
version 2.2.0 (beta only):
- Support for syncing Plex playlists to Kodi and vice-versa! (Kodi mixed music and video playlists cannot be supported as Plex does not support them)
version 2.1.6:
- Fix slow sync. Fix endless sync of corrupted PMS elements
version 2.1.5:
- Fix OnDeck widget for Direct Paths
version 2.1.4:
- Fix PKC settings suddenly getting lost
- Don't show artwork sync progress, reduce setting-writes
version 2.1.3:
- Fix default settings string, only show in English, hopefully fixes PKC loosing its settings
version 2.1.2:
- Compatibility with Kodi Krypton Alpha 2
- Check all Kodi database versions before starting PKC
- Fix KeyError on non-PKC playback startup
- PEP-8 stuff
version 2.1.1:
- Fix Library Sync crash on Android
version 2.1.0:
Finally a new update for the stable version. You will need to reconnect to your PMS and reset the Kodi database once. Highlights of v2 include:
- Support for Plex extras
- Huge improvements to Plex Companion
- Fixes to Alexa voice control
- Kodi 18 Leia Alpha 1 support
- Improvements to playback start-up
- Improvements to the syncing mechanism, which should get rid of a ton of small bugs
- Fixes to widgets and resuming playback
- Use of plex.direct paths instead of local IP addresses to ensure the SSL certificates shown by the PMS are deemed valid
- Fix Kodi screensaver
- Faster PKC startup
- And tons of other stuff...
version 2.0.30 (beta only):
- Fix resume for On Deck widget for direct paths
- Fix DB reset on Startup if PMS connection fails
- Fix searching for PMS if there is no internet connection
- Fix context menu missing "Delete item from PMS"
version 2.0.29 (beta only):
- Fix a racing condition leading to e.g. Plex Companion not working as intended
- Force a sync on startup even if Kodi is playing something
- Include Plex Home username in "Log-out Plex Home user"
- Direct paths: Don't download PMS sections twice
- Less logging
version 2.0.28 (beta only):
- Fix endless reboots if Plex music library missing
- Fix Plex Companion failing leading to PMS connection loss
- Fix PKC add-on setting user changes not saving
- Fix playback of last item not starting up
- Update Czech translation
- Declare PMS connection dead on first failed connection
- Fix KeyErrors if Kodi player does not return position
- Fix AttributeError
- Fix logging
- Use float for resume and runtime instead of int
version 2.0.27 (beta only):
- WARNING: You will need to reset the Kodi database! Sorry for that...
- Fix PKC not connecting: Fix ValueError if plex.tv returns Plex Cloud URIs
- Fix episode widget resume not working (add-on paths)
- Speed up PKC start-up
- Speed up checking of PMS connection, e.g. on startup
- Improve collection lookup; fix PKC caching wrong url
- Revert "Default to not show image caching notifications"
version 2.0.26 (beta only):
- Reduce CPU strain for artwork caching progress
- Fallback connection if plex.direct does not resolve
- Prettify Plex context menu, thanks @dazedcrazy
- Update translations
- Default to not show image caching notifications
version 2.0.25 (beta only):

View file

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*-
###############################################################################
from __future__ import absolute_import, division, unicode_literals
from sys import listitem
from urllib import urlencode
@ -40,10 +39,9 @@ def main():
'kodi_id': kodi_id,
'kodi_type': kodi_type
}
while window.getProperty('plexkodiconnect.command'):
while window.getProperty('plex_command'):
sleep(20)
window.setProperty('plexkodiconnect.command',
'CONTEXT_menu?%s' % urlencode(args))
window.setProperty('plex_command', 'CONTEXT_menu?%s' % urlencode(args))
if __name__ == "__main__":

View file

@ -1,22 +1,49 @@
# -*- coding: utf-8 -*-
###############################################################################
from __future__ import absolute_import, division, unicode_literals
import logging
from sys import argv
from os import path as os_path
from sys import path as sys_path, argv
from urlparse import parse_qsl
import xbmc
import xbmcgui
import xbmcplugin
from xbmc import translatePath, sleep, executebuiltin
from xbmcaddon import Addon
from xbmcgui import ListItem
from xbmcplugin import setResolvedUrl
from resources.lib import entrypoint, utils, transfer, variables as v, loghandler
from resources.lib.tools import unicode_paths
_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)
###############################################################################
import entrypoint
from utils import window, reset, passwords_xml, language as lang, dialog, \
plex_command
from pickler import unpickle_me, pickl_window
from PKC_listitem import convert_PKC_to_listitem
import variables as v
###############################################################################
import loghandler
loghandler.config()
LOG = logging.getLogger('PLEX.default')
log = logging.getLogger('PLEX.default')
###############################################################################
@ -27,15 +54,9 @@ 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', '')
@ -45,117 +66,108 @@ 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_type=params.get('plex_type'),
section_id=params.get('section_id'),
synched=params.get('synched') != 'false',
prompt=params.get('prompt'),
query=params.get('query'))
plex_section_id=params.get('id'))
elif mode == 'show_section':
entrypoint.show_section(params.get('section_index'))
elif mode == 'getsubfolders':
entrypoint.GetSubFolders(itemid)
elif mode == 'watchlater':
entrypoint.watchlater()
elif mode == '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)
entrypoint.channels()
elif mode == 'extras':
entrypoint.extras(plex_id=params.get('plex_id'))
elif mode == 'settings':
xbmc.executebuiltin('Addon.OpenSettings(%s)' % v.ADDON_ID)
executebuiltin('Addon.OpenSettings(%s)' % v.ADDON_ID)
elif mode == 'enterPMS':
LOG.info('Request to manually enter new PMS address')
transfer.plex_command('enter_new_pms_address')
entrypoint.enterPMS()
elif mode == 'reset':
transfer.plex_command('RESET-PKC')
reset()
elif mode == 'togglePlexTV':
LOG.info('Toggle of Plex.tv sign-in requested')
transfer.plex_command('toggle_plex_tv_sign_in')
entrypoint.togglePlexTV()
elif mode == 'resetauth':
entrypoint.resetAuth()
elif mode == 'passwords':
from resources.lib.windows import direct_path_sources
direct_path_sources.start()
passwords_xml()
elif mode == 'switchuser':
LOG.info('Plex home user switch requested')
transfer.plex_command('switch_plex_user')
entrypoint.switchPlexUser()
elif mode in ('manualsync', 'repair'):
if mode == 'repair':
LOG.info('Requesting repair lib sync')
transfer.plex_command('repair-scan')
elif mode == 'manualsync':
LOG.info('Requesting full library scan')
transfer.plex_command('full-scan')
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':
log.info('Requesting repair lib sync')
plex_command('RUN_LIB_SCAN', 'repair')
elif mode == 'manualsync':
log.info('Requesting full library scan')
plex_command('RUN_LIB_SCAN', 'full')
elif mode == 'texturecache':
LOG.info('Requesting texture caching of all textures')
transfer.plex_command('textures-scan')
log.info('Requesting texture caching of all textures')
plex_command('RUN_LIB_SCAN', 'textures')
elif mode == 'chooseServer':
LOG.info("Choosing PMS server requested, starting")
transfer.plex_command('choose_pms_server')
entrypoint.chooseServer()
elif mode == 'refreshplaylist':
log.info('Requesting playlist/nodes refresh')
plex_command('RUN_LIB_SCAN', 'views')
elif mode == 'deviceid':
self.deviceid()
elif mode == 'fanart':
LOG.info('User requested fanarttv refresh')
transfer.plex_command('fanart-scan')
log.info('User requested fanarttv refresh')
plex_command('RUN_LIB_SCAN', 'fanart')
elif '/extrafanart' in path:
plexpath = arguments[1:]
elif '/extrafanart' in argv[0]:
plexpath = argv[2][1:]
plexid = itemid
entrypoint.extra_fanart(plexid, plexpath)
entrypoint.get_video_files(plexid, plexpath)
entrypoint.getExtraFanArt(plexid, plexpath)
entrypoint.getVideoFiles(plexid, plexpath)
# Called by e.g. 3rd party plugin video extras
elif ('/Extras' in path or '/VideoFiles' in path or
'/Extras' in arguments):
elif ('/Extras' in argv[0] or '/VideoFiles' in argv[0] or
'/Extras' in argv[2]):
plexId = itemid or None
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')
entrypoint.getVideoFiles(plexId, params)
else:
entrypoint.show_main_menu(content_type=params.get('content_type'))
entrypoint.doMainListing(content_type=params.get('content_type'))
@staticmethod
def play():
@ -164,46 +176,44 @@ class Main():
"""
request = '%s&handle=%s' % (argv[2], HANDLE)
# Put the request into the 'queue'
transfer.plex_command('PLAY-%s' % request)
plex_command('PLAY', 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)
# 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)
@staticmethod
def deviceid():
window = xbmcgui.Window(10000)
deviceId_old = window.getProperty('plex_client_Id')
from resources.lib import clientinfo
deviceId_old = window('plex_client_Id')
from clientinfo import getDeviceId
try:
deviceId = clientinfo.getDeviceId(reset=True)
deviceId = getDeviceId(reset=True)
except Exception as e:
LOG.error('Failed to generate a new device Id: %s' % e)
utils.messageDialog(utils.lang(29999), utils.lang(33032))
log.error('Failed to generate a new device Id: %s' % e)
dialog('ok', lang(29999), 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'
utils.messageDialog(utils.lang(29999), utils.lang(33033))
xbmc.executebuiltin('RestartApp')
dialog('ok', lang(29999), lang(33033))
executebuiltin('RestartApp')
if __name__ == '__main__':
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 started' % v.ADDON_ID)
Main()
log.info('%s stopped' % v.ADDON_ID)

BIN
empty_video.mp4 Normal file

Binary file not shown.

View file

@ -155,6 +155,11 @@ msgctxt "#30028"
msgid "PKC-only image caching completed"
msgstr ""
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid "To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to use Kodi's default skin \"Estuary\" for initial set-up and for possible database resets. Continue?"
msgstr ""
msgctxt "#30030"
msgid "Port Number"
msgstr ""
@ -1093,6 +1098,11 @@ msgctxt "#39074"
msgid "TV Shows"
msgstr ""
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync
msgctxt "#39076"
msgid "If you use several Plex libraries of one kind, e.g. \"Kids Movies\" and \"Parents Movies\", be sure to check the Wiki: https://goo.gl/JFtQV9"

View file

@ -1,160 +1,16 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Used to shovel data from separate Kodi Python instances to the main thread
and vice versa.
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import json
import xbmc
import xbmcgui
LOG = getLogger('PLEX.transfer')
WINDOW = xbmcgui.Window(10000)
WINDOW_UPSTREAM = 'plexkodiconnect.result.upstream'.encode('utf-8')
WINDOW_DOWNSTREAM = 'plexkodiconnect.result.downstream'.encode('utf-8')
WINDOW_COMMAND = 'plexkodiconnect.command'.encode('utf-8')
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
###############################################################################
from xbmcgui import ListItem
def cast(func, value):
def convert_PKC_to_listitem(PKC_listitem):
"""
Cast the specified value to the specified type (returned by func). Currently
this only support int, float, bool. Should be extended if needed.
Parameters:
func (func): Calback function to used cast to type (int, bool, float).
value (any): value to be cast and returned.
Returns None if something goes wrong
Insert a PKC_listitem and you will receive a valid XBMC listitem
"""
if value is None:
return value
elif func == bool:
return bool(int(value))
elif func == unicode:
if isinstance(value, (int, long, float)):
return unicode(value)
elif isinstance(value, unicode):
return value
else:
return value.decode('utf-8')
elif func == str:
if isinstance(value, (int, long, float)):
return str(value)
elif isinstance(value, str):
return value
else:
return value.encode('utf-8')
elif func == int:
try:
return int(value)
except ValueError:
try:
# Converting e.g. '8.0' fails; need to convert to float first
return int(float(value))
except ValueError:
return
elif func == float:
try:
return float(value)
except ValueError:
return
return func(value)
def kodi_window(property, value=None, clear=False):
"""
Get or set window property - thread safe! value must be string
"""
if clear:
WINDOW.clearProperty(property)
elif value is not None:
WINDOW.setProperty(property, value)
else:
return WINDOW.getProperty(property)
def plex_command(value):
"""
Used to funnel states between different Python instances. NOT really thread
safe - let's hope the Kodi user can't click fast enough
"""
while kodi_window(WINDOW_COMMAND):
xbmc.sleep(50)
kodi_window(WINDOW_COMMAND, value=value)
def serialize(obj):
if isinstance(obj, PKCListItem):
return {'type': 'PKCListItem', 'data': obj.data}
else:
return {'type': 'other', 'data': obj}
return
def de_serialize(answ):
if answ['type'] == 'PKCListItem':
result = PKCListItem()
result.data = answ['data']
return convert_pkc_to_listitem(result)
elif answ['type'] == 'other':
return answ['data']
else:
raise NotImplementedError('Not implemented: %s' % answ)
def send(pkc_listitem, target='default'):
"""
Pickles the obj to the window variable. Use to transfer Python
objects between different PKC python instances (e.g. if default.py is
called and you'd want to use the service.py instance)
obj can be pretty much any Python object. However, classes and
functions won't work. See the Pickle documentation
Set target='default' if you send data TO another Python default.py
instance, 'main' if your default.py needs to send to the main thread
"""
window = WINDOW_DOWNSTREAM if target == 'default' else WINDOW_UPSTREAM
LOG.debug('Sending: %s', pkc_listitem)
kodi_window(window,
value=json.dumps(serialize(pkc_listitem)))
def wait_for_transfer(source='main'):
"""
Set source='default' if you wait for data FROM another Python default.py
instance, 'main' if your default.py needs to wait for the main thread
"""
LOG.debug('Waiting for transfer from %s', source)
window = WINDOW_DOWNSTREAM if source == 'main' else WINDOW_UPSTREAM
result = ''
while not result:
result = kodi_window(window)
if result:
kodi_window(window, clear=True)
LOG.debug('Received')
result = json.loads(result)
return de_serialize(result)
xbmc.sleep(50)
def convert_pkc_to_listitem(pkc_listitem):
"""
Insert a PKCListItem() and you will receive a valid XBMC listitem
"""
data = pkc_listitem.data
if KODIVERSION >= 18:
listitem = xbmcgui.ListItem(label=data.get('label'),
label2=data.get('label2'),
path=data.get('path'),
offscreen=True)
else:
listitem = xbmcgui.ListItem(label=data.get('label'),
label2=data.get('label2'),
path=data.get('path'))
data = PKC_listitem.data
listitem = ListItem(label=data.get('label'),
label2=data.get('label2'),
path=data.get('path'))
if data['info']:
listitem.setInfo(**data['info'])
for stream in data['stream_info']:
@ -164,22 +20,20 @@ def convert_pkc_to_listitem(pkc_listitem):
if data['art']:
listitem.setArt(data['art'])
for key, value in data['property'].iteritems():
listitem.setProperty(key, cast(str, value))
listitem.setProperty(key, value)
if data['subtitles']:
listitem.setSubtitles(data['subtitles'])
if data['contextmenu']:
listitem.addContextMenuItems(data['contextmenu'])
return listitem
class PKCListItem(object):
class PKC_ListItem(object):
"""
Imitates xbmcgui.ListItem and its functions. Pass along PKC_Listitem().data
when pickling!
WARNING: set/get path only via setPath and getPath! (not getProperty)
"""
def __init__(self, label=None, label2=None, path=None, offscreen=True):
def __init__(self, label=None, label2=None, path=None):
self.data = {
'stream_info': [], # (type, values: dict { label: value })
'art': {}, # dict
@ -189,10 +43,9 @@ class PKCListItem(object):
'path': path, # string
'property': {}, # (key, value)
'subtitles': [], # strings
'contextmenu': None
}
def addContextMenuItems(self, items):
def addContextMenuItems(self, items, replaceItems):
"""
Adds item(s) to the context menu for media lists.
@ -210,7 +63,7 @@ class PKCListItem(object):
Once you use a keyword, all following arguments require the keyword.
"""
self.data['contextmenu'] = items
raise NotImplementedError
def addStreamInfo(self, type, values):
"""

1638
resources/lib/PlexAPI.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,82 +1,36 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
The Plex Companion master python file
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from threading import Thread
from Queue import Empty
from socket import SHUT_RDWR
from xbmc import executebuiltin
from urllib import urlencode
from .plexbmchelper import listener, plexgdm, subscribers, httppersist
from .plex_api import API
from . import utils
from . import plex_functions as PF
from . import playlist_func as PL
from . import playback
from . import json_rpc as js
from . import playqueue as PQ
from . import variables as v
from . import backgroundthread
from . import app
from . import exceptions
from xbmc import sleep, executebuiltin, Player
from utils import settings, thread_methods, language as lang, dialog
from plexbmchelper import listener, plexgdm, subscribers, httppersist
from plexbmchelper.subscribers import LOCKER
from PlexFunctions import ParseContainerKey, GetPlexMetadata, DownloadChunks
from PlexAPI import API
from playlist_func import get_pms_playqueue, get_plextype_from_xml, \
get_playlist_details_from_xml
from playback import playback_triage, play_xml
import json_rpc as js
import variables as v
import state
import playqueue as PQ
###############################################################################
LOG = getLogger('PLEX.plex_companion')
LOG = getLogger("PLEX." + __name__)
###############################################################################
def update_playqueue_from_PMS(playqueue,
playqueue_id=None,
repeat=None,
offset=None,
transient_token=None,
start_plex_id=None):
"""
Completely updates the Kodi playqueue with the new Plex playqueue. Pass
in playqueue_id if we need to fetch a new playqueue
repeat = 0, 1, 2
offset = time offset in Plextime (milliseconds)
"""
LOG.info('New playqueue %s received from Plex companion with offset '
'%s, repeat %s, start_plex_id %s',
playqueue_id, offset, repeat, start_plex_id)
# Safe transient token from being deleted
if transient_token is None:
transient_token = playqueue.plex_transient_token
with app.APP.lock_playqueues:
try:
xml = PL.get_PMS_playlist(playqueue, playqueue_id)
except exceptions.PlaylistError:
LOG.error('Could now download playqueue %s', playqueue_id)
return
if playqueue.id == playqueue_id:
# This seems to be happening ONLY if a Plex Companion device
# reconnects and Kodi is already playing something - silly, really
# For all other cases, a new playqueue is generated by Plex
LOG.debug('Update for existing playqueue detected')
return
playqueue.clear()
# Get new metadata for the playqueue first
try:
PL.get_playlist_details_from_xml(playqueue, xml)
except exceptions.PlaylistError:
LOG.error('Could not get playqueue ID %s', playqueue_id)
return
playqueue.repeat = 0 if not repeat else int(repeat)
playqueue.plex_transient_token = transient_token
playback.play_xml(playqueue,
xml,
offset=offset,
start_plex_id=start_plex_id)
class PlexCompanion(backgroundthread.KillableThread):
@thread_methods(add_suspends=['PMS_STATUS'])
class PlexCompanion(Thread):
"""
Plex Companion monitoring class. Invoke only once
"""
@ -87,82 +41,75 @@ class PlexCompanion(backgroundthread.KillableThread):
self.client = plexgdm.plexgdm()
self.client.clientDetails()
LOG.debug("Registration string is:\n%s", self.client.getClientDetails())
# kodi player instance
self.player = Player()
self.httpd = False
self.subscription_manager = None
super(PlexCompanion, self).__init__()
Thread.__init__(self)
@staticmethod
def _process_alexa(data):
if 'key' not in data or 'containerKey' not in data:
LOG.error('Received malformed Alexa data: %s', data)
return
xml = PF.GetPlexMetadata(data['key'])
@LOCKER.lockthis
def _process_alexa(self, data):
xml = GetPlexMetadata(data['key'])
try:
xml[0].attrib
except (AttributeError, IndexError, TypeError):
LOG.error('Could not download Plex metadata for: %s', data)
return
api = API(xml[0])
if api.plex_type == v.PLEX_TYPE_ALBUM:
if api.plex_type() == v.PLEX_TYPE_ALBUM:
LOG.debug('Plex music album detected')
PQ.init_playqueue_from_plex_children(
api.plex_id,
api.plex_id(),
transient_token=data.get('token'))
elif data['containerKey'].startswith('/playQueues/'):
_, container_key, _ = PF.ParseContainerKey(data['containerKey'])
xml = PF.DownloadChunks('{server}/playQueues/%s' % container_key)
_, container_key, _ = ParseContainerKey(data['containerKey'])
xml = DownloadChunks('{server}/playQueues/%s?' % container_key)
if xml is None:
# "Play error"
utils.dialog('notification',
utils.lang(29999),
utils.lang(30128),
icon='{error}')
dialog('notification', lang(29999), lang(30128), icon='{error}')
return
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()])
playqueue.clear()
PL.get_playlist_details_from_xml(playqueue, xml)
get_playlist_details_from_xml(playqueue, xml)
playqueue.plex_transient_token = data.get('token')
if data.get('offset') != '0':
offset = float(data['offset']) / 1000.0
else:
offset = None
playback.play_xml(playqueue, xml, offset)
play_xml(playqueue, xml, offset)
else:
app.CONN.plex_transient_token = data.get('token')
playback.playback_triage(api.plex_id,
api.plex_type,
resolve=False,
resume=data.get('offset') not in ('0', None))
state.PLEX_TRANSIENT_TOKEN = data.get('token')
if data.get('offset') != '0':
state.RESUMABLE = True
state.RESUME_PLAYBACK = True
playback_triage(api.plex_id(), api.plex_type(), resolve=False)
@staticmethod
def _process_node(data):
"""
E.g. watch later initiated by Companion. Basically navigating Plex
"""
app.CONN.plex_transient_token = data.get('key')
state.PLEX_TRANSIENT_TOKEN = data.get('key')
params = {
'mode': 'plex_node',
'key': '{server}%s' % data.get('key'),
'offset': data.get('offset')
}
handle = 'RunPlugin(plugin://%s)' % utils.extend_url(v.ADDON_ID, params)
executebuiltin(handle.encode('utf-8'))
executebuiltin('RunPlugin(plugin://%s?%s)'
% (v.ADDON_ID, urlencode(params)))
@staticmethod
def _process_playlist(data):
if 'containerKey' not in data:
LOG.error('Received malformed playlist data: %s', data)
return
@LOCKER.lockthis
def _process_playlist(self, data):
# Get the playqueue ID
_, container_key, query = PF.ParseContainerKey(data['containerKey'])
_, container_key, query = ParseContainerKey(data['containerKey'])
try:
playqueue = PQ.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 = PF.GetPlexMetadata(data['key'])
xml = GetPlexMetadata(data['key'])
try:
xml[0].attrib
except (AttributeError, IndexError, TypeError):
@ -170,56 +117,47 @@ class PlexCompanion(backgroundthread.KillableThread):
return
api = API(xml[0])
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
key = data.get('key')
if key:
_, key, _ = PF.ParseContainerKey(key)
update_playqueue_from_PMS(playqueue,
playqueue_id=container_key,
repeat=query.get('repeat'),
offset=utils.cast(int, data.get('offset')),
transient_token=data.get('token'),
start_plex_id=key)
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()])
PQ.update_playqueue_from_PMS(
playqueue,
playqueue_id=container_key,
repeat=query.get('repeat'),
offset=data.get('offset'),
transient_token=data.get('token'))
@staticmethod
def _process_streams(data):
@LOCKER.lockthis
def _process_streams(self, data):
"""
Plex Companion client adjusted audio or subtitle stream
"""
if 'type' not in data:
LOG.error('Received malformed stream data: %s', data)
return
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
pos = js.get_position(playqueue.playlistid)
if 'audioStreamID' in data:
index = playqueue.items[pos].kodi_stream_index(
data['audioStreamID'], 'audio')
app.APP.player.setAudioStream(index)
self.player.setAudioStream(index)
elif 'subtitleStreamID' in data:
if data['subtitleStreamID'] == '0':
app.APP.player.showSubtitles(False)
self.player.showSubtitles(False)
else:
index = playqueue.items[pos].kodi_stream_index(
data['subtitleStreamID'], 'subtitle')
app.APP.player.setSubtitleStream(index)
self.player.setSubtitleStream(index)
else:
LOG.error('Unknown setStreams command: %s', data)
@staticmethod
def _process_refresh(data):
@LOCKER.lockthis
def _process_refresh(self, data):
"""
example data: {'playQueueID': '8475', 'commandID': '11'}
"""
if 'playQueueID' not in data:
LOG.error('Received malformed refresh data: %s', data)
return
xml = PL.get_pms_playqueue(data['playQueueID'])
xml = get_pms_playqueue(data['playQueueID'])
if xml is None:
return
if len(xml) == 0:
LOG.debug('Empty playqueue received - clearing playqueue')
plex_type = PL.get_plextype_from_xml(xml)
plex_type = get_plextype_from_xml(xml)
if plex_type is None:
return
playqueue = PQ.get_playqueue_from_type(
@ -228,7 +166,7 @@ class PlexCompanion(backgroundthread.KillableThread):
return
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']])
update_playqueue_from_PMS(playqueue, data['playQueueID'])
PQ.update_playqueue_from_PMS(playqueue, data['playQueueID'])
def _process_tasks(self, task):
"""
@ -248,28 +186,21 @@ class PlexCompanion(backgroundthread.KillableThread):
LOG.debug('Processing: %s', task)
data = task['data']
if task['action'] == 'alexa':
with app.APP.lock_playqueues:
self._process_alexa(data)
self._process_alexa(data)
elif (task['action'] == 'playlist' and
data.get('address') == 'node.plexapp.com'):
self._process_node(data)
elif task['action'] == 'playlist':
with app.APP.lock_playqueues:
self._process_playlist(data)
self._process_playlist(data)
elif task['action'] == 'refreshPlayQueue':
with app.APP.lock_playqueues:
self._process_refresh(data)
self._process_refresh(data)
elif task['action'] == 'setStreams':
try:
self._process_streams(data)
except KeyError:
pass
self._process_streams(data)
def run(self):
"""
Ensure that sockets will be closed no matter what
"""
app.APP.register_thread(self)
try:
self._run()
finally:
@ -282,21 +213,22 @@ class PlexCompanion(backgroundthread.KillableThread):
self.httpd.socket.close()
except AttributeError:
pass
app.APP.deregister_thread(self)
LOG.info("----===## Plex Companion stopped ##===----")
LOG.info("----===## Plex Companion stopped ##===----")
def _run(self):
httpd = self.httpd
# Cache for quicker while loops
client = self.client
stopped = self.stopped
suspended = self.suspended
# Start up instances
request_mgr = httppersist.RequestMgr()
subscription_manager = subscribers.SubscriptionMgr(request_mgr,
app.APP.player)
self.player)
self.subscription_manager = subscription_manager
if utils.settings('plexCompanion') == 'true':
if settings('plexCompanion') == 'true':
# Start up httpd
start_count = 0
while True:
@ -306,13 +238,13 @@ class PlexCompanion(backgroundthread.KillableThread):
subscription_manager,
('', v.COMPANION_PORT),
listener.MyHandler)
httpd.timeout = 10.0
httpd.timeout = 0.95
break
except Exception:
except:
LOG.error("Unable to start PlexCompanion. Traceback:")
import traceback
LOG.error(traceback.print_exc())
app.APP.monitor.waitForAbort(3)
sleep(3000)
if start_count == 3:
LOG.error("Error: Unable to start web helper.")
httpd = False
@ -325,13 +257,14 @@ class PlexCompanion(backgroundthread.KillableThread):
if httpd:
thread = Thread(target=httpd.handle_request)
while not self.should_cancel():
while not stopped():
# If we are not authorized, sleep
# Otherwise, we trigger a download which leads to a
# re-authorizations
if self.should_suspend():
if self.wait_while_suspended():
while suspended():
if stopped():
break
sleep(1000)
try:
message_count += 1
if httpd:
@ -355,21 +288,21 @@ class PlexCompanion(backgroundthread.KillableThread):
subscription_manager.notify()
if not httpd:
message_count = 0
except Exception:
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 = app.APP.companion_queue.get(block=False)
task = state.COMPANION_QUEUE.get(block=False)
except Empty:
pass
else:
# Got instructions, process them
self._process_tasks(task)
app.APP.companion_queue.task_done()
state.COMPANION_QUEUE.task_done()
# Don't sleep
continue
self.sleep(0.05)
sleep(50)
subscription_manager.signal_stop()
client.stop_all()

View file

@ -0,0 +1,828 @@
# -*- coding: utf-8 -*-
from logging import getLogger
from urllib import urlencode, quote_plus
from ast import literal_eval
from urlparse import urlparse, parse_qsl
from re import compile as re_compile
from copy import deepcopy
from time import time
from threading import Thread
from xbmc import sleep
from downloadutils import DownloadUtils as DU
from utils import settings, try_encode, try_decode
from variables import PLEX_TO_KODI_TIMEFACTOR
import plex_tv
###############################################################################
LOG = getLogger("PLEX." + __name__)
CONTAINERSIZE = int(settings('limitindex'))
REGEX_PLEX_KEY = re_compile(r'''/(.+)/(\d+)$''')
REGEX_PLEX_DIRECT = re_compile(r'''\.plex\.direct:\d+$''')
# For discovery of PMS in the local LAN
PLEX_GDM_IP = '239.0.0.250' # multicast to PMS
PLEX_GDM_PORT = 32414
PLEX_GDM_MSG = 'M-SEARCH * HTTP/1.0'
###############################################################################
def ConvertPlexToKodiTime(plexTime):
"""
Converts Plextime to Koditime. Returns an int (in seconds).
"""
if plexTime is None:
return None
return int(float(plexTime) * PLEX_TO_KODI_TIMEFACTOR)
def GetPlexKeyNumber(plexKey):
"""
Deconstructs e.g. '/library/metadata/xxxx' to the tuple
('library/metadata', 'xxxx')
Returns ('','') if nothing is found
"""
try:
result = REGEX_PLEX_KEY.findall(plexKey)[0]
except IndexError:
result = ('', '')
return result
def ParseContainerKey(containerKey):
"""
Parses e.g. /playQueues/3045?own=1&repeat=0&window=200 to:
'playQueues', '3045', {'window': '200', 'own': '1', 'repeat': '0'}
Output hence: library, key, query (str, str, dict)
"""
result = urlparse(containerKey)
library, key = GetPlexKeyNumber(result.path)
query = dict(parse_qsl(result.query))
return library, key, query
def LiteralEval(string):
"""
Turns a string e.g. in a dict, safely :-)
"""
return literal_eval(string)
def GetMethodFromPlexType(plexType):
methods = {
'movie': 'add_update',
'episode': 'add_updateEpisode',
'show': 'add_update',
'season': 'add_updateSeason',
'track': 'add_updateSong',
'album': 'add_updateAlbum',
'artist': 'add_updateArtist'
}
return methods[plexType]
def GetPlexLoginFromSettings():
"""
Returns a dict:
'plexLogin': settings('plexLogin'),
'plexToken': settings('plexToken'),
'plexhome': settings('plexhome'),
'plexid': settings('plexid'),
'myplexlogin': settings('myplexlogin'),
'plexAvatar': settings('plexAvatar'),
'plexHomeSize': settings('plexHomeSize')
Returns strings or unicode
Returns empty strings '' for a setting if not found.
myplexlogin is 'true' if user opted to log into plex.tv (the default)
plexhome is 'true' if plex home is used (the default)
"""
return {
'plexLogin': settings('plexLogin'),
'plexToken': settings('plexToken'),
'plexhome': settings('plexhome'),
'plexid': settings('plexid'),
'myplexlogin': settings('myplexlogin'),
'plexAvatar': settings('plexAvatar'),
'plexHomeSize': settings('plexHomeSize')
}
def check_connection(url, token=None, verifySSL=None):
"""
Checks connection to a Plex server, available at url. Can also be used
to check for connection with plex.tv.
Override SSL to skip the check by setting verifySSL=False
if 'None', SSL will be checked (standard requests setting)
if 'True', SSL settings from file settings are used (False/True)
Input:
url URL to Plex server (e.g. https://192.168.1.1:32400)
token appropriate token to access server. If None is passed,
the current token is used
Output:
False if server could not be reached or timeout occured
200 if connection was successfull
int or other HTML status codes as received from the server
"""
# Add '/clients' to URL because then an authentication is necessary
# If a plex.tv URL was passed, this does not work.
header_options = None
if token is not None:
header_options = {'X-Plex-Token': token}
if verifySSL is True:
verifySSL = None if settings('sslverify') == 'true' else False
if 'plex.tv' in url:
url = 'https://plex.tv/api/home/users'
else:
url = url + '/library/onDeck'
LOG.debug("Checking connection to server %s with verifySSL=%s",
url, verifySSL)
answer = DU().downloadUrl(url,
authenticate=False,
headerOptions=header_options,
verifySSL=verifySSL,
timeout=10)
if answer is None:
LOG.debug("Could not connect to %s", url)
return False
try:
# xml received?
answer.attrib
except AttributeError:
if answer is True:
# Maybe no xml but connection was successful nevertheless
answer = 200
else:
# Success - we downloaded an xml!
answer = 200
# We could connect but maybe were not authenticated. No worries
LOG.debug("Checking connection successfull. Answer: %s", answer)
return answer
def discover_pms(token=None):
"""
Optional parameter:
token token for plex.tv
Returns a list of available PMS to connect to, one entry is the dict:
{
'machineIdentifier' [str] unique identifier of the PMS
'name' [str] name of the PMS
'token' [str] token needed to access that PMS
'ownername' [str] name of the owner of this PMS or None if
the owner itself supplied tries to connect
'product' e.g. 'Plex Media Server' or None
'version' e.g. '1.11.2.4772-3e...' or None
'device': e.g. 'PC' or 'Windows' or None
'platform': e.g. 'Windows', 'Android' or None
'local' [bool] True if plex.tv supplied
'publicAddressMatches'='1'
or if found using Plex GDM in the local LAN
'owned' [bool] True if it's the owner's PMS
'relay' [bool] True if plex.tv supplied 'relay'='1'
'presence' [bool] True if plex.tv supplied 'presence'='1'
'httpsRequired' [bool] True if plex.tv supplied
'httpsRequired'='1'
'scheme' [str] either 'http' or 'https'
'ip': [str] IP of the PMS, e.g. '192.168.1.1'
'port': [str] Port of the PMS, e.g. '32400'
'baseURL': [str] <scheme>://<ip>:<port> of the PMS
}
"""
LOG.info('Start discovery of Plex Media Servers')
# Look first for local PMS in the LAN
local_pms_list = _plex_gdm()
LOG.debug('PMS found in the local LAN using Plex GDM: %s', local_pms_list)
# Get PMS from plex.tv
if token:
LOG.info('Checking with plex.tv for more PMS to connect to')
plex_pms_list = _pms_list_from_plex_tv(token)
LOG.debug('PMS found on plex.tv: %s', plex_pms_list)
else:
LOG.info('No plex token supplied, only checked LAN for available PMS')
plex_pms_list = []
# Add PMS found only in the LAN to the Plex.tv PMS list
for pms in local_pms_list:
for plex_pms in plex_pms_list:
if pms['machineIdentifier'] == plex_pms['machineIdentifier']:
break
else:
# Only found PMS using GDM - add it to the PMS from plex.tv
https = _pms_https_enabled('%s:%s' % (pms['ip'], pms['port']))
if https is None:
# Error contacting url. Skip and ignore this PMS for now
LOG.error('Could not contact PMS %s but we should have', pms)
continue
elif https is True:
pms['scheme'] = 'https'
else:
pms['scheme'] = 'http'
pms['baseURL'] = '%s://%s:%s' % (pms['scheme'],
pms['ip'],
pms['port'])
plex_pms_list.append(pms)
LOG.debug('Found the following PMS in total: %s', plex_pms_list)
return plex_pms_list
def _plex_gdm():
"""
PlexGDM - looks for PMS in the local LAN and returns a list of the PMS found
"""
# Import here because we might not need to do gdm because we already
# connected to a PMS successfully in the past
import struct
import socket
# setup socket for discovery -> multicast message
gdm = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
gdm.settimeout(2.0)
# Set the time-to-live for messages to 2 for local network
ttl = struct.pack('b', 2)
gdm.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
return_data = []
try:
# Send data to the multicast group
gdm.sendto(PLEX_GDM_MSG, (PLEX_GDM_IP, PLEX_GDM_PORT))
# Look for responses from all recipients
while True:
try:
data, server = gdm.recvfrom(1024)
return_data.append({'from': server, 'data': data})
except socket.timeout:
break
except Exception as e:
# Probably error: (101, 'Network is unreachable')
LOG.error(e)
import traceback
LOG.error("Traceback:\n%s", traceback.format_exc())
finally:
gdm.close()
LOG.debug('Plex GDM returned the data: %s', return_data)
pms_list = []
for response in return_data:
# Check if we had a positive HTTP response
if '200 OK' not in response['data']:
continue
pms = {
'ip': response['from'][0],
'scheme': None,
'local': True, # Since we found it using GDM
'product': None,
'baseURL': None,
'name': None,
'version': None,
'token': None,
'ownername': None,
'device': None,
'platform': None,
'owned': None,
'relay': None,
'presence': True, # Since we're talking to the PMS
'httpsRequired': None,
}
for line in response['data'].split('\n'):
if 'Content-Type:' in line:
pms['product'] = try_decode(line.split(':')[1].strip())
elif 'Host:' in line:
pms['baseURL'] = line.split(':')[1].strip()
elif 'Name:' in line:
pms['name'] = try_decode(line.split(':')[1].strip())
elif 'Port:' in line:
pms['port'] = line.split(':')[1].strip()
elif 'Resource-Identifier:' in line:
pms['machineIdentifier'] = line.split(':')[1].strip()
elif 'Version:' in line:
pms['version'] = line.split(':')[1].strip()
pms_list.append(pms)
return pms_list
def _pms_list_from_plex_tv(token):
"""
get Plex media Server List from plex.tv/pms/resources
"""
xml = DU().downloadUrl('https://plex.tv/api/resources',
authenticate=False,
parameters={'includeHttps': 1},
headerOptions={'X-Plex-Token': token})
try:
xml.attrib
except AttributeError:
LOG.error('Could not get list of PMS from plex.tv')
return
from Queue import Queue
queue = Queue()
thread_queue = []
max_age_in_seconds = 2*60*60*24
for device in xml.findall('Device'):
if 'server' not in device.get('provides'):
# No PMS - skip
continue
if device.find('Connection') is None:
# no valid connection - skip
continue
# check MyPlex data age - skip if >2 days
info_age = time() - int(device.get('lastSeenAt'))
if info_age > max_age_in_seconds:
LOG.debug("Skip server %s not seen for 2 days", device.get('name'))
continue
pms = {
'machineIdentifier': device.get('clientIdentifier'),
'name': device.get('name'),
'token': device.get('accessToken'),
'ownername': device.get('sourceTitle'),
'product': device.get('product'), # e.g. 'Plex Media Server'
'version': device.get('productVersion'), # e.g. '1.11.2.4772-3e...'
'device': device.get('device'), # e.g. 'PC' or 'Windows'
'platform': device.get('platform'), # e.g. 'Windows', 'Android'
'local': device.get('publicAddressMatches') == '1',
'owned': device.get('owned') == '1',
'relay': device.get('relay') == '1',
'presence': device.get('presence') == '1',
'httpsRequired': device.get('httpsRequired') == '1',
'connections': []
}
# Try a local connection first, no matter what plex.tv tells us
for connection in device.findall('Connection'):
if connection.get('local') == '1':
pms['connections'].append(connection)
# Then try non-local
for connection in device.findall('Connection'):
if connection.get('local') != '1':
pms['connections'].append(connection)
# Spawn threads to ping each PMS simultaneously
thread = Thread(target=_poke_pms, args=(pms, queue))
thread_queue.append(thread)
max_threads = 5
threads = []
# poke PMS, own thread for each PMS
while True:
# Remove finished threads
for thread in threads:
if not thread.isAlive():
threads.remove(thread)
if len(threads) < max_threads:
try:
thread = thread_queue.pop()
except IndexError:
# We have done our work
break
else:
thread.start()
threads.append(thread)
else:
sleep(50)
# wait for requests being answered
for thread in threads:
thread.join()
# declare new PMSs
pms_list = []
while not queue.empty():
pms = queue.get()
del pms['connections']
pms_list.append(pms)
queue.task_done()
return pms_list
def _poke_pms(pms, queue):
data = pms['connections'][0].attrib
url = data['uri']
if data['local'] == '1' and REGEX_PLEX_DIRECT.findall(url):
# In case DNS resolve of plex.direct does not work, append a new
# connection that will directly access the local IP (e.g. internet down)
conn = deepcopy(pms['connections'][0])
# Overwrite plex.direct
conn.attrib['uri'] = '%s://%s:%s' % (data['protocol'],
data['address'],
data['port'])
pms['connections'].insert(1, conn)
protocol, address, port = url.split(':', 2)
address = address.replace('/', '')
xml = DU().downloadUrl('%s/identity' % url,
authenticate=False,
headerOptions={'X-Plex-Token': pms['token']},
verifySSL=False,
timeout=10)
try:
xml.attrib['machineIdentifier']
except (AttributeError, KeyError):
# No connection, delete the one we just tested
del pms['connections'][0]
if pms['connections']:
# Still got connections left, try them
return _poke_pms(pms, queue)
return
else:
# Connection successful - correct pms?
if xml.get('machineIdentifier') == pms['machineIdentifier']:
# process later
pms['baseURL'] = url
pms['scheme'] = protocol
pms['ip'] = address
pms['port'] = port
queue.put(pms)
return
LOG.info('Found a pms at %s, but the expected machineIdentifier of '
'%s did not match the one we found: %s',
url, pms['uuid'], xml.get('machineIdentifier'))
def GetPlexMetadata(key):
"""
Returns raw API metadata for key as an etree XML.
Can be called with either Plex key '/library/metadata/xxxx'metadata
OR with the digits 'xxxx' only.
Returns None or 401 if something went wrong
"""
key = str(key)
if '/library/metadata/' in key:
url = "{server}" + key
else:
url = "{server}/library/metadata/" + key
arguments = {
'checkFiles': 0,
'includeExtras': 1, # Trailers and Extras => Extras
'includeReviews': 1,
'includeRelated': 0, # Similar movies => Video -> Related
# 'includeRelatedCount': 0,
# 'includeOnDeck': 1,
# 'includeChapters': 1,
# 'includePopularLeaves': 1,
# 'includeConcerts': 1
}
url = url + '?' + urlencode(arguments)
xml = DU().downloadUrl(url)
if xml == 401:
# Either unauthorized (taken care of by doUtils) or PMS under strain
return 401
# Did we receive a valid XML?
try:
xml.attrib
# Nope we did not receive a valid XML
except AttributeError:
LOG.error("Error retrieving metadata for %s", url)
xml = None
return xml
def GetAllPlexChildren(key):
"""
Returns a list (raw xml API dump) of all Plex children for the key.
(e.g. /library/metadata/194853/children pointing to a season)
Input:
key Key to a Plex item, e.g. 12345
"""
return DownloadChunks("{server}/library/metadata/%s/children?" % key)
def GetPlexSectionResults(viewId, args=None):
"""
Returns a list (XML API dump) of all Plex items in the Plex
section with key = viewId.
Input:
args: optional dict to be urlencoded
Returns None if something went wrong
"""
url = "{server}/library/sections/%s/all?" % viewId
if args:
url += urlencode(args) + '&'
return DownloadChunks(url)
def DownloadChunks(url):
"""
Downloads PMS url in chunks of CONTAINERSIZE.
url MUST end with '?' (if no other url encoded args are present) or '&'
Returns a stitched-together xml or None.
"""
xml = None
pos = 0
error_counter = 0
while error_counter < 10:
args = {
'X-Plex-Container-Size': CONTAINERSIZE,
'X-Plex-Container-Start': pos
}
xmlpart = DU().downloadUrl(url + urlencode(args))
# If something went wrong - skip in the hope that it works next time
try:
xmlpart.attrib
except AttributeError:
LOG.error('Error while downloading chunks: %s',
url + urlencode(args))
pos += CONTAINERSIZE
error_counter += 1
continue
# Very first run: starting xml (to retain data in xml's root!)
if xml is None:
xml = deepcopy(xmlpart)
if len(xmlpart) < CONTAINERSIZE:
break
else:
pos += CONTAINERSIZE
continue
# Build answer xml - containing the entire library
for child in xmlpart:
xml.append(child)
# Done as soon as we don't receive a full complement of items
if len(xmlpart) < CONTAINERSIZE:
break
pos += CONTAINERSIZE
if error_counter == 10:
LOG.error('Fatal error while downloading chunks for %s', url)
return None
return xml
def GetAllPlexLeaves(viewId, lastViewedAt=None, updatedAt=None):
"""
Returns a list (raw XML API dump) of all Plex subitems for the key.
(e.g. /library/sections/2/allLeaves pointing to all TV shows)
Input:
viewId Id of Plex library, e.g. '2'
lastViewedAt Unix timestamp; only retrieves PMS items viewed
since that point of time until now.
updatedAt Unix timestamp; only retrieves PMS items updated
by the PMS since that point of time until now.
If lastViewedAt and updatedAt=None, ALL PMS items are returned.
Warning: lastViewedAt and updatedAt are combined with AND by the PMS!
Relevant "master time": PMS server. I guess this COULD lead to problems,
e.g. when server and client are in different time zones.
"""
args = []
url = "{server}/library/sections/%s/allLeaves" % viewId
if lastViewedAt:
args.append('lastViewedAt>=%s' % lastViewedAt)
if updatedAt:
args.append('updatedAt>=%s' % updatedAt)
if args:
url += '?' + '&'.join(args) + '&'
else:
url += '?'
return DownloadChunks(url)
def GetPlexOnDeck(viewId):
"""
"""
return DownloadChunks("{server}/library/sections/%s/onDeck?" % viewId)
def get_plex_sections():
"""
Returns all Plex sections (libraries) of the PMS as an etree xml
"""
return DU().downloadUrl('{server}/library/sections')
def init_plex_playqueue(itemid, librarySectionUUID, mediatype='movie',
trailers=False):
"""
Returns raw API metadata XML dump for a playlist with e.g. trailers.
"""
url = "{server}/playQueues"
args = {
'type': mediatype,
'uri': ('library://' + librarySectionUUID +
'/item/%2Flibrary%2Fmetadata%2F' + itemid),
'includeChapters': '1',
'shuffle': '0',
'repeat': '0'
}
if trailers is True:
args['extrasPrefixCount'] = settings('trailerNumber')
xml = DU().downloadUrl(url + '?' + urlencode(args), action_type="POST")
try:
xml[0].tag
except (IndexError, TypeError, AttributeError):
LOG.error("Error retrieving metadata for %s", url)
return
return xml
def _pms_https_enabled(url):
"""
Returns True if the PMS can talk https, False otherwise.
None if error occured, e.g. the connection timed out
Call with e.g. url='192.168.0.1:32400' (NO http/https)
This is done by GET /identity (returns an error if https is enabled and we
are trying to use http)
Prefers HTTPS over HTTP
"""
res = DU().downloadUrl('https://%s/identity' % url,
authenticate=False,
verifySSL=False)
try:
res.attrib
except AttributeError:
# Might have SSL deactivated. Try with http
res = DU().downloadUrl('http://%s/identity' % url,
authenticate=False,
verifySSL=False)
try:
res.attrib
except AttributeError:
LOG.error("Could not contact PMS %s", url)
return None
else:
# Received a valid XML. Server wants to talk HTTP
return False
else:
# Received a valid XML. Server wants to talk HTTPS
return True
def GetMachineIdentifier(url):
"""
Returns the unique PMS machine identifier of url
Returns None if something went wrong
"""
xml = DU().downloadUrl('%s/identity' % url,
authenticate=False,
verifySSL=False,
timeout=10)
try:
machineIdentifier = xml.attrib['machineIdentifier']
except (AttributeError, KeyError):
LOG.error('Could not get the PMS machineIdentifier for %s', url)
return None
LOG.debug('Found machineIdentifier %s for the PMS %s',
machineIdentifier, url)
return machineIdentifier
def GetPMSStatus(token):
"""
token: Needs to be authorized with a master Plex token
(not a managed user token)!
Calls /status/sessions on currently active PMS. Returns a dict with:
'sessionKey':
{
'userId': Plex ID of the user (if applicable, otherwise '')
'username': Plex name (if applicable, otherwise '')
'ratingKey': Unique Plex id of item being played
}
or an empty dict.
"""
answer = {}
xml = DU().downloadUrl('{server}/status/sessions',
headerOptions={'X-Plex-Token': token})
try:
xml.attrib
except AttributeError:
return answer
for item in xml:
ratingKey = item.attrib.get('ratingKey')
sessionKey = item.attrib.get('sessionKey')
userId = item.find('User')
username = ''
if userId is not None:
username = userId.attrib.get('title', '')
userId = userId.attrib.get('id', '')
else:
userId = ''
answer[sessionKey] = {
'userId': userId,
'username': username,
'ratingKey': ratingKey
}
return answer
def scrobble(ratingKey, state):
"""
Tells the PMS to set an item's watched state to state="watched" or
state="unwatched"
"""
args = {
'key': ratingKey,
'identifier': 'com.plexapp.plugins.library'
}
if state == "watched":
url = "{server}/:/scrobble?" + urlencode(args)
elif state == "unwatched":
url = "{server}/:/unscrobble?" + urlencode(args)
else:
return
DU().downloadUrl(url)
LOG.info("Toggled watched state for Plex item %s", ratingKey)
def delete_item_from_pms(plexid):
"""
Deletes the item plexid from the Plex Media Server (and the harddrive!).
Do make sure that the currently logged in user has the credentials
Returns True if successful, False otherwise
"""
if DU().downloadUrl('{server}/library/metadata/%s' % plexid,
action_type="DELETE") is True:
LOG.info('Successfully deleted Plex id %s from the PMS', plexid)
return True
LOG.error('Could not delete Plex id %s from the PMS', plexid)
return False
def get_PMS_settings(url, token):
"""
Retrieve the PMS' settings via <url>/:/prefs
Call with url: scheme://ip:port
"""
return DU().downloadUrl(
'%s/:/prefs' % url,
authenticate=False,
verifySSL=False,
headerOptions={'X-Plex-Token': token} if token else None)
def GetUserArtworkURL(username):
"""
Returns the URL for the user's Avatar. Or False if something went
wrong.
"""
users = plex_tv.list_home_users(settings('plexToken'))
url = ''
# If an error is encountered, set to False
if not users:
LOG.info("Couldnt get user from plex.tv. No URL for user avatar")
return False
for user in users:
if username in user['title']:
url = user['thumb']
LOG.debug("Avatar url for user %s is: %s", username, url)
return url
def transcode_image_path(key, AuthToken, path, width, height):
"""
Transcode Image support
parameters:
key
AuthToken
path - source path of current XML: path[srcXML]
width
height
result:
final path to image file
"""
# external address - can we get a transcoding request for external images?
if key.startswith('http://') or key.startswith('https://'):
path = key
elif key.startswith('/'): # internal full path.
path = 'http://127.0.0.1:32400' + key
else: # internal path, add-on
path = 'http://127.0.0.1:32400' + path + '/' + key
path = try_encode(path)
# This is bogus (note the extra path component) but ATV is stupid when it
# comes to caching images, it doesn't use querystrings. Fortunately PMS is
# lenient...
transcode_path = ('/photo/:/transcode/%sx%s/%s'
% (width, height, quote_plus(path)))
args = {
'width': width,
'height': height,
'url': path
}
if AuthToken:
args['X-Plex-Token'] = AuthToken
return transcode_path + '?' + urlencode(args)

View file

@ -1,40 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Used to save PKC's application state and share between modules. Be careful
if you invoke another PKC Python instance (!!) when e.g. PKC.movies is called
"""
from __future__ import absolute_import, division, unicode_literals
from .account import Account
from .application import App
from .connection import Connection
from .libsync import Sync
from .playstate import PlayState
ACCOUNT = None
APP = None
CONN = None
SYNC = None
PLAYSTATE = None
def init(entrypoint=False):
"""
entrypoint=True initiates only the bare minimum - for other PKC python
instances
"""
global ACCOUNT, APP, CONN, SYNC, PLAYSTATE
APP = App(entrypoint)
CONN = Connection(entrypoint)
ACCOUNT = Account(entrypoint)
SYNC = Sync(entrypoint)
if not entrypoint:
PLAYSTATE = PlayState()
def reload():
"""
Reload PKC settings from xml file, e.g. on user-switch
"""
global APP, SYNC
APP.reload()
SYNC.reload()

View file

@ -1,147 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from .. import utils
LOG = getLogger('PLEX.account')
class Account(object):
def __init__(self, entrypoint=False):
self.plex_login = None
self.plex_login_id = None
self.plex_username = None
self.plex_user_id = None
self.plex_token = None
# Personal access token per specific user and PMS
# As a rule of thumb, always use this token!
self.pms_token = None
self.avatar = None
self.myplexlogin = None
self.restricted_user = None
self.force_login = None
self._session = None
self.authenticated = False
if entrypoint:
self.load_entrypoint()
else:
utils.window('plex_authenticated', clear=True)
self.load()
def set_authenticated(self):
self.authenticated = True
utils.window('plex_authenticated', value='true')
def set_unauthenticated(self):
self.authenticated = False
utils.window('plex_authenticated', clear=True)
def reset_session(self):
try:
self._session.stopSession()
except AttributeError:
pass
from .. import downloadutils
self._session = downloadutils.DownloadUtils()
self._session.startSession(reset=True)
def load(self):
LOG.debug('Loading account settings')
# User name we used to sign in to plex.tv
self.plex_login = utils.settings('plexLogin') or None
self.plex_login_id = utils.settings('plexid') or None
# plex.tv username
self.plex_username = utils.settings('username') or None
# Plex ID of that user (e.g. for plex.tv) as a STRING
self.plex_user_id = utils.settings('userid') or None
# Token for that user for plex.tv
self.plex_token = utils.settings('plexToken') or None
# Plex token for the active PMS for the active user
# (might be diffent to plex_token)
self.pms_token = utils.settings('accessToken') or None
self.avatar = utils.settings('plexAvatar') or None
self.myplexlogin = utils.settings('myplexlogin') == 'true'
# Plex home user? Then "False"
self.restricted_user = utils.settings('plex_restricteduser') == 'true'
# Force user to enter Pin if set?
self.force_login = utils.settings('enforceUserLogin') == 'true'
# Also load these settings to Kodi window variables - they'll be
# available for other PKC Python instances
utils.window('plex_restricteduser',
value='true' if self.restricted_user else 'false')
utils.window('plex_token', value=self.plex_token or '')
utils.window('pms_token', value=self.pms_token or '')
utils.window('plexAvatar', value=self.avatar or '')
# Start download session
self.reset_session()
LOG.debug('Loaded user %s, %s with plex token %s... and pms token %s...',
self.plex_username, self.plex_user_id,
self.plex_token[:5] if self.plex_token else None,
self.pms_token[:5] if self.pms_token else None)
LOG.debug('User is restricted Home user: %s', self.restricted_user)
def load_entrypoint(self):
self.pms_token = utils.settings('accessToken') or None
def log_out(self):
LOG.debug('Logging-out user %s', self.plex_username)
self.plex_username = None
self.plex_user_id = None
self.pms_token = None
self.avatar = None
self.restricted_user = None
self.authenticated = False
try:
self._session.stopSession()
except AttributeError:
pass
self._session = None
utils.settings('username', value='')
utils.settings('userid', value='')
utils.settings('plex_restricteduser', value='')
utils.settings('accessToken', value='')
utils.settings('plexAvatar', value='')
utils.window('plex_restricteduser', clear=True)
utils.window('pms_token', clear=True)
utils.window('plexAvatar', clear=True)
utils.window('plex_authenticated', clear=True)
def clear(self):
LOG.debug('Clearing account settings')
self.plex_username = None
self.plex_user_id = None
self.plex_token = None
self.pms_token = None
self.avatar = None
self.restricted_user = None
self.authenticated = False
self.plex_login = None
self.plex_login_id = None
try:
self._session.stopSession()
except AttributeError:
pass
self._session = None
utils.settings('username', value='')
utils.settings('userid', value='')
utils.settings('plex_restricteduser', value='')
utils.settings('plexToken', value='')
utils.settings('accessToken', value='')
utils.settings('plexAvatar', value='')
utils.settings('plexLogin', value='')
utils.settings('plexid', value='')
utils.window('plex_restricteduser', clear=True)
utils.window('plex_token', clear=True)
utils.window('pms_token', clear=True)
utils.window('plexAvatar', clear=True)
utils.window('plex_authenticated', clear=True)

View file

@ -1,173 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import Queue
from threading import Lock, RLock
import xbmc
from .. import utils
LOG = getLogger('PLEX.app')
class App(object):
"""
This class is used to store variables across PKC modules
"""
def __init__(self, entrypoint=False):
self.fetch_pms_item_number = None
self.force_reload_skin = None
# All thread instances
self.threads = []
if entrypoint:
self.load_entrypoint()
else:
self.reload()
# Quit PKC?
self.stop_pkc = False
# This will suspend the main thread also
self.suspend = False
# Update Kodi widgets
self.update_widgets = False
# Need to lock all methods and functions messing with Plex Companion subscribers
self.lock_subscriber = RLock()
# Need to lock everything messing with Kodi/PKC playqueues
self.lock_playqueues = RLock()
# Necessary to temporarily hold back librarysync/websocket listener when doing
# a full sync
self.lock_playlists = Lock()
# Plex Companion Queue()
self.companion_queue = Queue.Queue(maxsize=100)
# Websocket_client queue to communicate with librarysync
self.websocket_queue = Queue.Queue()
# xbmc.Monitor() instance from kodimonitor.py
self.monitor = None
# xbmc.Player() instance
self.player = None
# Instance of FanartThread()
self.fanart_thread = None
# Instance of ImageCachingThread()
self.caching_thread = None
# Dialog to skip intro
self.skip_intro_dialog = None
@property
def is_playing(self):
return self.player.isPlaying() == 1
@property
def is_playing_video(self):
return self.player.isPlayingVideo() == 1
def register_fanart_thread(self, thread):
self.fanart_thread = thread
self.threads.append(thread)
def deregister_fanart_thread(self, thread):
self.fanart_thread.unblock_callers()
self.fanart_thread = None
self.threads.remove(thread)
def suspend_fanart_thread(self, block=True):
try:
self.fanart_thread.suspend(block=block)
except AttributeError:
pass
def resume_fanart_thread(self):
try:
self.fanart_thread.resume()
except AttributeError:
pass
def register_caching_thread(self, thread):
self.caching_thread = thread
self.threads.append(thread)
def deregister_caching_thread(self, thread):
self.caching_thread.unblock_callers()
self.caching_thread = None
self.threads.remove(thread)
def suspend_caching_thread(self, block=True):
try:
self.caching_thread.suspend(block=block)
except AttributeError:
pass
def resume_caching_thread(self):
try:
self.caching_thread.resume()
except AttributeError:
pass
def register_thread(self, thread):
"""
Hit with thread [backgroundthread.Killablethread instance] to register
any and all threads
"""
self.threads.append(thread)
def deregister_thread(self, thread):
"""
Sync thread has done it's work and is e.g. about to die
"""
thread.unblock_callers()
self.threads.remove(thread)
def suspend_threads(self, block=True):
"""
Suspend all threads' activity with or without blocking.
Returns True only if PKC shutdown requested
"""
LOG.debug('Suspending threads: %s', self.threads)
for thread in self.threads:
thread.suspend()
if block:
while True:
for thread in self.threads:
if not thread.is_suspended():
LOG.debug('Waiting for thread to suspend: %s', thread)
# Send suspend signal again in case self.threads
# changed
thread.suspend(block=True)
else:
break
return self.monitor.abortRequested()
def resume_threads(self):
"""
Resume all thread activity with or without blocking.
Returns True only if PKC shutdown requested
"""
LOG.debug('Resuming threads: %s', self.threads)
for thread in self.threads:
thread.resume()
return self.monitor.abortRequested()
def stop_threads(self, block=True):
"""
Stop all threads. Will block until all threads are stopped
Will NOT quit if PKC should exit!
"""
LOG.debug('Killing threads: %s', self.threads)
for thread in self.threads:
thread.cancel()
if block:
while self.threads:
LOG.debug('Waiting for threads to exit: %s', self.threads)
if xbmc.sleep(100):
return True
def reload(self):
# Number of items to fetch and display in widgets
self.fetch_pms_item_number = int(utils.settings('fetch_pms_item_number'))
# Hack to force Kodi widget for "in progress" to show up if it was empty
# before
self.force_reload_skin = utils.settings('forceReloadSkinOnPlaybackStop') == 'true'
def load_entrypoint(self):
self.fetch_pms_item_number = int(utils.settings('fetch_pms_item_number'))

View file

@ -1,98 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from .. import utils, json_rpc as js, variables as v
LOG = getLogger('PLEX.connection')
class Connection(object):
def __init__(self, entrypoint=False):
self.verify_ssl_cert = None
self.ssl_cert_path = None
self.machine_identifier = None
self.server_name = None
self.https = None
self.host = None
self.port = None
self.server = None
self.online = False
self.webserver_host = None
self.webserver_port = None
self.webserver_username = None
self.webserver_password = None
if entrypoint:
self.load_entrypoint()
else:
self.load_webserver()
self.load()
# Token passed along, e.g. if playback initiated by Plex Companion. Might be
# another user playing something! Token identifies user
self.plex_transient_token = None
def load_webserver(self):
"""
PKC needs Kodi webserver to work correctly
"""
LOG.debug('Loading Kodi webserver details')
# Kodi webserver details
if js.get_setting('services.webserver') in (None, False):
# Enable the webserver, it is disabled
js.set_setting('services.webserver', True)
self.webserver_host = 'localhost'
self.webserver_port = js.get_setting('services.webserverport')
self.webserver_username = js.get_setting('services.webserverusername')
self.webserver_password = js.get_setting('services.webserverpassword')
def load(self):
LOG.debug('Loading connection settings')
# Shall we verify SSL certificates? "None" will leave SSL enabled
# Ignore this setting for Kodi >= 18 as Kodi 18 is much stricter
# with checking SSL certs
self.verify_ssl_cert = None if v.KODIVERSION >= 18 or utils.settings('sslverify') == 'true' \
else False
# Do we have an ssl certificate for PKC we need to use?
self.ssl_cert_path = utils.settings('sslcert') \
if utils.settings('sslcert') != 'None' else None
self.machine_identifier = utils.settings('plex_machineIdentifier') or None
self.server_name = utils.settings('plex_servername') or None
self.https = utils.settings('https') == 'true'
self.host = utils.settings('ipaddress') or None
self.port = int(utils.settings('port')) if utils.settings('port') else None
if not self.host:
self.server = None
elif self.https:
self.server = 'https://%s:%s' % (self.host, self.port)
else:
self.server = 'http://%s:%s' % (self.host, self.port)
self.online = False
LOG.debug('Set server %s (%s) to %s',
self.server_name, self.machine_identifier, self.server)
def load_entrypoint(self):
self.verify_ssl_cert = None if v.KODIVERSION >= 18 or utils.settings('sslverify') == 'true' \
else False
self.ssl_cert_path = utils.settings('sslcert') \
if utils.settings('sslcert') != 'None' else None
self.https = utils.settings('https') == 'true'
self.host = utils.settings('ipaddress') or None
self.port = int(utils.settings('port')) if utils.settings('port') else None
if not self.host:
self.server = None
elif self.https:
self.server = 'https://%s:%s' % (self.host, self.port)
else:
self.server = 'http://%s:%s' % (self.host, self.port)
def clear(self):
LOG.debug('Clearing connection settings')
self.machine_identifier = None
self.server_name = None
self.https = None
self.host = None
self.port = None
self.server = None

View file

@ -1,130 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from .. import utils
def remove_trailing_slash(path):
"""
Removes trailing slashes or backslashes from path [unicode], and is NOT
dependent on os.path
"""
if '/' in path:
path = path[:-1] if path.endswith('/') else path
else:
path = path[:-1] if path.endswith('\\') else path
return path
class Sync(object):
def __init__(self, entrypoint=False):
# Direct Paths (True) or Addon Paths (False)?
self.direct_paths = None
# Is synching of Plex music enabled?
self.enable_music = None
# Do we sync artwork from the PMS to Kodi?
self.artwork = None
# Path remapping mechanism (e.g. smb paths)
# Do we replace \\myserver\path to smb://myserver/path?
self.replace_smb_path = None
# Do we generally remap?
self.remap_path = None
self.force_transcode_pix = None
# Mappings for REMAP_PATH:
self.remapSMBmovieOrg = None
self.remapSMBmovieNew = None
self.remapSMBtvOrg = None
self.remapSMBtvNew = None
self.remapSMBmusicOrg = None
self.remapSMBmusicNew = None
self.remapSMBphotoOrg = None
self.remapSMBphotoNew = None
# Escape path?
self.escape_path = None
self.escape_path_safe_chars = None
# Shall we replace custom user ratings with the number of versions available?
self.indicate_media_versions = None
# Will sync movie trailer differently: either play trailer directly or show
# all the Plex extras for the user to choose
self.show_extras_instead_of_playing_trailer = None
# Only sync specific Plex playlists to Kodi?
self.sync_specific_plex_playlists = None
# Only sync specific Kodi playlists to Plex?
self.sync_specific_kodi_playlists = None
# Shall we show Kodi dialogs when synching?
self.sync_dialog = None
# How often shall we sync?
self.full_sync_intervall = None
# How long shall we wait with synching a new item to make sure Plex got all
# metadata?
self.backgroundsync_saftymargin = None
# How many threads to download Plex metadata on sync?
self.sync_thread_number = None
# Shall Kodi show dialogs for syncing/caching images? (e.g. images left
# to sync)
self.image_sync_notifications = None
# Do we need to run a special library scan?
self.run_lib_scan = None
# Set if user decided to cancel sync
self.stop_sync = False
# Could we access the paths?
self.path_verified = False
# List of Section() items representing Plex library sections
self._sections = []
# List of section_ids we're synching to Kodi - will be automatically
# re-built if sections are set a-new
self.section_ids = set()
self.load()
@property
def sections(self):
return self._sections
@sections.setter
def sections(self, sections):
self._sections = sections
# Sets are faster when using "in" test than lists
self.section_ids = set([x.section_id for x in sections if x.sync_to_kodi])
def load(self):
self.direct_paths = utils.settings('useDirectPaths') == '1'
self.enable_music = utils.settings('enableMusic') == 'true'
self.artwork = utils.settings('usePlexArtwork') == 'true'
self.replace_smb_path = utils.settings('replaceSMB') == 'true'
self.remap_path = utils.settings('remapSMB') == 'true'
self.remapSMBmovieOrg = remove_trailing_slash(utils.settings('remapSMBmovieOrg'))
self.remapSMBmovieNew = remove_trailing_slash(utils.settings('remapSMBmovieNew'))
self.remapSMBtvOrg = remove_trailing_slash(utils.settings('remapSMBtvOrg'))
self.remapSMBtvNew = remove_trailing_slash(utils.settings('remapSMBtvNew'))
self.remapSMBmusicOrg = remove_trailing_slash(utils.settings('remapSMBmusicOrg'))
self.remapSMBmusicNew = remove_trailing_slash(utils.settings('remapSMBmusicNew'))
self.remapSMBphotoOrg = remove_trailing_slash(utils.settings('remapSMBphotoOrg'))
self.remapSMBphotoNew = remove_trailing_slash(utils.settings('remapSMBphotoNew'))
self.escape_path = utils.settings('escapePath') == 'true'
self.escape_path_safe_chars = utils.settings('escapePathSafeChars').encode('utf-8')
self.indicate_media_versions = utils.settings('indicate_media_versions') == "true"
self.sync_specific_plex_playlists = utils.settings('syncSpecificPlexPlaylists') == 'true'
self.sync_specific_kodi_playlists = utils.settings('syncSpecificKodiPlaylists') == 'true'
self.sync_thread_number = int(utils.settings('syncThreadNumber'))
self.reload()
def reload(self):
"""
Any settings unrelated to syncs to the Kodi database - can thus be
safely reset without a Kodi reboot
"""
self.sync_dialog = utils.settings('dbSyncIndicator') == 'true'
self.full_sync_intervall = int(utils.settings('fullSyncInterval')) * 60
self.backgroundsync_saftymargin = int(utils.settings('backgroundsync_saftyMargin'))
self.image_sync_notifications = utils.settings('imageSyncNotifications') == 'true'
self.force_transcode_pix = utils.settings('force_transcode_pix') == 'true'
# Trailers in Kodi DB will remain UNTIL DB is reset!
self.show_extras_instead_of_playing_trailer = utils.settings('showExtrasInsteadOfTrailer') == 'true'

View file

@ -1,66 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
class PlayState(object):
# "empty" dict for the PLAYER_STATES above. Use copy.deepcopy to duplicate!
template = {
'type': None,
'time': {
'hours': 0,
'minutes': 0,
'seconds': 0,
'milliseconds': 0},
'totaltime': {
'hours': 0,
'minutes': 0,
'seconds': 0,
'milliseconds': 0},
'speed': 0,
'shuffled': False,
'repeat': 'off',
'position': None,
'playlistid': None,
'currentvideostream': -1,
'currentaudiostream': -1,
'subtitleenabled': False,
'currentsubtitle': -1,
'file': None,
'kodi_id': None,
'kodi_type': None,
'plex_id': None,
'plex_type': None,
'container_key': None,
'volume': 100,
'muted': False,
'playmethod': None,
'playcount': None,
'external_player': False, # bool - xbmc.Player().isExternalPlayer()
'intro_markers': [],
}
def __init__(self):
# Kodi player states - here, initial values are set
self.player_states = {
0: {},
1: {},
2: {}
}
# The LAST playstate once playback is finished
self.old_player_states = {
0: {},
1: {},
2: {}
}
self.played_info = {}
# Currently playing PKC item, a PlaylistItem()
self.item = None
# Was the playback initiated by the user using the Kodi context menu?
self.context_menu_play = False
# Set by context menu - shall we force-transcode the next playing item?
self.force_transcode = False
# Which Kodi player is/has been active? (either int 1, 2 or 3)
self.active_players = set()

View file

@ -1,139 +1,346 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
###############################################################################
from logging import getLogger
from Queue import Queue, Empty
from shutil import rmtree
from urllib import quote_plus, unquote
from threading import Thread
from os import makedirs
import requests
from .kodi_db import KodiVideoDB, KodiMusicDB, KodiTextureDB
from . import app, backgroundthread, utils
from xbmc import sleep, translatePath
from xbmcvfs import exists
LOG = getLogger('PLEX.artwork')
from utils import settings, language as lang, kodi_sql, try_encode, try_decode,\
thread_methods, dialog, exists_dir
import state
###############################################################################
LOG = getLogger("PLEX." + __name__)
# Disable annoying requests warnings
requests.packages.urllib3.disable_warnings()
ARTWORK_QUEUE = Queue()
IMAGE_CACHING_SUSPENDS = ['SUSPEND_LIBRARY_THREAD', 'DB_SCAN', 'STOP_SYNC']
if not settings('imageSyncDuringPlayback') == 'true':
IMAGE_CACHING_SUSPENDS.append('SUSPEND_SYNC')
# Potentially issues with limited number of threads Hence let Kodi wait till
# download is successful
TIMEOUT = (35.1, 35.1)
BATCH_SIZE = 500
###############################################################################
def double_urlencode(text):
return utils.quote_plus(utils.quote_plus(text))
return quote_plus(quote_plus(text))
def double_urldecode(text):
return utils.unquote(utils.unquote(text))
return unquote(unquote(text))
class ImageCachingThread(backgroundthread.KillableThread):
@thread_methods(add_suspends=IMAGE_CACHING_SUSPENDS)
class Image_Cache_Thread(Thread):
sleep_between = 50
# Potentially issues with limited number of threads
# Hence let Kodi wait till download is successful
timeout = (35.1, 35.1)
def __init__(self):
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
self.queue = ARTWORK_QUEUE
Thread.__init__(self)
def run(self):
LOG.info("---===### Starting ImageCachingThread ###===---")
app.APP.register_caching_thread(self)
LOG.info("---===### Starting Image_Cache_Thread ###===---")
stopped = self.stopped
suspended = self.suspended
queue = self.queue
sleep_between = self.sleep_between
counter = 0
set_zero = False
while not stopped():
# In the event the server goes offline
while suspended():
# Set in service.py
if stopped():
# Abort was requested while waiting. We should exit
LOG.info("---===### Stopped Image_Cache_Thread ###===---")
return
sleep(1000)
try:
url = queue.get(block=False)
except Empty:
if not set_zero:
# Avoid saving '0' all the time
set_zero = True
settings('caching_artwork_count', value='0')
sleep(1000)
continue
set_zero = False
if isinstance(url, ArtworkSyncMessage):
if state.IMAGE_SYNC_NOTIFICATIONS:
dialog('notification',
heading=lang(29999),
message=url.message,
icon='{plex}',
sound=False)
queue.task_done()
continue
url = double_urlencode(try_encode(url))
sleeptime = 0
while True:
try:
requests.head(
url="http://%s:%s/image/image://%s"
% (state.WEBSERVER_HOST,
state.WEBSERVER_PORT,
url),
auth=(state.WEBSERVER_USERNAME,
state.WEBSERVER_PASSWORD),
timeout=self.timeout)
except requests.Timeout:
# We don't need the result, only trigger Kodi to start the
# download. All is well
break
except requests.ConnectionError:
if stopped():
# Kodi terminated
break
# Server thinks its a DOS attack, ('error 10053')
# Wait before trying again
if sleeptime > 5:
LOG.error('Repeatedly got ConnectionError for url %s',
double_urldecode(url))
break
LOG.debug('Were trying too hard to download art, server '
'over-loaded. Sleep %s seconds before trying '
'again to download %s',
2**sleeptime, double_urldecode(url))
sleep((2**sleeptime) * 1000)
sleeptime += 1
continue
except Exception as err:
LOG.error('Unknown exception for url %s: %s'.
double_urldecode(url), err)
import traceback
LOG.error("Traceback:\n%s", traceback.format_exc())
break
# We did not even get a timeout
break
queue.task_done()
# Update the caching state in the PKC settings.
counter += 1
if counter > 20:
counter = 0
settings('caching_artwork_count', value=str(queue.qsize()))
# Sleep for a bit to reduce CPU strain
sleep(sleep_between)
LOG.info("---===### Stopped Image_Cache_Thread ###===---")
class Artwork():
enableTextureCache = settings('enableTextureCache') == "true"
if enableTextureCache:
queue = ARTWORK_QUEUE
def cache_major_artwork(self):
"""
Takes the existing Kodi library and caches posters and fanart.
Necessary because otherwise PKC caches artwork e.g. from fanart.tv
which basically blocks Kodi from getting needed artwork fast (e.g.
while browsing the library)
"""
if not self.enableTextureCache:
return
artworks = list()
# Get all posters and fanart/background for video and music
for kind in ('video', 'music'):
connection = kodi_sql(kind)
cursor = connection.cursor()
for typus in ('poster', 'fanart'):
cursor.execute('SELECT url FROM art WHERE type == ?',
(typus, ))
artworks.extend(cursor.fetchall())
connection.close()
artworks_to_cache = list()
connection = kodi_sql('texture')
cursor = connection.cursor()
for url in artworks:
query = 'SELECT url FROM texture WHERE url == ? LIMIT 1'
cursor.execute(query, (url[0], ))
if not cursor.fetchone():
artworks_to_cache.append(url)
connection.close()
if not artworks_to_cache:
LOG.info('Caching of major images to Kodi texture cache done')
# Set to "None"
settings('caching_artwork_count', value=lang(30069))
return
length = len(artworks_to_cache)
LOG.info('Caching has not been completed - caching %s major images',
length)
settings('caching_artwork_count', value=str(length))
# Caching %s Plex images
self.queue.put(ArtworkSyncMessage(lang(30006) % length))
for i, url in enumerate(artworks_to_cache):
self.queue.put(url[0])
# Plex image caching done
self.queue.put(ArtworkSyncMessage(lang(30007)))
def fullTextureCacheSync(self):
"""
This method will sync all Kodi artwork to textures13.db
and cache them locally. This takes diskspace!
"""
if not dialog('yesno', "Image Texture Cache", lang(39250)):
return
LOG.info("Doing Image Cache Sync")
# ask to rest all existing or not
if dialog('yesno', "Image Texture Cache", lang(39251)):
LOG.info("Resetting all cache data first")
# Remove all existing textures first
path = try_decode(translatePath("special://thumbnails/"))
if exists_dir(path):
rmtree(path, ignore_errors=True)
self.restore_cache_directories()
# remove all existing data from texture DB
connection = kodi_sql('texture')
cursor = connection.cursor()
query = 'SELECT tbl_name FROM sqlite_master WHERE type=?'
cursor.execute(query, ('table', ))
rows = cursor.fetchall()
for row in rows:
tableName = row[0]
if tableName != "version":
cursor.execute("DELETE FROM %s" % tableName)
connection.commit()
connection.close()
# Cache all entries in video DB
connection = kodi_sql('video')
cursor = connection.cursor()
# dont include actors
query = "SELECT url FROM art WHERE media_type != ?"
cursor.execute(query, ('actor', ))
result = cursor.fetchall()
total = len(result)
LOG.info("Image cache sync about to process %s video images", total)
connection.close()
for url in result:
self.cache_texture(url[0])
# Cache all entries in music DB
connection = kodi_sql('music')
cursor = connection.cursor()
cursor.execute("SELECT url FROM art")
result = cursor.fetchall()
total = len(result)
LOG.info("Image cache sync about to process %s music images", total)
connection.close()
for url in result:
self.cache_texture(url[0])
def cache_texture(self, url):
'''
Cache a single image url to the texture cache. url: unicode
'''
if url and self.enableTextureCache:
self.queue.put(url)
def modify_artwork(self, artworks, kodi_id, kodi_type, cursor):
"""
Pass in an artworks dict (see PlexAPI) to set an items artwork.
"""
for kodi_art, url in artworks.iteritems():
self.modify_art(url, kodi_id, kodi_type, kodi_art, cursor)
def modify_art(self, url, kodi_id, kodi_type, kodi_art, cursor):
"""
Adds or modifies the artwork of kind kodi_art (e.g. 'poster') in the
Kodi art table for item kodi_id/kodi_type. Will also cache everything
except actor portraits.
"""
query = '''
SELECT url FROM art
WHERE media_id = ? AND media_type = ? AND type = ?
LIMIT 1
'''
cursor.execute(query, (kodi_id, kodi_type, kodi_art,))
try:
self._run()
except Exception:
utils.ERROR()
# Update the artwork
old_url = cursor.fetchone()[0]
except TypeError:
# Add the artwork
LOG.debug('Adding Art Link for %s kodi_id %s, kodi_type %s: %s',
kodi_art, kodi_id, kodi_type, url)
query = '''
INSERT INTO art(media_id, media_type, type, url)
VALUES (?, ?, ?, ?)
'''
cursor.execute(query, (kodi_id, kodi_type, kodi_art, url))
else:
if url == old_url:
# Only cache artwork if it changed
return
self.delete_cached_artwork(old_url)
LOG.debug("Updating Art url for %s kodi_id %s, kodi_type %s to %s",
kodi_art, kodi_id, kodi_type, url)
query = '''
UPDATE art SET url = ?
WHERE media_id = ? AND media_type = ? AND type = ?
'''
cursor.execute(query, (url, kodi_id, kodi_type, kodi_art))
# Cache fanart and poster in Kodi texture cache
if kodi_type != 'actor':
self.cache_texture(url)
def delete_artwork(self, kodiId, mediaType, cursor):
query = 'SELECT url FROM art WHERE media_id = ? AND media_type = ?'
cursor.execute(query, (kodiId, mediaType,))
for row in cursor.fetchall():
self.delete_cached_artwork(row[0])
@staticmethod
def delete_cached_artwork(url):
"""
Deleted the cached artwork with path url (if it exists)
"""
connection = kodi_sql('texture')
cursor = connection.cursor()
try:
cursor.execute("SELECT cachedurl FROM texture WHERE url=? LIMIT 1",
(url,))
cachedurl = cursor.fetchone()[0]
except TypeError:
# Could not find cached url
pass
else:
# Delete thumbnail as well as the entry
path = translatePath("special://thumbnails/%s" % cachedurl)
LOG.debug("Deleting cached thumbnail: %s", path)
if exists(path):
rmtree(try_decode(path), ignore_errors=True)
cursor.execute("DELETE FROM texture WHERE url = ?", (url,))
connection.commit()
finally:
app.APP.deregister_caching_thread(self)
LOG.info("---===### Stopped ImageCachingThread ###===---")
connection.close()
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
@staticmethod
def restore_cache_directories():
LOG.info("Restoring cache directories...")
paths = ("", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"a", "b", "c", "d", "e", "f",
"Video", "plex")
for path in paths:
makedirs(try_decode(translatePath("special://thumbnails/%s"
% path)))
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"
% (app.CONN.webserver_host,
app.CONN.webserver_port,
url),
auth=(app.CONN.webserver_username,
app.CONN.webserver_password),
timeout=TIMEOUT)
except requests.Timeout:
# We don't need the result, only trigger Kodi to start the
# download. All is well
break
except requests.ConnectionError:
if app.APP.stop_pkc or (should_suspend and should_suspend()):
break
# Server thinks its a DOS attack, ('error 10053')
# Wait before trying again
# OR: Kodi refuses Webserver connection (no password set)
if sleeptime > 5:
LOG.error('Repeatedly got ConnectionError for url %s',
double_urldecode(url))
break
LOG.debug('Were trying too hard to download art, server '
'over-loaded. Sleep %s seconds before trying '
'again to download %s',
2**sleeptime, double_urldecode(url))
app.APP.monitor.waitForAbort((2**sleeptime))
sleeptime += 1
continue
except Exception as err:
LOG.error('Unknown exception for url %s: %s'.
double_urldecode(url), err)
import traceback
LOG.error("Traceback:\n%s", traceback.format_exc())
break
# We did not even get a timeout
break
class ArtworkSyncMessage(object):
"""
Put in artwork queue to display the message as a Kodi notification
"""
def __init__(self, message):
self.message = message

View file

@ -1,520 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from time import time as _time
import threading
import Queue
import heapq
from collections import deque
from . import utils, app, variables as v
WORKER_COUNT = 3
LOG = getLogger('PLEX.threads')
class KillableThread(threading.Thread):
def __init__(self, group=None, target=None, name=None, args=(), kwargs={}):
self._canceled = False
self._suspended = False
self._is_not_suspended = threading.Event()
self._is_not_suspended.set()
self._suspension_reached = threading.Event()
self._is_not_asleep = threading.Event()
self._is_not_asleep.set()
self.suspension_timeout = None
super(KillableThread, self).__init__(group, target, name, args, kwargs)
def should_cancel(self):
"""
Returns True if the thread should be stopped immediately
"""
return self._canceled or app.APP.stop_pkc
def cancel(self):
"""
Call from another thread to stop this current thread
"""
self._canceled = True
# Make sure thread is running in order to exit quickly
self._is_not_asleep.set()
self._is_not_suspended.set()
def should_suspend(self):
"""
Returns True if the current thread should be suspended immediately
"""
return self._suspended
def suspend(self, block=False, timeout=None):
"""
Call from another thread to suspend the current thread. Provide a
timeout [float] in seconds optionally. block=True will block the caller
until the thread-to-be-suspended is indeed suspended
Will wake a thread that is asleep!
"""
self.suspension_timeout = timeout
self._suspended = True
self._is_not_suspended.clear()
# Make sure thread wakes up in order to suspend
self._is_not_asleep.set()
if block:
self._suspension_reached.wait()
def resume(self):
"""
Call from another thread to revive a suspended or asleep current thread
back to life
"""
self._suspended = False
self._is_not_asleep.set()
self._is_not_suspended.set()
def wait_while_suspended(self):
"""
Blocks until thread is not suspended anymore or the thread should
exit or for a period of self.suspension_timeout (set by the caller of
suspend())
Returns the value of should_cancel()
"""
self._suspension_reached.set()
self._is_not_suspended.wait(self.suspension_timeout)
self._suspension_reached.clear()
return self.should_cancel()
def is_suspended(self):
"""
Check from another thread whether the current thread is suspended
"""
return self._suspension_reached.is_set()
def sleep(self, timeout):
"""
Only call from the current thread in order to sleep for a period of
timeout [float, seconds]. Will unblock immediately if thread should
cancel (should_cancel()) or the thread should_suspend
"""
self._is_not_asleep.clear()
self._is_not_asleep.wait(timeout)
self._is_not_asleep.set()
def is_asleep(self):
"""
Check from another thread whether the current thread is asleep
"""
return not self._is_not_asleep.is_set()
def unblock_callers(self):
"""
Ensures that any other thread that requested this thread's suspension
is released
"""
self._suspension_reached.set()
class ProcessingQueue(Queue.Queue, object):
"""
Queue of queues that processes a queue completely before moving on to the
next queue. There's one queue per Section(). You need to initialize each
section with add_section(section) first.
Put tuples (count, item) into this queue, with count being the respective
position of the item in the queue, starting with 0 (zero).
(None, None) is the sentinel for a single queue being exhausted, added by
add_sentinel()
"""
def _init(self, maxsize):
self.queue = deque()
self._sections = deque()
self._queues = deque()
self._current_section = None
self._current_queue = None
# Item-index for the currently active queue
self._counter = 0
def _qsize(self):
return self._current_queue._qsize() if self._current_queue else 0
def _put(self, item):
for i, section in enumerate(self._sections):
if item[1]['section'] == section:
self._queues[i]._put(item)
break
else:
raise RuntimeError('Could not find section for item %s' % item[1])
def add_sentinel(self, section):
"""
Adds a new empty section as a sentinel. Call with an empty Section()
object. Call this method immediately after having added all sections
with add_section().
Once the get()-method returns None, you've received the sentinel and
you've thus exhausted the queue
"""
with self.not_full:
section.number_of_items = 1
self._add_section(section)
# Add the actual sentinel to the queue we just added
self._queues[-1]._put((None, None))
self.unfinished_tasks += 1
self.not_empty.notify()
def add_section(self, section):
"""
Add a new Section() to this Queue. Each section will be entirely
processed before moving on to the next section.
Be sure to set section.number_of_items correctly as it will signal
when processing is completely done for a specific section!
"""
with self.mutex:
self._add_section(section)
def change_section_number_of_items(self, section, number_of_items):
"""
Hit this method if you've reset section.number_of_items to make
sure we're not blocking
"""
with self.mutex:
self._change_section_number_of_items(section, number_of_items)
def _change_section_number_of_items(self, section, number_of_items):
section.number_of_items = number_of_items
if (self._current_section == section
and self._counter == number_of_items):
# We were actually waiting for more items to come in - but there
# aren't any!
self._init_next_section()
if self._qsize() > 0:
self.not_empty.notify()
def _add_section(self, section):
self._sections.append(section)
self._queues.append(
OrderedQueue() if section.plex_type == v.PLEX_TYPE_ALBUM
else Queue.Queue())
if self._current_section is None:
self._activate_next_section()
def _init_next_section(self):
"""
Call only when a section has been completely exhausted
"""
self._sections.popleft()
self._queues.popleft()
self._activate_next_section()
def _activate_next_section(self):
self._counter = 0
self._current_section = self._sections[0] if self._sections else None
self._current_queue = self._queues[0] if self._queues else None
def _get(self):
item = self._current_queue._get()
self._counter += 1
if self._counter == self._current_section.number_of_items:
self._init_next_section()
return item[1]
class OrderedQueue(Queue.PriorityQueue, object):
"""
Queue that enforces an order on the items it returns. An item you push
onto the queue must be a tuple
(index, item)
where index=-1 is the item that will be returned first. The Queue will block
until index=-1, 0, 1, 2, 3, ... is then made available
maxsize will be rather fuzzy, as _qsize returns 0 if we're still waiting
for the next smalles index. put() thus might not block always when it
should.
"""
def __init__(self, maxsize=0):
self.next_index = 0
super(OrderedQueue, self).__init__(maxsize)
def _qsize(self, len=len):
try:
return len(self.queue) if self.queue[0][0] == self.next_index else 0
except IndexError:
return 0
def _get(self, heappop=heapq.heappop):
self.next_index += 1
return heappop(self.queue)
class Tasks(list):
def add(self, task):
for t in self:
if not t.isValid():
self.remove(t)
if isinstance(task, list):
self += task
else:
self.append(task)
def cancel(self):
while self:
self.pop().cancel()
class Task(object):
def __init__(self, priority=None):
self.priority = priority
self._canceled = False
self.finished = False
def __cmp__(self, other):
return self.priority - other.priority
def start(self):
BGThreader.addTask(self)
def _run(self):
self.run()
self.finished = True
def run(self):
raise NotImplementedError
def cancel(self):
self._canceled = True
def should_cancel(self):
return self._canceled or app.APP.monitor.abortRequested()
def isValid(self):
return not self.finished and not self._canceled
class ShutdownSentinel(Task):
def run(self):
pass
class FunctionAsTask(Task):
def __init__(self, function, callback, *args, **kwargs):
self._function = function
self._callback = callback
self._args = args
self._kwargs = kwargs
super(FunctionAsTask, self).__init__()
def run(self):
result = self._function(*self._args, **self._kwargs)
if self._callback:
self._callback(result)
class MutablePriorityQueue(Queue.PriorityQueue):
def _get(self, heappop=heapq.heappop):
self.queue.sort()
return heappop(self.queue)
def lowest(self):
"""Return the lowest priority item in the queue (not reliable!)."""
self.mutex.acquire()
try:
lowest = self.queue and min(self.queue) or None
except Exception:
lowest = None
utils.ERROR(notify=True)
finally:
self.mutex.release()
return lowest
class BackgroundWorker(object):
def __init__(self, queue, name=None):
self._queue = queue
self.name = name
self._thread = None
self._abort = False
self._task = None
@staticmethod
def _runTask(task):
if task._canceled:
return
try:
task._run()
except Exception:
utils.ERROR(notify=True)
def abort(self):
self._abort = True
return self
def aborted(self):
return self._abort or app.APP.monitor.abortRequested()
def start(self):
if self._thread and self._thread.isAlive():
return
self._thread = KillableThread(target=self._queueLoop, name='BACKGROUND-WORKER({0})'.format(self.name))
self._thread.start()
def _queueLoop(self):
if self._queue.empty():
return
LOG.debug('(%s): Active', self.name)
try:
while not self.aborted():
self._task = self._queue.get_nowait()
self._runTask(self._task)
self._queue.task_done()
self._task = None
except Queue.Empty:
LOG.debug('(%s): Idle', self.name)
def shutdown(self, block=True):
self.abort()
if self._task:
self._task.cancel()
if block and self._thread and self._thread.isAlive():
LOG.debug('thread (%s): Waiting...', self.name)
self._thread.join()
LOG.debug('thread (%s): Done', self.name)
def working(self):
return self._thread and self._thread.isAlive()
class NonstoppingBackgroundWorker(BackgroundWorker):
def __init__(self, queue, name=None):
self._working = False
super(NonstoppingBackgroundWorker, self).__init__(queue, name)
def _queueLoop(self):
LOG.debug('Starting Worker %s', self.name)
while not self.aborted():
self._task = self._queue.get()
if self._task is ShutdownSentinel:
break
self._working = True
self._runTask(self._task)
self._working = False
self._queue.task_done()
self._task = None
LOG.debug('Exiting Worker %s', self.name)
def working(self):
return self._working
class BackgroundThreader:
def __init__(self, name=None, worker=BackgroundWorker, worker_count=6):
self.name = name
self._queue = MutablePriorityQueue()
self._abort = False
self.priority = -1
self.workers = [
worker(self._queue, 'queue.{0}:worker.{1}'.format(self.name, x))
for x in range(worker_count)
]
def _nextPriority(self):
self.priority += 1
return self.priority
def abort(self):
self._abort = True
for w in self.workers:
w.abort()
return self
def aborted(self):
return self._abort or app.APP.monitor.abortRequested()
def shutdown(self, block=True):
self.abort()
self.addTasksToFront([ShutdownSentinel() for _ in self.workers])
for w in self.workers:
w.shutdown(block)
def addTask(self, task):
task.priority = self._nextPriority()
self._queue.put(task)
self.startWorkers()
def addTasks(self, tasks):
for t in tasks:
t.priority = self._nextPriority()
self._queue.put(t)
self.startWorkers()
def addTasksToFront(self, tasks):
lowest = self.getLowestPrority()
if lowest is None:
return self.addTasks(tasks)
p = lowest - len(tasks)
for t in tasks:
t.priority = p
self._queue.put(t)
p += 1
self.startWorkers()
def startWorkers(self):
for w in self.workers:
w.start()
def working(self):
return not self._queue.empty() or self.hasTask()
def hasTask(self):
return any([w.working() for w in self.workers])
def getLowestPrority(self):
lowest = self._queue.lowest()
if not lowest:
return None
return lowest.priority
def moveToFront(self, qitem):
lowest = self.getLowestPrority()
if lowest is None:
return
qitem.priority = lowest - 1
class ThreaderManager:
def __init__(self,
worker=NonstoppingBackgroundWorker,
worker_count=WORKER_COUNT):
self.index = 0
self.abandoned = []
self._workerhandler = worker
self.threader = BackgroundThreader(name=str(self.index),
worker=worker,
worker_count=worker_count)
def __getattr__(self, name):
return getattr(self.threader, name)
def reset(self):
if self.threader._queue.empty() and not self.threader.hasTask():
return
self.index += 1
self.abandoned.append(self.threader.abort())
self.threader = BackgroundThreader(name=str(self.index),
worker=self._workerhandler)
def shutdown(self, block=True):
self.threader.shutdown(block)
for a in self.abandoned:
a.shutdown(block)
BGThreader = ThreaderManager()

View file

@ -1,16 +1,14 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
###############################################################################
from logging import getLogger
import xbmc
from . import utils
from . import variables as v
from utils import window, settings
import variables as v
###############################################################################
LOG = getLogger('PLEX.clientinfo')
log = getLogger("PLEX."+__name__)
###############################################################################
@ -33,19 +31,20 @@ def getXArgsDeviceInfo(options=None, include_token=True):
'Connection': 'keep-alive',
"Content-Type": "application/x-www-form-urlencoded",
# "Access-Control-Allow-Origin": "*",
'Accept-Language': xbmc.getLanguage(xbmc.ISO_639_1),
'X-Plex-Device': v.DEVICE,
'X-Plex-Model': v.MODEL,
# 'X-Plex-Language': 'en',
'X-Plex-Device': v.ADDON_NAME,
'X-Plex-Client-Platform': v.PLATFORM,
'X-Plex-Device-Name': v.DEVICENAME,
'X-Plex-Platform': v.PLATFORM,
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
# 'X-Plex-Platform-Version': 'unknown',
# 'X-Plex-Model': 'unknown',
'X-Plex-Product': v.ADDON_NAME,
'X-Plex-Version': v.ADDON_VERSION,
'X-Plex-Client-Identifier': getDeviceId(),
'X-Plex-Provides': 'client,controller,player,pubsub-player',
}
if include_token and utils.window('pms_token'):
xargs['X-Plex-Token'] = utils.window('pms_token')
if include_token and window('pms_token'):
xargs['X-Plex-Token'] = window('pms_token')
if options is not None:
xargs.update(options)
return xargs
@ -61,26 +60,26 @@ def getDeviceId(reset=False):
"""
if reset is True:
v.PKC_MACHINE_IDENTIFIER = None
utils.window('plex_client_Id', clear=True)
utils.settings('plex_client_Id', value="")
window('plex_client_Id', clear=True)
settings('plex_client_Id', value="")
client_id = v.PKC_MACHINE_IDENTIFIER
if client_id:
return client_id
client_id = utils.settings('plex_client_Id')
client_id = settings('plex_client_Id')
# Because Kodi appears to cache file settings!!
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)
window('plex_client_Id', value=client_id)
log.info("Unique device Id plex_client_Id loaded: %s", client_id)
return client_id
LOG.info("Generating a new deviceid.")
log.info("Generating a new deviceid.")
from uuid import uuid4
client_id = str(uuid4())
utils.settings('plex_client_Id', value=client_id)
settings('plex_client_Id', value=client_id)
v.PKC_MACHINE_IDENTIFIER = client_id
utils.window('plex_client_Id', value=client_id)
LOG.info("Unique device Id plex_client_Id generated: %s", client_id)
window('plex_client_Id', value=client_id)
log.info("Unique device Id plex_client_Id generated: %s", client_id)
return client_id

View file

@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
###############################################################################
import logging
from threading import Thread
from xbmc import sleep
from utils import window, thread_methods
import state
###############################################################################
LOG = logging.getLogger("PLEX." + __name__)
###############################################################################
@thread_methods
class Monitor_Window(Thread):
"""
Monitors window('plex_command') for new entries that we need to take care
of, e.g. for new plays initiated on the Kodi side with addon paths.
Adjusts state.py accordingly
"""
def run(self):
stopped = self.stopped
queue = state.COMMAND_PIPELINE_QUEUE
LOG.info("----===## Starting Kodi_Play_Client ##===----")
while not stopped():
if window('plex_command'):
value = window('plex_command')
window('plex_command', clear=True)
if value.startswith('PLAY-'):
queue.put(value.replace('PLAY-', ''))
elif value == 'SUSPEND_LIBRARY_THREAD-True':
state.SUSPEND_LIBRARY_THREAD = True
elif value == 'SUSPEND_LIBRARY_THREAD-False':
state.SUSPEND_LIBRARY_THREAD = False
elif value == 'STOP_SYNC-True':
state.STOP_SYNC = True
elif value == 'STOP_SYNC-False':
state.STOP_SYNC = False
elif value == 'PMS_STATUS-Auth':
state.PMS_STATUS = 'Auth'
elif value == 'PMS_STATUS-401':
state.PMS_STATUS = '401'
elif value == 'SUSPEND_USER_CLIENT-True':
state.SUSPEND_USER_CLIENT = True
elif value == 'SUSPEND_USER_CLIENT-False':
state.SUSPEND_USER_CLIENT = False
elif value.startswith('PLEX_TOKEN-'):
state.PLEX_TOKEN = value.replace('PLEX_TOKEN-', '') or None
elif value.startswith('PLEX_USERNAME-'):
state.PLEX_USERNAME = \
value.replace('PLEX_USERNAME-', '') or None
elif value.startswith('RUN_LIB_SCAN-'):
state.RUN_LIB_SCAN = value.replace('RUN_LIB_SCAN-', '')
elif value.startswith('CONTEXT_menu?'):
queue.put('dummy?mode=context_menu&%s'
% value.replace('CONTEXT_menu?', ''))
elif value.startswith('NAVIGATE'):
queue.put(value.replace('NAVIGATE-', ''))
else:
raise NotImplementedError('%s not implemented' % value)
else:
sleep(50)
# Put one last item into the queue to let playback_starter end
queue.put(None)
LOG.info("----===## Kodi_Play_Client stopped ##===----")

View file

@ -1,18 +1,19 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
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 . import playqueue as PQ, plex_functions as PF
from . import json_rpc as js, variables as v, app
from variables import ALEXA_TO_COMPANION
import playqueue as PQ
from PlexFunctions import GetPlexKeyNumber
import json_rpc as js
import state
###############################################################################
LOG = getLogger('PLEX.companion')
LOG = getLogger("PLEX." + __name__)
###############################################################################
@ -24,7 +25,7 @@ def skip_to(params):
Does not seem to be implemented yet by Plex!
"""
playqueue_item_id = params.get('playQueueItemID')
_, plex_id = PF.GetPlexKeyNumber(params.get('key'))
_, plex_id = GetPlexKeyNumber(params.get('key'))
LOG.debug('Skipping to playQueueItemID %s, plex_id %s',
playqueue_item_id, plex_id)
found = True
@ -49,9 +50,9 @@ def convert_alexa_to_companion(dictionary):
"""
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]
for key in dictionary:
if key in ALEXA_TO_COMPANION:
dictionary[ALEXA_TO_COMPANION[key]] = dictionary[key]
del dictionary[key]
@ -65,12 +66,12 @@ def process_command(request_path, params):
if request_path == 'player/playback/playMedia':
# We need to tell service.py
action = 'alexa' if params.get('deviceName') == 'Alexa' else 'playlist'
app.APP.companion_queue.put({
state.COMPANION_QUEUE.put({
'action': action,
'data': params
})
elif request_path == 'player/playback/refreshPlayQueue':
app.APP.companion_queue.put({
state.COMPANION_QUEUE.put({
'action': 'refreshPlayQueue',
'data': params
})
@ -112,7 +113,7 @@ def process_command(request_path, params):
elif request_path == "player/navigation/back":
js.input_back()
elif request_path == "player/playback/setStreams":
app.APP.companion_queue.put({
state.COMPANION_QUEUE.put({
'action': 'setStreams',
'data': params
})

View file

@ -1,16 +1,17 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
###############################################################################
from logging import getLogger
import xbmcgui
from os.path import join
from . import utils
from . import path_ops
from . import variables as v
import xbmcgui
from xbmcaddon import Addon
from utils import window
###############################################################################
LOG = getLogger('PLEX.context')
LOG = getLogger("PLEX." + __name__)
ADDON = Addon('plugin.video.plexkodiconnect')
ACTION_PARENT_DIR = 9
ACTION_PREVIOUS_MENU = 10
@ -43,8 +44,8 @@ class ContextMenu(xbmcgui.WindowXMLDialog):
return self.selected_option
def onInit(self):
if utils.window('plexAvatar'):
self.getControl(USER_IMAGE).setImage(utils.window('plexAvatar'))
if window('PlexUserImage'):
self.getControl(USER_IMAGE).setImage(window('PlexUserImage'))
height = 479 + (len(self._options) * 55)
LOG.debug("options: %s", self._options)
self.list_ = self.getControl(LIST)
@ -60,16 +61,15 @@ class ContextMenu(xbmcgui.WindowXMLDialog):
if action in (ACTION_SELECT_ITEM, ACTION_MOUSE_LEFT_CLICK):
if self.getFocusId() == LIST:
option = self.list_.getSelectedItem()
self.selected_option = option.getLabel().decode('utf-8')
self.selected_option = option.getLabel()
LOG.info('option selected: %s', self.selected_option)
self.close()
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'))
media = join(ADDON.getAddonInfo('path'),
'resources', 'skins', 'default', 'media')
control = xbmcgui.ControlImage(0, 0, 0, 0,
filename=filename,
filename=join(media, "white.png"),
aspectRatio=0,
colorDiffuse="ff111111")
control.setPosition(x, y)

View file

@ -1,29 +1,35 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
###############################################################################
from logging import getLogger
import xbmc
import xbmcgui
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
from xbmcaddon import Addon
import xbmc
import xbmcplugin
import xbmcgui
import context
import plexdb_functions as plexdb
from utils import window, settings, dialog, language as lang
import PlexFunctions as PF
from PlexAPI import API
import playqueue as PQ
import variables as v
import state
###############################################################################
LOG = getLogger('PLEX.context_entry')
LOG = getLogger("PLEX." + __name__)
OPTIONS = {
'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)
'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
'Extras': lang(30235)
}
###############################################################################
@ -60,15 +66,22 @@ class ContextMenu(object):
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')
@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']
with plexdb.Get_Plex_DB() as plexcursor:
item = plexcursor.getItem_byKodiId(kodi_id, kodi_type)
try:
plex_id = item[0]
except TypeError:
LOG.info('Could not get the Plex id for context menu')
return plex_id
def _select_menu(self):
@ -79,20 +92,20 @@ class ContextMenu(object):
# if user uses direct paths, give option to initiate playback via PMS
if self.api and self.api.extras():
options.append(OPTIONS['Extras'])
if app.SYNC.direct_paths and self.kodi_type in v.KODI_VIDEOTYPES:
if state.DIRECT_PATHS and self.kodi_type in v.KODI_VIDEOTYPES:
options.append(OPTIONS['PMS_Play'])
if self.kodi_type in v.KODI_VIDEOTYPES:
options.append(OPTIONS['Transcode'])
# Delete item, only if the Plex Home main user is logged in
if (utils.window('plex_restricteduser') != 'true' and
utils.window('plex_allows_mediaDeletion') == 'true'):
if (window('plex_restricteduser') != 'true' and
window('plex_allows_mediaDeletion') == 'true'):
options.append(OPTIONS['Delete'])
# Addon settings
options.append(OPTIONS['Addon'])
context_menu = context.ContextMenu(
"script-plex-context.xml",
utils.try_encode(v.ADDON_PATH),
Addon('plugin.video.plexkodiconnect').getAddonInfo('path'),
"default",
"1080i")
context_menu.set_options(options)
@ -107,7 +120,7 @@ class ContextMenu(object):
"""
selected = self._selected_option
if selected == OPTIONS['Transcode']:
app.PLAYSTATE.force_transcode = True
state.FORCE_TRANSCODE = True
self._PMS_play()
elif selected == OPTIONS['PMS_Play']:
self._PMS_play()
@ -125,14 +138,14 @@ class ContextMenu(object):
Delete item on PMS
"""
delete = True
if utils.settings('skipContextMenu') != "true":
if not utils.dialog("yesno", heading="{plex}", line1=utils.lang(33041)):
if settings('skipContextMenu') != "true":
if not dialog("yesno", heading="{plex}", line1=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.plex_id)
if PF.delete_item_from_pms(self.plex_id) is False:
utils.dialog("ok", heading="{plex}", line1=utils.lang(30414))
dialog("ok", heading="{plex}", line1=lang(30414))
def _PMS_play(self):
"""
@ -141,10 +154,12 @@ class ContextMenu(object):
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'))
state.CONTEXT_MENU_PLAY = True
handle = ('plugin://%s/?plex_id=%s&plex_type=%s&mode=play'
% (v.ADDON_TYPE[self.plex_type],
self.plex_id,
self.plex_type))
xbmc.executebuiltin('RunPlugin(%s)' % handle)
def _extras(self):
"""

View file

@ -1,97 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import sqlite3
from functools import wraps
from . import variables as v, app
from .exceptions import LockedDatabase
DB_WRITE_ATTEMPTS = 100
DB_WRITE_ATTEMPTS_TIMEOUT = 1 # in seconds
DB_CONNECTION_TIMEOUT = 10
def catch_operationalerrors(method):
"""
sqlite.OperationalError is raised immediately if another DB connection
is open, reading something that we're trying to change
So let's catch it and try again
Also see https://github.com/mattn/go-sqlite3/issues/274
"""
@wraps(method)
def wrapper(self, *args, **kwargs):
attempts = DB_WRITE_ATTEMPTS
while True:
try:
return method(self, *args, **kwargs)
except sqlite3.OperationalError as err:
if 'database is locked' not in err:
# Not an error we want to catch, so reraise it
raise
attempts -= 1
if attempts == 0:
# Reraise in order to NOT catch nested OperationalErrors
raise LockedDatabase('Database is locked')
# Need to close the transactions and begin new ones
self.kodiconn.commit()
if self.artconn:
self.artconn.commit()
if app.APP.monitor.waitForAbort(DB_WRITE_ATTEMPTS_TIMEOUT):
# PKC needs to quit
return
# Start new transactions
self.kodiconn.execute('BEGIN')
if self.artconn:
self.artconn.execute('BEGIN')
return wrapper
def _initial_db_connection_setup(conn):
"""
Set-up DB e.g. for WAL journal mode, if that hasn't already been done
before. Also start a transaction
"""
conn.execute('PRAGMA journal_mode = WAL;')
conn.execute('PRAGMA cache_size = -8000;')
conn.execute('PRAGMA synchronous = NORMAL;')
conn.execute('BEGIN')
def connect(media_type=None):
"""
Open a connection to the Kodi database.
media_type: 'video' (standard if not passed), 'plex', 'music', 'texture'
"""
if media_type == "plex":
db_path = v.DB_PLEX_PATH
elif media_type == 'plex-copy':
db_path = v.DB_PLEX_COPY_PATH
elif media_type == "music":
db_path = v.DB_MUSIC_PATH
elif media_type == "texture":
db_path = v.DB_TEXTURE_PATH
else:
db_path = v.DB_VIDEO_PATH
conn = sqlite3.connect(db_path,
timeout=DB_CONNECTION_TIMEOUT,
isolation_level=None)
attempts = DB_WRITE_ATTEMPTS
while True:
try:
_initial_db_connection_setup(conn)
except sqlite3.OperationalError as err:
if 'database is locked' not in err:
# Not an error we want to catch, so reraise it
raise
attempts -= 1
if attempts == 0:
# Reraise in order to NOT catch nested OperationalErrors
raise LockedDatabase('Database is locked')
if app.APP.monitor.waitForAbort(0.05):
# PKC needs to quit
raise LockedDatabase('Database was locked and we need to exit')
else:
break
return conn

View file

@ -1,39 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
xml.etree.ElementTree tries to encode with text.encode('ascii') - which is
just plain BS. This etree will always return unicode, not string
"""
from __future__ import absolute_import, division, unicode_literals
# Originally tried faster cElementTree, but does NOT work reliably with Kodi
from defusedxml.ElementTree import DefusedXMLParser, _generate_etree_functions
from xml.etree.ElementTree import TreeBuilder as _TreeBuilder
from xml.etree.ElementTree import parse as _parse
from xml.etree.ElementTree import iterparse as _iterparse
from xml.etree.ElementTree import tostring
class UnicodeXMLParser(DefusedXMLParser):
"""
PKC Hack to ensure we're always receiving unicode, not str
"""
@staticmethod
def _fixtext(text):
"""
Do NOT try to convert every entry to str with entry.encode('ascii')!
"""
return text
# aliases
XMLTreeBuilder = XMLParse = UnicodeXMLParser
parse, iterparse, fromstring = _generate_etree_functions(UnicodeXMLParser,
_TreeBuilder, _parse,
_iterparse)
XML = fromstring
__all__ = ['XML', 'XMLParse', 'XMLTreeBuilder', 'fromstring', 'iterparse',
'parse', 'tostring']

View file

@ -1,11 +1,14 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import requests
import requests.exceptions as exceptions
from . import utils, clientinfo, app
###############################################################################
from logging import getLogger
import xml.etree.ElementTree as etree
import requests
from utils import window, language as lang, dialog
import clientinfo as client
import state
###############################################################################
@ -13,7 +16,7 @@ from . import utils, clientinfo, app
import requests.packages.urllib3
requests.packages.urllib3.disable_warnings()
LOG = getLogger('PLEX.download')
LOG = getLogger("PLEX." + __name__)
###############################################################################
@ -29,23 +32,34 @@ class DownloadUtils():
_shared_state = {}
# How many failed attempts before declaring PMS dead?
connection_attempts = 1
count_error = 0
connectionAttempts = 2
# How many 401 returns before declaring unauthorized?
unauthorized_attempts = 2
count_unauthorized = 0
unauthorizedAttempts = 2
# How long should we wait for an answer from the
timeout = 30.0
def __init__(self):
self.__dict__ = self._shared_state
def setSSL(self):
def setServer(self, server):
"""
Reserved for userclient only
"""
self.server = server
LOG.debug("Set server: %s", server)
def setSSL(self, verifySSL=None, certificate=None):
"""
Reserved for userclient only
verifySSL must be 'true' to enable certificate validation
certificate must be path to certificate or 'None'
"""
verifySSL = app.CONN.verify_ssl_cert
certificate = app.CONN.ssl_cert_path
if verifySSL is None:
verifySSL = state.VERIFY_SSL_CERT
if certificate is None:
certificate = state.SSL_CERT_PATH
# Set the session's parameters
self.s.verify = verifySSL
if certificate:
@ -55,49 +69,53 @@ class DownloadUtils():
def startSession(self, reset=False):
"""
User should be authenticated when this method is called
User should be authenticated when this method is called (via
userclient)
"""
# Start session
self.s = requests.Session()
self.deviceId = clientinfo.getDeviceId()
self.deviceId = client.getDeviceId()
# Attach authenticated header to the session
self.s.headers = clientinfo.getXArgsDeviceInfo()
self.s.headers = client.getXArgsDeviceInfo()
self.s.encoding = 'utf-8'
# Set SSL settings
self.setSSL()
# Set other stuff
self.setServer(window('pms_server'))
# 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:
self.count_error = 0
self.count_unauthorized = 0
window('countUnauthorized', value='0')
window('countError', value='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.debug("Requests session started on: %s", app.CONN.server)
LOG.info("Requests session started on: %s", self.server)
def stopSession(self):
try:
self.s.close()
except Exception:
except:
LOG.info("Requests session already closed")
try:
del self.s
except AttributeError:
except:
pass
LOG.info('Request session stopped')
@staticmethod
def getHeader(options=None):
header = clientinfo.getXArgsDeviceInfo()
def getHeader(self, options=None):
header = client.getXArgsDeviceInfo()
if options is not None:
header.update(options)
return header
@staticmethod
def _doDownload(s, action_type, **kwargs):
def _doDownload(self, s, action_type, **kwargs):
if action_type == "GET":
r = s.get(**kwargs)
elif action_type == "POST":
@ -113,7 +131,7 @@ class DownloadUtils():
def downloadUrl(self, url, action_type="GET", postBody=None,
parameters=None, authenticate=True, headerOptions=None,
verifySSL=True, timeout=None, return_response=False,
headerOverride=None, reraise=False):
headerOverride=None):
"""
Override SSL check with verifySSL=False
@ -139,7 +157,7 @@ class DownloadUtils():
self.startSession()
s = self.s
# Replace for the real values
url = url.replace("{server}", app.CONN.server)
url = url.replace("{server}", self.server)
else:
# User is not (yet) authenticated. Used to communicate with
# plex.tv and to check for PMS servers
@ -148,9 +166,9 @@ class DownloadUtils():
headerOptions = self.getHeader(options=headerOptions)
else:
headerOptions = headerOverride
kwargs['verify'] = app.CONN.verify_ssl_cert
if app.CONN.ssl_cert_path:
kwargs['cert'] = app.CONN.ssl_cert_path
kwargs['verify'] = state.VERIFY_SSL_CERT
if state.SSL_CERT_PATH:
kwargs['cert'] = state.SSL_CERT_PATH
# Set the variables we were passed (fallback to request session
# otherwise - faster)
@ -167,68 +185,53 @@ class DownloadUtils():
kwargs['timeout'] = timeout
# ACTUAL DOWNLOAD HAPPENING HERE
success = False
try:
r = self._doDownload(s, action_type, **kwargs)
# THE EXCEPTIONS
except exceptions.SSLError as e:
except requests.exceptions.SSLError as e:
LOG.warn("Invalid SSL certificate for: %s", url)
LOG.warn(e)
if reraise:
raise
except exceptions.ConnectionError as e:
except requests.exceptions.ConnectionError as e:
# Connection error
LOG.warn("Server unreachable at: %s", url)
LOG.warn(e)
if reraise:
raise
except exceptions.Timeout as e:
except requests.exceptions.Timeout as e:
LOG.warn("Server timeout at: %s", url)
LOG.warn(e)
if reraise:
raise
except exceptions.HTTPError as e:
except requests.exceptions.HTTPError as e:
LOG.warn('HTTP Error at %s', url)
LOG.warn(e)
if reraise:
raise
except exceptions.TooManyRedirects as e:
except requests.exceptions.TooManyRedirects as e:
LOG.warn("Too many redirects connecting to: %s", url)
LOG.warn(e)
if reraise:
raise
except exceptions.RequestException as e:
except requests.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')
self.stopSession()
if reraise:
raise
except Exception:
except:
LOG.warn('Unknown error while downloading. Traceback:')
import traceback
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:
self.count_error = 0
window('countError', value='0')
if r.status_code != 401:
self.count_unauthorized = 0
window('countUnauthorized', value='0')
if return_response is True:
# return the entire response object
return r
elif r.status_code == 204:
if r.status_code == 204:
# No body in the response
# But read (empty) content to release connection back to pool
# (see requests: keep-alive documentation)
@ -244,16 +247,22 @@ class DownloadUtils():
LOG.info(r.text)
if '401 Unauthorized' in r.text:
# Truly unauthorized
self.count_unauthorized += 1
if self.count_unauthorized >= self.unauthorized_attempts:
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)
# Unauthorized access, user no longer has access
app.ACCOUNT.log_out()
utils.dialog('notification',
utils.lang(29999),
utils.lang(30017),
icon='{error}')
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),
icon='{error}')
else:
# there might be other 401 where e.g. PMS under strain
LOG.info('PMS might only be under strain')
@ -262,11 +271,14 @@ class DownloadUtils():
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 = utils.defused_etree.fromstring(r.content)
r = etree.fromstring(r.content)
return r
except Exception:
except:
r.encoding = 'utf-8'
if r.text == '':
# Answer does not contain a body
@ -275,7 +287,7 @@ class DownloadUtils():
# UNICODE - JSON object
r = r.json()
return r
except Exception:
except:
if '200 OK' in r.text:
# Received fucked up OK from PMS on playstate
# update
@ -289,19 +301,24 @@ class DownloadUtils():
elif r.status_code == 403:
# E.g. deleting a PMS item
LOG.warn('PMS sent 403: Forbidden error for url %s', url)
return
return None
else:
r.encoding = 'utf-8'
LOG.warn('Unknown answer from PMS %s with status code %s: %s',
url, r.status_code, r.text)
LOG.warn('Unknown answer from PMS %s with status code %s. ',
url, r.status_code)
return True
finally:
if not success and authenticate:
# Deal with the consequences of the exceptions
# Make the addon aware of status
self.count_error += 1
if self.count_error >= self.connection_attempts:
# And now deal with the consequences of the exceptions
if authenticate is True:
# 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)
app.CONN.online = False
window('plex_online', value="false")
except:
# 'countError' not yet set
pass
return None

File diff suppressed because it is too large Load diff

View file

@ -1,32 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
class PlaylistError(Exception):
"""
Exception for our playlist constructs
"""
pass
class LockedDatabase(Exception):
"""
Dedicated class to make sure we're not silently catching locked DBs.
"""
pass
class SubtitleError(Exception):
"""
Exceptions relating to subtitles
"""
pass
class ProcessingNotDone(Exception):
"""
Exception to detect whether we've completed our sync and did not have to
abort or suspend.
"""
pass

View file

@ -1,29 +1,137 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
###############################################################################
from logging import getLogger
from Queue import Queue
import xml.etree.ElementTree as etree
from xbmc import executebuiltin
from xbmc import executebuiltin, translatePath
from . import utils
from .utils import 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
from utils import settings, window, language as lang, try_decode, dialog, \
XmlKodiSetting, reboot_kodi
from migration import check_migration
from downloadutils import DownloadUtils as DU
from userclient import UserClient
from clientinfo import getDeviceId
import PlexFunctions as PF
import plex_tv
import json_rpc as js
import playqueue as PQ
from videonodes import VideoNodes
import state
import variables as v
###############################################################################
LOG = getLogger('PLEX.initialsetup')
LOG = getLogger("PLEX." + __name__)
###############################################################################
if not path_ops.exists(v.EXTERNAL_SUBTITLE_TEMP_PATH):
path_ops.makedirs(v.EXTERNAL_SUBTITLE_TEMP_PATH)
WINDOW_PROPERTIES = (
"plex_online", "plex_serverStatus", "plex_shouldStop", "plex_dbScan",
"plex_customplayqueue", "plex_playbackProps",
"pms_token", "plex_token", "pms_server", "plex_machineIdentifier",
"plex_servername", "plex_authenticated", "PlexUserImage", "useDirectPaths",
"countError", "countUnauthorized", "plex_restricteduser",
"plex_allows_mediaDeletion", "plex_command", "plex_result",
"plex_force_transcode_pix"
)
def reload_pkc():
"""
Will reload state.py entirely and then initiate some values from the Kodi
settings file
"""
LOG.info('Start (re-)loading PKC settings')
# Reset state.py
reload(state)
# Reset window props
for prop in WINDOW_PROPERTIES:
window(prop, clear=True)
# Clear video nodes properties
VideoNodes().clearProperties()
# Initializing
state.VERIFY_SSL_CERT = settings('sslverify') == 'true'
state.SSL_CERT_PATH = settings('sslcert') \
if settings('sslcert') != 'None' else None
state.FULL_SYNC_INTERVALL = int(settings('fullSyncInterval')) * 60
state.SYNC_THREAD_NUMBER = int(settings('syncThreadNumber'))
state.SYNC_DIALOG = settings('dbSyncIndicator') == 'true'
state.ENABLE_MUSIC = settings('enableMusic') == 'true'
state.BACKGROUND_SYNC_DISABLED = settings(
'enableBackgroundSync') == 'false'
state.BACKGROUNDSYNC_SAFTYMARGIN = int(
settings('backgroundsync_saftyMargin'))
state.REPLACE_SMB_PATH = settings('replaceSMB') == 'true'
state.REMAP_PATH = settings('remapSMB') == 'true'
state.KODI_PLEX_TIME_OFFSET = float(settings('kodiplextimeoffset'))
state.FETCH_PMS_ITEM_NUMBER = settings('fetch_pms_item_number')
state.FORCE_RELOAD_SKIN = settings('forceReloadSkinOnPlaybackStop') == 'true'
# Init some Queues()
state.COMMAND_PIPELINE_QUEUE = Queue()
state.COMPANION_QUEUE = Queue(maxsize=100)
state.WEBSOCKET_QUEUE = Queue()
set_replace_paths()
set_webserver()
# To detect Kodi profile switches
window('plex_kodiProfile',
value=try_decode(translatePath("special://profile")))
getDeviceId()
# Initialize the PKC playqueues
PQ.init_playqueues()
LOG.info('Done (re-)loading PKC settings')
def set_replace_paths():
"""
Sets our values for direct paths correctly (including using lower-case
protocols like smb:// and NOT SMB://)
"""
for typus in v.REMAP_TYPE_FROM_PLEXTYPE.values():
for arg in ('Org', 'New'):
key = 'remapSMB%s%s' % (typus, arg)
value = settings(key)
if '://' in value:
protocol = value.split('://', 1)[0]
value = value.replace(protocol, protocol.lower())
setattr(state, key, value)
def set_webserver():
"""
Set the Kodi webserver details - used to set the texture cache
"""
if js.get_setting('services.webserver') in (None, False):
# Enable the webserver, it is disabled
js.set_setting('services.webserver', True)
# Set standard port and username
# set_setting('services.webserverport', 8080)
# set_setting('services.webserverusername', 'kodi')
# Webserver already enabled
state.WEBSERVER_PORT = js.get_setting('services.webserverport')
state.WEBSERVER_USERNAME = js.get_setting('services.webserverusername')
state.WEBSERVER_PASSWORD = js.get_setting('services.webserverpassword')
def _write_pms_settings(url, token):
"""
Sets certain settings for server by asking for the PMS' settings
Call with url: scheme://ip:port
"""
xml = PF.get_PMS_settings(url, token)
try:
xml.attrib
except AttributeError:
LOG.error('Could not get PMS settings for %s', url)
return
for entry in xml:
if entry.attrib.get('id', '') == 'allowMediaDeletion':
settings('plex_allows_mediaDeletion',
value=entry.attrib.get('value', 'true'))
window('plex_allows_mediaDeletion',
value=entry.attrib.get('value', 'true'))
class InitialSetup(object):
@ -33,130 +141,30 @@ class InitialSetup(object):
"""
def __init__(self):
LOG.debug('Entering initialsetup class')
self.server = UserClient().get_server()
self.serverid = settings('plex_machineIdentifier')
# Get Plex credentials from settings file, if they exist
plexdict = PF.GetPlexLoginFromSettings()
self.myplexlogin = plexdict['myplexlogin'] == 'true'
self.plex_login = plexdict['plexLogin']
self.plex_login_id = plexdict['plexid']
self.plex_token = plexdict['plexToken']
self.plexid = plexdict['plexid']
# Token for the PMS, not plex.tv
self.pms_token = utils.settings('accessToken')
self.pms_token = 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
result = plex_tv.sign_in_with_pin()
if result:
self.plex_login = result['username']
self.plex_token = result['token']
self.plexid = result['plexid']
return True
return False
@ -172,20 +180,20 @@ class InitialSetup(object):
# 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='')
settings('plexToken', value='')
settings('plexLogin', value='')
# Could not login, please try again
utils.messageDialog(utils.lang(29999), utils.lang(39009))
dialog('ok', lang(29999), lang(39009))
answer = self.plex_tv_sign_in()
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))
dialog('ok', lang(29999), lang(39010))
answer = False
else:
LOG.info('plex.tv connection with token successful')
utils.settings('plex_status', value=utils.lang(39227))
settings('plex_status', value=lang(39227))
# Refresh the info from Plex.tv
xml = DU().downloadUrl('https://plex.tv/users/account',
authenticate=False,
@ -195,13 +203,15 @@ class InitialSetup(object):
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'))
settings('plexLogin', value=self.plex_login)
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
@staticmethod
def check_existing_pms():
def check_existing_pms(self):
"""
Check the PMS that was set in file settings.
Will return False if we need to reconnect, because:
@ -212,27 +222,26 @@ class InitialSetup(object):
not set before
"""
answer = True
chk = PF.check_connection(app.CONN.server,
verifySSL=True if v.KODIVERSION >= 18 else False)
chk = PF.check_connection(self.server, verifySSL=False)
if chk is False:
LOG.warn('Could not reach PMS %s', app.CONN.server)
LOG.warn('Could not reach PMS %s', self.server)
answer = False
if answer is True and not app.CONN.machine_identifier:
if answer is True and not self.serverid:
LOG.info('No PMS machineIdentifier found for %s. Trying to '
'get the PMS unique ID', app.CONN.server)
app.CONN.machine_identifier = PF.GetMachineIdentifier(app.CONN.server)
if app.CONN.machine_identifier is None:
'get the PMS unique ID', self.server)
self.serverid = PF.GetMachineIdentifier(self.server)
if self.serverid is None:
LOG.warn('Could not retrieve machineIdentifier')
answer = False
else:
utils.settings('plex_machineIdentifier', value=app.CONN.machine_identifier)
settings('plex_machineIdentifier', value=self.serverid)
elif answer is True:
temp_server_id = PF.GetMachineIdentifier(app.CONN.server)
if temp_server_id != app.CONN.machine_identifier:
temp_server_id = PF.GetMachineIdentifier(self.server)
if temp_server_id != 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',
app.CONN.server, app.CONN.machine_identifier, temp_server_id)
self.server, self.serverid, temp_server_id)
answer = False
return answer
@ -241,20 +250,22 @@ class InitialSetup(object):
"""
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']:
# Deactive SSL verification if the server is local for Kodi 17
verifySSL = True if v.KODIVERSION >= 18 else False
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
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)
chk = PF.check_connection(url,
token=server['token'],
verifySSL=verifySSL)
return chk
def pick_pms(self, showDialog=False, inform_of_search=False):
def pick_pms(self, showDialog=False):
"""
Searches for PMS in local Lan and optionally (if self.plex_token set)
also on plex.tv
@ -287,16 +298,19 @@ class InitialSetup(object):
}
or None if unsuccessful
"""
server = None
# If no server is set, let user choose one
if not app.CONN.server or not app.CONN.machine_identifier:
if not self.server or not self.serverid:
showDialog = True
if showDialog is True:
server = self._user_pick_pms()
else:
server = self._auto_pick_pms(show_dialog=inform_of_search)
server = self._auto_pick_pms()
if server is not None:
_write_pms_settings(server['baseURL'], server['token'])
return server
def _auto_pick_pms(self, show_dialog=False):
def _auto_pick_pms(self):
"""
Will try to pick PMS based on machineIdentifier saved in file settings
but only once
@ -305,47 +319,35 @@ class InitialSetup(object):
"""
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)
while True:
if https_updated is False:
serverlist = PF.discover_pms(self.plex_token)
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
LOG.info('We found a server to automatically connect to: %s',
server['name'])
return server
finally:
if show_dialog:
executebuiltin("Dialog.Close(all, true)")
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
def _user_pick_pms(self):
"""
@ -355,18 +357,18 @@ class InitialSetup(object):
"""
https_updated = False
# Searching for PMS
utils.dialog('notification',
heading='{plex}',
message=utils.lang(30001),
icon='{plex}',
time=60000)
dialog('notification',
heading='{plex}',
message=lang(30001),
icon='{plex}',
time=5000)
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))
dialog('ok', lang(29999), lang(39011))
return
# Get a nicer list
dialoglist = []
@ -374,10 +376,10 @@ class InitialSetup(object):
if server['local']:
# server is in the same network as client.
# Add"local"
msg = utils.lang(39022)
msg = lang(39022)
else:
# Add 'remote'
msg = utils.lang(39054)
msg = lang(39054)
if server.get('ownername'):
# Display username if its not our PMS
dialoglist.append('%s (%s, %s)'
@ -388,9 +390,7 @@ class InitialSetup(object):
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)
resp = dialog('select', lang(39012), dialoglist)
if resp == -1:
# User cancelled
return
@ -406,20 +406,20 @@ class InitialSetup(object):
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)))
# Please sign in to plex.tv
dialog('ok',
lang(29999),
lang(39013) + server['name'],
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
answ = dialog('yesno', lang(29999), lang(39015))
# Exit while loop if user chooses No
if not answ:
return
# Otherwise: connection worked!
else:
@ -430,62 +430,36 @@ class InitialSetup(object):
"""
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')
settings('plex_machineIdentifier', server['machineIdentifier'])
settings('plex_servername', server['name'])
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'])
settings('ipaddress', server['ip'])
settings('port', server['port'])
LOG.debug("Setting SSL verify to false, because server is "
"local")
utils.settings('sslverify', 'false')
settings('sslverify', 'false')
else:
baseURL = server['baseURL'].split(':')
scheme = baseURL[0]
utils.settings('ipaddress', baseURL[1].replace('//', ''))
utils.settings('port', baseURL[2])
settings('ipaddress', baseURL[1].replace('//', ''))
settings('port', baseURL[2])
LOG.debug("Setting SSL verify to true, because server is not "
"local")
utils.settings('sslverify', 'true')
settings('sslverify', 'true')
if scheme == 'https':
utils.settings('https', 'true')
settings('https', 'true')
else:
utils.settings('https', 'false')
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.
@ -495,22 +469,18 @@ class InitialSetup(object):
"""
LOG.info("Initial setup called.")
try:
with utils.XmlKodiSetting('advancedsettings.xml',
force_create=True,
top_element='advancedsettings') as xml:
with XmlKodiSetting('advancedsettings.xml',
force_create=True,
top_element='advancedsettings') as xml:
# Get current Kodi video cache setting
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')
# Disable cleaning of library - not compatible with PKC
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'],
@ -518,192 +488,161 @@ class InitialSetup(object):
xml.set_setting(['video', 'ignoresecondsatstart'],
value='60')
reboot = xml.write_xml
except utils.ParseError:
except etree.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)
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:
with XmlKodiSetting('sources.xml',
force_create=True,
top_element='sources') as xml:
root = xml.set_setting(['video'])
count = 2
for source in root.findall('.//path'):
if source.text == "smb://":
count -= 1
if count == 0:
# sources already set
break
else:
# Missing smb:// occurences, re-add.
for _ in range(0, count):
source = etree.SubElement(root, 'source')
etree.SubElement(
source,
'name').text = "PlexKodiConnect Masterlock Hack"
etree.SubElement(
source,
'path',
attrib={'pathversion': "1"}).text = "smb://"
etree.SubElement(source, 'allowsharing').text = "true"
if reboot is False:
reboot = xml.write_xml
except etree.ParseError:
pass
# Do we need to migrate stuff?
migration.check_migration()
check_migration()
# Reload the server IP cause we might've deleted it during migration
app.CONN.load()
self.server = UserClient().get_server()
# Display a warning if Kodi puts ALL movies into the queue, basically
# 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':
if js.settings_getsettingvalue('videoplayer.autoplaynextitem'):
LOG.warn('Kodi setting videoplayer.autoplaynextitem is enabled!')
if settings('warned_setting_videoplayer.autoplaynextitem') == 'false':
# Only warn once
utils.settings('warned_setting_videoplayer.autoplaynextitem',
value='true')
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)
if dialog('yesno', lang(29999), lang(30003)):
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 app.CONN.server:
LOG.info("PMS is already set: %s. Checking now...", app.CONN.server)
if self.server:
LOG.info("PMS is already set: %s. Checking now...", self.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()
self.server, self.serverid)
_write_pms_settings(self.server, self.pms_token)
if reboot is True:
utils.reboot_kodi()
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 self.plex_token and app.ACCOUNT.myplexlogin:
if not self.plex_token and self.myplexlogin:
self.plex_tv_sign_in()
server = self.pick_pms(inform_of_search=True)
server = self.pick_pms()
if server is not None:
# Write our chosen server to Kodi settings file
self.save_pms_settings(server['baseURL'], server['token'])
self.write_pms_to_settings(server)
# User already answered the installation questions
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 settings('InstallQuestionsAnswered') == 'true':
if reboot is True:
utils.reboot_kodi()
# Reload relevant settings
app.CONN.load()
app.ACCOUNT.load()
app.SYNC.load()
reboot_kodi()
return
LOG.info('Showing install questions')
# Additional settings where the user needs to choose
# Direct paths (\\NAS\mymovie.mkv) or addon (http)?
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:
if dialog('yesno',
lang(29999),
lang(39027),
lang(39028),
nolabel="Addon (Default)",
yeslabel="Native (Direct Paths)"):
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')
settings('useDirectPaths', value="1")
state.DIRECT_PATHS = 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 utils.yesno_dialog(utils.lang(29999), utils.lang(39033)):
if dialog('yesno', heading=lang(29999), line1=lang(39033)):
LOG.debug("User chose to replace paths with smb")
else:
utils.settings('replaceSMB', value="false")
settings('replaceSMB', value="false")
# complete replace all original Plex library paths with custom SMB
if utils.yesno_dialog(utils.lang(29999), utils.lang(39043)):
if dialog('yesno', heading=lang(29999), line1=lang(39043)):
LOG.debug("User chose custom smb paths")
utils.settings('remapSMB', value="true")
settings('remapSMB', value="true")
# Please enter your custom smb paths in the settings under
# "Sync Options" and then restart Kodi
utils.messageDialog(utils.lang(29999), utils.lang(39044))
dialog('ok', heading=lang(29999), line1=lang(39044))
goto_settings = True
# Go to network credentials?
if utils.yesno_dialog(utils.lang(39029), utils.lang(39030)):
if dialog('yesno',
heading=lang(29999),
line1=lang(39029),
line2=lang(39030)):
LOG.debug("Presenting network credentials dialog.")
from .windows import direct_path_sources
direct_path_sources.start()
from utils import passwords_xml
passwords_xml()
# Disable Plex music?
if utils.yesno_dialog(utils.lang(29999), utils.lang(39016)):
if dialog('yesno', heading=lang(29999), line1=lang(39016)):
LOG.debug("User opted to disable Plex music library.")
utils.settings('enableMusic', value="false")
settings('enableMusic', value="false")
# Download additional art from FanArtTV
if utils.yesno_dialog(utils.lang(29999), utils.lang(39061)):
if dialog('yesno', heading=lang(29999), line1=lang(39061)):
LOG.debug("User opted to use FanArtTV")
utils.settings('FanartTV', value="true")
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 utils.yesno_dialog(utils.lang(29999), utils.lang(39718)):
if dialog('yesno', heading=lang(29999), line1=lang(39718)):
LOG.debug("User opted to replace user ratings with version number")
utils.settings('indicate_media_versions', value="true")
settings('indicate_media_versions', value="true")
# If you use several Plex libraries of one kind, e.g. "Kids Movies" and
# "Parents Movies", be sure to check https://goo.gl/JFtQV9
# dialog.ok(heading=utils.lang(29999), line1=utils.lang(39076))
# dialog.ok(heading=lang(29999), line1=lang(39076))
# Need to tell about our image source for collections: themoviedb.org
# dialog.ok(heading=utils.lang(29999), line1=utils.lang(39717))
# dialog.ok(heading=lang(29999), line1=lang(39717))
# Make sure that we only ask these questions upon first installation
utils.settings('InstallQuestionsAnswered', value='true')
settings('InstallQuestionsAnswered', value='true')
if goto_settings is False:
# Open Settings page now? You will need to restart!
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)
goto_settings = dialog('yesno',
heading=lang(29999),
line1=lang(39017))
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()
state.PMS_STATUS = 'Stop'
executebuiltin('Addon.OpenSettings(plugin.video.plexkodiconnect)')
elif reboot is True:
reboot_kodi()

1826
resources/lib/itemtypes.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,30 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from .movies import Movie
from .tvshows import Show, Season, Episode
from .music import Artist, Album, Song
from .. import variables as v
# Note: always use same order of URL arguments, NOT urlencode:
# plex_id=<plex_id>&plex_type=<plex_type>&mode=play
ITEMTYPE_FROM_PLEXTYPE = {
v.PLEX_TYPE_MOVIE: Movie,
v.PLEX_TYPE_SHOW: Show,
v.PLEX_TYPE_SEASON: Season,
v.PLEX_TYPE_EPISODE: Episode,
v.PLEX_TYPE_ARTIST: Artist,
v.PLEX_TYPE_ALBUM: Album,
v.PLEX_TYPE_SONG: Song
}
ITEMTYPE_FROM_KODITYPE = {
v.KODI_TYPE_MOVIE: Movie,
v.KODI_TYPE_SHOW: Show,
v.KODI_TYPE_SEASON: Season,
v.KODI_TYPE_EPISODE: Episode,
v.KODI_TYPE_ARTIST: Artist,
v.KODI_TYPE_ALBUM: Album,
v.KODI_TYPE_SONG: Song
}

View file

@ -1,171 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from ntpath import dirname
from ..plex_db import PlexDB, PLEXDB_LOCK
from ..kodi_db import KodiVideoDB, KODIDB_LOCK
from .. import db, timing, app
LOG = getLogger('PLEX.itemtypes.common')
# Note: always use same order of URL arguments, NOT urlencode:
# plex_id=<plex_id>&plex_type=<plex_type>&mode=play
def process_path(playurl):
"""
Do NOT use os.path since we have paths that might not apply to the current
OS!
"""
if '\\' in playurl:
# Local path
path = '%s\\' % playurl
toplevelpath = '%s\\' % dirname(dirname(path))
else:
# Network path
path = '%s/' % playurl
toplevelpath = '%s/' % dirname(dirname(path))
return path, toplevelpath
class ItemBase(object):
"""
Items to be called with "with Items() as xxx:" to ensure that __enter__
method is called (opens db connections)
Input:
kodiType: optional argument; e.g. 'video' or 'music'
"""
def __init__(self, last_sync, plexdb=None, kodidb=None, lock=True):
self.last_sync = last_sync
self.lock = lock
self.plexdb = plexdb
self.kodidb = kodidb
self.plexconn = plexdb.plexconn if plexdb else None
self.plexcursor = plexdb.cursor if plexdb else None
self.kodiconn = kodidb.kodiconn if kodidb else None
self.kodicursor = kodidb.cursor if kodidb else None
self.artconn = kodidb.artconn if kodidb else None
self.artcursor = kodidb.artcursor if kodidb else None
def __enter__(self):
"""
Open DB connections and cursors
"""
if self.lock:
PLEXDB_LOCK.acquire()
KODIDB_LOCK.acquire()
self.plexconn = db.connect('plex')
self.plexcursor = self.plexconn.cursor()
self.kodiconn = db.connect('video')
self.kodicursor = self.kodiconn.cursor()
self.artconn = db.connect('texture')
self.artcursor = self.artconn.cursor()
self.plexdb = PlexDB(plexconn=self.plexconn, lock=False)
self.kodidb = KodiVideoDB(texture_db=True,
kodiconn=self.kodiconn,
artconn=self.artconn,
lock=False)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""
Make sure DB changes are committed and connection to DB is closed.
"""
try:
if exc_type:
# re-raise any exception
return False
self.plexconn.commit()
self.kodiconn.commit()
if self.artconn:
self.artconn.commit()
return self
finally:
self.plexconn.close()
self.kodiconn.close()
if self.artconn:
self.artconn.close()
if self.lock:
PLEXDB_LOCK.release()
KODIDB_LOCK.release()
def commit(self):
self.plexconn.commit()
self.plexconn.execute('BEGIN')
self.kodiconn.commit()
self.kodiconn.execute('BEGIN')
if self.artconn:
self.artconn.commit()
self.artconn.execute('BEGIN')
def set_fanart(self, artworks, kodi_id, kodi_type):
"""
Writes artworks [dict containing only set artworks] to the Kodi art DB
"""
self.kodidb.modify_artwork(artworks,
kodi_id,
kodi_type)
def update_playstate(self, mark_played, view_count, resume, duration,
kodi_fileid, kodi_fileid_2, lastViewedAt):
"""
Use with websockets, not xml
"""
# If the playback was stopped, check whether we need to increment the
# playcount. PMS won't tell us the playcount via websockets
if mark_played:
LOG.info('Marking item as completely watched in Kodi')
try:
view_count += 1
except TypeError:
view_count = 1
resume = 0
# Do the actual update
self.kodidb.set_resume(kodi_fileid,
resume,
duration,
view_count,
timing.plex_date_to_kodi(lastViewedAt))
if kodi_fileid_2:
# Our dirty hack for episodes
self.kodidb.set_resume(kodi_fileid_2,
resume,
duration,
view_count,
timing.plex_date_to_kodi(lastViewedAt))
@staticmethod
def sync_this_item(section_id):
"""
Returns False if we are NOT synching the corresponding Plex library
with section_id [int] to Kodi or if this sections has not yet been
encountered by PKC
"""
return section_id in app.SYNC.section_ids
def update_provider_ids(self, api, kodi_id):
"""
Updates the unique metadata provider ids (such as the IMDB id). Returns
a dict of the Kodi unique ids
"""
# We might have an old provider id stored!
self.kodidb.remove_uniqueid(kodi_id, api.kodi_type)
return self.add_provider_ids(api, kodi_id)
def add_provider_ids(self, api, kodi_id):
"""
Adds the unique ids for all metadata providers to the Kodi database,
such as IMDB or The Movie Database TMDB.
Returns a dict of the Kodi ids: {<provider>: <kodi_unique_id>}
"""
kodi_unique_ids = api.guids.copy()
for provider, provider_id in api.guids.iteritems():
kodi_unique_ids[provider] = self.kodidb.add_uniqueid(
kodi_id,
api.kodi_type,
provider_id,
provider)
return kodi_unique_ids

View file

@ -1,245 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from .common import ItemBase
from ..plex_api import API
from .. import app, variables as v, plex_functions as PF
LOG = getLogger('PLEX.movies')
class Movie(ItemBase):
"""
Used for plex library-type movies
"""
def add_update(self, xml, section_name=None, section_id=None,
children=None):
"""
Process single movie
"""
api = API(xml)
if not self.sync_this_item(section_id or api.library_section_id()):
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
'Kodi', api.plex_type, api.plex_id, api.title(),
section_id or api.library_section_id())
return
plex_id = api.plex_id
movie = self.plexdb.movie(plex_id)
if movie:
update_item = True
kodi_id = movie['kodi_id']
old_kodi_fileid = movie['kodi_fileid']
kodi_pathid = movie['kodi_pathid']
else:
update_item = False
kodi_id = self.kodidb.new_movie_id()
fullpath, path, filename = api.fullpath()
if app.SYNC.direct_paths and not fullpath.startswith('http'):
kodi_pathid = self.kodidb.add_path(path,
content='movies',
scraper='metadata.local')
else:
kodi_pathid = self.kodidb.get_path(path)
if update_item:
LOG.info('UPDATE movie plex_id: %s - %s', plex_id, api.title())
file_id = self.kodidb.modify_file(filename,
kodi_pathid,
api.date_created())
if file_id != old_kodi_fileid:
self.kodidb.remove_file(old_kodi_fileid)
rating_id = self.kodidb.update_ratings(kodi_id,
v.KODI_TYPE_MOVIE,
"default",
api.rating(),
api.votecount())
unique_id = self.update_provider_ids(api, kodi_id)
self.kodidb.modify_people(kodi_id,
v.KODI_TYPE_MOVIE,
api.people())
if app.SYNC.artwork:
self.kodidb.modify_artwork(api.artwork(),
kodi_id,
v.KODI_TYPE_MOVIE)
else:
LOG.info("ADD movie plex_id: %s - %s", plex_id, api.title())
file_id = self.kodidb.add_file(filename,
kodi_pathid,
api.date_created())
rating_id = self.kodidb.add_ratings(kodi_id,
v.KODI_TYPE_MOVIE,
"default",
api.rating(),
api.votecount())
unique_id = self.add_provider_ids(api, kodi_id)
self.kodidb.add_people(kodi_id,
v.KODI_TYPE_MOVIE,
api.people())
if app.SYNC.artwork:
self.kodidb.add_artwork(api.artwork(),
kodi_id,
v.KODI_TYPE_MOVIE)
unique_id = self._prioritize_provider_id(unique_id)
# Update Kodi's main entry
self.kodidb.add_movie(kodi_id,
file_id,
api.title(),
api.plot(),
api.shortplot(),
api.tagline(),
api.votecount(),
rating_id,
api.list_to_string(api.writers()),
api.year(),
unique_id,
api.sorttitle(),
api.runtime(),
api.content_rating(),
api.list_to_string(api.genres()),
api.list_to_string(api.directors()),
api.title(),
api.list_to_string(api.studios()),
api.trailer(),
api.list_to_string(api.countries()),
fullpath,
kodi_pathid,
api.premiere_date(),
api.userrating())
self.kodidb.modify_countries(kodi_id,
v.KODI_TYPE_MOVIE,
api.countries())
self.kodidb.modify_genres(kodi_id, v.KODI_TYPE_MOVIE, api.genres())
self.kodidb.modify_streams(file_id, api.mediastreams(), api.runtime())
self.kodidb.modify_studios(kodi_id, v.KODI_TYPE_MOVIE, api.studios())
tags = [section_name]
self._process_collections(api, tags, kodi_id, section_id, children)
self.kodidb.modify_tags(kodi_id, v.KODI_TYPE_MOVIE, tags)
# Process playstate
self.kodidb.set_resume(file_id,
api.resume_point(),
api.runtime(),
api.viewcount(),
api.lastplayed())
self.plexdb.add_movie(plex_id=plex_id,
checksum=api.checksum(),
section_id=section_id,
kodi_id=kodi_id,
kodi_fileid=file_id,
kodi_pathid=kodi_pathid,
last_sync=self.last_sync)
def remove(self, plex_id, plex_type=None):
"""
Remove a movie with all references and all orphaned associated entries
from the Kodi DB
"""
movie = self.plexdb.movie(plex_id)
try:
kodi_id = movie['kodi_id']
file_id = movie['kodi_fileid']
kodi_type = v.KODI_TYPE_MOVIE
LOG.debug('Removing movie with plex_id %s, kodi_id: %s',
plex_id, kodi_id)
except TypeError:
LOG.error('Movie with plex_id %s not found - cannot delete',
plex_id)
return
# Remove the plex reference
self.plexdb.remove(plex_id, v.PLEX_TYPE_MOVIE)
# Remove artwork
self.kodidb.delete_artwork(kodi_id, kodi_type)
set_id = self.kodidb.get_set_id(kodi_id)
self.kodidb.modify_countries(kodi_id, kodi_type)
self.kodidb.modify_people(kodi_id, kodi_type)
self.kodidb.modify_genres(kodi_id, kodi_type)
self.kodidb.modify_studios(kodi_id, kodi_type)
self.kodidb.modify_tags(kodi_id, kodi_type)
# Delete kodi movie and file
self.kodidb.remove_file(file_id)
self.kodidb.remove_movie(kodi_id)
if set_id:
self.kodidb.delete_possibly_empty_set(set_id)
self.kodidb.remove_uniqueid(kodi_id, kodi_type)
self.kodidb.remove_ratings(kodi_id, kodi_type)
LOG.debug('Deleted movie %s from kodi database', plex_id)
def update_userdata(self, xml_element, plex_type):
"""
Updates the Kodi watched state of the item from PMS. Also retrieves
Plex resume points for movies in progress.
Returns True if successful, False otherwise (e.g. item missing)
"""
api = API(xml_element)
# Get key and db entry on the Kodi db side
db_item = self.plexdb.item_by_id(api.plex_id, plex_type)
if not db_item:
LOG.info('Item not yet synced: %s', xml_element.attrib)
return False
# Write to Kodi DB
self.kodidb.set_resume(db_item['kodi_fileid'],
api.resume_point(),
api.runtime(),
api.viewcount(),
api.lastplayed())
self.kodidb.update_userrating(db_item['kodi_id'],
db_item['kodi_type'],
api.userrating())
return True
def _process_collections(self, api, tags, kodi_id, section_id, children):
for _, set_name in api.collections():
tags.append(set_name)
for plex_set_id, set_name in api.collections():
set_api = None
# Add any sets from Plex collection tags
kodi_set_id = self.kodidb.create_collection(set_name)
self.kodidb.assign_collection(kodi_set_id, kodi_id)
if not app.SYNC.artwork:
# Rest below is to get collection artwork
# TODO: continue instead of break (see TODO/break below)
break
if children is None:
# e.g. when added via websocket
LOG.debug('Costly looking up Plex collection %s: %s',
plex_set_id, set_name)
for index, coll_plex_id in api.collections_match(section_id):
# Get Plex artwork for collections - a pain
if index == plex_set_id:
set_xml = PF.GetPlexMetadata(coll_plex_id)
try:
set_xml.attrib
except AttributeError:
LOG.error('Could not get set metadata %s',
coll_plex_id)
continue
set_api = API(set_xml[0])
break
elif plex_set_id in children:
# Provided by get_metadata thread
set_api = API(children[plex_set_id][0])
if set_api:
self.kodidb.modify_artwork(set_api.artwork(),
kodi_set_id,
v.KODI_TYPE_SET)
# TODO: Once Kodi (19?) supports SEVERAL sets/collections per
# movie, support that. For now, we only take the very first
# collection/set that Plex returns
break
@staticmethod
def _prioritize_provider_id(unique_ids):
"""
Prioritize which ID ends up in the SHOW table (there can only be 1)
tvdb > imdb > tmdb
"""
return unique_ids.get('imdb',
unique_ids.get('tmdb',
unique_ids.get('tvdb')))

View file

@ -1,635 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from .common import ItemBase
from ..plex_api import API
from ..plex_db import PlexDB, PLEXDB_LOCK
from ..kodi_db import KodiMusicDB, KODIDB_LOCK
from .. import plex_functions as PF, db, timing, app, variables as v
LOG = getLogger('PLEX.music')
class MusicMixin(object):
def __enter__(self):
"""
Overwrite to use the Kodi music DB instead of the video DB
"""
if self.lock:
PLEXDB_LOCK.acquire()
KODIDB_LOCK.acquire()
self.plexconn = db.connect('plex')
self.plexcursor = self.plexconn.cursor()
self.kodiconn = db.connect('music')
self.kodicursor = self.kodiconn.cursor()
self.artconn = db.connect('texture')
self.artcursor = self.artconn.cursor()
self.plexdb = PlexDB(plexconn=self.plexconn, lock=False)
self.kodidb = KodiMusicDB(texture_db=True,
kodiconn=self.kodiconn,
artconn=self.artconn,
lock=False)
return self
def update_userdata(self, xml_element, plex_type):
"""
Updates the Kodi watched state of the item from PMS. Also retrieves
Plex resume points for movies in progress.
Returns True if successful, False otherwise (e.g. item missing)
"""
api = API(xml_element)
# Get key and db entry on the Kodi db side
db_item = self.plexdb.item_by_id(api.plex_id, plex_type)
if not db_item:
LOG.info('Item not yet synced: %s', xml_element.attrib)
return False
# Grab the user's viewcount, resume points etc. from PMS' answer
self.kodidb.update_userrating(db_item['kodi_id'],
db_item['kodi_type'],
api.userrating())
if plex_type == v.PLEX_TYPE_SONG:
self.kodidb.set_playcount(api.viewcount(),
api.lastplayed(),
db_item['kodi_id'],)
return True
def remove(self, plex_id, plex_type=None):
"""
Remove the entire music object, including all associated entries from
both Plex and Kodi DBs
"""
db_item = self.plexdb.item_by_id(plex_id, plex_type)
if not db_item:
LOG.debug('Cannot delete plex_id %s - not found in DB', plex_id)
return
LOG.debug('Removing %s %s with kodi_id: %s',
db_item['plex_type'], plex_id, db_item['kodi_id'])
# Remove the plex reference
self.plexdb.remove(plex_id, db_item['plex_type'])
# SONG #####
if db_item['plex_type'] == v.PLEX_TYPE_SONG:
# Delete episode, verify season and tvshow
self.remove_song(db_item['kodi_id'], db_item['kodi_pathid'])
# Album verification
if not self.plexdb.album_has_songs(db_item['album_id']):
# No songleft for this album - so delete the album
self.remove_album(db_item['parent_id'])
self.plexdb.remove(db_item['album_id'], v.PLEX_TYPE_ALBUM)
# Artist verification
if (not self.plexdb.artist_has_albums(db_item['artist_id']) and
not self.plexdb.artist_has_songs(db_item['artist_id'])):
self.remove_artist(db_item['grandparent_id'])
self.plexdb.remove(db_item['artist_id'], v.PLEX_TYPE_ARTIST)
# ALBUM #####
elif db_item['plex_type'] == v.PLEX_TYPE_ALBUM:
# Remove songs, album, verify artist
songs = list(self.plexdb.song_by_album(db_item['plex_id']))
for song in songs:
self.remove_song(song['kodi_id'], song['kodi_pathid'])
self.plexdb.remove(song['plex_id'], v.PLEX_TYPE_SONG)
# Remove the album
self.remove_album(db_item['kodi_id'])
# Show verification
if (not self.plexdb.artist_has_albums(db_item['kodi_id']) and
not self.plexdb.artist_has_songs(db_item['kodi_id'])):
# There's no other album or song left, delete the artist
self.remove_artist(db_item['parent_id'])
self.plexdb.remove(db_item['artist_id'], v.KODI_TYPE_ARTIST)
# ARTIST #####
elif db_item['plex_type'] == v.PLEX_TYPE_ARTIST:
# Remove songs, albums and the artist himself
songs = list(self.plexdb.song_by_artist(db_item['plex_id']))
for song in songs:
self.remove_song(song['kodi_id'], song['kodi_pathid'])
self.plexdb.remove(song['plex_id'], v.PLEX_TYPE_SONG)
albums = list(self.plexdb.album_by_artist(db_item['plex_id']))
for album in albums:
self.remove_album(album['kodi_id'])
self.plexdb.remove(album['plex_id'], v.PLEX_TYPE_ALBUM)
self.remove_artist(db_item['kodi_id'])
LOG.debug('Deleted %s %s from all databases',
db_item['plex_type'], db_item['plex_id'])
def remove_song(self, kodi_id, path_id=None):
"""
Remove song, orphaned artists and orphaned paths
"""
if not path_id:
path_id = self.kodidb.path_id_from_song(kodi_id)
self.kodidb.delete_song_from_song_artist(kodi_id)
self.kodidb.remove_song(kodi_id)
# Check whether we have orphaned path entries
if not self.kodidb.path_id_from_song(kodi_id):
self.kodidb.remove_path(path_id)
if v.KODIVERSION < 18:
self.kodidb.remove_albuminfosong(kodi_id)
self.kodidb.delete_artwork(kodi_id, v.KODI_TYPE_SONG)
def remove_album(self, kodi_id):
'''
Remove an album
'''
if v.KODIVERSION < 18:
self.kodidb.delete_album_from_album_genre(kodi_id)
self.kodidb.remove_album(kodi_id)
self.kodidb.delete_artwork(kodi_id, v.KODI_TYPE_ALBUM)
def remove_artist(self, kodi_id):
'''
Remove an artist and associated songs and albums
'''
self.kodidb.remove_artist(kodi_id)
self.kodidb.delete_artwork(kodi_id, v.KODI_TYPE_ARTIST)
class Artist(MusicMixin, ItemBase):
"""
For Plex library-type artists
"""
def add_update(self, xml, section_name=None, section_id=None,
children=None):
"""
Process a single artist
"""
api = API(xml)
if not self.sync_this_item(section_id or api.library_section_id()):
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
'Kodi', api.plex_type, api.plex_id, api.title(),
section_id or api.library_section_id())
return
plex_id = api.plex_id
artist = self.plexdb.artist(plex_id)
if not artist:
update_item = False
else:
update_item = True
kodi_id = artist['kodi_id']
# Not yet implemented by Plex
musicBrainzId = None
if app.SYNC.artwork:
artworks = api.artwork()
if 'poster' in artworks:
thumb = "<thumb>%s</thumb>" % artworks['poster']
else:
thumb = None
if 'fanart' in artworks:
fanart = "<fanart>%s</fanart>" % artworks['fanart']
else:
fanart = None
else:
thumb, fanart = None, None
# UPDATE THE ARTIST #####
if update_item:
LOG.info("UPDATE artist plex_id: %s - Name: %s", plex_id, api.title())
# OR ADD THE ARTIST #####
else:
LOG.info("ADD artist plex_id: %s - Name: %s", plex_id, api.title())
# safety checks: It looks like plex supports the same artist
# multiple times.
# Kodi doesn't allow that. In case that happens we just merge the
# artist entries.
kodi_id = self.kodidb.add_artist(api.title(), musicBrainzId)
self.kodidb.update_artist(api.list_to_string(api.genres()),
api.plot(),
thumb,
fanart,
timing.unix_date_to_kodi(self.last_sync),
kodi_id)
if app.SYNC.artwork:
self.kodidb.modify_artwork(artworks,
kodi_id,
v.KODI_TYPE_ARTIST)
self.plexdb.add_artist(plex_id,
api.checksum(),
section_id,
kodi_id,
self.last_sync)
class Album(MusicMixin, ItemBase):
def add_update(self, xml, section_name=None, section_id=None,
children=None, scan_children=True):
"""
Process a single album
scan_children: set to False if you don't want to add children, e.g. to
avoid infinite loops
"""
api = API(xml)
if not self.sync_this_item(section_id or api.library_section_id()):
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
'Kodi', api.plex_type, api.plex_id, api.title(),
section_id or api.library_section_id())
return
plex_id = api.plex_id
album = self.plexdb.album(plex_id)
if album:
update_item = True
kodi_id = album['kodi_id']
else:
update_item = False
# Parent artist - should always be present
parent_id = api.parent_id()
artist = self.plexdb.artist(parent_id)
if not artist:
LOG.info('Artist %s does not yet exist in DB', parent_id)
artist_xml = PF.GetPlexMetadata(parent_id)
try:
artist_xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not get artist %s xml for %s',
parent_id, xml.attrib)
return
Artist(self.last_sync,
plexdb=self.plexdb,
kodidb=self.kodidb).add_update(artist_xml[0],
section_name,
section_id)
artist = self.plexdb.artist(parent_id)
if not artist:
LOG.error('Adding artist %s failed for %s',
parent_id, xml.attrib)
return
artist_id = artist['kodi_id']
# See if we have a compilation - Plex does NOT feature a compilation
# flag for albums
compilation = 0
if children is None:
LOG.info('No children songs passed, getting them')
children = PF.GetAllPlexChildren(plex_id)
try:
children[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not get children for Plex id %s', plex_id)
return
for song in children:
if song.get('originalTitle') is not None:
compilation = 1
break
name = api.title()
# Not yet implemented by Plex, let's use unique last.fm or gracenote
musicBrainzId = None
genre = api.list_to_string(api.genres())
if app.SYNC.artwork:
artworks = api.artwork()
if 'poster' in artworks:
thumb = "<thumb>%s</thumb>" % artworks['poster']
else:
thumb = None
else:
thumb = None
# UPDATE THE ALBUM #####
if update_item:
LOG.info("UPDATE album plex_id: %s - Name: %s", plex_id, name)
if v.KODIVERSION >= 18:
self.kodidb.update_album(name,
musicBrainzId,
api.artist_name(),
genre,
api.year(),
compilation,
api.plot(),
thumb,
api.list_to_string(api.studios()),
api.userrating(),
timing.unix_date_to_kodi(self.last_sync),
'album',
kodi_id)
else:
self.kodidb.update_album_17(name,
musicBrainzId,
api.artist_name(),
genre,
api.year(),
compilation,
api.plot(),
thumb,
api.list_to_string(api.studios()),
api.userrating(),
timing.unix_date_to_kodi(self.last_sync),
'album',
kodi_id)
# OR ADD THE ALBUM #####
else:
LOG.info("ADD album plex_id: %s - Name: %s", plex_id, name)
kodi_id = self.kodidb.new_album_id()
if v.KODIVERSION >= 18:
self.kodidb.add_album(kodi_id,
name,
musicBrainzId,
api.artist_name(),
genre,
api.year(),
compilation,
api.plot(),
thumb,
api.list_to_string(api.studios()),
api.userrating(),
timing.unix_date_to_kodi(self.last_sync),
'album')
else:
self.kodidb.add_album_17(kodi_id,
name,
musicBrainzId,
api.artist_name(),
genre,
api.year(),
compilation,
api.plot(),
thumb,
api.list_to_string(api.studios()),
api.userrating(),
timing.unix_date_to_kodi(self.last_sync),
'album')
self.kodidb.add_albumartist(artist_id, kodi_id, api.artist_name())
if app.SYNC.artwork:
self.kodidb.modify_artwork(artworks,
kodi_id,
v.KODI_TYPE_ALBUM)
self.plexdb.add_album(plex_id,
api.checksum(),
section_id,
artist_id,
parent_id,
kodi_id,
self.last_sync)
# Add all children - all tracks
if scan_children:
context = Song(self.last_sync,
plexdb=self.plexdb,
kodidb=self.kodidb)
for song in children:
context.add_update(song,
section_name=section_name,
section_id=section_id,
album_xml=xml,
genres=api.genres(),
genre=genre,
compilation=compilation)
class Song(MusicMixin, ItemBase):
def add_update(self, xml, section_name=None, section_id=None,
children=None, album_xml=None, genres=None, genre=None,
compilation=None):
"""
Process single song/track
"""
api = API(xml)
if not self.sync_this_item(section_id or api.library_section_id()):
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
'Kodi', api.plex_type, api.plex_id, api.title(),
section_id or api.library_section_id())
return
plex_id = api.plex_id
song = self.plexdb.song(plex_id)
if song:
update_item = True
kodi_id = song['kodi_id']
kodi_pathid = song['kodi_pathid']
else:
update_item = False
kodi_id = self.kodidb.add_song_id()
artist_id = api.grandparent_id()
album_id = api.parent_id()
# The grandparent Artist - should always be present for every song!
artist = self.plexdb.artist(artist_id)
if not artist:
LOG.warn('Grandparent artist %s not found in DB, adding it',
artist_id)
artist_xml = PF.GetPlexMetadata(artist_id)
try:
artist_xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Grandparent tvartist %s xml download failed for %s',
artist_id, xml.attrib)
return
Artist(self.last_sync,
plexdb=self.plexdb,
kodidb=self.kodidb).add_update(artist_xml[0],
section_name,
section_id)
artist = self.plexdb.artist(artist_id)
if not artist:
LOG.error('Still could not find grandparent artist %s for %s',
artist_id, xml.attrib)
return
grandparent_id = artist['kodi_id']
# The parent Album
if not album_id:
# No album found, create a single's album
LOG.info('Creating singles album')
parent_id = self.kodidb.new_album_id()
if v.KODIVERSION >= 18:
self.kodidb.add_album(kodi_id,
None,
None,
None,
genre,
api.year(),
None,
None,
None,
None,
None,
timing.unix_date_to_kodi(self.last_sync),
'single')
else:
self.kodidb.add_album_17(kodi_id,
None,
None,
None,
genre,
api.year(),
None,
None,
None,
None,
None,
timing.unix_date_to_kodi(self.last_sync),
'single')
else:
album = self.plexdb.album(album_id)
if not album:
LOG.warn('Parent album %s not found in DB, adding it', album_id)
album_xml = PF.GetPlexMetadata(album_id)
try:
album_xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Parent album %s xml download failed for %s',
album_id, xml.attrib)
return
Album(self.last_sync,
plexdb=self.plexdb,
kodidb=self.kodidb).add_update(album_xml[0],
section_name,
section_id,
children=[xml],
scan_children=False)
album = self.plexdb.album(album_id)
if not album:
LOG.error('Still could not find parent album %s for %s',
album_id, xml.attrib)
return
parent_id = album['kodi_id']
title = api.title()
# Not yet implemented by Plex
musicBrainzId = None
comment = None
# Getting artists name is complicated
if compilation is not None:
if compilation == 0:
artists = api.grandparent_title()
else:
artists = xml.get('originalTitle')
else:
# compilation not set
artists = xml.get('originalTitle', api.grandparent_title())
tracknumber = api.index() or 0
disc = api.disc_number() or 1
if disc == 1:
track = tracknumber
else:
track = disc * 2 ** 16 + tracknumber
year = api.year()
if not year and album_xml is not None:
# Plex did not pass year info - get it from the parent album
album_api = API(album_xml)
year = album_api.year()
moods = []
for entry in xml:
if entry.tag == 'Mood':
moods.append(entry.attrib['tag'])
mood = api.list_to_string(moods)
_, path, filename = api.fullpath()
# UPDATE THE SONG #####
if update_item:
LOG.info("UPDATE song plex_id: %s - %s", plex_id, title)
# Use dummy strHash '123' for Kodi
self.kodidb.update_path(path, kodi_pathid)
# Update the song entry
if v.KODIVERSION >= 18:
# Kodi Leia
self.kodidb.update_song(parent_id,
artists,
genre,
title,
track,
api.runtime(),
year,
filename,
api.viewcount(),
api.lastplayed(),
api.userrating(),
comment,
mood,
api.date_created(),
kodi_id)
else:
self.kodidb.update_song_17(parent_id,
artists,
genre,
title,
track,
api.runtime(),
year,
filename,
api.viewcount(),
api.lastplayed(),
api.userrating(),
comment,
mood,
api.date_created(),
kodi_id)
# OR ADD THE SONG #####
else:
LOG.info("ADD song plex_id: %s - %s", plex_id, title)
# Add path
kodi_pathid = self.kodidb.add_path(path)
# Create the song entry
if v.KODIVERSION >= 18:
# Kodi Leia
self.kodidb.add_song(kodi_id,
parent_id,
kodi_pathid,
artists,
genre,
title,
track,
api.runtime(),
year,
filename,
musicBrainzId,
api.viewcount(),
api.lastplayed(),
api.userrating(),
0,
0,
mood,
api.date_created())
else:
self.kodidb.add_song_17(kodi_id,
parent_id,
kodi_pathid,
artists,
genre,
title,
track,
api.runtime(),
year,
filename,
musicBrainzId,
api.viewcount(),
api.lastplayed(),
api.userrating(),
0,
0,
mood,
api.date_created())
if v.KODIVERSION < 18:
# Link song to album
self.kodidb.add_albuminfosong(kodi_id,
parent_id,
track,
title,
api.runtime())
# Link song to artists
artist_name = api.grandparent_title()
# Do the actual linking
self.kodidb.add_song_artist(grandparent_id, kodi_id, artist_name)
# Add genres
if genres:
self.kodidb.add_music_genres(kodi_id, genres, v.KODI_TYPE_SONG)
if app.SYNC.artwork:
artworks = api.artwork()
self.kodidb.modify_artwork(artworks,
kodi_id,
v.KODI_TYPE_SONG)
if xml.get('parentKey') is None:
# Update album artwork
self.kodidb.modify_artwork(artworks,
parent_id,
v.KODI_TYPE_ALBUM)
self.plexdb.add_song(plex_id,
api.checksum(),
section_id,
artist_id,
grandparent_id,
album_id,
parent_id,
kodi_id,
kodi_pathid,
self.last_sync)

View file

@ -1,594 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from .common import ItemBase, process_path
from ..plex_api import API
from .. import plex_functions as PF, app, variables as v
LOG = getLogger('PLEX.tvshows')
class TvShowMixin(object):
def update_userdata(self, xml_element, plex_type):
"""
Updates the Kodi watched state of the item from PMS. Also retrieves
Plex resume points for movies in progress.
"""
api = API(xml_element)
# Get key and db entry on the Kodi db side
db_item = self.plexdb.item_by_id(api.plex_id, plex_type)
if not db_item:
LOG.info('Item not yet synced: %s', xml_element.attrib)
return False
# Grab the user's viewcount, resume points etc. from PMS' answer
self.kodidb.update_userrating(db_item['kodi_id'],
db_item['kodi_type'],
api.userrating())
if plex_type == v.PLEX_TYPE_EPISODE:
self.kodidb.set_resume(db_item['kodi_fileid'],
api.resume_point(),
api.runtime(),
api.viewcount(),
api.lastplayed())
if db_item['kodi_fileid_2']:
self.kodidb.set_resume(db_item['kodi_fileid_2'],
api.resume_point(),
api.runtime(),
api.viewcount(),
api.lastplayed())
return True
def remove(self, plex_id, plex_type=None):
"""
Remove the entire TV shows object (show, season or episode) including
all associated entries from the Kodi DB.
"""
db_item = self.plexdb.item_by_id(plex_id, plex_type)
if not db_item:
LOG.debug('Cannot delete plex_id %s - not found in DB', plex_id)
return
LOG.debug('Removing %s %s with kodi_id: %s',
db_item['plex_type'], plex_id, db_item['kodi_id'])
# Remove the plex reference
self.plexdb.remove(plex_id, db_item['plex_type'])
# EPISODE #####
if db_item['plex_type'] == v.PLEX_TYPE_EPISODE:
# Delete episode, verify season and tvshow
self.remove_episode(db_item)
# Season verification
if (db_item['season_id'] and
not self.plexdb.season_has_episodes(db_item['season_id'])):
# No episode left for this season - so delete the season
self.remove_season(db_item['parent_id'])
self.plexdb.remove(db_item['season_id'], v.PLEX_TYPE_SEASON)
# Show verification
if (not self.plexdb.show_has_seasons(db_item['show_id']) and
not self.plexdb.show_has_episodes(db_item['show_id'])):
# No seasons for show left - so delete entire show
self.remove_show(db_item['grandparent_id'])
self.plexdb.remove(db_item['show_id'], v.PLEX_TYPE_SHOW)
# SEASON #####
elif db_item['plex_type'] == v.PLEX_TYPE_SEASON:
# Remove episodes, season, verify tvshow
episodes = list(self.plexdb.episode_by_season(db_item['plex_id']))
for episode in episodes:
self.remove_episode(episode)
self.plexdb.remove(episode['plex_id'], v.PLEX_TYPE_EPISODE)
# Remove season
self.remove_season(db_item['kodi_id'])
# Show verification
if (not self.plexdb.show_has_seasons(db_item['show_id']) and
not self.plexdb.show_has_episodes(db_item['show_id'])):
# There's no other season or episode left, delete the show
self.remove_show(db_item['parent_id'])
self.plexdb.remove(db_item['show_id'], v.PLEX_TYPE_SHOW)
# TVSHOW #####
elif db_item['plex_type'] == v.PLEX_TYPE_SHOW:
# Remove episodes, seasons and the tvshow itself
seasons = list(self.plexdb.season_by_show(db_item['plex_id']))
for season in seasons:
self.remove_season(season['kodi_id'])
self.plexdb.remove(season['plex_id'], v.PLEX_TYPE_SEASON)
episodes = list(self.plexdb.episode_by_show(db_item['plex_id']))
for episode in episodes:
self.remove_episode(episode)
self.plexdb.remove(episode['plex_id'], v.PLEX_TYPE_EPISODE)
self.remove_show(db_item['kodi_id'])
LOG.debug('Deleted %s %s from all databases',
db_item['plex_type'], db_item['plex_id'])
def remove_show(self, kodi_id):
"""
Remove a TV show, and only the show, no seasons or episodes
"""
self.kodidb.modify_genres(kodi_id, v.KODI_TYPE_SHOW)
self.kodidb.modify_studios(kodi_id, v.KODI_TYPE_SHOW)
self.kodidb.modify_tags(kodi_id, v.KODI_TYPE_SHOW)
self.kodidb.delete_artwork(kodi_id, v.KODI_TYPE_SHOW)
self.kodidb.remove_show(kodi_id)
self.kodidb.remove_uniqueid(kodi_id, v.KODI_TYPE_SHOW)
self.kodidb.remove_ratings(kodi_id, v.KODI_TYPE_SHOW)
LOG.debug("Removed tvshow: %s", kodi_id)
def remove_season(self, kodi_id):
"""
Remove a season, and only a season, not the show or episodes
"""
self.kodidb.delete_artwork(kodi_id, v.KODI_TYPE_SEASON)
self.kodidb.remove_season(kodi_id)
LOG.debug("Removed season: %s", kodi_id)
def remove_episode(self, db_item):
"""
Remove an episode, and episode only from the Kodi DB (not Plex DB)
"""
self.kodidb.modify_people(db_item['kodi_id'], v.KODI_TYPE_EPISODE)
self.kodidb.remove_file(db_item['kodi_fileid'])
if db_item['kodi_fileid_2']:
self.kodidb.remove_file(db_item['kodi_fileid_2'])
self.kodidb.delete_artwork(db_item['kodi_id'], v.KODI_TYPE_EPISODE)
self.kodidb.remove_episode(db_item['kodi_id'])
self.kodidb.remove_uniqueid(db_item['kodi_id'], v.KODI_TYPE_EPISODE)
self.kodidb.remove_ratings(db_item['kodi_id'], v.KODI_TYPE_EPISODE)
LOG.debug("Removed episode: %s", db_item['kodi_id'])
class Show(TvShowMixin, ItemBase):
"""
For Plex library-type TV shows
"""
def add_update(self, xml, section_name=None, section_id=None,
children=None):
"""
Process a single show
"""
api = API(xml)
if not self.sync_this_item(section_id or api.library_section_id()):
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
'Kodi', api.plex_type, api.plex_id, api.title(),
section_id or api.library_section_id())
return
plex_id = api.plex_id
show = self.plexdb.show(plex_id)
if not show:
update_item = False
kodi_id = self.kodidb.new_show_id()
else:
update_item = True
kodi_id = show['kodi_id']
kodi_pathid = show['kodi_pathid']
# GET THE FILE AND PATH #####
if app.SYNC.direct_paths:
# Direct paths is set the Kodi way
playurl = api.validate_playurl(api.tv_show_path(),
api.plex_type,
folder=True)
if playurl is None:
return
path, toplevelpath = process_path(playurl)
toppathid = self.kodidb.add_path(toplevelpath,
content='tvshows',
scraper='metadata.local')
else:
# Set plugin path
toplevelpath = "plugin://%s.tvshows/" % v.ADDON_ID
path = "%s%s/" % (toplevelpath, plex_id)
# Do NOT set a parent id because addon-path cannot be "stacked"
toppathid = None
kodi_pathid = self.kodidb.add_path(path,
date_added=api.date_created(),
id_parent_path=toppathid)
# UPDATE THE TVSHOW #####
if update_item:
LOG.info("UPDATE tvshow plex_id: %s - %s", plex_id, api.title())
# update new ratings Kodi 17
rating_id = self.kodidb.update_ratings(kodi_id,
v.KODI_TYPE_SHOW,
"default",
api.rating(),
api.votecount())
unique_id = self._prioritize_provider_id(
self.update_provider_ids(api, kodi_id))
self.kodidb.modify_people(kodi_id,
v.KODI_TYPE_SHOW,
api.people())
if app.SYNC.artwork:
self.kodidb.modify_artwork(api.artwork(),
kodi_id,
v.KODI_TYPE_SHOW)
# Update the tvshow entry
self.kodidb.update_show(api.title(),
api.plot(),
rating_id,
api.premiere_date(),
api.list_to_string(api.genres()),
api.title(),
unique_id,
api.content_rating(),
api.list_to_string(api.studios()),
api.sorttitle(),
kodi_id)
# OR ADD THE TVSHOW #####
else:
LOG.info("ADD tvshow plex_id: %s - %s", plex_id, api.title())
# Link the path
self.kodidb.add_showlinkpath(kodi_id, kodi_pathid)
rating_id = self.kodidb.add_ratings(kodi_id,
v.KODI_TYPE_SHOW,
"default",
api.rating(),
api.votecount())
unique_id = self._prioritize_provider_id(
self.add_provider_ids(api, kodi_id))
self.kodidb.add_people(kodi_id,
v.KODI_TYPE_SHOW,
api.people())
if app.SYNC.artwork:
self.kodidb.add_artwork(api.artwork(),
kodi_id,
v.KODI_TYPE_SHOW)
# Create the tvshow entry
self.kodidb.add_show(kodi_id,
api.title(),
api.plot(),
rating_id,
api.premiere_date(),
api.list_to_string(api.genres()),
api.title(),
unique_id,
api.content_rating(),
api.list_to_string(api.studios()),
api.sorttitle())
self.kodidb.modify_genres(kodi_id, v.KODI_TYPE_SHOW, api.genres())
# Process studios
self.kodidb.modify_studios(kodi_id, v.KODI_TYPE_SHOW, api.studios())
# Process tags: view, PMS collection tags
tags = [section_name]
tags.extend([i for _, i in api.collections()])
self.kodidb.modify_tags(kodi_id, v.KODI_TYPE_SHOW, tags)
self.plexdb.add_show(plex_id=plex_id,
checksum=api.checksum(),
section_id=section_id,
kodi_id=kodi_id,
kodi_pathid=kodi_pathid,
last_sync=self.last_sync)
@staticmethod
def _prioritize_provider_id(unique_ids):
"""
Prioritize which ID ends up in the SHOW table (there can only be 1)
tvdb > imdb > tmdb
"""
return unique_ids.get('tvdb',
unique_ids.get('imdb',
unique_ids.get('tmdb')))
class Season(TvShowMixin, ItemBase):
def add_update(self, xml, section_name=None, section_id=None,
children=None):
"""
Process a single season of a certain tv show
"""
api = API(xml)
if not self.sync_this_item(section_id or api.library_section_id()):
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
'Kodi', api.plex_type, api.plex_id, api.season_name(),
section_id or api.library_section_id())
return
plex_id = api.plex_id
season = self.plexdb.season(plex_id)
if not season:
update_item = False
else:
update_item = True
show_id = api.parent_id()
show = self.plexdb.show(show_id)
if not show:
LOG.warn('Parent TV show %s not found in DB, adding it', show_id)
show_xml = PF.GetPlexMetadata(show_id)
try:
show_xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error("Parent tvshow %s xml download failed", show_id)
return False
Show(self.last_sync,
plexdb=self.plexdb,
kodidb=self.kodidb).add_update(show_xml[0],
section_name,
section_id)
show = self.plexdb.show(show_id)
if not show:
LOG.error('Still could not find parent tv show %s', show_id)
return
parent_id = show['kodi_id']
if app.SYNC.artwork:
parent_artwork = api.artwork(kodi_id=parent_id,
kodi_type=v.KODI_TYPE_SHOW)
artwork = api.artwork()
# Remove all artwork that is identical for the season's show
for key in parent_artwork:
if key in artwork and artwork[key] == parent_artwork[key]:
del artwork[key]
if update_item:
LOG.info('UPDATE season plex_id %s - %s',
plex_id, api.season_name())
kodi_id = season['kodi_id']
self.kodidb.update_season(kodi_id,
parent_id,
api.index(),
api.season_name(),
api.userrating() or None)
if app.SYNC.artwork:
self.kodidb.modify_artwork(artwork,
kodi_id,
v.KODI_TYPE_SEASON)
else:
LOG.info('ADD season plex_id %s - %s', plex_id, api.season_name())
kodi_id = self.kodidb.add_season(parent_id,
api.index(),
api.season_name(),
api.userrating() or None)
if app.SYNC.artwork:
self.kodidb.add_artwork(artwork,
kodi_id,
v.KODI_TYPE_SEASON)
self.plexdb.add_season(plex_id=plex_id,
checksum=api.checksum(),
section_id=section_id,
show_id=show_id,
parent_id=parent_id,
kodi_id=kodi_id,
last_sync=self.last_sync)
class Episode(TvShowMixin, ItemBase):
def add_update(self, xml, section_name=None, section_id=None,
children=None):
"""
Process single episode
"""
api = API(xml)
if not self.sync_this_item(section_id or api.library_section_id()):
LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
'Kodi', api.plex_type, api.plex_id, api.title(),
section_id or api.library_section_id())
return
plex_id = api.plex_id
episode = self.plexdb.episode(plex_id)
if not episode:
update_item = False
kodi_id = self.kodidb.new_episode_id()
else:
update_item = True
kodi_id = episode['kodi_id']
old_kodi_fileid = episode['kodi_fileid']
old_kodi_fileid_2 = episode['kodi_fileid_2']
kodi_pathid = episode['kodi_pathid']
airs_before_season = "-1"
airs_before_episode = "-1"
# The grandparent TV show
show = self.plexdb.show(api.show_id())
if not show:
LOG.warn('Grandparent TV show %s not found in DB, adding it', api.show_id())
show_xml = PF.GetPlexMetadata(api.show_id())
try:
show_xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error("Grandparent tvshow %s xml download failed", api.show_id())
return False
Show(self.last_sync,
plexdb=self.plexdb,
kodidb=self.kodidb).add_update(show_xml[0],
section_name,
section_id)
show = self.plexdb.show(api.show_id())
if not show:
LOG.error('Still could not find grandparent tv show %s', api.show_id())
return
grandparent_id = show['kodi_id']
# The parent Season
season = self.plexdb.season(api.season_id())
if not season and api.season_id():
LOG.warn('Parent season %s not found in DB, adding it', api.season_id())
season_xml = PF.GetPlexMetadata(api.season_id())
try:
season_xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error("Parent season %s xml download failed", api.season_id())
return False
Season(self.last_sync,
plexdb=self.plexdb,
kodidb=self.kodidb).add_update(season_xml[0],
section_name,
section_id)
season = self.plexdb.season(api.season_id())
if not season:
LOG.error('Still could not find parent season %s', api.season_id())
return
parent_id = season['kodi_id'] if season else None
fullpath, path, filename = api.fullpath()
if app.SYNC.direct_paths and not fullpath.startswith('http'):
parent_path_id = self.kodidb.parent_path_id(path)
kodi_pathid = self.kodidb.add_path(path,
id_parent_path=parent_path_id)
else:
# Root path tvshows/ already saved in Kodi DB
kodi_pathid = self.kodidb.add_path(path)
# need to set a 2nd file entry for a path without plex show id
# This fixes e.g. context menu and widgets working as they
# should
# A dirty hack, really
path_2 = 'plugin://%s.tvshows/' % v.ADDON_ID
# filename_2 is exactly the same as filename
# so WITH plex show id!
kodi_pathid_2 = self.kodidb.add_path(path_2)
# UPDATE THE EPISODE #####
if update_item:
LOG.info("UPDATE episode plex_id: %s - %s", plex_id, api.title())
kodi_fileid = self.kodidb.modify_file(filename,
kodi_pathid,
api.date_created())
if not app.SYNC.direct_paths:
kodi_fileid_2 = self.kodidb.modify_file(filename,
kodi_pathid_2,
api.date_created())
else:
kodi_fileid_2 = None
if kodi_fileid != old_kodi_fileid:
self.kodidb.remove_file(old_kodi_fileid)
if not app.SYNC.direct_paths:
self.kodidb.remove_file(old_kodi_fileid_2)
ratingid = self.kodidb.update_ratings(kodi_id,
v.KODI_TYPE_EPISODE,
"default",
api.rating(),
api.votecount())
unique_id = self._prioritize_provider_id(
self.update_provider_ids(api, kodi_id))
self.kodidb.modify_people(kodi_id,
v.KODI_TYPE_EPISODE,
api.people())
if app.SYNC.artwork:
self.kodidb.modify_artwork(api.artwork(),
kodi_id,
v.KODI_TYPE_EPISODE)
self.kodidb.update_episode(api.title(),
api.plot(),
ratingid,
api.list_to_string(api.writers()),
api.premiere_date(),
api.runtime(),
api.list_to_string(api.directors()),
api.season_number(),
api.index(),
api.title(),
airs_before_season,
airs_before_episode,
fullpath,
kodi_pathid,
unique_id,
kodi_fileid, # and NOT kodi_fileid_2
parent_id,
api.userrating(),
kodi_id)
self.kodidb.set_resume(kodi_fileid,
api.resume_point(),
api.runtime(),
api.viewcount(),
api.lastplayed())
if not app.SYNC.direct_paths:
self.kodidb.set_resume(kodi_fileid_2,
api.resume_point(),
api.runtime(),
api.viewcount(),
api.lastplayed())
self.plexdb.add_episode(plex_id=plex_id,
checksum=api.checksum(),
section_id=section_id,
show_id=api.show_id(),
grandparent_id=grandparent_id,
season_id=api.season_id(),
parent_id=parent_id,
kodi_id=kodi_id,
kodi_fileid=kodi_fileid,
kodi_fileid_2=kodi_fileid_2,
kodi_pathid=kodi_pathid,
last_sync=self.last_sync)
# OR ADD THE EPISODE #####
else:
LOG.info("ADD episode plex_id: %s - %s", plex_id, api.title())
kodi_fileid = self.kodidb.add_file(filename,
kodi_pathid,
api.date_created())
if not app.SYNC.direct_paths:
kodi_fileid_2 = self.kodidb.add_file(filename,
kodi_pathid_2,
api.date_created())
else:
kodi_fileid_2 = None
rating_id = self.kodidb.add_ratings(kodi_id,
v.KODI_TYPE_EPISODE,
"default",
api.rating(),
api.votecount())
unique_id = self._prioritize_provider_id(
self.add_provider_ids(api, kodi_id))
self.kodidb.add_people(kodi_id,
v.KODI_TYPE_EPISODE,
api.people())
if app.SYNC.artwork:
self.kodidb.add_artwork(api.artwork(),
kodi_id,
v.KODI_TYPE_EPISODE)
self.kodidb.add_episode(kodi_id,
kodi_fileid, # and NOT kodi_fileid_2
api.title(),
api.plot(),
rating_id,
api.list_to_string(api.writers()),
api.premiere_date(),
api.runtime(),
api.list_to_string(api.directors()),
api.season_number(),
api.index(),
api.title(),
grandparent_id,
airs_before_season,
airs_before_episode,
fullpath,
kodi_pathid,
unique_id,
parent_id,
api.userrating())
self.kodidb.set_resume(kodi_fileid,
api.resume_point(),
api.runtime(),
api.viewcount(),
api.lastplayed())
if not app.SYNC.direct_paths:
self.kodidb.set_resume(kodi_fileid_2,
api.resume_point(),
api.runtime(),
api.viewcount(),
api.lastplayed())
self.plexdb.add_episode(plex_id=plex_id,
checksum=api.checksum(),
section_id=section_id,
show_id=api.show_id(),
grandparent_id=grandparent_id,
season_id=api.season_id(),
parent_id=parent_id,
kodi_id=kodi_id,
kodi_fileid=kodi_fileid,
kodi_fileid_2=kodi_fileid_2,
kodi_pathid=kodi_pathid,
last_sync=self.last_sync)
self.kodidb.modify_streams(kodi_fileid, # and NOT kodi_fileid_2
api.mediastreams(),
api.runtime())
@staticmethod
def _prioritize_provider_id(unique_ids):
"""
Prioritize which ID ends up in the SHOW table (there can only be 1)
tvdb > imdb > tmdb
"""
return unique_ids.get('tvdb',
unique_ids.get('imdb',
unique_ids.get('tmdb')))

View file

@ -1,34 +1,11 @@
#!/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 utils import millis_to_kodi_time
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):
"""
@ -170,12 +147,12 @@ def stop():
def seek_to(offset):
"""
Seeks all Kodi players to offset [int] in milliseconds
Seeks all Kodi players to offset [int]
"""
for playerid in get_player_ids():
return JsonRPC("Player.Seek").execute(
JsonRPC("Player.Seek").execute(
{"playerid": playerid,
"value": timing.millis_to_kodi_time(offset)})
"value": millis_to_kodi_time(offset)})
def smallforward():
@ -421,41 +398,6 @@ def get_item(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:
@ -611,16 +553,3 @@ def settings_setsettingvalue(setting, value):
'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 {}

View file

@ -1,92 +0,0 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
'''
script.module.metadatautils
kodi_constants.py
Several common constants for use with Kodi json api
'''
FIELDS_BASE = ['dateadded', 'file', 'lastplayed', 'plot', 'title', 'art',
'playcount']
FIELDS_FILE = FIELDS_BASE + ['streamdetails', 'director', 'resume', 'runtime']
FIELDS_MOVIES = FIELDS_FILE + ['plotoutline', 'sorttitle', 'cast', 'votes',
'showlink', 'top250', 'trailer', 'year', 'country', 'studio', 'set',
'genre', 'mpaa', 'setid', 'rating', 'tag', 'tagline', 'writer',
'originaltitle', 'imdbnumber', 'uniqueid']
FIELDS_TVSHOWS = FIELDS_BASE + ['sorttitle', 'mpaa', 'premiered', 'year',
'episode', 'watchedepisodes', 'votes', 'rating', 'studio', 'season',
'genre', 'cast', 'episodeguide', 'tag', 'originaltitle', 'imdbnumber']
FIELDS_SEASON = ['art', 'playcount', 'season', 'showtitle', 'episode',
'tvshowid', 'watchedepisodes', 'userrating', 'fanart', 'thumbnail']
FIELDS_EPISODES = FIELDS_FILE + ['cast', 'productioncode', 'rating', 'votes',
'episode', 'showtitle', 'tvshowid', 'season', 'firstaired', 'writer',
'originaltitle']
FIELDS_MUSICVIDEOS = FIELDS_FILE + ['genre', 'artist', 'tag', 'album', 'track',
'studio', 'year']
FIELDS_FILES = FIELDS_FILE + ['plotoutline', 'sorttitle', 'cast', 'votes',
'trailer', 'year', 'country', 'studio', 'genre', 'mpaa', 'rating',
'tagline', 'writer', 'originaltitle', 'imdbnumber', 'premiered', 'episode',
'showtitle', 'firstaired', 'watchedepisodes', 'duration', 'season']
FIELDS_SONGS = ['artist', 'displayartist', 'title', 'rating', 'fanart',
'thumbnail', 'duration', 'disc', 'playcount', 'comment', 'file', 'album',
'lastplayed', 'genre', 'musicbrainzartistid', 'track', 'dateadded']
FIELDS_ALBUMS = ['title', 'fanart', 'thumbnail', 'genre', 'displayartist',
'artist', 'musicbrainzalbumartistid', 'year', 'rating', 'artistid',
'musicbrainzalbumid', 'theme', 'description', 'type', 'style', 'playcount',
'albumlabel', 'mood', 'dateadded']
FIELDS_ARTISTS = ['born', 'formed', 'died', 'style', 'yearsactive', 'mood',
'fanart', 'thumbnail', 'musicbrainzartistid', 'disbanded', 'description',
'instrument']
FIELDS_RECORDINGS = ['art', 'channel', 'directory', 'endtime', 'file', 'genre',
'icon', 'playcount', 'plot', 'plotoutline', 'resume', 'runtime',
'starttime', 'streamurl', 'title']
FIELDS_CHANNELS = ['broadcastnow', 'channeltype', 'hidden', 'locked',
'lastplayed', 'thumbnail', 'channel']
FILTER_UNWATCHED = {
'operator': 'lessthan',
'field': 'playcount',
'value': '1'
}
FILTER_WATCHED = {
'operator': 'isnot',
'field': 'playcount',
'value': '0'
}
FILTER_RATING = {
'operator': 'greaterthan',
'field': 'rating',
'value': '7'
}
FILTER_RATING_MUSIC = {
'operator': 'greaterthan',
'field': 'rating',
'value': '3'
}
FILTER_INPROGRESS = {
'operator': 'true',
'field': 'inprogress',
'value': ''
}
SORT_RATING = {
'method': 'rating',
'order': 'descending'
}
SORT_RANDOM = {
'method': 'random',
'order': 'descending'
}
SORT_TITLE = {
'method': 'title',
'order': 'ascending'
}
SORT_DATEADDED = {
'method': 'dateadded',
'order': 'descending'
}
SORT_LASTPLAYED = {
'method': 'lastplayed',
'order': 'descending'
}
SORT_EPISODE = {
'method': 'episode'
}

View file

@ -1,126 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from .common import KODIDB_LOCK
from .video import KodiVideoDB
from .music import KodiMusicDB
from .texture import KodiTextureDB
from .. import path_ops, utils, variables as v
LOG = getLogger('PLEX.kodi_db')
def kodiid_from_filename(path, kodi_type=None, db_type=None):
"""
Returns kodi_id if we have an item in the Kodi video or audio database with
said path. Feed with either koditype, e.v. 'movie', 'song' or the DB
you want to poll ('video' or 'music')
Returns None, <kodi_type> if not possible
"""
kodi_id = None
path = utils.try_decode(path)
# Make sure path ends in either '/' or '\'
# We CANNOT use path_ops.path.join as this can result in \ where we need /
try:
filename = path.rsplit('/', 1)[1]
path = path.rsplit('/', 1)[0] + '/'
except IndexError:
filename = path.rsplit('\\', 1)[1]
path = path.rsplit('\\', 1)[0] + '\\'
if kodi_type == v.KODI_TYPE_SONG or db_type == 'music':
with KodiMusicDB(lock=False) as kodidb:
try:
kodi_id = kodidb.song_id_from_filename(filename, path)
except TypeError:
LOG.debug('No Kodi audio db element found for path %s', path)
else:
kodi_type = v.KODI_TYPE_SONG
else:
with KodiVideoDB(lock=False) as kodidb:
try:
kodi_id, kodi_type = kodidb.video_id_from_filename(filename,
path)
except TypeError:
LOG.debug('No kodi video db element found for path %s file %s',
path, filename)
return kodi_id, kodi_type
def setup_kodi_default_entries():
"""
Makes sure that we retain the Kodi standard databases. E.g. that there
is a dummy artist with ID 1
"""
if utils.settings('enableMusic') == 'true':
with KodiMusicDB() as kodidb:
kodidb.setup_kodi_default_entries()
def reset_cached_images():
LOG.info('Resetting cached artwork')
LOG.debug('Resetting the Kodi texture DB')
with KodiTextureDB() as kodidb:
kodidb.wipe()
LOG.debug('Deleting all cached image files')
path = path_ops.translate_path('special://thumbnails/')
if path_ops.exists(path):
path_ops.rmtree(path, ignore_errors=True)
paths = ('', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'a', 'b', 'c', 'd', 'e', 'f',
'Video', 'plex')
for path in paths:
new_path = path_ops.translate_path('special://thumbnails/%s' % path)
try:
path_ops.makedirs(path_ops.encode_path(new_path))
except OSError as err:
LOG.warn('Could not create thumbnail directory %s: %s',
new_path, err)
LOG.info('Done resetting cached artwork')
def wipe_dbs(music=True):
"""
Completely resets the Kodi databases 'video', 'texture' and 'music' (if
music sync is enabled)
We need to connect without sqlite WAL mode as Kodi might still be accessing
the dbs and we need to prevent that
"""
LOG.warn('Wiping Kodi databases!')
LOG.info('Wiping Kodi video database')
with KodiVideoDB() as kodidb:
kodidb.wipe()
if music:
LOG.info('Wiping Kodi music database')
with KodiMusicDB() as kodidb:
kodidb.wipe()
reset_cached_images()
setup_kodi_default_entries()
# Delete SQLITE wal files
import xbmc
# Make sure Kodi knows we wiped the databases
xbmc.executebuiltin('UpdateLibrary(video)')
if utils.settings('enableMusic') == 'true':
xbmc.executebuiltin('UpdateLibrary(music)')
def create_kodi_db_indicees():
"""
Index the "actors" because we got a TON - speed up SELECT and WHEN
"""
with KodiVideoDB() as kodidb:
kodidb.create_kodi_db_indicees()
KODIDB_FROM_PLEXTYPE = {
v.PLEX_TYPE_MOVIE: KodiVideoDB,
v.PLEX_TYPE_SHOW: KodiVideoDB,
v.PLEX_TYPE_SEASON: KodiVideoDB,
v.PLEX_TYPE_EPISODE: KodiVideoDB,
v.PLEX_TYPE_ARTIST: KodiMusicDB,
v.PLEX_TYPE_ALBUM: KodiMusicDB,
v.PLEX_TYPE_SONG: KodiMusicDB
}

View file

@ -1,155 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from threading import Lock
from .. import db, path_ops
KODIDB_LOCK = Lock()
# Names of tables we generally leave untouched and e.g. don't wipe
UNTOUCHED_TABLES = ('version', 'versiontagscan')
class KodiDBBase(object):
"""
Kodi database methods used for all types of items
"""
def __init__(self, texture_db=False, kodiconn=None, artconn=None,
lock=True):
"""
Allows direct use with a cursor instead of context mgr
"""
self._texture_db = texture_db
self.lock = lock
self.kodiconn = kodiconn
self.cursor = self.kodiconn.cursor() if self.kodiconn else None
self.artconn = artconn
self.artcursor = self.artconn.cursor() if self.artconn else None
def __enter__(self):
if self.lock:
KODIDB_LOCK.acquire()
self.kodiconn = db.connect(self.db_kind)
self.cursor = self.kodiconn.cursor()
self.artconn = db.connect('texture') if self._texture_db \
else None
self.artcursor = self.artconn.cursor() if self._texture_db else None
return self
def __exit__(self, e_typ, e_val, trcbak):
try:
if e_typ:
# re-raise any exception
return False
self.kodiconn.commit()
if self.artconn:
self.artconn.commit()
finally:
self.kodiconn.close()
if self.artconn:
self.artconn.close()
if self.lock:
KODIDB_LOCK.release()
def art_urls(self, kodi_id, kodi_type):
return (x[0] for x in
self.cursor.execute('SELECT url FROM art WHERE media_id = ? AND media_type = ?',
(kodi_id, kodi_type)))
def artwork_generator(self, kodi_type, limit, offset):
query = 'SELECT url FROM art WHERE type == ? LIMIT ? OFFSET ?'
return (x[0] for x in
self.cursor.execute(query, (kodi_type, limit, offset)))
def add_artwork(self, artworks, kodi_id, kodi_type):
"""
Pass in an artworks dict (see PlexAPI) to set an items artwork.
"""
for kodi_art, url in artworks.iteritems():
self.add_art(url, kodi_id, kodi_type, kodi_art)
@db.catch_operationalerrors
def add_art(self, url, kodi_id, kodi_type, kodi_art):
"""
Adds or modifies the artwork of kind kodi_art (e.g. 'poster') in the
Kodi art table for item kodi_id/kodi_type. Will also cache everything
except actor portraits.
"""
self.cursor.execute('''
INSERT INTO art(media_id, media_type, type, url)
VALUES (?, ?, ?, ?)
''', (kodi_id, kodi_type, kodi_art, url))
def modify_artwork(self, artworks, kodi_id, kodi_type):
"""
Pass in an artworks dict (see PlexAPI) to set an items artwork.
"""
for kodi_art, url in artworks.iteritems():
self.modify_art(url, kodi_id, kodi_type, kodi_art)
@db.catch_operationalerrors
def modify_art(self, url, kodi_id, kodi_type, kodi_art):
"""
Adds or modifies the artwork of kind kodi_art (e.g. 'poster') in the
Kodi art table for item kodi_id/kodi_type. Will also cache everything
except actor portraits.
"""
self.cursor.execute('''
SELECT url FROM art
WHERE media_id = ? AND media_type = ? AND type = ?
LIMIT 1
''', (kodi_id, kodi_type, kodi_art,))
try:
# Update the artwork
old_url = self.cursor.fetchone()[0]
except TypeError:
# Add the artwork
self.cursor.execute('''
INSERT INTO art(media_id, media_type, type, url)
VALUES (?, ?, ?, ?)
''', (kodi_id, kodi_type, kodi_art, url))
else:
if url == old_url:
# Only cache artwork if it changed
return
self.delete_cached_artwork(old_url)
self.cursor.execute('''
UPDATE art SET url = ?
WHERE media_id = ? AND media_type = ? AND type = ?
''', (url, kodi_id, kodi_type, kodi_art))
def delete_artwork(self, kodi_id, kodi_type):
self.cursor.execute('SELECT url FROM art WHERE media_id = ? AND media_type = ?',
(kodi_id, kodi_type, ))
for row in self.cursor.fetchall():
self.delete_cached_artwork(row[0])
@db.catch_operationalerrors
def delete_cached_artwork(self, url):
try:
self.artcursor.execute("SELECT cachedurl FROM texture WHERE url = ? LIMIT 1",
(url, ))
cachedurl = self.artcursor.fetchone()[0]
except TypeError:
# Could not find cached url
pass
else:
# Delete thumbnail as well as the entry
path = path_ops.translate_path("special://thumbnails/%s"
% cachedurl)
if path_ops.exists(path):
path_ops.rmtree(path, ignore_errors=True)
self.artcursor.execute("DELETE FROM texture WHERE url = ?", (url, ))
@db.catch_operationalerrors
def wipe(self):
"""
Completely wipes the corresponding Kodi database
"""
self.cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table'")
tables = [i[0] for i in self.cursor.fetchall()]
for table in UNTOUCHED_TABLES:
if table in tables:
tables.remove(table)
for table in tables:
self.cursor.execute('DELETE FROM %s' % table)

View file

@ -1,628 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from . import common
from .. import db, variables as v, app, timing
LOG = getLogger('PLEX.kodi_db.music')
class KodiMusicDB(common.KodiDBBase):
db_kind = 'music'
@db.catch_operationalerrors
def add_path(self, path):
"""
Add the path (unicode) to the music DB, if it does not exist already.
Returns the path id
"""
# SQL won't return existing paths otherwise
path = '' if path is None else path
self.cursor.execute('SELECT idPath FROM path WHERE strPath = ?',
(path,))
try:
pathid = self.cursor.fetchone()[0]
except TypeError:
self.cursor.execute('INSERT INTO path(strPath, strHash) VALUES (?, ?)',
(path, '123'))
pathid = self.cursor.lastrowid
return pathid
@db.catch_operationalerrors
def setup_kodi_default_entries(self):
"""
Makes sure that we retain the Kodi standard databases. E.g. that there
is a dummy artist with ID 1
"""
self.cursor.execute('''
INSERT OR REPLACE INTO artist(
idArtist,
strArtist,
strMusicBrainzArtistID)
VALUES (?, ?, ?)
''', (1, '[Missing Tag]', 'Artist Tag Missing'))
self.cursor.execute('''
INSERT OR REPLACE INTO role(
idRole,
strRole)
VALUES (?, ?)
''', (1, 'Artist'))
if v.KODIVERSION >= 18:
self.cursor.execute('DELETE FROM versiontagscan')
self.cursor.execute('''
INSERT INTO versiontagscan(
idVersion,
iNeedsScan,
lastscanned)
VALUES (?, ?, ?)
''', (v.DB_MUSIC_VERSION,
0,
timing.kodi_now()))
@db.catch_operationalerrors
def update_path(self, path, kodi_pathid):
self.cursor.execute('''
UPDATE path
SET strPath = ?, strHash = ?
WHERE idPath = ?
''', (path, '123', kodi_pathid))
def song_id_from_filename(self, filename, path):
"""
Returns the Kodi song_id from the Kodi music database or None if not
found OR something went wrong.
"""
self.cursor.execute('SELECT idPath FROM path WHERE strPath = ?',
(path, ))
path_ids = self.cursor.fetchall()
if len(path_ids) != 1:
LOG.debug('Found wrong number of path ids: %s for path %s, abort',
path_ids, path)
return
self.cursor.execute('SELECT idSong FROM song WHERE strFileName = ? AND idPath = ?',
(filename, path_ids[0][0]))
song_ids = self.cursor.fetchall()
if len(song_ids) != 1:
LOG.info('Found wrong number of songs %s, abort', song_ids)
return
return song_ids[0][0]
@db.catch_operationalerrors
def delete_song_from_song_artist(self, song_id):
"""
Deletes son from song_artist table and possibly orphaned roles
"""
self.cursor.execute('''
SELECT idArtist, idRole FROM song_artist
WHERE idSong = ? LIMIT 1
''', (song_id, ))
artist = self.cursor.fetchone()
if not artist:
# No entry to begin with
return
# Delete the entry
self.cursor.execute('DELETE FROM song_artist WHERE idSong = ?',
(song_id, ))
@db.catch_operationalerrors
def delete_song_from_song_genre(self, song_id):
"""
Deletes the one entry with id song_id from the song_genre table.
Will also delete orphaned genres from genre table
"""
self.cursor.execute('SELECT idGenre FROM song_genre WHERE idSong = ?',
(song_id, ))
genres = self.cursor.fetchall()
self.cursor.execute('DELETE FROM song_genre WHERE idSong = ?',
(song_id, ))
# Check for orphaned genres in both song_genre and album_genre tables
for genre in genres:
self.cursor.execute('SELECT idGenre FROM song_genre WHERE idGenre = ? LIMIT 1',
(genre[0], ))
if not self.cursor.fetchone():
self.cursor.execute('SELECT idGenre FROM album_genre WHERE idGenre = ? LIMIT 1',
(genre[0], ))
if not self.cursor.fetchone():
self.delete_genre(genre[0])
@db.catch_operationalerrors
def delete_genre(self, genre_id):
"""
Dedicated method in order to catch OperationalErrors correctly
"""
self.cursor.execute('DELETE FROM genre WHERE idGenre = ?',
(genre_id, ))
@db.catch_operationalerrors
def delete_album_from_album_genre(self, album_id):
"""
Deletes the one entry with id album_id from the album_genre table.
Will also delete orphaned genres from genre table
"""
self.cursor.execute('SELECT idGenre FROM album_genre WHERE idAlbum = ?',
(album_id, ))
genres = self.cursor.fetchall()
self.cursor.execute('DELETE FROM album_genre WHERE idAlbum = ?',
(album_id, ))
# Check for orphaned genres in both album_genre and song_genre tables
for genre in genres:
self.cursor.execute('SELECT idGenre FROM album_genre WHERE idGenre = ? LIMIT 1',
(genre[0], ))
if not self.cursor.fetchone():
self.cursor.execute('SELECT idGenre FROM song_genre WHERE idGenre = ? LIMIT 1',
(genre[0], ))
if not self.cursor.fetchone():
self.delete_genre(genre[0])
def new_album_id(self):
self.cursor.execute('SELECT COALESCE(MAX(idAlbum), 0) FROM album')
return self.cursor.fetchone()[0] + 1
@db.catch_operationalerrors
def add_album_17(self, *args):
"""
strReleaseType: 'album' or 'single'
"""
if app.SYNC.artwork:
self.cursor.execute('''
INSERT INTO album(
idAlbum,
strAlbum,
strMusicBrainzAlbumID,
strArtists,
strGenres,
iYear,
bCompilation,
strReview,
strImage,
strLabel,
iUserrating,
lastScraped,
strReleaseType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (args))
else:
args = list(args)
del args[8]
self.cursor.execute('''
INSERT INTO album(
idAlbum,
strAlbum,
strMusicBrainzAlbumID,
strArtists,
strGenres,
iYear,
bCompilation,
strReview,
strLabel,
iUserrating,
lastScraped,
strReleaseType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (args))
@db.catch_operationalerrors
def update_album_17(self, *args):
if app.SYNC.artwork:
self.cursor.execute('''
UPDATE album
SET strAlbum = ?,
strMusicBrainzAlbumID = ?,
strArtists = ?,
strGenres = ?,
iYear = ?,
bCompilation = ?,
strReview = ?,
strImage = ?,
strLabel = ?,
iUserrating = ?,
lastScraped = ?,
strReleaseType = ?
WHERE idAlbum = ?
''', (args))
else:
args = list(args)
del args[7]
self.cursor.execute('''
UPDATE album
SET strAlbum = ?,
strMusicBrainzAlbumID = ?,
strArtists = ?,
strGenres = ?,
iYear = ?,
bCompilation = ?,
strReview = ?,
strLabel = ?,
iUserrating = ?,
lastScraped = ?,
strReleaseType = ?
WHERE idAlbum = ?
''', (args))
@db.catch_operationalerrors
def add_album(self, *args):
"""
strReleaseType: 'album' or 'single'
"""
if app.SYNC.artwork:
self.cursor.execute('''
INSERT INTO album(
idAlbum,
strAlbum,
strMusicBrainzAlbumID,
strArtistDisp,
strGenres,
iYear,
bCompilation,
strReview,
strImage,
strLabel,
iUserrating,
lastScraped,
strReleaseType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (args))
else:
args = list(args)
del args[8]
self.cursor.execute('''
INSERT INTO album(
idAlbum,
strAlbum,
strMusicBrainzAlbumID,
strArtistDisp,
strGenres,
iYear,
bCompilation,
strReview,
strLabel,
iUserrating,
lastScraped,
strReleaseType)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (args))
@db.catch_operationalerrors
def update_album(self, *args):
if app.SYNC.artwork:
self.cursor.execute('''
UPDATE album
SET strAlbum = ?,
strMusicBrainzAlbumID = ?,
strArtistDisp = ?,
strGenres = ?,
iYear = ?,
bCompilation = ?,
strReview = ?,
strImage = ?,
strLabel = ?,
iUserrating = ?,
lastScraped = ?,
strReleaseType = ?
WHERE idAlbum = ?
''', (args))
else:
args = list(args)
del args[7]
self.cursor.execute('''
UPDATE album
SET strAlbum = ?,
strMusicBrainzAlbumID = ?,
strArtistDisp = ?,
strGenres = ?,
iYear = ?,
bCompilation = ?,
strReview = ?,
strLabel = ?,
iUserrating = ?,
lastScraped = ?,
strReleaseType = ?
WHERE idAlbum = ?
''', (args))
@db.catch_operationalerrors
def add_albumartist(self, artist_id, kodi_id, artistname):
self.cursor.execute('''
INSERT OR REPLACE INTO album_artist(
idArtist,
idAlbum,
strArtist)
VALUES (?, ?, ?)
''', (artist_id, kodi_id, artistname))
@db.catch_operationalerrors
def add_music_genres(self, kodiid, genres, mediatype):
"""
Adds a list of genres (list of unicode) for a certain Kodi item
"""
if mediatype == "album":
# Delete current genres for clean slate
self.cursor.execute('DELETE FROM album_genre WHERE idAlbum = ?',
(kodiid, ))
for genre in genres:
self.cursor.execute('SELECT idGenre FROM genre WHERE strGenre = ?',
(genre, ))
try:
genreid = self.cursor.fetchone()[0]
except TypeError:
# Create the genre
self.cursor.execute('INSERT INTO genre(strGenre) VALUES(?)',
(genre, ))
genreid = self.cursor.lastrowid
self.cursor.execute('''
INSERT OR REPLACE INTO album_genre(
idGenre,
idAlbum)
VALUES (?, ?)
''', (genreid, kodiid))
elif mediatype == "song":
# Delete current genres for clean slate
self.cursor.execute('DELETE FROM song_genre WHERE idSong = ?',
(kodiid, ))
for genre in genres:
self.cursor.execute('SELECT idGenre FROM genre WHERE strGenre = ?',
(genre, ))
try:
genreid = self.cursor.fetchone()[0]
except TypeError:
# Create the genre
self.cursor.execute('INSERT INTO genre(strGenre) VALUES (?)',
(genre, ))
genreid = self.cursor.lastrowid
self.cursor.execute('''
INSERT OR REPLACE INTO song_genre(
idGenre,
idSong,
iOrder)
VALUES (?, ?, ?)
''', (genreid, kodiid, 0))
def add_song_id(self):
self.cursor.execute('SELECT COALESCE(MAX(idSong),0) FROM song')
return self.cursor.fetchone()[0] + 1
@db.catch_operationalerrors
def add_song(self, *args):
self.cursor.execute('''
INSERT INTO song(
idSong,
idAlbum,
idPath,
strArtistDisp,
strGenres,
strTitle,
iTrack,
iDuration,
iYear,
strFileName,
strMusicBrainzTrackID,
iTimesPlayed,
lastplayed,
rating,
iStartOffset,
iEndOffset,
mood,
dateAdded)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (args))
@db.catch_operationalerrors
def add_song_17(self, *args):
self.cursor.execute('''
INSERT INTO song(
idSong,
idAlbum,
idPath,
strArtists,
strGenres,
strTitle,
iTrack,
iDuration,
iYear,
strFileName,
strMusicBrainzTrackID,
iTimesPlayed,
lastplayed,
rating,
iStartOffset,
iEndOffset,
mood,
dateAdded)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (args))
@db.catch_operationalerrors
def update_song(self, *args):
self.cursor.execute('''
UPDATE song
SET idAlbum = ?,
strArtistDisp = ?,
strGenres = ?,
strTitle = ?,
iTrack = ?,
iDuration = ?,
iYear = ?,
strFilename = ?,
iTimesPlayed = ?,
lastplayed = ?,
rating = ?,
comment = ?,
mood = ?,
dateAdded = ?
WHERE idSong = ?
''', (args))
@db.catch_operationalerrors
def set_playcount(self, *args):
self.cursor.execute('''
UPDATE song
SET iTimesPlayed = ?,
lastplayed = ?
WHERE idSong = ?
''', (args))
@db.catch_operationalerrors
def update_song_17(self, *args):
self.cursor.execute('''
UPDATE song
SET idAlbum = ?,
strArtists = ?,
strGenres = ?,
strTitle = ?,
iTrack = ?,
iDuration = ?,
iYear = ?,
strFilename = ?,
iTimesPlayed = ?,
lastplayed = ?,
rating = ?,
comment = ?,
mood = ?,
dateAdded = ?
WHERE idSong = ?
''', (args))
def path_id_from_song(self, kodi_id):
self.cursor.execute('SELECT idPath FROM song WHERE idSong = ? LIMIT 1',
(kodi_id, ))
try:
return self.cursor.fetchone()[0]
except TypeError:
pass
@db.catch_operationalerrors
def add_artist(self, name, musicbrainz):
"""
Adds a single artist's name to the db
"""
self.cursor.execute('''
SELECT idArtist, strArtist
FROM artist
WHERE strMusicBrainzArtistID = ?
''', (musicbrainz, ))
try:
result = self.cursor.fetchone()
artistid = result[0]
artistname = result[1]
except TypeError:
self.cursor.execute('SELECT idArtist FROM artist WHERE strArtist = ? COLLATE NOCASE',
(name, ))
try:
artistid = self.cursor.fetchone()[0]
except TypeError:
# Krypton has a dummy first entry idArtist: 1 strArtist:
# [Missing Tag] strMusicBrainzArtistID: Artist Tag Missing
self.cursor.execute('''
INSERT INTO artist(strArtist, strMusicBrainzArtistID)
VALUES (?, ?)
''', (name, musicbrainz))
artistid = self.cursor.lastrowid
else:
if artistname != name:
self.cursor.execute('UPDATE artist SET strArtist = ? WHERE idArtist = ?',
(name, artistid,))
return artistid
@db.catch_operationalerrors
def update_artist(self, *args):
if app.SYNC.artwork:
self.cursor.execute('''
UPDATE artist
SET strGenres = ?,
strBiography = ?,
strImage = ?,
strFanart = ?,
lastScraped = ?
WHERE idArtist = ?
''', (args))
else:
args = list(args)
del args[3], args[2]
self.cursor.execute('''
UPDATE artist
SET strGenres = ?,
strBiography = ?,
lastScraped = ?
WHERE idArtist = ?
''', (args))
@db.catch_operationalerrors
def remove_song(self, kodi_id):
self.cursor.execute('DELETE FROM song WHERE idSong = ?', (kodi_id, ))
@db.catch_operationalerrors
def remove_path(self, path_id):
self.cursor.execute('DELETE FROM path WHERE idPath = ?', (path_id, ))
@db.catch_operationalerrors
def add_song_artist(self, artist_id, song_id, artist_name):
self.cursor.execute('''
INSERT OR REPLACE INTO song_artist(
idArtist,
idSong,
idRole,
iOrder,
strArtist)
VALUES (?, ?, ?, ?, ?)
''', (artist_id, song_id, 1, 0, artist_name))
@db.catch_operationalerrors
def add_albuminfosong(self, song_id, album_id, track_no, track_title,
runtime):
"""
Kodi 17 only
"""
self.cursor.execute('''
INSERT OR REPLACE INTO albuminfosong(
idAlbumInfoSong,
idAlbumInfo,
iTrack,
strTitle,
iDuration)
VALUES (?, ?, ?, ?, ?)
''', (song_id, album_id, track_no, track_title, runtime))
@db.catch_operationalerrors
def update_userrating(self, kodi_id, kodi_type, userrating):
"""
Updates userrating for songs and albums
"""
if kodi_type == v.KODI_TYPE_SONG:
column = 'userrating'
identifier = 'idSong'
elif kodi_type == v.KODI_TYPE_ALBUM:
column = 'iUserrating'
identifier = 'idAlbum'
else:
return
self.cursor.execute('''UPDATE %s SET %s = ? WHERE ? = ?'''
% (kodi_type, column),
(userrating, identifier, kodi_id))
@db.catch_operationalerrors
def remove_albuminfosong(self, kodi_id):
"""
Kodi 17 only
"""
self.cursor.execute('DELETE FROM albuminfosong WHERE idAlbumInfoSong = ?',
(kodi_id, ))
@db.catch_operationalerrors
def remove_album(self, kodi_id):
if v.KODIVERSION < 18:
self.cursor.execute('DELETE FROM albuminfosong WHERE idAlbumInfo = ?',
(kodi_id, ))
self.cursor.execute('DELETE FROM album_artist WHERE idAlbum = ?',
(kodi_id, ))
self.cursor.execute('DELETE FROM album WHERE idAlbum = ?', (kodi_id, ))
@db.catch_operationalerrors
def remove_artist(self, kodi_id):
self.cursor.execute('DELETE FROM album_artist WHERE idArtist = ?',
(kodi_id, ))
self.cursor.execute('DELETE FROM artist WHERE idArtist = ?',
(kodi_id, ))
self.cursor.execute('DELETE FROM song_artist WHERE idArtist = ?',
(kodi_id, ))

View file

@ -1,17 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from . import common
class KodiTextureDB(common.KodiDBBase):
db_kind = 'texture'
def url_not_yet_cached(self, url):
"""
Returns True if url has not yet been cached to the Kodi texture cache
"""
self.artcursor.execute('SELECT url FROM texture WHERE url = ? LIMIT 1',
(url, ))
return self.artcursor.fetchone() is None

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,42 +1,73 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
PKC Kodi Monitoring implementation
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from json import loads
from threading import Thread
import copy
import json
import binascii
import xbmc
from xbmcgui import Window
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
import plexdb_functions as plexdb
import kodidb_functions as kodidb
from utils import window, settings, plex_command, thread_methods, try_encode, \
kodi_time_to_millis, unix_date_to_kodi, unix_timestamp
from PlexFunctions import scrobble
from downloadutils import DownloadUtils as DU
from kodidb_functions import kodiid_from_filename
from plexbmchelper.subscribers import LOCKER
from playback import playback_triage
from initialsetup import set_replace_paths
import playqueue as PQ
import json_rpc as js
import playlist_func as PL
import state
import variables as v
LOG = getLogger('PLEX.kodimonitor')
###############################################################################
LOG = getLogger("PLEX." + __name__)
# settings: window-variable
WINDOW_SETTINGS = {
'plex_restricteduser': 'plex_restricteduser',
'force_transcode_pix': 'plex_force_transcode_pix'
}
# settings: state-variable (state.py)
# Need to use getattr and setattr!
STATE_SETTINGS = {
'dbSyncIndicator': 'SYNC_DIALOG',
'remapSMB': 'REMAP_PATH',
'remapSMBmovieOrg': 'remapSMBmovieOrg',
'remapSMBmovieNew': 'remapSMBmovieNew',
'remapSMBtvOrg': 'remapSMBtvOrg',
'remapSMBtvNew': 'remapSMBtvNew',
'remapSMBmusicOrg': 'remapSMBmusicOrg',
'remapSMBmusicNew': 'remapSMBmusicNew',
'remapSMBphotoOrg': 'remapSMBphotoOrg',
'remapSMBphotoNew': 'remapSMBphotoNew',
'enableMusic': 'ENABLE_MUSIC',
'forceReloadSkinOnPlaybackStop': 'FORCE_RELOAD_SKIN',
'fetch_pms_item_number': 'FETCH_PMS_ITEM_NUMBER',
'imageSyncNotifications': 'IMAGE_SYNC_NOTIFICATIONS'
}
###############################################################################
class KodiMonitor(xbmc.Monitor):
"""
PKC implementation of the Kodi Monitor class. Invoke only once.
"""
def __init__(self):
self.xbmcplayer = xbmc.Player()
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)
for playerid in state.PLAYER_STATES:
state.PLAYER_STATES[playerid] = copy.deepcopy(state.PLAYSTATE)
state.OLD_PLAYER_STATES[playerid] = copy.deepcopy(state.PLAYSTATE)
LOG.info("Kodi monitor started.")
def onScanStarted(self, library):
@ -56,6 +87,46 @@ class KodiMonitor(xbmc.Monitor):
Monitor the PKC settings for changes made by the user
"""
LOG.debug('PKC settings change detected')
changed = False
# Reset the window variables from the settings variables
for settings_value, window_value in WINDOW_SETTINGS.iteritems():
if window(window_value) != settings(settings_value):
changed = True
LOG.debug('PKC window settings changed: %s is now %s',
settings_value, settings(settings_value))
window(window_value, value=settings(settings_value))
# Reset the state variables in state.py
for settings_value, state_name in STATE_SETTINGS.iteritems():
new = settings(settings_value)
if new == 'true':
new = True
elif new == 'false':
new = False
if getattr(state, state_name) != new:
changed = True
LOG.debug('PKC state settings %s changed from %s to %s',
settings_value, getattr(state, state_name), new)
setattr(state, state_name, new)
if state_name == 'FETCH_PMS_ITEM_NUMBER':
LOG.info('Requesting playlist/nodes refresh')
plex_command('RUN_LIB_SCAN', 'views')
# Special cases, overwrite all internal settings
set_replace_paths()
state.BACKGROUND_SYNC_DISABLED = settings(
'enableBackgroundSync') == 'false'
state.FULL_SYNC_INTERVALL = int(settings('fullSyncInterval')) * 60
state.BACKGROUNDSYNC_SAFTYMARGIN = int(
settings('backgroundsync_saftyMargin'))
state.SYNC_THREAD_NUMBER = int(settings('syncThreadNumber'))
state.SSL_CERT_PATH = settings('sslcert') \
if settings('sslcert') != 'None' else None
# Never set through the user
# state.KODI_PLEX_TIME_OFFSET = float(settings('kodiplextimeoffset'))
if changed is True:
# Assume that the user changed the settings so that we can now find
# the path to all media files
state.STOP_SYNC = False
state.PATH_VERIFIED = False
def onNotification(self, sender, method, data):
"""
@ -66,50 +137,72 @@ class KodiMonitor(xbmc.Monitor):
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)
state.SUSPEND_SYNC = True
self.PlayBackStart(data)
elif method == "Player.OnStop":
with app.APP.lock_playqueues:
_playback_cleanup(ended=data.get('end'))
# Should refresh our video nodes, e.g. on deck
# xbmc.executebuiltin('ReloadSkin()')
if data.get('end'):
if state.PKC_CAUSED_STOP is True:
state.PKC_CAUSED_STOP = False
LOG.debug('PKC caused this playback stop - ignoring')
else:
_playback_cleanup(ended=True)
else:
_playback_cleanup()
state.PKC_CAUSED_STOP_DONE = True
state.SUSPEND_SYNC = False
elif method == 'Playlist.OnAdd':
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)
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)
self._playlist_onclear(data)
elif method == "VideoLibrary.OnUpdate":
with app.APP.lock_playqueues:
_videolibrary_onupdate(data)
# Manually marking as watched/unwatched
playcount = data.get('playcount')
item = data.get('item')
if playcount is None or item is None:
return
try:
kodiid = item['id']
item_type = item['type']
except (KeyError, TypeError):
LOG.info("Item is invalid for playstate update.")
return
# Send notification to the server.
with plexdb.Get_Plex_DB() as plexcur:
plex_dbitem = plexcur.getItem_byKodiId(kodiid, item_type)
try:
itemid = plex_dbitem[0]
except TypeError:
LOG.error("Could not find itemid in plex database for a "
"video library update")
else:
# notify the server
if playcount > 0:
scrobble(itemid, 'watched')
else:
scrobble(itemid, 'unwatched')
elif method == "VideoLibrary.OnRemove":
pass
elif method == "System.OnSleep":
# Connection is going to sleep
LOG.info("Marking the server as offline. SystemOnSleep activated.")
window('plex_online', value="sleep")
elif method == "System.OnWake":
# Allow network to wake up
self.waitForAbort(10)
app.CONN.online = False
xbmc.sleep(10000)
window('plex_online', value="false")
elif method == "GUI.OnScreensaverDeactivated":
if utils.settings('dbSyncScreensaver') == "true":
self.waitForAbort(5)
app.SYNC.run_lib_scan = 'full'
if settings('dbSyncScreensaver') == "true":
xbmc.sleep(5000)
plex_command('RUN_LIB_SCAN', 'full')
elif method == "System.OnQuit":
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)
state.STOP_PKC = True
@LOCKER.lockthis
def _playlist_onadd(self, data):
"""
Called if an item is added to a Kodi playlist. Example data dict:
@ -122,7 +215,26 @@ class KodiMonitor(xbmc.Monitor):
}
Will NOT be called if playback initiated by Kodi widgets
"""
pass
if 'id' not in data['item']:
return
old = state.OLD_PLAYER_STATES[data['playlistid']]
if (not state.DIRECT_PATHS and data['position'] == 0 and
not PQ.PLAYQUEUES[data['playlistid']].items and
data['item']['type'] == old['kodi_type'] and
data['item']['id'] == old['kodi_id']):
# Hack we need for RESUMABLE items because Kodi lost the path of the
# last played item that is now being replayed (see playback.py's
# Player().play()) Also see playqueue.py _compare_playqueues()
LOG.info('Detected re-start of playback of last item')
kwargs = {
'plex_id': old['plex_id'],
'plex_type': old['plex_type'],
'path': old['file'],
'resolve': False
}
thread = Thread(target=playback_triage, kwargs=kwargs)
thread.start()
return
def _playlist_onremove(self, data):
"""
@ -134,8 +246,8 @@ class KodiMonitor(xbmc.Monitor):
"""
pass
@staticmethod
def _playlist_onclear(data):
@LOCKER.lockthis
def _playlist_onclear(self, data):
"""
Called if a Kodi playlist is cleared. Example data dict:
{
@ -149,8 +261,7 @@ class KodiMonitor(xbmc.Monitor):
else:
LOG.debug('Detected PKC clear - ignoring')
@staticmethod
def _get_ids(kodi_id, kodi_type, path):
def _get_ids(self, kodi_id, kodi_type, path):
"""
Returns the tuple (plex_id, plex_type) or (None, None)
"""
@ -159,13 +270,16 @@ class KodiMonitor(xbmc.Monitor):
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)
kodi_id = 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']
with plexdb.Get_Plex_DB() as plex_db:
plex_dbitem = plex_db.getItem_byKodiId(kodi_id, kodi_type)
try:
plex_id = plex_dbitem[0]
plex_type = plex_dbitem[2]
except TypeError:
# No plex id, hence item not in the library. E.g. clips
pass
return plex_id, plex_type
@staticmethod
@ -182,8 +296,8 @@ class KodiMonitor(xbmc.Monitor):
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:
PL.add_item_to_PMS_playlist(playqueue, i + 1, kodi_item=item)
except PL.PlaylistError:
LOG.info('Could not build Plex playlist for: %s', items)
def _json_item(self, playerid):
@ -197,29 +311,14 @@ class KodiMonitor(xbmc.Monitor):
# 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
xbmc.sleep(1000)
json_item = js.get_item(playerid)
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'))
@LOCKER.lockthis
def PlayBackStart(self, data):
"""
Called whenever playback is started. Example data:
@ -237,41 +336,23 @@ class KodiMonitor(xbmc.Monitor):
LOG.info('Aborting playback report - item invalid for updates %s',
data)
return
kodi_id = data['item'].get('id') if 'item' in data else None
kodi_type = data['item'].get('type') if 'item' in data else None
path = data['item'].get('file') if 'item' in data else None
if playerid == -1:
# Kodi might return -1 for "last player"
# Getting the playerid is really a PITA
try:
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:
LOG.error('Unexpected type %s, data %s', kodi_type, data)
return
playerid = js.get_playlist_id(playlist_type)
if not playerid:
LOG.error('Coud not get playerid for data %s', data)
return
LOG.error('Could not retreive active player - aborting')
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:
pos = info['position'] if info['position'] != -1 else 0
LOG.debug('Detected position %s for %s', pos, playqueue)
status = app.PLAYSTATE.player_states[playerid]
pos = info['position'] if info['position'] != -1 else 0
LOG.debug('Detected position %s for %s', pos, playqueue)
status = state.PLAYER_STATES[playerid]
kodi_id = data.get('id')
kodi_type = data.get('type')
path = data.get('file')
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)
@ -289,24 +370,9 @@ class KodiMonitor(xbmc.Monitor):
# 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
initialize = True
else:
initialize = False
if initialize:
@ -316,14 +382,9 @@ class KodiMonitor(xbmc.Monitor):
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)
state.PLAYER_STATES[playerid] = copy.deepcopy(state.PLAYSTATE)
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
item = PL.init_Plex_playlist(playqueue, plex_id=plex_id)
# Set the Plex container key (e.g. using the Plex playqueue)
container_key = None
if info['playlistid'] != -1:
@ -343,13 +404,8 @@ class KodiMonitor(xbmc.Monitor):
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)
state.ACTIVE_PLAYERS.append(playerid)
status.update(info)
LOG.debug('Set the Plex container_key to: %s', container_key)
status['container_key'] = container_key
@ -360,46 +416,34 @@ class KodiMonitor(xbmc.Monitor):
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)
@thread_methods
class SpecialMonitor(Thread):
"""
Detect the resume dialog for widgets.
Could also be used to detect external players (see Emby implementation)
"""
def run(self):
LOG.info("----====# Starting Special Monitor #====----")
# "Start from beginning", "Play from beginning"
strings = (try_encode(xbmc.getLocalizedString(12021)),
try_encode(xbmc.getLocalizedString(12023)))
while not self.stopped():
if xbmc.getCondVisibility('Window.IsVisible(DialogContextMenu.xml)'):
if xbmc.getInfoLabel('Control.GetLabel(1002)') in strings:
# Remember that the item IS indeed resumable
control = int(Window(10106).getFocusId())
state.RESUME_PLAYBACK = True if control == 1001 else False
else:
# Different context menu is displayed
state.RESUME_PLAYBACK = False
xbmc.sleep(200)
LOG.info("#====---- Special Monitor Stopped ----====#")
@LOCKER.lockthis
def _playback_cleanup(ended=False):
"""
PKC cleanup after playback ends/is stopped. Pass ended=True if Kodi
@ -407,19 +451,16 @@ def _playback_cleanup(ended=False):
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
state.ACTIVE_PLAYERS)
# 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]
state.PLEX_TRANSIENT_TOKEN = None
for playerid in state.ACTIVE_PLAYERS:
status = state.PLAYER_STATES[playerid]
# Remember the last played item later
app.PLAYSTATE.old_player_states[playerid] = copy.deepcopy(status)
state.OLD_PLAYER_STATES[playerid] = copy.deepcopy(status)
# Stop transcoding
if status['playmethod'] == v.PLAYBACK_METHOD_TRANSCODE:
if status['playmethod'] == 'Transcode':
LOG.debug('Tell the PMS to stop transcoding')
DU().downloadUrl(
'{server}/video/:/transcode/universal/stop',
@ -430,11 +471,9 @@ def _playback_cleanup(ended=False):
# started playback via PMS
_record_playstate(status, ended)
# Reset the player's status
app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
state.PLAYER_STATES[playerid] = copy.deepcopy(state.PLAYSTATE)
# As all playback has halted, reset the players that have been active
app.PLAYSTATE.active_players = set()
app.PLAYSTATE.item = None
utils.delete_temporary_subtitles()
state.ACTIVE_PLAYERS = []
LOG.debug('Finished PKC playback cleanup')
@ -442,44 +481,30 @@ 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:
with plexdb.Get_Plex_DB() as plex_db:
kodi_db_item = plex_db.getItem_byId(status['plex_id'])
if kodi_db_item is None:
# 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
totaltime = float(kodi_time_to_millis(status['totaltime'])) / 1000
if ended:
progress = 0.99
time = v.IGNORE_SECONDS_AT_START + 1
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)
time = float(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()
last_played = unix_date_to_kodi(unix_timestamp())
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'])
with kodidb.GetKodiDB('video') as kodi_db:
playcount = kodi_db.get_playcount(kodi_db_item[1])
playcount = 0 if playcount is None else playcount
if time < v.IGNORE_SECONDS_AT_START:
LOG.debug('Ignoring playback less than %s seconds',
@ -493,43 +518,20 @@ def _record_playstate(status, ended):
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)
with kodidb.GetKodiDB('video') as kodi_db:
kodi_db.addPlaystate(kodi_db_item[1],
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
if (state.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
thread = Thread(target=_clean_file_table)
thread.setDaemon(True)
thread.start()
def _clean_file_table():
@ -540,146 +542,16 @@ def _clean_file_table():
This function tries for at most 5 seconds to clean the file table.
"""
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:
i = 0
while i < 100 and not state.STOP_PKC:
with kodidb.GetKodiDB('video') as kodi_db:
files = kodi_db.obsolete_file_ids()
if files:
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')
i += 1
xbmc.sleep(50)
with kodidb.GetKodiDB('video') as kodi_db:
for file_id in files:
LOG.debug('Removing obsolete Kodi file_id %s', file_id)
kodi_db.remove_file(file_id[0], remove_orphans=False)
LOG.debug('Done cleaning up Kodi file table')

View file

@ -1,9 +1 @@
# -*- 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
# Dummy file to make this directory a package.

View file

@ -1,73 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import xbmc
from .. import utils, app, variables as v
LOG = getLogger('PLEX.sync')
PLAYLIST_SYNC_ENABLED = (v.DEVICE != 'Microsoft UWP' and
utils.settings('enablePlaylistSync') == 'true')
class LibrarySyncMixin(object):
def suspend(self, block=False, timeout=None):
"""
Let's NOT suspend sync threads but immediately terminate them
"""
self.cancel()
def wait_while_suspended(self):
"""
Return immediately
"""
return self.should_cancel()
def run(self):
app.APP.register_thread(self)
LOG.debug('##===--- Starting %s ---===##', self.__class__.__name__)
try:
self._run()
except Exception as err:
LOG.error('Exception encountered: %s', err)
utils.ERROR(notify=True)
finally:
app.APP.deregister_thread(self)
LOG.debug('##===--- %s Stopped ---===##', self.__class__.__name__)
def update_kodi_library(video=True, music=True):
"""
Updates the Kodi library and thus refreshes the Kodi views and widgets
"""
if video:
if not xbmc.getCondVisibility('Window.IsMedia'):
xbmc.executebuiltin('UpdateLibrary(video)')
else:
# Prevent cursor from moving - refresh later
xbmc.executebuiltin('Container.Refresh')
app.APP.update_widgets = True
if music:
xbmc.executebuiltin('UpdateLibrary(music)')
def tag_last(iterable):
"""
Given some iterable, returns (last, item), where last is only True if you
are on the final iteration.
"""
iterator = iter(iterable)
gotone = False
try:
lookback = next(iterator)
gotone = True
while True:
cur = next(iterator)
yield False, lookback
lookback = cur
except StopIteration:
if gotone:
yield True, lookback
raise StopIteration()

View file

@ -1,154 +1,99 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from threading import Thread
from Queue import Empty
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
from xbmc import sleep
from utils import thread_methods, settings, language as lang, dialog
import plexdb_functions as plexdb
import itemtypes
from artwork import ArtworkSyncMessage
import variables as v
import state
###############################################################################
LOG = getLogger("PLEX." + __name__)
###############################################################################
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):
@thread_methods(add_suspends=['SUSPEND_LIBRARY_THREAD',
'DB_SCAN',
'STOP_SYNC',
'SUSPEND_SYNC'])
class ThreadedProcessFanart(Thread):
"""
This will potentially take hours!
"""
def __init__(self, callback, refresh=False):
self.callback = callback
self.refresh = refresh
super(FanartThread, self).__init__()
Threaded download of additional fanart in the background
def should_suspend(self):
return self._suspended or app.APP.is_playing_video
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
}
"""
def __init__(self, queue):
self.queue = queue
Thread.__init__(self)
def run(self):
LOG.info('Starting FanartThread')
app.APP.register_fanart_thread(self)
try:
self._run()
except Exception:
utils.ERROR(notify=True)
finally:
app.APP.deregister_fanart_thread(self)
"""
Do the work
"""
LOG.debug("---===### Starting FanartSync ###===---")
stopped = self.stopped
suspended = self.suspended
queue = self.queue
counter = 0
set_zero = False
while not stopped():
# In the event the server goes offline
while suspended():
# Set in service.py
if stopped():
# Abort was requested while waiting. We should exit
LOG.info("---===### Stopped FanartSync ###===---")
return
sleep(1000)
# grabs Plex item from queue
try:
item = queue.get(block=False)
except Empty:
if not set_zero:
# Avoid saving '0' all the time
set_zero = True
settings('fanarttv_lookups', value='0')
sleep(200)
continue
set_zero = False
if isinstance(item, ArtworkSyncMessage):
if state.IMAGE_SYNC_NOTIFICATIONS:
dialog('notification',
heading=lang(29999),
message=item.message,
icon='{plex}',
sound=False)
queue.task_done()
continue
def _loop(self):
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):
"""
This task will also be executed while library sync is suspended!
"""
def setup(self, plex_id, plex_type, refresh=False):
self.plex_id = plex_id
self.plex_type = plex_type
self.refresh = refresh
def run(self):
process_fanart(self.plex_id, self.plex_type, self.refresh)
def process_fanart(plex_id, plex_type, refresh=False):
"""
Will look for additional fanart for the plex_type item with plex_id.
Will check if we already got all artwork and only look if some are indeed
missing.
Will set the fanart_synced flag in the Plex DB if successful.
"""
done = False
try:
artworks = None
with PlexDB() as plexdb:
db_item = plexdb.item_by_id(plex_id,
plex_type)
if not db_item:
LOG.error('Could not get Kodi id for plex id %s', plex_id)
return
if not refresh:
with KodiVideoDB() as kodidb:
artworks = kodidb.get_art(db_item['kodi_id'],
db_item['kodi_type'])
# Check if we even need to get additional art
for key in v.ALL_KODI_ARTWORK:
if key not in artworks:
break
else:
done = True
return
xml = PF.GetPlexMetadata(plex_id)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.warn('Could not get metadata for %s. Skipping that item '
'for now', plex_id)
return
api = API(xml[0])
if artworks is None:
artworks = api.artwork()
# Get additional missing artwork from fanart artwork sites
artworks = api.fanart_artwork(artworks)
with itemtypes.ITEMTYPE_FROM_PLEXTYPE[plex_type](None) as context:
context.set_fanart(artworks,
db_item['kodi_id'],
db_item['kodi_type'])
# Additional fanart for sets/collections
if plex_type == v.PLEX_TYPE_MOVIE:
for _, setname in api.collections():
LOG.debug('Getting artwork for movie set %s', setname)
with KodiVideoDB() as kodidb:
setid = kodidb.create_collection(setname)
external_set_artwork = api.set_artwork()
if external_set_artwork and PREFER_KODI_COLLECTION_ART:
kodi_artwork = api.artwork(kodi_id=setid,
kodi_type=v.KODI_TYPE_SET)
for art in kodi_artwork:
if art in external_set_artwork:
del external_set_artwork[art]
with itemtypes.Movie(None) as movie:
movie.kodidb.modify_artwork(external_set_artwork,
setid,
v.KODI_TYPE_SET)
done = True
finally:
if done is True:
with PlexDB() as plexdb:
plexdb.set_fanart_synced(plex_id, plex_type)
LOG.debug('Get additional fanart for Plex id %s', item['plex_id'])
with getattr(itemtypes,
v.ITEMTYPE_FROM_PLEXTYPE[item['plex_type']])() as item_type:
result = item_type.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'])
# Update the caching state in the PKC settings. Avoid saving '0'
counter += 1
if counter > 10:
counter = 0
settings('fanarttv_lookups', value=str(queue.qsize()))
queue.task_done()
LOG.debug("---===### Stopped FanartSync ###===---")

View file

@ -1,80 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from Queue import Full
from . import common, sections
from ..plex_db import PlexDB
from .. import backgroundthread
LOG = getLogger('PLEX.sync.fill_metadata_queue')
QUEUE_TIMEOUT = 60 # seconds
class FillMetadataQueue(common.LibrarySyncMixin,
backgroundthread.KillableThread):
"""
Determines which plex_ids we need to sync and puts these ids in a separate
queue. Will use a COPIED plex.db file (plex-copy.db) in order to read much
faster without the writing thread stalling
"""
def __init__(self, repair, section_queue, get_metadata_queue,
processing_queue):
self.repair = repair
self.section_queue = section_queue
self.get_metadata_queue = get_metadata_queue
self.processing_queue = processing_queue
super(FillMetadataQueue, self).__init__()
def _process_section(self, section):
# Initialize only once to avoid loosing the last value before we're
# breaking the for loop
LOG.debug('Process section %s with %s items',
section, section.number_of_items)
count = 0
do_process_section = False
with PlexDB(lock=False, copy=True) as plexdb:
for xml in section.iterator:
if self.should_cancel():
break
plex_id = int(xml.get('ratingKey'))
checksum = int('{}{}'.format(
plex_id,
abs(int(xml.get('updatedAt',
xml.get('addedAt', '1541572987'))))))
if (not self.repair and
plexdb.checksum(plex_id, section.plex_type) == checksum):
continue
if not do_process_section:
do_process_section = True
self.processing_queue.add_section(section)
LOG.debug('Put section in processing queue: %s', section)
try:
self.get_metadata_queue.put((count, plex_id, section),
timeout=QUEUE_TIMEOUT)
except Full:
LOG.error('Putting %s in get_metadata_queue timed out - '
'aborting sync now', plex_id)
section.sync_successful = False
break
else:
count += 1
# We might have received LESS items from the PMS than anticipated.
# Ensures that our queues finish
self.processing_queue.change_section_number_of_items(section,
count)
LOG.debug('%s items to process for section %s',
section.number_of_items, section)
def _run(self):
while not self.should_cancel():
section = self.section_queue.get()
self.section_queue.task_done()
if section is None:
break
self._process_section(section)
# Signal the download metadata threads to stop with a sentinel
self.get_metadata_queue.put(None)
# Sentinel for the process_thread once we added everything else
self.processing_queue.add_sentinel(sections.Section())

View file

@ -1,325 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import Queue
import xbmcgui
from .get_metadata import GetMetadataThread
from .fill_metadata_queue import FillMetadataQueue
from .process_metadata import ProcessMetadataThread
from . import common, sections
from .. import utils, timing, backgroundthread as bg, variables as v, app
from .. import plex_functions as PF, itemtypes, path_ops
if common.PLAYLIST_SYNC_ENABLED:
from .. import playlists
LOG = getLogger('PLEX.sync.full_sync')
DELETION_BATCH_SIZE = 250
PLAYSTATE_BATCH_SIZE = 5000
# Max. number of plex_ids held in memory for later processing
BACKLOG_QUEUE_SIZE = 10000
# Max number of xmls held in memory
XML_QUEUE_SIZE = 500
# Safety margin to filter PMS items - how many seconds to look into the past?
UPDATED_AT_SAFETY = 60 * 5
LAST_VIEWED_AT_SAFETY = 60 * 5
class FullSync(common.LibrarySyncMixin, bg.KillableThread):
def __init__(self, repair, callback, show_dialog):
"""
repair=True: force sync EVERY item
"""
self.successful = True
self.repair = repair
self.callback = callback
# For progress dialog
self.show_dialog = show_dialog
self.show_dialog_userdata = utils.settings('playstate_sync_indicator') == 'true'
if self.show_dialog:
self.dialog = xbmcgui.DialogProgressBG()
self.dialog.create(utils.lang(39714))
else:
self.dialog = None
self.current_time = timing.plex_now()
self.last_section = sections.Section()
self.install_sync_done = utils.settings('SyncInstallRunDone') == 'true'
super(FullSync, self).__init__()
def update_progressbar(self, section, title, current):
if not self.dialog:
return
current += 1
try:
progress = int(float(current) / float(section.number_of_items) * 100.0)
except ZeroDivisionError:
progress = 0
self.dialog.update(progress,
'%s (%s)' % (section.name, section.section_type_text),
'%s %s/%s'
% (title, current, section.number_of_items))
if app.APP.is_playing_video:
self.dialog.close()
self.dialog = None
@staticmethod
def copy_plex_db():
"""
Takes the current plex.db file and copies it to plex-copy.db
This will allow us to have "concurrent" connections during adding/
updating items, increasing sync speed tremendously.
Using the same DB with e.g. WAL mode did not really work out...
"""
path_ops.copyfile(v.DB_PLEX_PATH, v.DB_PLEX_COPY_PATH)
@utils.log_time
def process_new_and_changed_items(self, section_queue, processing_queue):
LOG.debug('Start working')
get_metadata_queue = Queue.Queue(maxsize=BACKLOG_QUEUE_SIZE)
scanner_thread = FillMetadataQueue(self.repair,
section_queue,
get_metadata_queue,
processing_queue)
scanner_thread.start()
metadata_threads = [
GetMetadataThread(get_metadata_queue, processing_queue)
for _ in range(int(utils.settings('syncThreadNumber')))
]
for t in metadata_threads:
t.start()
process_thread = ProcessMetadataThread(self.current_time,
processing_queue,
self.update_progressbar)
process_thread.start()
LOG.debug('Waiting for scanner thread to finish up')
scanner_thread.join()
LOG.debug('Waiting for metadata download threads to finish up')
for t in metadata_threads:
t.join()
LOG.debug('Download metadata threads finished')
process_thread.join()
self.successful = process_thread.successful
LOG.debug('threads finished work. successful: %s', self.successful)
@utils.log_time
def processing_loop_playstates(self, section_queue):
while not self.should_cancel():
section = section_queue.get()
section_queue.task_done()
if section is None:
break
self.playstate_per_section(section)
def playstate_per_section(self, section):
LOG.debug('Processing %s playstates for library section %s',
section.number_of_items, section)
try:
with section.context(self.current_time) as context:
for xml in section.iterator:
section.count += 1
if not context.update_userdata(xml, section.plex_type):
# Somehow did not sync this item yet
context.add_update(xml,
section_name=section.name,
section_id=section.section_id)
context.plexdb.update_last_sync(int(xml.attrib['ratingKey']),
section.plex_type,
self.current_time)
self.update_progressbar(section, '', section.count - 1)
if section.count % PLAYSTATE_BATCH_SIZE == 0:
context.commit()
except RuntimeError:
LOG.error('Could not entirely process section %s', section)
self.successful = False
def threaded_get_generators(self, kinds, section_queue, items):
"""
Getting iterators is costly, so let's do it in a dedicated thread
"""
LOG.debug('Start threaded_get_generators')
try:
for kind in kinds:
for section in (x for x in app.SYNC.sections
if x.section_type == kind[1]):
if self.should_cancel():
LOG.debug('Need to exit now')
return
if not section.sync_to_kodi:
LOG.info('User chose to not sync section %s', section)
continue
section = sections.get_sync_section(section,
plex_type=kind[0])
timestamp = section.last_sync - UPDATED_AT_SAFETY \
if section.last_sync else None
if items == 'all':
updated_at = None
last_viewed_at = None
elif items == 'watched':
if not timestamp:
# No need to sync playstate updates since section
# has not yet been synched
continue
else:
updated_at = None
last_viewed_at = timestamp
elif items == 'updated':
updated_at = timestamp
last_viewed_at = None
try:
section.iterator = PF.get_section_iterator(
section.section_id,
plex_type=section.plex_type,
updated_at=updated_at,
last_viewed_at=last_viewed_at)
except RuntimeError:
LOG.error('Sync at least partially unsuccessful!')
LOG.error('Error getting section iterator %s', section)
else:
section.number_of_items = section.iterator.total
if section.number_of_items > 0:
section_queue.put(section)
LOG.debug('Put section in queue with %s items: %s',
section.number_of_items, section)
except Exception:
utils.ERROR(notify=True)
finally:
# Sentinel for the section queue
section_queue.put(None)
LOG.debug('Exiting threaded_get_generators')
def full_library_sync(self):
section_queue = Queue.Queue()
processing_queue = bg.ProcessingQueue(maxsize=XML_QUEUE_SIZE)
kinds = [
(v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_MOVIE),
(v.PLEX_TYPE_SHOW, v.PLEX_TYPE_SHOW),
(v.PLEX_TYPE_SEASON, v.PLEX_TYPE_SHOW),
(v.PLEX_TYPE_EPISODE, v.PLEX_TYPE_SHOW)
]
if app.SYNC.enable_music:
kinds.extend([
(v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_ARTIST),
(v.PLEX_TYPE_ALBUM, v.PLEX_TYPE_ARTIST),
])
# ADD NEW ITEMS
# We need to enforce syncing e.g. show before season before episode
bg.FunctionAsTask(self.threaded_get_generators,
None,
kinds,
section_queue,
items='all' if self.repair else 'updated').start()
# Do the heavy lifting
self.process_new_and_changed_items(section_queue, processing_queue)
common.update_kodi_library(video=True, music=True)
if self.should_cancel() or not self.successful:
return
# In order to not delete all your songs again for playstate synch
if app.SYNC.enable_music:
kinds.extend([
(v.PLEX_TYPE_SONG, v.PLEX_TYPE_ARTIST),
])
# Update playstate progress since last sync - especially useful for
# users of very large libraries since this step is very fast
# These playstates will be synched twice
LOG.debug('Start synching playstate for last watched items')
bg.FunctionAsTask(self.threaded_get_generators,
None,
kinds,
section_queue,
items='watched').start()
self.processing_loop_playstates(section_queue)
if self.should_cancel() or not self.successful:
return
# Sync Plex playlists to Kodi and vice-versa
if common.PLAYLIST_SYNC_ENABLED:
LOG.debug('Start playlist sync')
if self.show_dialog:
if self.dialog:
self.dialog.close()
self.dialog = xbmcgui.DialogProgressBG()
# "Synching playlists"
self.dialog.create(utils.lang(39715))
if not playlists.full_sync() or self.should_cancel():
return
# SYNC PLAYSTATE of ALL items (otherwise we won't pick up on items that
# were set to unwatched or changed user ratings). Also mark all items on
# the PMS to be able to delete the ones still in Kodi
LOG.debug('Start synching playstate and userdata for every item')
# Make sure we're not showing an item's title in the sync dialog
if not self.show_dialog_userdata and self.dialog:
# Close the progress indicator dialog
self.dialog.close()
self.dialog = None
bg.FunctionAsTask(self.threaded_get_generators,
None,
kinds,
section_queue,
items='all').start()
self.processing_loop_playstates(section_queue)
if self.should_cancel() or not self.successful:
return
# Delete movies that are not on Plex anymore
LOG.debug('Looking for items to delete')
kinds = [
(v.PLEX_TYPE_MOVIE, itemtypes.Movie),
(v.PLEX_TYPE_SHOW, itemtypes.Show),
(v.PLEX_TYPE_SEASON, itemtypes.Season),
(v.PLEX_TYPE_EPISODE, itemtypes.Episode)
]
if app.SYNC.enable_music:
kinds.extend([
(v.PLEX_TYPE_ARTIST, itemtypes.Artist),
(v.PLEX_TYPE_ALBUM, itemtypes.Album),
(v.PLEX_TYPE_SONG, itemtypes.Song)
])
for plex_type, context in kinds:
# Delete movies that are not on Plex anymore
while True:
with context(self.current_time) as ctx:
plex_ids = list(
ctx.plexdb.plex_id_by_last_sync(plex_type,
self.current_time,
DELETION_BATCH_SIZE))
for plex_id in plex_ids:
if self.should_cancel():
return
ctx.remove(plex_id, plex_type)
if len(plex_ids) < DELETION_BATCH_SIZE:
break
LOG.debug('Done looking for items to delete')
@utils.log_time
def _run(self):
try:
# Get latest Plex libraries and build playlist and video node files
if self.should_cancel() or not sections.sync_from_pms(self):
return
self.copy_plex_db()
self.full_library_sync()
finally:
common.update_kodi_library(video=True, music=True)
if self.dialog:
self.dialog.close()
if not self.successful and not self.should_cancel():
# "ERROR in library sync"
utils.dialog('notification',
heading='{plex}',
message=utils.lang(39410),
icon='{error}')
self.callback(self.successful)
def start(show_dialog, repair=False, callback=None):
# Call run() and NOT start in order to not spawn another thread
FullSync(repair, callback, show_dialog).run()

View file

@ -1,123 +1,122 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from threading import Thread
from Queue import Empty
from . import common
from ..plex_api import API
from .. import backgroundthread, plex_functions as PF, utils, variables as v
from xbmc import sleep
LOG = getLogger('PLEX.sync.get_metadata')
LOCK = backgroundthread.threading.Lock()
from utils import thread_methods, window
from PlexFunctions import GetPlexMetadata, GetAllPlexChildren
import sync_info
###############################################################################
LOG = getLogger("PLEX." + __name__)
###############################################################################
class GetMetadataThread(common.LibrarySyncMixin,
backgroundthread.KillableThread):
@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD',
'STOP_SYNC',
'SUSPEND_SYNC'])
class ThreadedGetMetadata(Thread):
"""
Threaded download of Plex XML metadata for a certain library item.
Fills the queue with the downloaded etree XML objects
Fills the out_queue with the downloaded etree XML objects
Input:
queue Queue.Queue() object that you'll need to fill up
with plex_ids
out_queue Queue() object where this thread will store
the downloaded metadata XMLs as etree objects
"""
def __init__(self, get_metadata_queue, processing_queue):
self.get_metadata_queue = get_metadata_queue
self.processing_queue = processing_queue
super(GetMetadataThread, self).__init__()
def __init__(self, queue, out_queue):
self.queue = queue
self.out_queue = out_queue
Thread.__init__(self)
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:
collection_xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not get collection %s %s',
collection_plex_id, set_name)
continue
collection_xmls[plex_set_id] = collection_xml
break
else:
LOG.error('Did not find Plex collection %s %s',
plex_set_id, set_name)
continue
item['children'][plex_set_id] = collection_xmls[plex_set_id]
def _process_abort(self, count, section):
# Make sure other threads will also receive sentinel
self.get_metadata_queue.put(None)
if count is not None:
self._process_skipped_item(count, section)
def _process_skipped_item(self, count, section):
section.sync_successful = False
# Add a "dummy" item so we're not skipping a beat
self.processing_queue.put((count, {'section': section, 'xml': None}))
def _run(self):
while True:
item = self.get_metadata_queue.get()
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:
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)
self.queue.get(block=False)
except Empty:
sleep(10)
continue
else:
self.queue.task_done()
if self.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
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',
plex_id)
self._process_skipped_item(count, section)
continue
else:
item['children'] = children_xml
self.processing_queue.put((count, item))
finally:
self.get_metadata_queue.task_done()
else:
self.out_queue.task_done()
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
stopped = self.stopped
while 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['plex_id'])
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['plex_id'])
# 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()
break
item['xml'] = xml
if item.get('get_children') is True:
children_xml = GetAllPlexChildren(item['plex_id'])
try:
children_xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not get children for Plex id %s',
item['plex_id'])
item['children'] = []
else:
item['children'] = children_xml
# place item into out queue
out_queue.put(item)
# Keep track of where we are at
with sync_info.LOCK:
sync_info.GET_METADATA_COUNT += 1
# signals to queue job is done
queue.task_done()
# Empty queue in case PKC was shut down (main thread hangs otherwise)
self.terminate_now()
LOG.debug('Get metadata thread terminated')

View file

@ -1,412 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
import urllib
import copy
from ..utils import etree
from .. import variables as v, utils
ICON_PATH = 'special://home/addons/plugin.video.plexkodiconnect/icon.png'
RECOMMENDED_SCORE_LOWER_BOUND = 7
# Logic of the following nodes:
# (node_type,
# label/node name,
# args for PKC add-on callback,
# Kodi "content",
# Bool: does this node's xml even point back to PKC add-on callback?
# )
NODE_TYPES = {
v.PLEX_TYPE_MOVIE: (
('plex_ondeck',
utils.lang(39500), # "On Deck"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/onDeck',
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_MOVIE,
True),
('ondeck',
utils.lang(39502), # "PKC On Deck (faster)"
{},
v.CONTENT_TYPE_MOVIE,
False),
('recent',
utils.lang(30174), # "Recently Added"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/recentlyAdded',
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_MOVIE,
False),
('all',
'{self.name}', # We're using this section's name
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/all',
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_MOVIE,
False),
('recommended',
utils.lang(30230), # "Recommended"
{
'mode': 'browseplex',
'key': ('/library/sections/{self.section_id}&%s'
% urllib.urlencode({'sort': 'rating:desc'})),
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_MOVIE,
False),
('genres',
utils.lang(135), # "Genres"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/genre',
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_MOVIE,
False),
('sets',
utils.lang(39501), # "Collections"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/collection',
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_MOVIE,
False),
('random',
utils.lang(30227), # "Random"
{
'mode': 'browseplex',
'key': ('/library/sections/{self.section_id}&%s'
% urllib.urlencode({'sort': 'random'})),
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_MOVIE,
False),
('lastplayed',
utils.lang(568), # "Last played"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/recentlyViewed',
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_MOVIE,
False),
('browse',
utils.lang(39702), # "Browse by folder"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/folder',
'plex_type': '{self.section_type}',
'section_id': '{self.section_id}',
'folder': True
},
v.CONTENT_TYPE_MOVIE,
True),
('more',
utils.lang(22082), # "More..."
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}',
'section_id': '{self.section_id}',
'folder': True
},
v.CONTENT_TYPE_FILE,
True),
),
###########################################################
v.PLEX_TYPE_SHOW: (
('ondeck',
utils.lang(39500), # "On Deck"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/onDeck',
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_EPISODE,
True),
('recent',
utils.lang(30174), # "Recently Added"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/recentlyAdded',
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_EPISODE,
False),
('all',
'{self.name}', # We're using this section's name
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/all',
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_SHOW,
False),
('recommended',
utils.lang(30230), # "Recommended"
{
'mode': 'browseplex',
'key': ('/library/sections/{self.section_id}&%s'
% urllib.urlencode({'sort': 'rating:desc'})),
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_SHOW,
False),
('genres',
utils.lang(135), # "Genres"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/genre',
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_SHOW,
False),
('sets',
utils.lang(39501), # "Collections"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/collection',
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_SHOW,
True), # There are no sets/collections for shows with Kodi
('random',
utils.lang(30227), # "Random"
{
'mode': 'browseplex',
'key': ('/library/sections/{self.section_id}&%s'
% urllib.urlencode({'sort': 'random'})),
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_SHOW,
False),
('lastplayed',
utils.lang(568), # "Last played"
{
'mode': 'browseplex',
'key': ('/library/sections/{self.section_id}/recentlyViewed&%s'
% urllib.urlencode({'type': v.PLEX_TYPE_NUMBER_FROM_PLEX_TYPE[v.PLEX_TYPE_EPISODE]})),
'section_id': '{self.section_id}'
},
v.CONTENT_TYPE_EPISODE,
False),
('browse',
utils.lang(39702), # "Browse by folder"
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}/folder',
'section_id': '{self.section_id}',
'folder': True
},
v.CONTENT_TYPE_EPISODE,
True),
('more',
utils.lang(22082), # "More..."
{
'mode': 'browseplex',
'key': '/library/sections/{self.section_id}',
'section_id': '{self.section_id}',
'folder': True
},
v.CONTENT_TYPE_FILE,
True),
),
}
def node_pms(section, node_name, args):
"""
Nodes where the logic resides with the PMS - we're NOT building an
xml that filters and sorts, but point to PKC add-on path
Be sure to set args['folder'] = True if the listing is a folder and does
not contain playable elements like movies, episodes or tracks
"""
if 'folder' in args:
args = copy.deepcopy(args)
args.pop('folder')
folder = True
else:
folder = False
xml = etree.Element('node',
attrib={'order': unicode(section.order),
'type': 'folder' if folder else 'filter'})
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml, 'path').text = section.addon_path(args)
return xml
def node_ondeck(section, node_name):
"""
For movies only - returns in-progress movies sorted by last played
"""
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
etree.SubElement(xml, 'rule', attrib={'field': 'inprogress',
'operator': 'true'})
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml, 'limit').text = utils.settings('widgetLimit')
etree.SubElement(xml,
'order',
attrib={'direction':
'descending'}).text = 'lastplayed'
return xml
def node_recent(section, node_name):
xml = etree.Element('node',
attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
if ((section.section_type == v.PLEX_TYPE_SHOW and
utils.settings('TVShowWatched') == 'false') or
(section.section_type == v.PLEX_TYPE_MOVIE and
utils.settings('MovieShowWatched') == 'false')):
# Adds an additional rule if user deactivated the PKC setting
# "Recently Added: Also show already watched episodes"
# or
# "Recently Added: Also show already watched episodes"
rule = etree.SubElement(xml, 'rule', attrib={'field': 'playcount',
'operator': 'is'})
etree.SubElement(rule, 'value').text = '0'
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml, 'limit').text = utils.settings('widgetLimit')
etree.SubElement(xml,
'order',
attrib={'direction':
'descending'}).text = 'dateadded'
return xml
def node_all(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml,
'order',
attrib={'direction':
'ascending'}).text = 'sorttitle'
return xml
def node_recommended(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
# rule = etree.SubElement(xml, 'rule', attrib={'field': 'rating',
# 'operator': 'greaterthan'})
# etree.SubElement(rule, 'value').text = unicode(RECOMMENDED_SCORE_LOWER_BOUND)
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml, 'limit').text = utils.settings('widgetLimit')
etree.SubElement(xml,
'order',
attrib={'direction':
'descending'}).text = 'rating'
return xml
def node_genres(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml,
'order',
attrib={'direction':
'ascending'}).text = 'sorttitle'
etree.SubElement(xml, 'group').text = 'genres'
return xml
def node_sets(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
# "Collections"
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml,
'order',
attrib={'direction':
'ascending'}).text = 'sorttitle'
etree.SubElement(xml, 'group').text = 'sets'
return xml
def node_random(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml, 'limit').text = utils.settings('widgetLimit')
etree.SubElement(xml,
'order',
attrib={'direction':
'ascending'}).text = 'random'
return xml
def node_lastplayed(section, node_name):
xml = etree.Element('node', attrib={'order': unicode(section.order),
'type': 'filter'})
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = section.name
rule = etree.SubElement(xml, 'rule', attrib={'field': 'playcount',
'operator': 'greaterthan'})
etree.SubElement(rule, 'value').text = '0'
etree.SubElement(xml, 'label').text = node_name
etree.SubElement(xml, 'icon').text = ICON_PATH
etree.SubElement(xml, 'content').text = section.content
etree.SubElement(xml, 'limit').text = utils.settings('widgetLimit')
etree.SubElement(xml,
'order',
attrib={'direction':
'descending'}).text = 'lastplayed'
return xml

View file

@ -1,92 +1,88 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from threading import Thread
from Queue import Empty
from . import common, sections
from ..plex_db import PlexDB
from .. import backgroundthread, app
from xbmc import sleep
LOG = getLogger('PLEX.sync.process_metadata')
from utils import thread_methods
import itemtypes
import sync_info
COMMIT_TO_DB_EVERY_X_ITEMS = 500
###############################################################################
LOG = getLogger("PLEX." + __name__)
###############################################################################
class ProcessMetadataThread(common.LibrarySyncMixin,
backgroundthread.KillableThread):
@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD',
'STOP_SYNC',
'SUSPEND_SYNC'])
class ThreadedProcessMetadata(Thread):
"""
Invoke once in order to process the received PMS metadata xmls
Not yet implemented for more than 1 thread - if ever. Only to be called by
ONE thread!
Processes the XML metadata in the queue
Input:
queue: Queue.Queue() object that you'll need to fill up with
the downloaded XML eTree objects
item_class: as used to call functions in itemtypes.py e.g. 'Movies' =>
itemtypes.Movies()
"""
def __init__(self, current_time, processing_queue, update_progressbar):
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__()
def __init__(self, queue, item_class):
self.queue = queue
self.item_class = item_class
Thread.__init__(self)
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:
LOG.debug('Resume processing section %s', section)
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
else:
self.queue.task_done()
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 _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'])
processed += 1
section.count += 1
if processed == COMMIT_TO_DB_EVERY_X_ITEMS:
processed = 0
context.commit()
item = self._get()
self.finish_last_section()
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_class)
# cache local variables because it's faster
queue = self.queue
stopped = self.stopped
with item_fct() as item_class:
while 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'):
item_method(item['xml'][0],
viewtag=item['view_name'],
viewid=item['view_id'],
children=item['children'])
else:
item_method(item['xml'][0],
viewtag=item['view_name'],
viewid=item['view_id'])
# Keep track of where we are at
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')

View file

@ -1,745 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import copy
from . import nodes
from ..plex_db import PlexDB
from ..plex_api import API
from .. import kodi_db
from .. import itemtypes, path_ops
from .. import plex_functions as PF, music, utils, variables as v, app
from ..utils import etree
LOG = getLogger('PLEX.sync.sections')
BATCH_SIZE = 500
# Need a way to interrupt our synching process
SHOULD_CANCEL = None
LIBRARY_PATH = path_ops.translate_path('special://profile/library/video/')
# The video library might not yet exist for this user - create it
if not path_ops.exists(LIBRARY_PATH):
path_ops.copy_tree(
src=path_ops.translate_path('special://xbmc/system/library/video'),
dst=LIBRARY_PATH,
preserve_mode=0) # dont copy permission bits so we have write access!
PLAYLISTS_PATH = path_ops.translate_path("special://profile/playlists/video/")
if not path_ops.exists(PLAYLISTS_PATH):
path_ops.makedirs(PLAYLISTS_PATH)
# Windows variables we set for each node
WINDOW_ARGS = ('index', 'title', 'id', 'path', 'type', 'content', 'artwork')
class Section(object):
"""
Setting the attribute section_type will automatically set content and
sync_to_kodi
"""
def __init__(self, index=None, xml_element=None, section_db_element=None):
# Unique Plex id of this Plex library section
self._section_id = None # int
# Building block for window variable
self._node = None # unicode
# Index of this section (as section_id might not be subsequent)
# This follows 1:1 the sequence in with the PMS returns the sections
self._index = None # Codacy-bug
self.index = index # int
# This section's name for the user to display
self.name = None # unicode
# Library type section (NOT the same as the KODI_TYPE_...)
# E.g. 'movies', 'tvshows', 'episodes'
self.content = None # unicode
# Setting the section_type WILL re_set sync_to_kodi!
self._section_type = None # unicode
# E.g. "season" or "movie" (translated)
self.section_type_text = None
# Do we sync all items of this section to the Kodi DB?
# This will be set with section_type!!
self.sync_to_kodi = None # bool
# For sections to be synched, the section name will be recorded as a
# tag. This is the corresponding id for this tag
self.kodi_tagid = None # int
# When was this section last successfully/completely synched to the
# Kodi database?
self.last_sync = None # int
# Path to the Kodi userdata library FOLDER for this section
self._path = None # unicode
# Path to the smart playlist for this section
self._playlist_path = None
# "Poster" for this section
self.icon = None # unicode
# Background image for this section
self.artwork = None
# Thumbnail for this section, similar for each section type
self.thumb = None
# Order number in which xmls will be listed inside Kodei
self.order = None
# Original PMS xml for this section, including children
self.xml = None
# A section_type encompasses possible several plex_types! E.g. shows
# contain shows, seasons, episodes
self._plex_type = None
if xml_element is not None:
self.from_xml(xml_element)
elif section_db_element:
self.from_db_element(section_db_element)
def __repr__(self):
return ("{{"
"'index': {self.index}, "
"'name': '{self.name}', "
"'section_id': {self.section_id}, "
"'section_type': '{self.section_type}', "
"'plex_type': '{self.plex_type}', "
"'sync_to_kodi': {self.sync_to_kodi}, "
"'last_sync': {self.last_sync}"
"}}").format(self=self).encode('utf-8')
__str__ = __repr__
def __nonzero__(self):
return (self.section_id is not None and
self.name is not None and
self.section_type is not None)
def __eq__(self, section):
"""
Sections compare equal if their section_id, name and plex_type (first
prio) OR section_type (if there is no plex_type is set) compare equal
"""
if not isinstance(section, Section):
return False
return (self.section_id == section.section_id and
self.name == section.name and
(self.plex_type == section.plex_type if self.plex_type else
self.section_type == section.section_type))
def __ne__(self, section):
return not self == section
@property
def section_id(self):
return self._section_id
@section_id.setter
def section_id(self, value):
self._section_id = value
self._path = path_ops.path.join(LIBRARY_PATH, 'Plex-%s' % value, '')
self._playlist_path = path_ops.path.join(PLAYLISTS_PATH,
'Plex %s.xsp' % value)
@property
def section_type(self):
return self._section_type
@section_type.setter
def section_type(self, value):
self._section_type = value
self.content = v.CONTENT_FROM_PLEX_TYPE[value]
# Default values whether we sync or not based on the Plex type
if value == v.PLEX_TYPE_PHOTO:
self.sync_to_kodi = False
elif not app.SYNC.enable_music and value == v.PLEX_TYPE_ARTIST:
self.sync_to_kodi = False
else:
self.sync_to_kodi = True
@property
def plex_type(self):
return self._plex_type
@plex_type.setter
def plex_type(self, value):
self._plex_type = value
self.section_type_text = utils.lang(v.TRANSLATION_FROM_PLEXTYPE[value])
@property
def index(self):
return self._index
@index.setter
def index(self, value):
self._index = value
self._node = 'Plex.nodes.%s' % value
@property
def node(self):
return self._node
@property
def path(self):
return self._path
@property
def playlist_path(self):
return self._playlist_path
def from_db_element(self, section_db_element):
self.section_id = section_db_element['section_id']
self.name = section_db_element['section_name']
self.section_type = section_db_element['plex_type']
self.kodi_tagid = section_db_element['kodi_tagid']
self.sync_to_kodi = section_db_element['sync_to_kodi']
self.last_sync = section_db_element['last_sync']
def from_xml(self, xml_element):
"""
Reads section from a PMS xml (Plex id, name, Plex type)
"""
api = API(xml_element)
self.section_id = utils.cast(int, xml_element.get('key'))
self.name = api.title()
self.section_type = api.plex_type
self.icon = api.one_artwork('composite')
self.artwork = api.one_artwork('art')
self.thumb = api.one_artwork('thumb')
self.xml = xml_element
def from_plex_db(self, section_id, plexdb=None):
"""
Reads section with id section_id from the plex.db
"""
if plexdb:
section = plexdb.section(section_id)
else:
with PlexDB(lock=False) as plexdb:
section = plexdb.section(section_id)
if section:
self.from_db_element(section)
def to_plex_db(self, plexdb=None):
"""
Writes this Section to the plex.db, potentially overwriting
(INSERT OR REPLACE)
"""
if not self:
raise RuntimeError('Section not clearly defined: %s' % self)
if plexdb:
plexdb.add_section(self.section_id,
self.name,
self.section_type,
self.kodi_tagid,
self.sync_to_kodi,
self.last_sync)
else:
with PlexDB(lock=False) as plexdb:
plexdb.add_section(self.section_id,
self.name,
self.section_type,
self.kodi_tagid,
self.sync_to_kodi,
self.last_sync)
def addon_path(self, args):
"""
Returns the plugin path pointing back to PKC for key in order to browse
args is a dict. Its values may contain string info of the form
{key: '{self.<Section attribute>}'}
"""
args = copy.deepcopy(args)
for key, value in args.iteritems():
args[key] = value.format(self=self)
return utils.extend_url('plugin://%s' % v.ADDON_ID, args)
def to_kodi(self):
"""
Writes this section's nodes to the library folder in the Kodi userdata
directory
Won't do anything if self.sync_to_kodi is not True
"""
if self.index is None:
raise RuntimeError('Index not initialized')
# Main list entry for this section - which will show the different
# nodes as "submenus" once the user navigates into this section
if self.sync_to_kodi and self.section_type in v.PLEX_VIDEOTYPES:
# Node showing a menu for this section
args = {
'mode': 'show_section',
'section_index': self.index
}
index = utils.extend_url('plugin://%s' % v.ADDON_ID, args)
# Node directly displaying all content
path = 'library://video/Plex-{0}/{0}_all.xml'
path = path.format(self.section_id)
else:
# Node showing a menu for this section
args = {
'mode': 'browseplex',
'key': '/library/sections/%s' % self.section_id,
'section_id': unicode(self.section_id)
}
if not self.sync_to_kodi:
args['synched'] = 'false'
# No library xmls to speed things up
# Immediately show the PMS options for this section
index = self.addon_path(args)
# Node directly displaying all content
args = {
'mode': 'browseplex',
'key': '/library/sections/%s/all' % self.section_id,
'section_id': unicode(self.section_id)
}
if not self.sync_to_kodi:
args['synched'] = 'false'
path = self.addon_path(args)
utils.window('%s.index' % self.node, value=index)
utils.window('%s.title' % self.node, value=self.name)
utils.window('%s.type' % self.node, value=self.content)
utils.window('%s.content' % self.node, value=index)
# .path leads to all elements of this library
if self.section_type in v.PLEX_VIDEOTYPES:
utils.window('%s.path' % self.node,
value='ActivateWindow(videos,%s,return)' % path)
elif self.section_type == v.PLEX_TYPE_ARTIST:
utils.window('%s.path' % self.node,
value='ActivateWindow(music,%s,return)' % path)
else:
# Pictures
utils.window('%s.path' % self.node,
value='ActivateWindow(pictures,%s,return)' % path)
utils.window('%s.id' % self.node, value=str(self.section_id))
if not self.sync_to_kodi:
self.remove_files_from_kodi()
return
if self.section_type == v.PLEX_TYPE_ARTIST:
# Todo: Write window variables for music
return
if self.section_type == v.PLEX_TYPE_PHOTO:
# Todo: Write window variables for photos
return
# Create a dedicated directory for this section
if not path_ops.exists(self.path):
path_ops.makedirs(self.path)
# Create a tag just like the section name in the Kodi DB
with kodi_db.KodiVideoDB(lock=False) as kodidb:
self.kodi_tagid = kodidb.create_tag(self.name)
# The xmls are numbered in order of appearance
self.order = 0
if not path_ops.exists(path_ops.path.join(self.path, 'index.xml')):
LOG.debug('Creating index.xml for section %s', self.name)
xml = etree.Element('node',
attrib={'order': unicode(self.order)})
etree.SubElement(xml, 'label').text = self.name
etree.SubElement(xml, 'icon').text = self.icon or nodes.ICON_PATH
self._write_xml(xml, 'index.xml')
self.order += 1
# Create the one smart playlist for this section
if not path_ops.exists(self.playlist_path):
self._write_playlist()
# Now build all nodes for this section - potentially creating xmls
for node in nodes.NODE_TYPES[self.section_type]:
self._build_node(*node)
def _build_node(self, node_type, node_name, args, content, pms_node):
self.content = content
node_name = node_name.format(self=self)
if pms_node:
# Do NOT write a Kodi video library xml - can't use type="filter"
# to point back to plugin://plugin.video.plexkodiconnect
xml = nodes.node_pms(self, node_name, args)
args.pop('folder', None)
path = self.addon_path(args)
else:
# Write a Kodi video library xml
xml_name = '%s_%s.xml' % (self.section_id, node_type)
path = path_ops.path.join(self.path, xml_name)
if not path_ops.exists(path):
# Let's use Kodi's logic to sort/filter the Kodi library
xml = getattr(nodes, 'node_%s' % node_type)(self, node_name)
self._write_xml(xml, xml_name)
path = 'library://video/Plex-%s/%s' % (self.section_id, xml_name)
self.order += 1
self._window_node(path, node_name, node_type, pms_node)
def _write_xml(self, xml, xml_name):
LOG.debug('Creating xml for section %s: %s', self.name, xml_name)
utils.indent(xml)
etree.ElementTree(xml).write(path_ops.path.join(self.path, xml_name),
encoding='utf-8',
xml_declaration=True)
def _write_playlist(self):
LOG.debug('Creating smart playlist for section %s: %s',
self.name, self.playlist_path)
xml = etree.Element('smartplaylist',
attrib={'type': v.CONTENT_FROM_PLEX_TYPE[self.section_type]})
etree.SubElement(xml, 'name').text = self.name
etree.SubElement(xml, 'match').text = 'all'
rule = etree.SubElement(xml, 'rule', attrib={'field': 'tag',
'operator': 'is'})
etree.SubElement(rule, 'value').text = self.name
utils.indent(xml)
etree.ElementTree(xml).write(self.playlist_path, encoding='utf-8')
def _window_node(self, path, node_name, node_type, pms_node):
"""
Will save this section's node to the Kodi window variables
Uses the same conventions/logic as Emby for Kodi does
"""
if pms_node or not self.sync_to_kodi:
# Check: elif node_type in ('browse', 'homevideos', 'photos'):
window_path = path
elif self.section_type == v.PLEX_TYPE_ARTIST:
window_path = 'ActivateWindow(Music,%s,return)' % path
else:
window_path = 'ActivateWindow(Videos,%s,return)' % path
# if node_type == 'all':
# var = self.node
# utils.window('%s.index' % var,
# value=path.replace('%s_all.xml' % self.section_id, ''))
# utils.window('%s.title' % var, value=self.name)
# else:
var = '%s.%s' % (self.node, node_type)
utils.window('%s.index' % var, value=path)
utils.window('%s.title' % var, value=node_name)
utils.window('%s.id' % var, value=str(self.section_id))
utils.window('%s.path' % var, value=window_path)
utils.window('%s.type' % var, value=self.content)
utils.window('%s.content' % var, value=path)
utils.window('%s.artwork' % var, value=self.artwork)
def remove_files_from_kodi(self):
"""
Removes this sections from the Kodi userdata library folder (if appl.)
Also removes the smart playlist
"""
if self.section_type in (v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_PHOTO):
# No files created for these types
return
if path_ops.exists(self.path):
path_ops.rmtree(self.path, ignore_errors=True)
if path_ops.exists(self.playlist_path):
try:
path_ops.remove(self.playlist_path)
except (OSError, IOError):
LOG.warn('Could not delete smart playlist for section %s: %s',
self.name, self.playlist_path)
def remove_window_vars(self):
"""
Removes all windows variables 'Plex.nodes.<section_id>.xxx'
"""
if self.index is not None:
_clear_window_vars(self.index)
def remove_from_plex(self, plexdb=None):
"""
Removes this sections completely from the Plex DB
"""
if plexdb:
plexdb.remove_section(self.section_id)
else:
with PlexDB(lock=False) as plexdb:
plexdb.remove_section(self.section_id)
def remove(self):
"""
Completely and utterly removes this section from Kodi and Plex DB
as well as from the window variables
"""
self.remove_files_from_kodi()
self.remove_window_vars()
self.remove_from_plex()
def _get_children(plex_type):
if plex_type == v.PLEX_TYPE_ALBUM:
return True
else:
return False
def get_sync_section(section, plex_type):
"""
Deep-copies section and adds certain arguments in order to prep section
for the library sync
"""
section = copy.deepcopy(section)
section.plex_type = plex_type
section.context = itemtypes.ITEMTYPE_FROM_PLEXTYPE[plex_type]
section.get_children = _get_children(plex_type)
# Some more init stuff
# Has sync for this section been successful?
section.sync_successful = True
# List of tuples: (collection index [as in an item's metadata with
# "Collection id"], collection plex id)
section.collection_match = None
# Dict with entries of the form <collection index>: <collection xml>
section.collection_xmls = {}
# Keep count during sync
section.count = 0
# Total number of items that we need to sync
section.number_of_items = 0
# Iterator to get one sync item after the other
section.iterator = None
return section
def force_full_sync():
"""
Resets the sync timestamp for all sections to 0, thus forcing a subsequent
full sync (not delta)
"""
LOG.info('Telling PKC to do a full sync instead of a delta sync')
with PlexDB() as plexdb:
plexdb.force_full_sync()
def _save_sections_to_plex_db(sections):
with PlexDB() as plexdb:
for section in sections:
section.to_plex_db(plexdb=plexdb)
def _retrieve_old_settings(sections, old_sections):
"""
Overwrites the PKC settings for sections, grabing them from old_sections
if a particular section is in both sections and old_sections
Thus sets to the old values:
section.last_sync
section.kodi_tagid
section.sync_to_kodi
section.last_sync
"""
for section in sections:
for old_section in old_sections:
if section == old_section:
section.last_sync = old_section.last_sync
section.kodi_tagid = old_section.kodi_tagid
section.sync_to_kodi = old_section.sync_to_kodi
section.last_sync = old_section.last_sync
def _delete_kodi_db_items(section):
if section.section_type == v.PLEX_TYPE_MOVIE:
kodi_context = kodi_db.KodiVideoDB
types = ((v.PLEX_TYPE_MOVIE, itemtypes.Movie), )
elif section.section_type == v.PLEX_TYPE_SHOW:
kodi_context = kodi_db.KodiVideoDB
types = ((v.PLEX_TYPE_SHOW, itemtypes.Show),
(v.PLEX_TYPE_SEASON, itemtypes.Season),
(v.PLEX_TYPE_EPISODE, itemtypes.Episode))
elif section.section_type == v.PLEX_TYPE_ARTIST:
kodi_context = kodi_db.KodiMusicDB
types = ((v.PLEX_TYPE_ARTIST, itemtypes.Artist),
(v.PLEX_TYPE_ALBUM, itemtypes.Album),
(v.PLEX_TYPE_SONG, itemtypes.Song))
else:
types = ()
LOG.debug('Skipping deletion of DB elements for section %s', section)
for plex_type, context in types:
while True:
with PlexDB() as plexdb:
plex_ids = list(plexdb.plexid_by_sectionid(section.section_id,
plex_type,
BATCH_SIZE))
with kodi_context(texture_db=True) as kodidb:
typus = context(None, plexdb=plexdb, kodidb=kodidb)
for plex_id in plex_ids:
if SHOULD_CANCEL():
return False
typus.remove(plex_id)
if len(plex_ids) < BATCH_SIZE:
break
return True
def _choose_libraries(sections):
"""
Displays a dialog for the user to select the libraries he wants synched
Returns True if the user chose new sections, False if he aborted
"""
import xbmcgui
selectable_sections = []
preselected = []
index = 0
for section in sections:
if not app.SYNC.enable_music and section.section_type == v.PLEX_TYPE_ARTIST:
LOG.info('Ignoring music section: %s', section)
continue
elif section.section_type == v.PLEX_TYPE_PHOTO:
# We won't ever show Photo sections
continue
else:
# Offer user the new section
selectable_sections.append(section.name)
# Sections have been either preselected by the user or they are new
if section.sync_to_kodi:
preselected.append(index)
index += 1
# Don't ask the user again for this PMS even if user cancel the sync dialog
utils.settings('sections_asked_for_machine_identifier',
value=app.CONN.machine_identifier)
# "Select Plex libraries to sync"
selected_sections = xbmcgui.Dialog().multiselect(utils.lang(30524),
selectable_sections,
preselect=preselected,
useDetails=False)
if selected_sections is None:
LOG.info('User chose not to select which libraries to sync')
return False
index = 0
for section in sections:
if not app.SYNC.enable_music and section.section_type == v.PLEX_TYPE_ARTIST:
continue
elif section.section_type == v.PLEX_TYPE_PHOTO:
continue
else:
section.sync_to_kodi = index in selected_sections
index += 1
return True
def delete_playlists():
"""
Clean up the playlists
"""
path = path_ops.translate_path('special://profile/playlists/video/')
for root, _, files in path_ops.walk(path):
for file in files:
if file.startswith('Plex'):
path_ops.remove(path_ops.path.join(root, file))
def delete_nodes():
"""
Clean up video nodes
"""
path = path_ops.translate_path("special://profile/library/video/")
for root, dirs, _ in path_ops.walk(path):
for directory in dirs:
if directory.startswith('Plex-'):
path_ops.rmtree(path_ops.path.join(root, directory))
break
def delete_files():
"""
Deletes both all the Plex-xxx video node xmls as well as smart playlists
"""
delete_nodes()
delete_playlists()
def sync_from_pms(parent_self, pick_libraries=False):
"""
Sync the Plex library sections.
pick_libraries=True will prompt the user the select the libraries he
wants to sync
"""
global SHOULD_CANCEL
LOG.info('Starting synching sections from the PMS')
SHOULD_CANCEL = parent_self.should_cancel
try:
return _sync_from_pms(pick_libraries)
finally:
SHOULD_CANCEL = None
LOG.info('Done synching sections from the PMS: %s', app.SYNC.sections)
def _sync_from_pms(pick_libraries):
# Re-set value in order to make sure we got the lastest user input
app.SYNC.enable_music = utils.settings('enableMusic') == 'true'
xml = PF.get_plex_sections()
if xml is None:
LOG.error("Error download PMS sections, abort")
return False
sections = []
old_sections = []
for i, xml_element in enumerate(xml.findall('Directory')):
api = API(xml_element)
if api.plex_type in v.UNSUPPORTED_PLEX_TYPES:
continue
sections.append(Section(index=i, xml_element=xml_element))
with PlexDB() as plexdb:
for section_db in plexdb.all_sections():
old_sections.append(Section(section_db_element=section_db))
# Update our latest PMS sections with info saved in the PMS DB
_retrieve_old_settings(sections, old_sections)
if (app.CONN.machine_identifier != utils.settings('sections_asked_for_machine_identifier') or
pick_libraries):
if not pick_libraries:
LOG.info('First time connecting to this PMS, choosing libraries')
_choose_libraries(sections)
# We got everything - save to Plex db in case Kodi restarts before we're
# done here
_save_sections_to_plex_db(sections)
# Tweak some settings so Kodi does NOT scan the music folders
if app.SYNC.direct_paths is True:
# Will reboot Kodi is new library detected
music.excludefromscan_music_folders(sections)
# Delete all old sections that are obsolete
# This will also delete sections whose name (or type) have changed
for old_section in old_sections:
for section in sections:
if old_section == section:
break
else:
if not old_section.sync_to_kodi:
continue
LOG.info('Deleting entire section: %s', old_section)
# Remove all linked items
if not _delete_kodi_db_items(old_section):
return False
# Remove the section itself
old_section.remove()
# Clear all existing window vars because we did NOT remove them with the
# command section.remove()
clear_window_vars()
# Time to write the sections to Kodi
for section in sections:
section.to_kodi()
# Counter that tells us how many sections we have - e.g. for skins and
# listings
utils.window('Plex.nodes.total', str(len(sections)))
app.SYNC.sections = sections
return True
def _clear_window_vars(index):
node = 'Plex.nodes.%s' % index
utils.window('%s.index' % node, clear=True)
utils.window('%s.title' % node, clear=True)
utils.window('%s.type' % node, clear=True)
utils.window('%s.content' % node, clear=True)
utils.window('%s.path' % node, clear=True)
utils.window('%s.id' % node, clear=True)
# Just clear everything here, ignore the plex_type
for typus in (x[0] for y in nodes.NODE_TYPES.values() for x in y):
for kind in WINDOW_ARGS:
node = 'Plex.nodes.%s.%s.%s' % (index, typus, kind)
utils.window(node, clear=True)
def clear_window_vars():
"""
Removes all references to sections stored in window vars 'Plex.nodes...'
"""
LOG.debug('Clearing all the Plex video node variables')
number_of_nodes = int(utils.window('Plex.nodes.total') or 0)
utils.window('Plex.nodes.total', clear=True)
for index in range(number_of_nodes):
_clear_window_vars(index)
def delete_videonode_files():
"""
Removes all the PKC video node files under userdata/library/video that
start with 'Plex-'
"""
for root, dirs, _ in path_ops.walk(LIBRARY_PATH):
for directory in dirs:
if directory.startswith('Plex-'):
abs_path = path_ops.path.join(root, directory)
LOG.info('Removing video node directory %s', abs_path)
path_ops.rmtree(abs_path, ignore_errors=True)
break

View file

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
from logging import getLogger
from threading import Thread, Lock
from xbmc import sleep
from xbmcgui import DialogProgressBG
from utils import thread_methods, language as lang
###############################################################################
LOG = getLogger("PLEX." + __name__)
GET_METADATA_COUNT = 0
PROCESS_METADATA_COUNT = 0
PROCESSING_VIEW_NAME = ''
LOCK = Lock()
###############################################################################
@thread_methods(add_stops=['SUSPEND_LIBRARY_THREAD',
'STOP_SYNC',
'SUSPEND_SYNC'])
class ThreadedShowSyncInfo(Thread):
"""
Threaded class to show the Kodi statusbar of the metadata download.
Input:
total: Total number of items to get
item_type:
"""
def __init__(self, total, item_type):
self.total = total
self.item_type = item_type
Thread.__init__(self)
def run(self):
"""
Do the work
"""
LOG.debug('Show sync info thread started')
# cache local variables because it's faster
total = self.total
dialog = DialogProgressBG('dialoglogProgressBG')
dialog.create("%s %s: %s %s"
% (lang(39714), self.item_type, str(total), lang(39715)))
total = 2 * total
total_progress = 0
while not self.stopped():
with LOCK:
get_progress = GET_METADATA_COUNT
process_progress = PROCESS_METADATA_COUNT
view_name = PROCESSING_VIEW_NAME
total_progress = get_progress + process_progress
try:
percentage = int(float(total_progress) / float(total)*100.0)
except ZeroDivisionError:
percentage = 0
dialog.update(percentage,
message="%s %s. %s %s: %s"
% (get_progress,
lang(39712),
process_progress,
lang(39713),
view_name))
# Sleep for x milliseconds
sleep(200)
dialog.close()
LOG.debug('Show sync info thread terminated')

View file

@ -1,372 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from .common import update_kodi_library, PLAYLIST_SYNC_ENABLED
from .fanart import SYNC_FANART, FanartTask
from ..plex_api import API
from ..plex_db import PlexDB
from .. import kodi_db
from .. import backgroundthread, plex_functions as PF, itemtypes
from .. import artwork, utils, timing, variables as v, app
if PLAYLIST_SYNC_ENABLED:
from .. import playlists
LOG = getLogger('PLEX.sync.websocket')
CACHING_ENALBED = utils.settings('enableTextureCache') == "true"
WEBSOCKET_MESSAGES = []
# Dict to save info for Plex items currently being played somewhere
PLAYSTATE_SESSIONS = {}
def multi_delete(input_list, delete_list):
"""
Deletes the list items of input_list at the positions in delete_list
(which can be in any arbitrary order)
"""
for index in sorted(delete_list, reverse=True):
del input_list[index]
return input_list
def store_websocket_message(message):
"""
processes json.loads() messages from websocket. Triage what we need to
do with "process_" methods
"""
if message['type'] == 'playing':
process_playing(message['PlaySessionStateNotification'])
elif message['type'] == 'timeline':
store_timeline_message(message['TimelineEntry'])
elif message['type'] == 'activity':
store_activity_message(message['ActivityNotification'])
def process_websocket_messages():
"""
Periodically called to process new/updated PMS items
PMS needs a while to download info from internet AFTER it
showed up under 'timeline' websocket messages
data['type']:
1: movie
2: tv show??
3: season??
4: episode
8: artist (band)
9: album
10: track (song)
12: trailer, extras?
data['state']:
0: 'created',
2: 'matching',
3: 'downloading',
4: 'loading',
5: 'finished',
6: 'analyzing',
9: 'deleted'
"""
global WEBSOCKET_MESSAGES
now = timing.unix_timestamp()
update_kodi_video_library, update_kodi_music_library = False, False
delete_list = []
for i, message in enumerate(WEBSOCKET_MESSAGES):
if message['state'] == 9:
successful, video, music = process_delete_message(message)
elif now - message['timestamp'] < app.SYNC.backgroundsync_saftymargin:
# We haven't waited long enough for the PMS to finish processing the
# item. Do it later (excepting deletions)
continue
else:
successful, video, music = process_new_item_message(message)
if (successful and SYNC_FANART and
message['plex_type'] in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)):
task = FanartTask()
task.setup(message['plex_id'],
message['plex_type'],
refresh=False)
backgroundthread.BGThreader.addTask(task)
if successful is True:
delete_list.append(i)
update_kodi_video_library = True if video else update_kodi_video_library
update_kodi_music_library = True if music else update_kodi_music_library
else:
# Safety net if we can't process an item
message['attempt'] += 1
if message['attempt'] > 3:
LOG.error('Repeatedly could not process message %s, abort',
message)
delete_list.append(i)
# Get rid of the items we just processed
if delete_list:
WEBSOCKET_MESSAGES = multi_delete(WEBSOCKET_MESSAGES, delete_list)
# Let Kodi know of the change
if update_kodi_video_library or update_kodi_music_library:
update_kodi_library(video=update_kodi_video_library,
music=update_kodi_music_library)
def process_new_item_message(message):
LOG.debug('Message: %s', message)
xml = PF.GetPlexMetadata(message['plex_id'])
try:
plex_type = xml[0].attrib['type']
except (IndexError, KeyError, TypeError):
LOG.error('Could not download metadata for %s', message['plex_id'])
return False, False, False
LOG.debug("Processing new/updated PMS item: %s", message['plex_id'])
with itemtypes.ITEMTYPE_FROM_PLEXTYPE[plex_type](timing.unix_timestamp()) as typus:
typus.add_update(xml[0],
section_name=xml.get('librarySectionTitle'),
section_id=utils.cast(int, xml.get('librarySectionID')))
cache_artwork(message['plex_id'], plex_type)
return True, plex_type in v.PLEX_VIDEOTYPES, plex_type in v.PLEX_AUDIOTYPES
def process_delete_message(message):
plex_type = message['plex_type']
with itemtypes.ITEMTYPE_FROM_PLEXTYPE[plex_type](None) as typus:
typus.remove(message['plex_id'], plex_type=plex_type)
return True, plex_type in v.PLEX_VIDEOTYPES, plex_type in v.PLEX_AUDIOTYPES
def store_timeline_message(data):
"""
PMS is messing with the library items, e.g. new or changed. Put in our
"processing queue" for later
"""
global WEBSOCKET_MESSAGES
for message in data:
if 'tv.plex' in message.get('identifier', ''):
# Ommit Plex DVR messages - the Plex IDs are not corresponding
# (DVR ratingKeys are not unique and might correspond to a
# movie or episode)
continue
try:
typus = v.PLEX_TYPE_FROM_WEBSOCKET[int(message['type'])]
except KeyError:
# E.g. -1 - thanks Plex!
LOG.info('Ignoring invalid message %s', data)
continue
if typus in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_SET):
# No need to process extras or trailers
continue
status = int(message['state'])
if typus == 'playlist' and PLAYLIST_SYNC_ENABLED:
playlists.websocket(plex_id=unicode(message['itemID']),
status=status)
elif status == 9:
# Immediately and always process deletions (as the PMS will
# send additional message with other codes)
WEBSOCKET_MESSAGES.append({
'state': status,
'plex_type': typus,
'plex_id': utils.cast(int, message['itemID']),
'timestamp': timing.unix_timestamp(),
'attempt': 0
})
elif typus in (v.PLEX_TYPE_MOVIE,
v.PLEX_TYPE_EPISODE,
v.PLEX_TYPE_SONG) and status == 5:
plex_id = int(message['itemID'])
# Have we already added this element for processing?
for existing_message in WEBSOCKET_MESSAGES:
if existing_message['plex_id'] == plex_id:
break
else:
# Haven't added this element to the queue yet
WEBSOCKET_MESSAGES.append({
'state': status,
'plex_type': typus,
'plex_id': plex_id,
'timestamp': timing.unix_timestamp(),
'attempt': 0
})
def store_activity_message(data):
"""
PMS is re-scanning an item, e.g. after having changed a movie poster.
WATCH OUT for this if it's triggered by our PKC library scan!
"""
global WEBSOCKET_MESSAGES
for message in data:
if message['event'] != 'ended':
# Scan still going on, so skip for now
continue
elif message['Activity'].get('Context') is None:
# Not related to any Plex element, but entire library
continue
elif message['Activity']['type'] != 'library.refresh.items':
# Not the type of message relevant for us
continue
plex_id = PF.GetPlexKeyNumber(message['Activity']['Context']['key'])[1]
if not plex_id:
# Likely a Plex id like /library/metadata/3/children
continue
# We're only looking at existing elements - have we synced yet?
with PlexDB(lock=False) as plexdb:
typus = plexdb.item_by_id(plex_id, plex_type=None)
if not typus:
LOG.debug('plex_id %s not synced yet - skipping', plex_id)
continue
# Have we already added this element?
for existing_message in WEBSOCKET_MESSAGES:
if existing_message['plex_id'] == plex_id:
break
else:
# Haven't added this element to the queue yet
WEBSOCKET_MESSAGES.append({
'state': None, # Don't need a state here
'plex_type': typus['plex_type'],
'plex_id': plex_id,
'timestamp': timing.unix_timestamp(),
'attempt': 0
})
def process_playing(data):
"""
Someone (not necessarily the user signed in) is playing something some-
where
"""
global PLAYSTATE_SESSIONS
for message in data:
status = message['state']
if status == 'buffering' or status == 'stopped':
# Drop buffering and stop messages immediately - no value
continue
plex_id = utils.cast(int, message['ratingKey'])
skip = False
for pid in (0, 1, 2):
if plex_id == app.PLAYSTATE.player_states[pid]['plex_id']:
# Kodi is playing this message - no need to set the playstate
skip = True
if skip:
continue
if 'sessionKey' not in message:
LOG.warn('Received malformed message from the PMS: %s', message)
continue
session_key = message['sessionKey']
# Do we already have a sessionKey stored?
if session_key not in PLAYSTATE_SESSIONS:
with PlexDB(lock=False) as plexdb:
typus = plexdb.item_by_id(plex_id, plex_type=None)
if not typus or 'kodi_fileid' not in typus:
# Item not (yet) in Kodi library or not affiliated with a file
continue
if utils.settings('plex_serverowned') == 'false':
# Not our PMS, we are not authorized to get the sessions
# On the bright side, it must be us playing :-)
PLAYSTATE_SESSIONS[session_key] = {}
else:
# PMS is ours - get all current sessions
pms_sessions = PF.GetPMSStatus(app.ACCOUNT.plex_token)
if session_key not in pms_sessions:
LOG.info('Session key %s still unknown! Skip '
'playstate update', session_key)
continue
PLAYSTATE_SESSIONS[session_key] = pms_sessions[session_key]
LOG.debug('Updated current sessions. They are: %s',
PLAYSTATE_SESSIONS)
# Attach Kodi info to the session
PLAYSTATE_SESSIONS[session_key]['kodi_fileid'] = typus['kodi_fileid']
if typus['plex_type'] == v.PLEX_TYPE_EPISODE:
PLAYSTATE_SESSIONS[session_key]['kodi_fileid_2'] = typus['kodi_fileid_2']
else:
PLAYSTATE_SESSIONS[session_key]['kodi_fileid_2'] = None
PLAYSTATE_SESSIONS[session_key]['kodi_id'] = typus['kodi_id']
PLAYSTATE_SESSIONS[session_key]['kodi_type'] = typus['kodi_type']
session = PLAYSTATE_SESSIONS[session_key]
if utils.settings('plex_serverowned') != 'false':
# Identify the user - same one as signed on with PKC? Skip
# update if neither session's username nor userid match
# (Owner sometime's returns id '1', not always)
if not app.ACCOUNT.plex_token and session['userId'] == '1':
# PKC not signed in to plex.tv. Plus owner of PMS is
# playing (the '1').
# Hence must be us (since several users require plex.tv
# token for PKC)
pass
elif not (session['userId'] == app.ACCOUNT.plex_user_id or
session['username'] == app.ACCOUNT.plex_username):
LOG.debug('Our username %s, userid %s did not match '
'the session username %s with userid %s',
app.ACCOUNT.plex_username,
app.ACCOUNT.plex_user_id,
session['username'],
session['userId'])
continue
# Get an up-to-date XML from the PMS because PMS will NOT directly
# tell us: duration of item viewCount
if not session.get('duration'):
xml = PF.GetPlexMetadata(plex_id)
if xml in (None, 401):
LOG.error('Could not get up-to-date xml for item %s',
plex_id)
continue
api = API(xml[0])
session['duration'] = api.runtime()
session['viewCount'] = api.viewcount()
# Sometimes, Plex tells us resume points in milliseconds and
# not in seconds - thank you very much!
if message['viewOffset'] > session['duration']:
resume = message['viewOffset'] / 1000
else:
resume = message['viewOffset']
if resume < v.IGNORE_SECONDS_AT_START:
continue
try:
completed = float(resume) / float(session['duration'])
except (ZeroDivisionError, TypeError):
LOG.error('Could not mark playstate for %s and session %s',
data, session)
continue
if completed >= v.MARK_PLAYED_AT:
# Only mark completely watched ONCE
if session.get('marked_played') is None:
session['marked_played'] = True
mark_played = True
else:
# Don't mark it as completely watched again
continue
else:
mark_played = False
LOG.debug('Update playstate for user %s for %s with plex id %s to '
'viewCount %s, resume %s, mark_played %s for item %s',
app.ACCOUNT.plex_username, session['kodi_type'], plex_id,
session['viewCount'], resume, mark_played, PLAYSTATE_SESSIONS[session_key])
func = itemtypes.ITEMTYPE_FROM_KODITYPE[session['kodi_type']]
with func(None) as fkt:
fkt.update_playstate(mark_played,
session['viewCount'],
resume,
session['duration'],
session['kodi_fileid'],
session['kodi_fileid_2'],
timing.unix_timestamp())
def cache_artwork(plex_id, plex_type, kodi_id=None, kodi_type=None):
"""
Triggers caching of artwork (if so enabled in the PKC settings)
"""
if not CACHING_ENALBED:
return
if not kodi_id:
with PlexDB(lock=False) as plexdb:
item = plexdb.item_by_id(plex_id, plex_type)
if not item:
LOG.error('Could not retrieve Plex db info for %s', plex_id)
return
kodi_id, kodi_type = item['kodi_id'], item['kodi_type']
with kodi_db.KODIDB_FROM_PLEXTYPE[plex_type]() as kodidb:
for url in kodidb.art_urls(kodi_id, kodi_type):
artwork.cache_url(url)

1662
resources/lib/librarysync.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
###############################################################################
import logging
import xbmc
###############################################################################
@ -38,11 +37,9 @@ def config():
class LogHandler(logging.StreamHandler):
def __init__(self):
logging.StreamHandler.__init__(self)
self.setFormatter(logging.Formatter(fmt=b"%(name)s: %(message)s"))
self.setFormatter(logging.Formatter(fmt="%(name)s: %(message)s"))
def emit(self, record):
if isinstance(record.msg, unicode):
record.msg = record.msg.encode('utf-8')
try:
xbmc.log(self.format(record), level=LEVELS[record.levelno])
except UnicodeEncodeError:

View file

@ -1,102 +1,29 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from . import variables as v
from . import utils
import variables as v
from utils import compare_version, settings
###############################################################################
LOG = getLogger('PLEX.migration')
log = getLogger("PLEX."+__name__)
def check_migration():
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)
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)
# Ensure later migration if user downgraded PKC!
settings('last_migrated_PKC_version', value=v.ADDON_VERSION)
return
if not utils.compare_version(last_migration, '1.8.2'):
LOG.info('Migrating to version 1.8.1')
if not compare_version(v.ADDON_VERSION, '1.8.2'):
log.info('Migrating to version 1.8.1')
# Set the new PKC theMovieDB key
utils.settings('themoviedbAPIKey',
value='19c90103adb9e98f2172c6a6a3d85dc4')
settings('themoviedbAPIKey', value='19c90103adb9e98f2172c6a6a3d85dc4')
if not utils.compare_version(last_migration, '2.0.25'):
LOG.info('Migrating to version 2.0.24')
if not compare_version(v.ADDON_VERSION, '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='')
settings('ipaddress', value='')
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)
settings('last_migrated_PKC_version', value=v.ADDON_VERSION)

View file

@ -1,47 +1,52 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import re
from re import compile as re_compile
from xml.etree.ElementTree import ParseError
from .plex_api.media import Media
from . import utils
from . import variables as v
from utils import XmlKodiSetting, reboot_kodi, language as lang
from PlexFunctions import get_plex_sections
from PlexAPI import API
import variables as v
###############################################################################
LOG = getLogger('PLEX.music.py')
LOG = getLogger("PLEX." + __name__)
REGEX_MUSICPATH = re_compile(r'''^\^(.+)\$$''')
###############################################################################
def excludefromscan_music_folders(sections):
def excludefromscan_music_folders():
"""
Gets a complete list of paths for music libraries from the PMS. Sets them
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
Reboots Kodi if new library detected
"""
xml = get_plex_sections()
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not get Plex sections')
return
# Build paths
paths = []
reboot = False
api = Media()
for section in sections:
if section.section_type != v.PLEX_TYPE_ARTIST:
api = API(item=None)
for library in xml:
if library.attrib['type'] != v.PLEX_TYPE_ARTIST:
# Only look at music libraries
continue
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,
omit_check=True)
paths.append(_turn_to_regex(path))
for location in library:
if location.tag == 'Location':
path = api.validate_playurl(location.attrib['path'],
typus=v.PLEX_TYPE_ARTIST,
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'])
with XmlKodiSetting('advancedsettings.xml',
force_create=True,
top_element='advancedsettings') as xml:
parent = xml.set_setting(['audio', 'excludefromscan'])
for path in paths:
for element in parent:
if element.text == path:
@ -49,15 +54,11 @@ def excludefromscan_music_folders(sections):
break
else:
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
xml.set_setting(['audio', 'excludefromscan', 'regexp'],
value=path, append=True)
# We only need to reboot if we ADD new paths!
reboot = xml.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:
@ -66,16 +67,16 @@ def excludefromscan_music_folders(sections):
LOG.info('Deleting music library from advancedsettings: %s',
element.text)
parent.remove(element)
xml_file.write_xml = True
except (utils.ParseError, IOError):
except (ParseError, IOError):
LOG.error('Could not adjust advancedsettings.xml')
reboot = False
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))
reboot_kodi(lang(39711))
def _turn_to_regex(path):
def __turn_to_regex(path):
"""
Turns a path into regex expression to be fed to Kodi's advancedsettings.xml
"""
@ -86,7 +87,7 @@ def _turn_to_regex(path):
else:
if not path.endswith('\\'):
path = '%s\\' % path
# Escape all characters that could cause problems
path = re.escape(path)
# Need to escape backslashes
path = path.replace('\\', '\\\\')
# Beginning of path only needs to be similar
return '^%s' % path

View file

@ -1,243 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
File and Path operations
Kodi xbmc*.*() functions usually take utf-8 encoded commands, thus try_encode
works.
Unfortunatly, working with filenames and paths seems to require an encoding in
the OS' getfilesystemencoding - it will NOT always work with unicode paths.
However, sys.getfilesystemencoding might return None.
Feed unicode to all the functions below and you're fine.
WARNING: os.path won't really work with smb paths (possibly others). For
xbmcvfs functions to work with smb paths, they need to be both in passwords.xml
as well as sources.xml
"""
from __future__ import absolute_import, division, unicode_literals
import shutil
import os
from os import path # allows to use path_ops.path.join, for example
from distutils import dir_util
import re
import xbmc
import xbmcvfs
from .tools import unicode_paths
# Kodi seems to encode in utf-8 in ALL cases (unlike e.g. the OS filesystem)
KODI_ENCODING = 'utf-8'
REGEX_FILE_NUMBERING = re.compile(r'''_(\d\d)\.\w+$''')
def encode_path(path):
"""
Filenames and paths are not necessarily utf-8 encoded. Use this function
instead of try_encode/trydecode if working with filenames and paths!
(os.walk only feeds on encoded paths. sys.getfilesystemencoding returns None
for Raspberry Pi)
"""
return unicode_paths.encode(path)
def decode_path(path):
"""
Filenames and paths are not necessarily utf-8 encoded. Use this function
instead of try_encode/trydecode if working with filenames and paths!
(os.walk only feeds on encoded paths. sys.getfilesystemencoding returns None
for Raspberry Pi)
"""
return unicode_paths.decode(path)
def translate_path(path):
"""
Returns the XBMC translated path [unicode]
e.g. Converts 'special://masterprofile/script_data'
-> '/home/user/XBMC/UserData/script_data' on Linux.
"""
translated = xbmc.translatePath(path.encode(KODI_ENCODING, 'strict'))
return translated.decode(KODI_ENCODING, 'strict')
def exists(path):
"""
Returns True if the path [unicode] exists. Folders NEED a trailing slash or
backslash!!
"""
return xbmcvfs.exists(path.encode(KODI_ENCODING, 'strict')) == 1
def rmtree(path, *args, **kwargs):
"""Recursively delete a directory tree.
If ignore_errors is set, errors are ignored; otherwise, if onerror
is set, it is called to handle the error with arguments (func,
path, exc_info) where func is os.listdir, os.remove, or os.rmdir;
path is the argument to that function that caused it to fail; and
exc_info is a tuple returned by sys.exc_info(). If ignore_errors
is false and onerror is None, an exception is raised.
"""
return shutil.rmtree(encode_path(path), *args, **kwargs)
def copyfile(src, dst):
"""Copy data from src to dst"""
return shutil.copyfile(encode_path(src), encode_path(dst))
def makedirs(path, *args, **kwargs):
"""makedirs(path [, mode=0777])
Super-mkdir; create a leaf directory and all intermediate ones. Works like
mkdir, except that any intermediate path segment (not just the rightmost)
will be created if it does not exist. This is recursive.
"""
return os.makedirs(encode_path(path), *args, **kwargs)
def remove(path):
"""
Remove (delete) the file path. If path is a directory, OSError is raised;
see rmdir() below to remove a directory. This is identical to the unlink()
function documented below. On Windows, attempting to remove a file that is
in use causes an exception to be raised; on Unix, the directory entry is
removed but the storage allocated to the file is not made available until
the original file is no longer in use.
"""
return os.remove(encode_path(path))
def walk(top, topdown=True, onerror=None, followlinks=False):
"""
Directory tree generator.
For each directory in the directory tree rooted at top (including top
itself, but excluding '.' and '..'), yields a 3-tuple
dirpath, dirnames, filenames
dirpath is a string, the path to the directory. dirnames is a list of
the names of the subdirectories in dirpath (excluding '.' and '..').
filenames is a list of the names of the non-directory files in dirpath.
Note that the names in the lists are just names, with no path components.
To get a full path (which begins with top) to a file or directory in
dirpath, do os.path.join(dirpath, name).
If optional arg 'topdown' is true or not specified, the triple for a
directory is generated before the triples for any of its subdirectories
(directories are generated top down). If topdown is false, the triple
for a directory is generated after the triples for all of its
subdirectories (directories are generated bottom up).
When topdown is true, the caller can modify the dirnames list in-place
(e.g., via del or slice assignment), and walk will only recurse into the
subdirectories whose names remain in dirnames; this can be used to prune the
search, or to impose a specific order of visiting. Modifying dirnames when
topdown is false is ineffective, since the directories in dirnames have
already been generated by the time dirnames itself is generated. No matter
the value of topdown, the list of subdirectories is retrieved before the
tuples for the directory and its subdirectories are generated.
By default errors from the os.listdir() call are ignored. If
optional arg 'onerror' is specified, it should be a function; it
will be called with one argument, an os.error instance. It can
report the error to continue with the walk, or raise the exception
to abort the walk. Note that the filename is available as the
filename attribute of the exception object.
By default, os.walk does not follow symbolic links to subdirectories on
systems that support them. In order to get this functionality, set the
optional argument 'followlinks' to true.
Caution: if you pass a relative pathname for top, don't change the
current working directory between resumptions of walk. walk never
changes the current directory, and assumes that the client doesn't
either.
Example:
import os
from os.path import join, getsize
for root, dirs, files in os.walk('python/Lib/email'):
print root, "consumes",
print sum([getsize(join(root, name)) for name in files]),
print "bytes in", len(files), "non-directory files"
if 'CVS' in dirs:
dirs.remove('CVS') # don't visit CVS directories
"""
# Get all the results from os.walk and store them in a list
walker = list(os.walk(encode_path(top),
topdown,
onerror,
followlinks))
for top, dirs, nondirs in walker:
yield (decode_path(top),
[decode_path(x) for x in dirs],
[decode_path(x) for x in nondirs])
def copy_tree(src, dst, *args, **kwargs):
"""
Copy an entire directory tree 'src' to a new location 'dst'.
Both 'src' and 'dst' must be directory names. If 'src' is not a
directory, raise DistutilsFileError. If 'dst' does not exist, it is
created with 'mkpath()'. The end result of the copy is that every
file in 'src' is copied to 'dst', and directories under 'src' are
recursively copied to 'dst'. Return the list of files that were
copied or might have been copied, using their output name. The
return value is unaffected by 'update' or 'dry_run': it is simply
the list of all files under 'src', with the names changed to be
under 'dst'.
'preserve_mode' and 'preserve_times' are the same as for
'copy_file'; note that they only apply to regular files, not to
directories. If 'preserve_symlinks' is true, symlinks will be
copied as symlinks (on platforms that support them!); otherwise
(the default), the destination of the symlink will be copied.
'update' and 'verbose' are the same as for 'copy_file'.
"""
src = encode_path(src)
dst = encode_path(dst)
return dir_util.copy_tree(src, dst, *args, **kwargs)
def basename(path):
"""
Returns the filename for path [unicode] or an empty string if not possible.
Safer than using os.path.basename, as we could be expecting \\ for / or
vice versa
"""
try:
return path.rsplit('/', 1)[1]
except IndexError:
try:
return path.rsplit('\\', 1)[1]
except IndexError:
return ''
def create_unique_path(directory, filename, extension):
"""
Checks whether 'directory/filename.extension' exists. If so, will start
numbering the filename until the file does not exist yet (up to 99)
"""
res = path.join(directory, '.'.join((filename, extension)))
while exists(res):
occurance = REGEX_FILE_NUMBERING.search(res)
if not occurance:
filename = '{}_00'.format(filename[:min(len(filename),
251 - len(extension))])
res = path.join(directory, '.'.join((filename, extension)))
else:
number = int(occurance.group(1)) + 1
if number > 99:
raise RuntimeError('Could not create unique file: {} {} {}'.format(
directory, filename, extension))
basename = re.sub(REGEX_FILE_NUMBERING, '', res)
res = '{}_{:02d}.{}'.format(basename, number, extension)
return res

View file

@ -1,21 +0,0 @@
# -*- coding: utf-8 -*-
# pathtools: File system path tools.
# Copyright (C) 2010 Yesudeep Mangalapilly <yesudeep@gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

View file

@ -1,206 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# path.py: Path functions.
#
# Copyright (C) 2010 Yesudeep Mangalapilly <yesudeep@gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
:module: pathtools.path
:synopsis: Directory walking, listing, and path sanitizing functions.
:author: Yesudeep Mangalapilly <yesudeep@gmail.com>
Functions
---------
.. autofunction:: get_dir_walker
.. autofunction:: walk
.. autofunction:: listdir
.. autofunction:: list_directories
.. autofunction:: list_files
.. autofunction:: absolute_path
.. autofunction:: real_absolute_path
.. autofunction:: parent_dir_path
"""
import os.path
from functools import partial
__all__ = [
'get_dir_walker',
'walk',
'listdir',
'list_directories',
'list_files',
'absolute_path',
'real_absolute_path',
'parent_dir_path',
]
def get_dir_walker(recursive, topdown=True, followlinks=False):
"""
Returns a recursive or a non-recursive directory walker.
:param recursive:
``True`` produces a recursive walker; ``False`` produces a non-recursive
walker.
:returns:
A walker function.
"""
if recursive:
walk = partial(os.walk, topdown=topdown, followlinks=followlinks)
else:
def walk(path, topdown=topdown, followlinks=followlinks):
try:
yield next(os.walk(path, topdown=topdown, followlinks=followlinks))
except NameError:
yield os.walk(path, topdown=topdown, followlinks=followlinks).next() #IGNORE:E1101
return walk
def walk(dir_pathname, recursive=True, topdown=True, followlinks=False):
"""
Walks a directory tree optionally recursively. Works exactly like
:func:`os.walk` only adding the `recursive` argument.
:param dir_pathname:
The directory to traverse.
:param recursive:
``True`` for walking recursively through the directory tree;
``False`` otherwise.
:param topdown:
Please see the documentation for :func:`os.walk`
:param followlinks:
Please see the documentation for :func:`os.walk`
"""
walk_func = get_dir_walker(recursive, topdown, followlinks)
for root, dirnames, filenames in walk_func(dir_pathname):
yield (root, dirnames, filenames)
def listdir(dir_pathname,
recursive=True,
topdown=True,
followlinks=False):
"""
Enlists all items using their absolute paths in a directory, optionally
recursively.
:param dir_pathname:
The directory to traverse.
:param recursive:
``True`` for walking recursively through the directory tree;
``False`` otherwise.
:param topdown:
Please see the documentation for :func:`os.walk`
:param followlinks:
Please see the documentation for :func:`os.walk`
"""
for root, dirnames, filenames\
in walk(dir_pathname, recursive, topdown, followlinks):
for dirname in dirnames:
yield absolute_path(os.path.join(root, dirname))
for filename in filenames:
yield absolute_path(os.path.join(root, filename))
def list_directories(dir_pathname,
recursive=True,
topdown=True,
followlinks=False):
"""
Enlists all the directories using their absolute paths within the specified
directory, optionally recursively.
:param dir_pathname:
The directory to traverse.
:param recursive:
``True`` for walking recursively through the directory tree;
``False`` otherwise.
:param topdown:
Please see the documentation for :func:`os.walk`
:param followlinks:
Please see the documentation for :func:`os.walk`
"""
for root, dirnames, filenames\
in walk(dir_pathname, recursive, topdown, followlinks):
for dirname in dirnames:
yield absolute_path(os.path.join(root, dirname))
def list_files(dir_pathname,
recursive=True,
topdown=True,
followlinks=False):
"""
Enlists all the files using their absolute paths within the specified
directory, optionally recursively.
:param dir_pathname:
The directory to traverse.
:param recursive:
``True`` for walking recursively through the directory tree;
``False`` otherwise.
:param topdown:
Please see the documentation for :func:`os.walk`
:param followlinks:
Please see the documentation for :func:`os.walk`
"""
for root, dirnames, filenames\
in walk(dir_pathname, recursive, topdown, followlinks):
for filename in filenames:
yield absolute_path(os.path.join(root, filename))
def absolute_path(path):
"""
Returns the absolute path for the given path and normalizes the path.
:param path:
Path for which the absolute normalized path will be found.
:returns:
Absolute normalized path.
"""
return os.path.abspath(os.path.normpath(path))
def real_absolute_path(path):
"""
Returns the real absolute normalized path for the given path.
:param path:
Path for which the real absolute normalized path will be found.
:returns:
Real absolute normalized path.
"""
return os.path.realpath(absolute_path(path))
def parent_dir_path(path):
"""
Returns the parent directory path.
:param path:
Path for which the parent directory will be obtained.
:returns:
Parent directory path.
"""
return absolute_path(os.path.dirname(path))

View file

@ -1,265 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# patterns.py: Common wildcard searching/filtering functionality for files.
#
# Copyright (C) 2010 Yesudeep Mangalapilly <yesudeep@gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
:module: pathtools.patterns
:synopsis: Wildcard pattern matching and filtering functions for paths.
:author: Yesudeep Mangalapilly <yesudeep@gmail.com>
Functions
---------
.. autofunction:: match_path
.. autofunction:: match_path_against
.. autofunction:: filter_paths
"""
from fnmatch import fnmatch, fnmatchcase
__all__ = ['match_path',
'match_path_against',
'match_any_paths',
'filter_paths']
def _string_lower(s):
"""
Convenience function to lowercase a string (the :mod:`string` module is
deprecated/removed in Python 3.0).
:param s:
The string which will be lowercased.
:returns:
Lowercased copy of string s.
"""
return s.lower()
def match_path_against(pathname, patterns, case_sensitive=True):
"""
Determines whether the pathname matches any of the given wildcard patterns,
optionally ignoring the case of the pathname and patterns.
:param pathname:
A path name that will be matched against a wildcard pattern.
:param patterns:
A list of wildcard patterns to match_path the filename against.
:param case_sensitive:
``True`` if the matching should be case-sensitive; ``False`` otherwise.
:returns:
``True`` if the pattern matches; ``False`` otherwise.
Doctests::
>>> match_path_against("/home/username/foobar/blah.py", ["*.py", "*.txt"], False)
True
>>> match_path_against("/home/username/foobar/blah.py", ["*.PY", "*.txt"], True)
False
>>> match_path_against("/home/username/foobar/blah.py", ["*.PY", "*.txt"], False)
True
>>> match_path_against("C:\\windows\\blah\\BLAH.PY", ["*.py", "*.txt"], True)
False
>>> match_path_against("C:\\windows\\blah\\BLAH.PY", ["*.py", "*.txt"], False)
True
"""
if case_sensitive:
match_func = fnmatchcase
pattern_transform_func = (lambda w: w)
else:
match_func = fnmatch
pathname = pathname.lower()
pattern_transform_func = _string_lower
for pattern in set(patterns):
pattern = pattern_transform_func(pattern)
if match_func(pathname, pattern):
return True
return False
def _match_path(pathname,
included_patterns,
excluded_patterns,
case_sensitive=True):
"""Internal function same as :func:`match_path` but does not check arguments.
Doctests::
>>> _match_path("/users/gorakhargosh/foobar.py", ["*.py"], ["*.PY"], True)
True
>>> _match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], True)
False
>>> _match_path("/users/gorakhargosh/foobar/", ["*.py"], ["*.txt"], False)
False
>>> _match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], False)
Traceback (most recent call last):
...
ValueError: conflicting patterns `set(['*.py'])` included and excluded
"""
if not case_sensitive:
included_patterns = set(map(_string_lower, included_patterns))
excluded_patterns = set(map(_string_lower, excluded_patterns))
else:
included_patterns = set(included_patterns)
excluded_patterns = set(excluded_patterns)
common_patterns = included_patterns & excluded_patterns
if common_patterns:
raise ValueError('conflicting patterns `%s` included and excluded'\
% common_patterns)
return (match_path_against(pathname, included_patterns, case_sensitive)\
and not match_path_against(pathname, excluded_patterns,
case_sensitive))
def match_path(pathname,
included_patterns=None,
excluded_patterns=None,
case_sensitive=True):
"""
Matches a pathname against a set of acceptable and ignored patterns.
:param pathname:
A pathname which will be matched against a pattern.
:param included_patterns:
Allow filenames matching wildcard patterns specified in this list.
If no pattern is specified, the function treats the pathname as
a match_path.
:param excluded_patterns:
Ignores filenames matching wildcard patterns specified in this list.
If no pattern is specified, the function treats the pathname as
a match_path.
:param case_sensitive:
``True`` if matching should be case-sensitive; ``False`` otherwise.
:returns:
``True`` if the pathname matches; ``False`` otherwise.
:raises:
ValueError if included patterns and excluded patterns contain the
same pattern.
Doctests::
>>> match_path("/Users/gorakhargosh/foobar.py")
True
>>> match_path("/Users/gorakhargosh/foobar.py", case_sensitive=False)
True
>>> match_path("/users/gorakhargosh/foobar.py", ["*.py"], ["*.PY"], True)
True
>>> match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], True)
False
>>> match_path("/users/gorakhargosh/foobar/", ["*.py"], ["*.txt"], False)
False
>>> match_path("/users/gorakhargosh/FOOBAR.PY", ["*.py"], ["*.PY"], False)
Traceback (most recent call last):
...
ValueError: conflicting patterns `set(['*.py'])` included and excluded
"""
included = ["*"] if included_patterns is None else included_patterns
excluded = [] if excluded_patterns is None else excluded_patterns
return _match_path(pathname, included, excluded, case_sensitive)
def filter_paths(pathnames,
included_patterns=None,
excluded_patterns=None,
case_sensitive=True):
"""
Filters from a set of paths based on acceptable patterns and
ignorable patterns.
:param pathnames:
A list of path names that will be filtered based on matching and
ignored patterns.
:param included_patterns:
Allow filenames matching wildcard patterns specified in this list.
If no pattern list is specified, ["*"] is used as the default pattern,
which matches all files.
:param excluded_patterns:
Ignores filenames matching wildcard patterns specified in this list.
If no pattern list is specified, no files are ignored.
:param case_sensitive:
``True`` if matching should be case-sensitive; ``False`` otherwise.
:returns:
A list of pathnames that matched the allowable patterns and passed
through the ignored patterns.
Doctests::
>>> pathnames = set(["/users/gorakhargosh/foobar.py", "/var/cache/pdnsd.status", "/etc/pdnsd.conf", "/usr/local/bin/python"])
>>> set(filter_paths(pathnames)) == pathnames
True
>>> set(filter_paths(pathnames, case_sensitive=False)) == pathnames
True
>>> set(filter_paths(pathnames, ["*.py", "*.conf"], ["*.status"], case_sensitive=True)) == set(["/users/gorakhargosh/foobar.py", "/etc/pdnsd.conf"])
True
"""
included = ["*"] if included_patterns is None else included_patterns
excluded = [] if excluded_patterns is None else excluded_patterns
for pathname in pathnames:
# We don't call the public match_path because it checks arguments
# and sets default values if none are found. We're already doing that
# above.
if _match_path(pathname, included, excluded, case_sensitive):
yield pathname
def match_any_paths(pathnames,
included_patterns=None,
excluded_patterns=None,
case_sensitive=True):
"""
Matches from a set of paths based on acceptable patterns and
ignorable patterns.
:param pathnames:
A list of path names that will be filtered based on matching and
ignored patterns.
:param included_patterns:
Allow filenames matching wildcard patterns specified in this list.
If no pattern list is specified, ["*"] is used as the default pattern,
which matches all files.
:param excluded_patterns:
Ignores filenames matching wildcard patterns specified in this list.
If no pattern list is specified, no files are ignored.
:param case_sensitive:
``True`` if matching should be case-sensitive; ``False`` otherwise.
:returns:
``True`` if any of the paths matches; ``False`` otherwise.
Doctests::
>>> pathnames = set(["/users/gorakhargosh/foobar.py", "/var/cache/pdnsd.status", "/etc/pdnsd.conf", "/usr/local/bin/python"])
>>> match_any_paths(pathnames)
True
>>> match_any_paths(pathnames, case_sensitive=False)
True
>>> match_any_paths(pathnames, ["*.py", "*.conf"], ["*.status"], case_sensitive=True)
True
>>> match_any_paths(pathnames, ["*.txt"], case_sensitive=False)
False
>>> match_any_paths(pathnames, ["*.txt"], case_sensitive=True)
False
"""
included = ["*"] if included_patterns is None else included_patterns
excluded = [] if excluded_patterns is None else excluded_patterns
for pathname in pathnames:
# We don't call the public match_path because it checks arguments
# and sets default values if none are found. We're already doing that
# above.
if _match_path(pathname, included, excluded, case_sensitive):
return True
return False

View file

@ -1,31 +0,0 @@
# -*- coding: utf-8 -*-
# version.py: Version information.
# Copyright (C) 2010 Yesudeep Mangalapilly <yesudeep@gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# When updating this version number, please update the
# ``docs/source/global.rst.inc`` file as well.
VERSION_MAJOR = 0
VERSION_MINOR = 1
VERSION_BUILD = 1
VERSION_INFO = (VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD)
VERSION_STRING = "%d.%d.%d" % VERSION_INFO
__version__ = VERSION_INFO

76
resources/lib/pickler.py Normal file
View file

@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
###############################################################################
from cPickle import dumps, loads
from xbmcgui import Window
from xbmc import log, LOGDEBUG
###############################################################################
WINDOW = Window(10000)
PREFIX = 'PLEX.%s: ' % __name__
###############################################################################
def try_encode(input_str, encoding='utf-8'):
"""
Will try to encode input_str (in unicode) to encoding. This possibly
fails with e.g. Android TV's Python, which does not accept arguments for
string.encode()
COPY to avoid importing utils on calling default.py
"""
if isinstance(input_str, str):
# already encoded
return input_str
try:
input_str = input_str.encode(encoding, "ignore")
except TypeError:
input_str = input_str.encode()
return input_str
def pickl_window(property, value=None, clear=False):
"""
Get or set window property - thread safe! For use with Pickle
Property and value must be string
"""
if clear:
WINDOW.clearProperty(property)
elif value is not None:
WINDOW.setProperty(property, value)
else:
return try_encode(WINDOW.getProperty(property))
def pickle_me(obj, window_var='plex_result'):
"""
Pickles the obj to the window variable. Use to transfer Python
objects between different PKC python instances (e.g. if default.py is
called and you'd want to use the service.py instance)
obj can be pretty much any Python object. However, classes and
functions won't work. See the Pickle documentation
"""
log('%sStart pickling' % PREFIX, level=LOGDEBUG)
pickl_window(window_var, value=dumps(obj))
log('%sSuccessfully pickled' % PREFIX, level=LOGDEBUG)
def unpickle_me(window_var='plex_result'):
"""
Unpickles a Python object from the window variable window_var.
Will then clear the window variable!
"""
result = pickl_window(window_var)
pickl_window(window_var, clear=True)
log('%sStart unpickling' % PREFIX, level=LOGDEBUG)
obj = loads(result)
log('%sSuccessfully unpickled' % PREFIX, level=LOGDEBUG)
return obj
class Playback_Successful(object):
"""
Used to communicate with another PKC Python instance
"""
listitem = None

View file

@ -1,39 +1,44 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Used to kick off Kodi playback
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from threading import Thread
import datetime
from os.path import join
import xbmc
from xbmc import Player, sleep
from .plex_api import API
from .plex_db import PlexDB
from .kodi_db import KodiVideoDB
from . import plex_functions as PF, playlist_func as PL, playqueue as PQ
from . import json_rpc as js, variables as v, utils, transfer
from . import playback_decision, app
from . import exceptions
from PlexAPI import API
from PlexFunctions import GetPlexMetadata, init_plex_playqueue
from downloadutils import DownloadUtils as DU
import plexdb_functions as plexdb
import kodidb_functions as kodidb
import playlist_func as PL
import playqueue as PQ
from playutils import PlayUtils
from PKC_listitem import PKC_ListItem
from pickler import pickle_me, Playback_Successful
import json_rpc as js
from utils import settings, dialog, language as lang, try_encode
from plexbmchelper.subscribers import LOCKER
import variables as v
import state
###############################################################################
LOG = getLogger('PLEX.playback')
LOG = getLogger("PLEX." + __name__)
# Do we need to return ultimately with a setResolvedUrl?
RESOLVE = True
TRY_TO_SEEK_FOR = 300 # =30 seconds
IGNORE_SECONDS_AT_START = 15
# We're "failing" playback with a video of 0 length
NULL_VIDEO = join(v.ADDON_FOLDER, 'addons', v.ADDON_ID, 'empty_video.mp4')
###############################################################################
def playback_triage(plex_id=None, plex_type=None, path=None, resolve=True,
resume=False):
@LOCKER.lockthis
def playback_triage(plex_id=None, plex_type=None, path=None, resolve=True):
"""
Hit this function for addon path playback, Plex trailers, etc.
Will setup playback first, then on second call complete playback.
Will set Playback_Successful() with potentially a PKCListItem() attached
Will set Playback_Successful() with potentially a PKC_ListItem() attached
(to be consumed by setResolvedURL in default.py)
If trailers or additional (movie-)parts are added, default.py is released
@ -44,156 +49,56 @@ def playback_triage(plex_id=None, plex_type=None, path=None, resolve=True,
the first pass - e.g. if you're calling this function from the original
service.py Python instance
"""
try:
_playback_triage(plex_id, plex_type, path, resolve, resume)
finally:
# Reset some playback variables the user potentially set to init
# playback
app.PLAYSTATE.context_menu_play = False
app.PLAYSTATE.force_transcode = False
def _playback_triage(plex_id, plex_type, path, resolve, resume):
plex_id = utils.cast(int, plex_id)
LOG.debug('playback_triage called with plex_id %s, plex_type %s, path %s, '
'resolve %s, resume %s', plex_id, plex_type, path, resolve, resume)
LOG.info('playback_triage called with plex_id %s, plex_type %s, path %s, '
'resolve %s,', plex_id, plex_type, path, resolve)
global RESOLVE
# If started via Kodi context menu, we never resolve
RESOLVE = resolve if not app.PLAYSTATE.context_menu_play else False
if not app.CONN.online or not app.ACCOUNT.authenticated:
if not app.CONN.online:
LOG.error('PMS not online for playback')
# "{0} offline"
utils.dialog('notification',
utils.lang(29999),
utils.lang(39213).format(app.CONN.server_name),
icon='{plex}')
else:
LOG.error('Not yet authenticated for PMS, abort starting playback')
# "Unauthorized for PMS"
utils.dialog('notification', utils.lang(29999), utils.lang(30017))
RESOLVE = resolve if not state.CONTEXT_MENU_PLAY else False
if not state.AUTHENTICATED:
LOG.error('Not yet authenticated for PMS, abort starting playback')
# "Unauthorized for PMS"
dialog('notification', lang(29999), lang(30017))
_ensure_resolve(abort=True)
return
with app.APP.lock_playqueues:
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
try:
pos = js.get_position(playqueue.playlistid)
except KeyError:
# Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for
# add-on paths
LOG.debug('No position returned from player! Assuming playlist')
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO)
try:
pos = js.get_position(playqueue.playlistid)
except KeyError:
LOG.debug('Assuming video instead of audio playlist playback')
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_VIDEO)
try:
pos = js.get_position(playqueue.playlistid)
except KeyError:
LOG.error('Still no position - abort')
# "Play error"
utils.dialog('notification',
utils.lang(29999),
utils.lang(30128),
icon='{error}')
_ensure_resolve(abort=True)
return
# HACK to detect playback of playlists for add-on paths
items = js.playlist_get_items(playqueue.playlistid)
try:
item = items[pos]
except IndexError:
LOG.debug('Could not apply playlist hack! Probably Widget playback')
else:
if ('id' not in item and
item.get('type') == 'unknown' and item.get('title') == ''):
LOG.debug('Kodi playlist play detected')
_playlist_playback(plex_id)
return
# Can return -1 (as in "no playlist")
pos = pos if pos != -1 else 0
LOG.debug('playQueue position %s for %s', pos, playqueue)
# Have we already initiated playback?
try:
item = playqueue.items[pos]
except IndexError:
LOG.debug('PKC playqueue yet empty, need to initialize playback')
initiate = True
else:
if item.plex_id != plex_id:
LOG.debug('Received new plex_id%s, expected %s',
plex_id, item.plex_id)
initiate = True
else:
initiate = False
if initiate:
_playback_init(plex_id, plex_type, playqueue, pos, resume)
else:
# kick off playback on second pass, resume was already set on first
# pass (threaded_playback will seek to resume)
_conclude_playback(playqueue, pos)
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
pos = js.get_position(playqueue.playlistid)
# Can return -1 (as in "no playlist")
pos = pos if pos != -1 else 0
LOG.debug('playQueue position %s for %s', pos, playqueue)
# Have we already initiated playback?
try:
item = playqueue.items[pos]
except IndexError:
initiate = True
else:
initiate = True if item.plex_id != plex_id else False
if initiate:
_playback_init(plex_id, plex_type, playqueue, pos)
else:
# kick off playback on second pass
_conclude_playback(playqueue, pos)
def _playlist_playback(plex_id):
"""
Really annoying Kodi behavior: Kodi will throw the ENTIRE playlist some-
where, causing Playlist.onAdd to fire for each item like this:
Playlist.OnAdd Data: {u'item': {u'type': u'episode', u'id': 164},
u'playlistid': 0,
u'position': 2}
This does NOT work for Addon paths, type and id will be unknown:
{u'item': {u'type': u'unknown'},
u'playlistid': 0,
u'position': 7}
At the end, only the element being played actually shows up in the Kodi
playqueue.
Hence: if we fail the first addon paths call, Kodi will start playback
for the next item in line :-)
(by the way: trying to get active Kodi player id will return [])
"""
xml = PF.GetPlexMetadata(plex_id, reraise=True)
if xml in (None, 401):
_ensure_resolve(abort=True)
return
# Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback
# has actually started. Need to tell Kodimonitor
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO)
playqueue.clear(kodi=False)
# Set the flag for the potentially WRONG audio playlist so Kodimonitor
# can pick up on it
playqueue.kodi_playlist_playback = True
playlist_item = PL.playlist_item_from_xml(xml[0])
playqueue.items.append(playlist_item)
_conclude_playback(playqueue, pos=0)
def _playback_init(plex_id, plex_type, playqueue, pos, resume):
def _playback_init(plex_id, plex_type, playqueue, pos):
"""
Playback setup if Kodi starts playing an item for the first time.
"""
LOG.debug('Initializing PKC playback')
# Stop playback so we don't get an error message that the last item of the
# queue failed to play
app.APP.player.stop()
xml = PF.GetPlexMetadata(plex_id, reraise=True)
if xml in (None, 401):
LOG.info('Initializing PKC playback')
xml = GetPlexMetadata(plex_id)
try:
xml[0].attrib
except (IndexError, TypeError, AttributeError):
LOG.error('Could not get a PMS xml for plex id %s', plex_id)
# "Play error"
dialog('notification', lang(29999), lang(30128), icon='{error}')
_ensure_resolve(abort=True)
return
if (xbmc.getCondVisibility('Window.IsVisible(Home.xml)') and
plex_type in v.PLEX_VIDEOTYPES and
playqueue.kodi_pl.size() > 1):
# playqueue.kodi_pl.size() could return more than one - since playback
# was initiated from the audio queue!
LOG.debug('Detected widget playback for videos')
elif playqueue.kodi_pl.size() > 1:
if playqueue.kodi_pl.size() > 1:
# Special case - we already got a filled Kodi playqueue
try:
_init_existing_kodi_playlist(playqueue, pos)
except exceptions.PlaylistError:
except PL.PlaylistError:
LOG.error('Playback_init for existing Kodi playlist failed')
_ensure_resolve(abort=True)
return
@ -203,59 +108,60 @@ def _playback_init(plex_id, plex_type, playqueue, pos, resume):
return
# "Usual" case - consider trailers and parts and build both Kodi and Plex
# playqueues
# Release default.py
# Pass dummy PKC video with 0 length so Kodi immediately stops playback
# and we can build our own playqueue.
_ensure_resolve()
api = API(xml[0])
if (app.PLAYSTATE.context_menu_play and
api.resume_point() and
api.plex_type in v.PLEX_VIDEOTYPES):
# User chose to either play via PMS or to force transcode
# Need to prompt whether we should resume_playback
resume = resume_dialog(int(api.resume_point()))
if resume is None:
# User cancelled dialog
return
LOG.debug('Using resume %s', resume)
resume = resume or False
trailers = False
if (not resume and plex_type == v.PLEX_TYPE_MOVIE and
utils.settings('enableCinema') == "true"):
if utils.settings('askCinema') == "true":
if (plex_type == v.PLEX_TYPE_MOVIE and not api.resume_point() and
settings('enableCinema') == "true"):
if settings('askCinema') == "true":
# "Play trailers?"
trailers = utils.yesno_dialog(utils.lang(29999), utils.lang(33016))
trailers = dialog('yesno', lang(29999), lang(33016))
trailers = True if trailers else False
else:
trailers = True
LOG.debug('Resuming: %s. Playing trailers: %s', resume, trailers)
LOG.debug('Playing trailers: %s', trailers)
if RESOLVE:
# Sleep a bit to let setResolvedUrl do its thing - bit ugly
sleep_timer = 0
while not state.PKC_CAUSED_STOP_DONE:
sleep(50)
sleep_timer += 1
if sleep_timer > 100:
break
playqueue.clear()
if plex_type != v.PLEX_TYPE_CLIP:
# Post to the PMS to create a playqueue - in any case due to Companion
xml = PF.init_plex_playqueue(plex_id,
plex_type,
xml.get('librarySectionUUID'),
trailers=trailers)
xml = init_plex_playqueue(plex_id,
xml.attrib.get('librarySectionUUID'),
mediatype=plex_type,
trailers=trailers)
if xml is None:
LOG.error('Could not get a playqueue xml for plex id %s', plex_id)
LOG.error('Could not get a playqueue xml for plex id %s, UUID %s',
plex_id, xml.attrib.get('librarySectionUUID'))
# "Play error"
utils.dialog('notification',
utils.lang(29999),
utils.lang(30128),
icon='{error}')
dialog('notification', lang(29999), lang(30128), icon='{error}')
# Do NOT use _ensure_resolve() because we resolved above already
state.CONTEXT_MENU_PLAY = False
state.FORCE_TRANSCODE = False
state.RESUME_PLAYBACK = False
return
PL.get_playlist_details_from_xml(playqueue, xml)
stack = _prep_playlist_stack(xml, resume)
stack = _prep_playlist_stack(xml)
_process_stack(playqueue, stack)
offset = _use_kodi_db_offset(playqueue.items[pos].plex_id,
playqueue.items[pos].plex_type,
playqueue.items[pos].offset) if resume else 0
# Always resume if playback initiated via PMS and there IS a resume
# point
offset = api.resume_point() * 1000 if state.CONTEXT_MENU_PLAY else None
# Reset some playback variables
state.CONTEXT_MENU_PLAY = False
state.FORCE_TRANSCODE = False
# New thread to release this one sooner (e.g. harddisk spinning up)
thread = Thread(target=threaded_playback,
args=(playqueue.kodi_pl, pos, offset))
thread.setDaemon(True)
LOG.debug('Done initializing playback, starting Kodi player at pos %s and '
'offset %s', pos, offset)
# Ensure that PKC playqueue monitor ignores the changes we just made
playqueue.pkc_edit = True
LOG.info('Done initializing playback, starting Kodi player at pos %s and '
'resume point %s', pos, offset)
# By design, PKC will start Kodi playback using Player().play(). Kodi
# caches paths like our plugin://pkc. If we use Player().play() between
# 2 consecutive startups of exactly the same Kodi library item, Kodi's
@ -263,6 +169,8 @@ def _playback_init(plex_id, plex_type, playqueue, pos, resume):
# plugin://pkc will be lost; Kodi will try to startup playback for an empty
# path: log entry is "CGUIWindowVideoBase::OnPlayMedia <missing path>"
thread.start()
# Ensure that PKC playqueue monitor ignores the changes we just made
playqueue.pkc_edit = True
def _ensure_resolve(abort=False):
@ -275,33 +183,19 @@ def _ensure_resolve(abort=False):
will be destroyed.
"""
if RESOLVE:
# Releases the other Python thread without a ListItem
transfer.send(True)
# Wait for default.py to have completed xbmcplugin.setResolvedUrl()
transfer.wait_for_transfer(source='default')
LOG.debug('Passing dummy path to Kodi')
# if not state.CONTEXT_MENU_PLAY:
# Because playback won't start with context menu play
state.PKC_CAUSED_STOP = True
state.PKC_CAUSED_STOP_DONE = False
result = Playback_Successful()
result.listitem = PKC_ListItem(path=NULL_VIDEO)
pickle_me(result)
if abort:
utils.dialog('notification',
heading='{plex}',
message=utils.lang(30128),
icon='{error}',
time=3000)
def resume_dialog(resume):
"""
Pass the resume [int] point in seconds. Returns True if user chose to
resume. Returns None if user cancelled
"""
# "Resume from {0:s}"
# "Start from beginning"
resume = datetime.timedelta(seconds=resume)
LOG.debug('Showing PKC resume dialog for resume: %s', resume)
answ = utils.dialog('contextmenu',
[utils.lang(12022).replace('{0:s}', '{0}').format(unicode(resume)),
utils.lang(12021)])
if answ == -1:
return
return answ == 0
# Reset some playback variables
state.CONTEXT_MENU_PLAY = False
state.FORCE_TRANSCODE = False
state.RESUME_PLAYBACK = False
def _init_existing_kodi_playlist(playqueue, pos):
@ -313,31 +207,27 @@ def _init_existing_kodi_playlist(playqueue, pos):
kodi_items = js.playlist_get_items(playqueue.playlistid)
if not kodi_items:
LOG.error('No Kodi items returned')
raise exceptions.PlaylistError('No Kodi items returned')
item = PL.init_plex_playqueue(playqueue, kodi_item=kodi_items[pos])
item.force_transcode = app.PLAYSTATE.force_transcode
raise PL.PlaylistError('No Kodi items returned')
item = PL.init_Plex_playlist(playqueue, kodi_item=kodi_items[pos])
item.force_transcode = state.FORCE_TRANSCODE
# playqueue.py will add the rest - this will likely put the PMS under
# a LOT of strain if the following Kodi setting is enabled:
# Settings -> Player -> Videos -> Play next video automatically
LOG.debug('Done init_existing_kodi_playlist')
def _prep_playlist_stack(xml, resume):
"""
resume [bool] will set the resume point of the LAST item of the stack, for
part 1 only
"""
def _prep_playlist_stack(xml):
stack = []
for i, item in enumerate(xml):
for item in xml:
api = API(item)
if (app.PLAYSTATE.context_menu_play is False and
api.plex_type not in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_EPISODE)):
if (state.CONTEXT_MENU_PLAY is False and
api.plex_type() not in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_EPISODE)):
# If user chose to play via PMS or force transcode, do not
# use the item path stored in the Kodi DB
with PlexDB(lock=False) as plexdb:
db_item = plexdb.item_by_id(api.plex_id, api.plex_type)
kodi_id = db_item['kodi_id'] if db_item else None
kodi_type = db_item['kodi_type'] if db_item else None
with plexdb.Get_Plex_DB() as plex_db:
plex_dbitem = plex_db.getItem_byId(api.plex_id())
kodi_id = plex_dbitem[0] if plex_dbitem else None
kodi_type = plex_dbitem[4] if plex_dbitem else None
else:
# We will never store clips (trailers) in the Kodi DB.
# Also set kodi_id to None for playback via PMS, so that we're
@ -348,20 +238,15 @@ def _prep_playlist_stack(xml, resume):
kodi_id = None
kodi_type = None
for part, _ in enumerate(item[0]):
api.part = part
api.set_part_number(part)
if kodi_id is None:
# Need to redirect again to PKC to conclude playback
path = api.fullpath(force_addon=True)[0]
# Using different paths than the ones saved in the Kodi DB
# fixes Kodi immediately resuming the video if one restarts
# the same video again after playback
# WARNING: This fixes startup, but renders Kodi unstable
# path = path.replace('plugin.video.plexkodiconnect.tvshows',
# 'plugin.video.plexkodiconnect', 1)
# path = path.replace('plugin.video.plexkodiconnect.movies',
# 'plugin.video.plexkodiconnect', 1)
listitem = api.listitem()
listitem.setPath(path.encode('utf-8'))
path = ('plugin://%s/?plex_id=%s&plex_type=%s&mode=play'
% (v.ADDON_TYPE[api.plex_type()],
api.plex_id(),
api.plex_type()))
listitem = api.create_listitem()
listitem.setPath(try_encode(path))
else:
# Will add directly via the Kodi DB
path = None
@ -375,7 +260,6 @@ def _prep_playlist_stack(xml, resume):
'part': part,
'playcount': api.viewcount(),
'offset': api.resume_point(),
'resume': resume if part == 0 and i + 1 == len(xml) else None,
'id': api.item_id()
})
return stack
@ -407,27 +291,10 @@ def _process_stack(playqueue, stack):
playlist_item.offset = item['offset']
playlist_item.part = item['part']
playlist_item.id = item['id']
playlist_item.force_transcode = app.PLAYSTATE.force_transcode
playlist_item.resume = item['resume']
playlist_item.force_transcode = state.FORCE_TRANSCODE
pos += 1
def _use_kodi_db_offset(plex_id, plex_type, plex_offset):
"""
Do NOT use item.offset directly but get it from the Kodi DB (Plex might not
have gotten the last resume point)
"""
if plex_type not in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_EPISODE):
return plex_offset
with PlexDB(lock=False) as plexdb:
db_item = plexdb.item_by_id(plex_id, plex_type)
if db_item:
with KodiVideoDB(lock=False) as kodidb:
return kodidb.get_resume(db_item['kodi_fileid'])
else:
return plex_offset
def _conclude_playback(playqueue, pos):
"""
ONLY if actually being played (e.g. at 5th position of a playqueue).
@ -443,30 +310,41 @@ def _conclude_playback(playqueue, pos):
start playback
return PKC listitem attached to result
"""
LOG.debug('Concluding playback for playqueue position %s', pos)
LOG.info('Concluding playback for playqueue position %s', pos)
result = Playback_Successful()
listitem = PKC_ListItem()
item = playqueue.items[pos]
if item.api.mediastream_number() is None:
# E.g. user could choose between several media streams and cancelled
LOG.debug('Did not get a mediastream_number')
_ensure_resolve()
return
item.api.part = item.part or 0
playback_decision.set_pkc_playmethod(item.api, item)
if not playback_decision.audio_subtitle_prefs(item.api, item):
LOG.info('Did not set audio subtitle prefs, aborting silently')
_ensure_resolve()
return
playback_decision.set_playurl(item.api, item)
if not item.file:
LOG.info('Did not get a playurl, aborting playback silently')
_ensure_resolve()
return
listitem = item.api.listitem(listitem=transfer.PKCListItem, resume=False)
listitem.setPath(item.file.encode('utf-8'))
if item.playmethod != v.PLAYBACK_METHOD_DIRECT_PATH:
listitem.setSubtitles(item.api.cache_external_subs())
transfer.send(listitem)
LOG.debug('Done concluding playback')
if item.xml is not None:
# Got a Plex element
api = API(item.xml)
api.set_part_number(item.part)
api.create_listitem(listitem)
playutils = PlayUtils(api, item)
playurl = playutils.getPlayUrl()
else:
playurl = item.file
listitem.setPath(try_encode(playurl))
if item.playmethod == 'DirectStream':
listitem.setSubtitles(api.cache_external_subs())
elif item.playmethod == 'Transcode':
playutils.audio_subtitle_prefs(listitem)
if state.RESUME_PLAYBACK is True:
state.RESUME_PLAYBACK = False
if (item.offset is None and
item.plex_type not in (v.PLEX_TYPE_SONG, v.PLEX_TYPE_CLIP)):
with plexdb.Get_Plex_DB() as plex_db:
plex_dbitem = plex_db.getItem_byId(item.plex_id)
file_id = plex_dbitem[1] if plex_dbitem else None
with kodidb.GetKodiDB('video') as kodi_db:
item.offset = kodi_db.get_resume(file_id)
LOG.info('Resuming playback at %s', item.offset)
listitem.setProperty('StartOffset', str(item.offset))
listitem.setProperty('resumetime', str(item.offset))
# Reset the resumable flag
result.listitem = listitem
pickle_me(result)
LOG.info('Done concluding playback')
def process_indirect(key, offset, resolve=True):
@ -480,65 +358,59 @@ def process_indirect(key, offset, resolve=True):
Set resolve to False if playback should be kicked off directly, not via
setResolvedUrl
"""
LOG.debug('process_indirect called with key: %s, offset: %s, resolve: %s',
key, offset, resolve)
LOG.info('process_indirect called with key: %s, offset: %s, resolve: %s',
key, offset, resolve)
global RESOLVE
RESOLVE = resolve
offset = int(v.PLEX_TO_KODI_TIMEFACTOR * float(offset)) if offset != '0' else None
result = Playback_Successful()
if key.startswith('http') or key.startswith('{server}'):
xml = PF.get_playback_xml(key, app.CONN.server_name)
xml = DU().downloadUrl(key)
elif key.startswith('/system/services'):
xml = PF.get_playback_xml('http://node.plexapp.com:32400%s' % key,
'plexapp.com',
authenticate=False,
token=app.ACCOUNT.plex_token)
xml = DU().downloadUrl('http://node.plexapp.com:32400%s' % key)
else:
xml = PF.get_playback_xml('{server}%s' % key, app.CONN.server_name)
if xml is None:
xml = DU().downloadUrl('{server}%s' % key)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not download PMS metadata')
_ensure_resolve(abort=True)
return
if offset != '0':
offset = int(v.PLEX_TO_KODI_TIMEFACTOR * float(offset))
# Todo: implement offset
api = API(xml[0])
listitem = api.listitem(listitem=transfer.PKCListItem, resume=False)
listitem = PKC_ListItem()
api.create_listitem(listitem)
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()])
playqueue.clear()
item = PL.playlist_item_from_xml(xml[0])
item.offset = offset
item.playmethod = v.PLAYBACK_METHOD_DIRECT_PLAY
item = PL.Playlist_Item()
item.xml = xml[0]
item.offset = int(offset)
item.plex_type = v.PLEX_TYPE_CLIP
item.playmethod = 'DirectStream'
# Need to get yet another xml to get the final playback url
xml = DU().downloadUrl('http://node.plexapp.com:32400%s'
% xml[0][0][0].attrib['key'])
try:
xml = PF.get_playback_xml('http://node.plexapp.com:32400%s'
% xml[0][0][0].attrib['key'],
'plexapp.com',
authenticate=False,
token=app.ACCOUNT.plex_token)
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('XML malformed: %s', xml.attrib)
xml = None
if xml is None:
LOG.error('Could not download last xml for playurl')
_ensure_resolve(abort=True)
return
try:
playurl = xml[0].attrib['key']
except (TypeError, IndexError, AttributeError):
LOG.error('Last xml malformed: %s', xml.attrib)
_ensure_resolve(abort=True)
return
playurl = xml[0].attrib['key']
item.file = playurl
listitem.setPath(utils.try_encode(playurl))
listitem.setPath(try_encode(playurl))
playqueue.items.append(item)
if resolve is True:
transfer.send(listitem)
result.listitem = listitem
pickle_me(result)
else:
thread = Thread(target=app.APP.player.play,
args={'item': utils.try_encode(playurl),
thread = Thread(target=Player().play,
args={'item': try_encode(playurl),
'listitem': listitem})
thread.setDaemon(True)
LOG.debug('Done initializing PKC playback, starting Kodi player')
LOG.info('Done initializing PKC playback, starting Kodi player')
thread.start()
@ -549,67 +421,41 @@ def play_xml(playqueue, xml, offset=None, start_plex_id=None):
Either supply the ratingKey of the starting Plex element. Or set
playqueue.selectedItemID
"""
offset = int(offset) / 1000 if offset else None
LOG.debug("play_xml called with offset %s, start_plex_id %s",
offset, start_plex_id)
start_item = start_plex_id if start_plex_id is not None \
else playqueue.selectedItemID
for startpos, video in enumerate(xml):
api = API(video)
if api.plex_id == start_item:
break
else:
startpos = 0
stack = _prep_playlist_stack(xml, resume=False)
if offset:
stack[startpos]['resume'] = True
LOG.info("play_xml called with offset %s, start_plex_id %s",
offset, start_plex_id)
stack = _prep_playlist_stack(xml)
_process_stack(playqueue, stack)
LOG.debug('Playqueue after play_xml update: %s', playqueue)
if start_plex_id is not None:
for startpos, item in enumerate(playqueue.items):
if item.plex_id == start_plex_id:
break
else:
startpos = 0
else:
for startpos, item in enumerate(playqueue.items):
if item.id == playqueue.selectedItemID:
break
else:
startpos = 0
thread = Thread(target=threaded_playback,
args=(playqueue.kodi_pl, startpos, offset))
LOG.debug('Done play_xml, starting Kodi player at position %s', startpos)
LOG.info('Done play_xml, starting Kodi player at position %s', startpos)
thread.start()
def threaded_playback(kodi_playlist, startpos, offset):
"""
Seek immediately after kicking off playback is not reliable. We even seek
to 0 (starting position) in case Kodi wants to resume but we want to start
over.
offset: resume position in seconds [int/float]
Seek immediately after kicking off playback is not reliable.
"""
LOG.debug('threaded_playback with startpos %s, offset %s',
startpos, offset)
app.APP.player.play(kodi_playlist, None, False, startpos)
offset = offset if offset else 0
i = 0
while not app.APP.is_playing or not js.get_player_ids():
if app.APP.monitor.waitForAbort(0.1):
# PKC needs to quit
return
i += 1
if i > TRY_TO_SEEK_FOR:
LOG.error('Could not seek to %s', offset)
return
try:
if offset == 0 and app.APP.player.getTime() < IGNORE_SECONDS_AT_START:
LOG.debug('Avoiding small jump to the very start of the video')
return
except RuntimeError:
# RuntimeError: XBMC is not playing any media file
pass
i = 0
answ = js.seek_to(offset * 1000)
while 'error' in answ:
# Kodi sometimes returns {u'message': u'Failed to execute method.',
# u'code': -32100} if user quickly switches videos
if app.APP.monitor.waitForAbort(0.1):
# PKC needs to quit
return
i += 1
if i > TRY_TO_SEEK_FOR:
LOG.error('Failed to seek to %s. Error: %s', offset, answ)
return
answ = js.seek_to(offset * 1000)
LOG.debug('Seek to offset %s successful', offset)
player = Player()
player.play(kodi_playlist, None, False, startpos)
if offset and offset != '0':
i = 0
while not player.isPlaying():
sleep(100)
i += 1
if i > 100:
LOG.error('Could not seek to %s', offset)
return
js.seek_to(int(offset))

View file

@ -1,447 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from requests import exceptions
from .downloadutils import DownloadUtils as DU
from .plex_api import API
from . import plex_functions as PF, utils, variables as v
LOG = getLogger('PLEX.playback_decision')
# largest signed 32bit integer: 2147483
MAX_SIGNED_INT = int(2**31 - 1)
# PMS answer codes
DIRECT_PLAY_OK = 1000
CONVERSION_OK = 1001 # PMS can either direct stream or transcode
def set_pkc_playmethod(api, item):
item.playmethod = int(utils.settings('playType'))
LOG.info('User chose playback method %s in PKC settings',
v.EXPLICIT_PLAYBACK_METHOD[item.playmethod])
_initial_best_playback_method(api, item)
LOG.info('PKC decided on playback method %s',
v.EXPLICIT_PLAYBACK_METHOD[item.playmethod])
def set_playurl(api, item):
try:
if item.playmethod == v.PLAYBACK_METHOD_DIRECT_PATH:
# No need to ask the PMS whether we can play - we circumvent
# the PMS entirely
return
LOG.info('Lets ask the PMS next')
try:
_pms_playback_decision(api, item)
except (exceptions.RequestException,
AttributeError,
IndexError,
SystemExit):
LOG.warn('Could not find suitable settings for playback, aborting')
utils.ERROR(notify=True)
item.playmethod = None
item.file = None
else:
item.file = api.transcode_video_path(item.playmethod,
quality=item.quality)
finally:
LOG.info('The playurl for %s is: %s',
v.EXPLICIT_PLAYBACK_METHOD[item.playmethod], item.file)
def _initial_best_playback_method(api, item):
"""
Sets the highest available playback method without talking to the PMS
Also sets self.path for a direct path, if available and accessible
"""
item.file = api.file_path()
item.file = api.validate_playurl(item.file, api.plex_type, force_check=True)
# Check whether we have a strm file that we need to throw at Kodi 1:1
if item.file is not None and item.file.endswith('.strm'):
# Use direct path in any case, regardless of user setting
LOG.debug('.strm file detected')
item.playmethod = v.PLAYBACK_METHOD_DIRECT_PATH
elif _must_transcode(api, item):
item.playmethod = v.PLAYBACK_METHOD_TRANSCODE
elif item.playmethod in (v.PLAYBACK_METHOD_DIRECT_PLAY,
v.PLAYBACK_METHOD_DIRECT_STREAM):
pass
elif item.file is None:
# E.g. direct path was not possible to access
item.playmethod = v.PLAYBACK_METHOD_DIRECT_PLAY
else:
item.playmethod = v.PLAYBACK_METHOD_DIRECT_PATH
def _pms_playback_decision(api, item):
"""
We CANNOT distinguish direct playing from direct streaming from the PMS'
answer
"""
ask_for_user_quality_settings = False
if item.playmethod <= 2:
LOG.info('Asking PMS with maximal quality settings')
item.quality = _max_quality()
decision_api = _ask_pms(api, item)
if decision_api.decision_code() > CONVERSION_OK:
ask_for_user_quality_settings = True
else:
ask_for_user_quality_settings = True
if ask_for_user_quality_settings:
item.quality = _transcode_quality()
LOG.info('Asking PMS with user quality settings')
decision_api = _ask_pms(api, item)
# Process the PMS answer
if decision_api.decision_code() > CONVERSION_OK:
LOG.error('Neither DirectPlay, DirectStream nor transcoding possible')
error = '%s\n%s' % (decision_api.general_play_decision_text(),
decision_api.transcode_decision_text())
utils.messageDialog(heading=utils.lang(29999),
msg=error)
raise AttributeError('Neither DirectPlay, DirectStream nor transcoding possible')
if (item.playmethod == v.PLAYBACK_METHOD_DIRECT_PLAY and
decision_api.decision_code() == DIRECT_PLAY_OK):
# All good
return
LOG.info('PMS video stream decision: %s, PMS audio stream decision: %s, '
'PMS subtitle stream decision: %s',
decision_api.video_decision(),
decision_api.audio_decision(),
decision_api.subtitle_decision())
# Only look at the video stream since that'll be most CPU-intensive for
# the PMS
video_direct_streaming = decision_api.video_decision() == 'copy'
if video_direct_streaming:
if item.playmethod < v.PLAYBACK_METHOD_DIRECT_STREAM:
LOG.warn('The PMS forces us to direct stream')
# "PMS enforced direct streaming"
utils.dialog('notification',
utils.lang(29999),
utils.lang(33005),
icon='{plex}')
item.playmethod = v.PLAYBACK_METHOD_DIRECT_STREAM
else:
if item.playmethod < v.PLAYBACK_METHOD_TRANSCODE:
LOG.warn('The PMS forces us to transcode')
# "PMS enforced transcoding"
utils.dialog('notification',
utils.lang(29999),
utils.lang(33004),
icon='{plex}')
item.playmethod = v.PLAYBACK_METHOD_TRANSCODE
def _ask_pms(api, item):
xml = PF.playback_decision(path=api.path_and_plex_id(),
media=api.mediastream,
part=api.part,
playmethod=item.playmethod,
video=api.plex_type in v.PLEX_VIDEOTYPES,
args=item.quality)
decision_api = API(xml)
LOG.info('PMS general decision %s: %s',
decision_api.general_play_decision_code(),
decision_api.general_play_decision_text())
LOG.info('PMS Direct Play decision %s: %s',
decision_api.direct_play_decision_code(),
decision_api.direct_play_decision_text())
LOG.info('PMS MDE decision %s: %s',
decision_api.mde_play_decision_code(),
decision_api.mde_play_decision_text())
LOG.info('PMS transcoding decision %s: %s',
decision_api.transcode_decision_code(),
decision_api.transcode_decision_text())
return decision_api
def _must_transcode(api, item):
"""
Returns True if we need to transcode because
- codec is in h265
- 10bit video codec
- HEVC codec
- playqueue_item force_transcode is set to True
- state variable FORCE_TRANSCODE set to True
(excepting trailers etc.)
- video bitrate above specified settings bitrate
if the corresponding file settings are set to 'true'
"""
if api.plex_type in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_SONG):
LOG.info('Plex clip or music track, not transcoding')
return False
if item.playmethod == v.PLAYBACK_METHOD_TRANSCODE:
return True
videoCodec = api.video_codec()
LOG.debug("videoCodec received from the PMS: %s", videoCodec)
if item.force_transcode is True:
LOG.info('User chose to force-transcode')
return True
codec = videoCodec['videocodec']
if codec is None:
# e.g. trailers. Avoids TypeError with "'h265' in codec"
LOG.info('No codec from PMS, not transcoding.')
return False
if ((utils.settings('transcodeHi10P') == 'true' and
videoCodec['bitDepth'] == '10') and
('h264' in codec)):
LOG.info('Option to transcode 10bit h264 video content enabled.')
return True
try:
bitrate = int(videoCodec['bitrate'])
except (TypeError, ValueError):
LOG.info('No video bitrate from PMS, not transcoding.')
return False
if bitrate > _get_max_bitrate():
LOG.info('Video bitrate of %s is higher than the maximal video'
'bitrate of %s that the user chose. Transcoding',
bitrate, _get_max_bitrate())
return True
try:
resolution = int(videoCodec['resolution'])
except (TypeError, ValueError):
if videoCodec['resolution'] == '4k':
resolution = 2160
else:
LOG.info('No video resolution from PMS, not transcoding.')
return False
if 'h265' in codec or 'hevc' in codec:
if resolution >= _getH265():
LOG.info('Option to transcode h265/HEVC enabled. Resolution '
'of the media: %s, transcoding limit resolution: %s',
resolution, _getH265())
return True
return False
def _transcode_quality():
return {
'maxVideoBitrate': get_bitrate(),
'videoResolution': get_resolution(),
'videoQuality': 100,
'mediaBufferSize': int(float(utils.settings('kodi_video_cache')) / 1024.0),
}
def _max_quality():
return {
'maxVideoBitrate': MAX_SIGNED_INT,
'videoResolution': '3840x2160', # 4K
'videoQuality': 100,
'mediaBufferSize': int(float(utils.settings('kodi_video_cache')) / 1024.0),
}
def get_bitrate():
"""
Get the desired transcoding bitrate from the settings
"""
videoQuality = utils.settings('transcoderVideoQualities')
bitrate = {
'0': 320,
'1': 720,
'2': 1500,
'3': 2000,
'4': 3000,
'5': 4000,
'6': 8000,
'7': 10000,
'8': 12000,
'9': 20000,
'10': 40000,
'11': 35000,
'12': 50000
}
# max bit rate supported by server (max signed 32bit integer)
return bitrate.get(videoQuality, MAX_SIGNED_INT)
def get_resolution():
"""
Get the desired transcoding resolutions from the settings
"""
chosen = utils.settings('transcoderVideoQualities')
res = {
'0': '720x480',
'1': '1024x768',
'2': '1280x720',
'3': '1280x720',
'4': '1920x1080',
'5': '1920x1080',
'6': '1920x1080',
'7': '1920x1080',
'8': '1920x1080',
'9': '3840x2160',
'10': '3840x2160'
}
return res[chosen]
def _get_max_bitrate():
max_bitrate = utils.settings('maxVideoQualities')
bitrate = {
'0': 320,
'1': 720,
'2': 1500,
'3': 2000,
'4': 3000,
'5': 4000,
'6': 8000,
'7': 10000,
'8': 12000,
'9': 20000,
'10': 40000,
'11': MAX_SIGNED_INT # deactivated
}
# max bit rate supported by server (max signed 32bit integer)
return bitrate.get(max_bitrate, MAX_SIGNED_INT)
def _getH265():
"""
Returns the user settings for transcoding h265: boundary resolutions
of 480, 720 or 1080 as an int
OR 2147483 (MAX_SIGNED_INT, int) if user chose not to transcode
"""
H265 = {
'0': MAX_SIGNED_INT,
'1': 480,
'2': 720,
'3': 1080,
'4': 2160
}
return H265[utils.settings('transcodeH265')]
def audio_subtitle_prefs(api, item):
"""
Sets the stage for transcoding, letting the user potentially choose both
audio and subtitle streams; subtitle streams to burn-into the video file.
Uses a PUT request to the PMS, simulating e.g. the user using Plex Web,
choosing a different stream in the video's metadata and THEN initiating
playback.
Returns None if user cancelled or we need to abort, True otherwise
"""
# Set media and part where we're at
if api.mediastream is None and api.mediastream_number() is None:
return
if item.playmethod != v.PLAYBACK_METHOD_TRANSCODE:
return True
return setup_transcoding_audio_subtitle_prefs(api.plex_media_streams(),
api.part_id())
def setup_transcoding_audio_subtitle_prefs(mediastreams, part_id):
audio_streams_list = []
audio_streams = []
audio_default = None
subtitle_default = None
subtitle_streams_list = []
# "Don't burn-in any subtitle"
subtitle_streams = ['1 %s' % utils.lang(39706)]
# selectAudioIndex = ""
select_subs_index = ""
audio_numb = 0
# Remember 'no subtitles'
sub_num = 1
for stream in mediastreams:
# Since Plex returns all possible tracks together, have to sort
# them.
index = stream.get('id')
typus = stream.get('streamType')
# Audio
if typus == "2":
codec = stream.get('codec')
channellayout = stream.get('audioChannelLayout', "")
try:
track = "%s %s - %s %s" % (audio_numb + 1,
stream.attrib['language'],
codec,
channellayout)
except KeyError:
track = "%s %s - %s %s" % (audio_numb + 1,
utils.lang(39707), # unknown
codec,
channellayout)
if stream.get('default'):
audio_default = audio_numb
audio_streams_list.append(index)
audio_streams.append(track.encode('utf-8'))
audio_numb += 1
# Subtitles
elif typus == "3":
if stream.get('key'):
# Subtitle can and will be downloaded - don't let user choose
# this subtitle to burn-in
continue
# Subtitle is available within the video file
# Burn in the subtitle, if user chooses to do so
forced = stream.get('forced')
try:
track = '{} {}'.format(sub_num + 1,
stream.attrib['displayTitle'])
except KeyError:
track = '{} {} ({})'.format(sub_num + 1,
utils.lang(39707), # unknown
stream.get('codec'))
if stream.get('default'):
subtitle_default = sub_num
track = "%s - %s" % (track, utils.lang(39708)) # Default
if forced:
track = "%s - %s" % (track, utils.lang(39709)) # Forced
track = "%s (%s)" % (track, utils.lang(39710)) # burn-in
subtitle_streams_list.append(index)
subtitle_streams.append(track.encode('utf-8'))
sub_num += 1
if audio_numb > 1:
# "Transcoding: Auto-pick audio and subtitle stream using Plex defaults"
if utils.settings('bestQuality') == 'true' and audio_default is not None:
resp = audio_default
else:
resp = utils.dialog('select', utils.lang(33013), audio_streams)
if resp == -1:
LOG.info('User aborted dialog to select audio stream')
return
args = {
'audioStreamID': audio_streams_list[resp],
'allParts': 1
}
DU().downloadUrl('{server}/library/parts/%s' % part_id,
action_type='PUT',
parameters=args)
# Zero telling the PMS to deactivate subs altogether
select_subs_index = 0
if sub_num == 1:
# Note: we DO need to tell the PMS that we DONT want any sub
# Otherwise, the PMS might pick-up the last one
LOG.info('No subtitles to burn-in')
else:
# "Transcoding: Auto-pick audio and subtitle stream using Plex defaults"
if utils.settings('bestQuality') == 'true' and subtitle_default is not None:
resp = subtitle_default
else:
resp = utils.dialog('select', utils.lang(33014), subtitle_streams)
if resp == -1:
LOG.info('User aborted dialog to select subtitle stream')
return
elif resp == 0:
# User did not select a subtitle or backed out of the dialog
LOG.info('User chose to not burn-in any subtitles')
else:
LOG.info('User chose to burn-in subtitle %s: %s',
select_subs_index,
subtitle_streams[resp].decode('utf-8'))
select_subs_index = subtitle_streams_list[resp - 1]
# Now prep the PMS for our choice
PF.change_subtitle(select_subs_index, part_id)
return True

View file

@ -1,56 +1,73 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
###############################################################################
from logging import getLogger
from threading import Thread
from urlparse import parse_qsl
from . import utils, playback, context_entry, transfer, backgroundthread
import playback
from context_entry import ContextMenu
import state
import json_rpc as js
from pickler import pickle_me, Playback_Successful
import kodidb_functions as kodidb
###############################################################################
LOG = getLogger('PLEX.playback_starter')
LOG = getLogger("PLEX." + __name__)
###############################################################################
class PlaybackTask(backgroundthread.Task):
class PlaybackStarter(Thread):
"""
Processes new plays
"""
def __init__(self, command):
self.command = command
super(PlaybackTask, self).__init__()
def run(self):
LOG.debug('Starting PlaybackTask with %s', self.command)
item = self.command
@staticmethod
def _triage(item):
try:
_, params = item.split('?', 1)
except ValueError:
# E.g. other add-ons scanning for Extras folder
LOG.debug('Detected 3rd party add-on call - ignoring')
transfer.send(True)
# Wait for default.py to have completed xbmcplugin.setResolvedUrl()
transfer.wait_for_transfer(source='default')
pickle_me(Playback_Successful())
return
params = dict(utils.parse_qsl(params))
params = dict(parse_qsl(params))
mode = params.get('mode')
resolve = False if params.get('handle') == '-1' else True
LOG.debug('Received mode: %s, params: %s', mode, params)
if mode == 'play':
if params.get('resume'):
resume = params.get('resume') == '1'
else:
resume = None
playback.playback_triage(plex_id=params.get('plex_id'),
plex_type=params.get('plex_type'),
path=params.get('path'),
resolve=resolve,
resume=resume)
resolve=resolve)
elif mode == 'plex_node':
playback.process_indirect(params['key'],
params['offset'],
resolve=resolve)
elif mode == 'navigation':
# e.g. when plugin://...tvshows is called for entire season
with kodidb.GetKodiDB('video') as kodi_db:
show_id = kodi_db.show_id_from_path(params.get('path'))
if show_id:
js.activate_window('videos',
'videodb://tvshows/titles/%s' % show_id)
else:
LOG.error('Could not find tv show id for %s', item)
if resolve:
pickle_me(Playback_Successful())
elif mode == 'context_menu':
context_entry.ContextMenu(kodi_id=params.get('kodi_id'),
kodi_type=params.get('kodi_type'))
LOG.debug('Finished PlaybackTask')
ContextMenu(kodi_id=params.get('kodi_id'),
kodi_type=params.get('kodi_type'))
def run(self):
queue = state.COMMAND_PIPELINE_QUEUE
LOG.info("----===## Starting PlaybackStarter ##===----")
while True:
item = queue.get()
if item is None:
# Need to shutdown - initiated by command_pipeline
break
else:
self._triage(item)
queue.task_done()
LOG.info("----===## PlaybackStarter stopped ##===----")

View file

@ -1,56 +1,46 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Collection of functions associated with Kodi and Plex playlists and playqueues
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from urllib import quote
from urlparse import parse_qsl, urlsplit
from re import compile as re_compile
from .plex_api import API
from .plex_db import PlexDB
from . import plex_functions as PF
from .kodi_db import kodiid_from_filename
from .downloadutils import DownloadUtils as DU
from . import utils
from .utils import cast
from . import json_rpc as js
from . import variables as v
from . import app
from .exceptions import PlaylistError
from .subtitles import accessible_plex_subtitles
import plexdb_functions as plexdb
from downloadutils import DownloadUtils as DU
from utils import try_decode, try_encode
from PlexAPI import API
from PlexFunctions import GetPlexMetadata
from kodidb_functions import kodiid_from_filename
import json_rpc as js
import variables as v
###############################################################################
LOG = getLogger("PLEX." + __name__)
REGEX = re_compile(r'''metadata%2F(\d+)''')
###############################################################################
LOG = getLogger('PLEX.playlist_func')
class Playqueue_Object(object):
class PlaylistError(Exception):
"""
PKC object to represent PMS playQueues and Kodi playlist for queueing
playlistid = None [int] Kodi playlist id (0, 1, 2)
type = None [str] Kodi type: 'audio', 'video', 'picture'
kodi_pl = None Kodi xbmc.PlayList object
items = [] [list] of Playlist_Items
id = None [str] Plex playQueueID, unique Plex identifier
version = None [int] Plex version of the playQueue
selectedItemID = None
[str] Plex selectedItemID, playing element in queue
selectedItemOffset = None
[str] Offset of the playing element in queue
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
If Companion playback is initiated by another user:
plex_transient_token = None
Exception for our playlist constructs
"""
kind = 'playQueue'
pass
class PlaylistObjectBaseclase(object):
"""
Base class
"""
def __init__(self):
self.id = None
self.type = None
self.playlistid = None
self.type = None
self.kodi_pl = None
self.items = []
self.id = None
self.version = None
self.selectedItemID = None
self.selectedItemOffset = None
@ -64,28 +54,25 @@ class Playqueue_Object(object):
self.pkc_edit = False
# Workaround to avoid endless loops of detecting PL clears
self._clear_list = []
# To keep track if Kodi playback was initiated from a Kodi playlist
# There are a couple of pitfalls, unfortunately...
self.kodi_playlist_playback = False
def __repr__(self):
answ = ("{{"
"'playlistid': {self.playlistid}, "
"'id': {self.id}, "
"'version': {self.version}, "
"'type': '{self.type}', "
"'selectedItemID': {self.selectedItemID}, "
"'selectedItemOffset': {self.selectedItemOffset}, "
"'shuffled': {self.shuffled}, "
"'repeat': {self.repeat}, "
"'kodi_playlist_playback': {self.kodi_playlist_playback}, "
"'pkc_edit': {self.pkc_edit}, ".format(self=self))
answ = answ.encode('utf-8')
# Since list.__repr__ will return string, not unicode
return answ + b"'items': {self.items}}}".format(self=self)
def __str__(self):
return self.__repr__()
"""
Print the playlist, e.g. to log. Returns utf-8 encoded string
"""
answ = u'{\'%s\': {\'id\': %s, ' % (self.__class__.__name__, self.id)
# For some reason, can't use dir directly
for key in self.__dict__:
if key in ('id', 'items', 'kodi_pl'):
continue
if isinstance(getattr(self, key), str):
answ += '\'%s\': \'%s\', ' % (key,
try_decode(getattr(self, key)))
elif isinstance(getattr(self, key), unicode):
answ += '\'%s\': \'%s\', ' % (key, getattr(self, key))
else:
# e.g. int
answ += '\'%s\': %s, ' % (key, unicode(getattr(self, key)))
return try_encode(answ + '\'items\': %s}}') % self.items
def is_pkc_clear(self):
"""
@ -119,270 +106,148 @@ class Playqueue_Object(object):
self.repeat = 0
self.plex_transient_token = None
self.old_kodi_pl = []
self.kodi_playlist_playback = False
LOG.debug('Playlist cleared: %s', self)
def position_from_plex_id(self, plex_id):
"""
Returns the position [int] for the very first item with plex_id [int]
(Plex seems uncapable of adding the same element multiple times to a
playqueue or playlist)
Raises KeyError if not found
"""
for position, item in enumerate(self.items):
if item.plex_id == plex_id:
break
else:
raise KeyError('Did not find plex_id %s in %s', plex_id, self)
return position
class Playlist_Object(PlaylistObjectBaseclase):
"""
To be done for synching Plex playlists to Kodi
"""
kind = 'playList'
class PlaylistItem(object):
class Playqueue_Object(PlaylistObjectBaseclase):
"""
PKC object to represent PMS playQueues and Kodi playlist for queueing
playlistid = None [int] Kodi playlist id (0, 1, 2)
type = None [str] Kodi type: 'audio', 'video', 'picture'
kodi_pl = None Kodi xbmc.PlayList object
items = [] [list] of Playlist_Items
id = None [str] Plex playQueueID, unique Plex identifier
version = None [int] Plex version of the playQueue
selectedItemID = None
[str] Plex selectedItemID, playing element in queue
selectedItemOffset = None
[str] Offset of the playing element in queue
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
If Companion playback is initiated by another user:
plex_transient_token = None
"""
kind = 'playQueue'
class Playlist_Item(object):
"""
Object to fill our playqueues and playlists with.
id = None [int] Plex playlist/playqueue id, e.g. playQueueItemID
plex_id = None [int] Plex unique item id, "ratingKey"
id = None [str] Plex playlist/playqueue id, e.g. playQueueItemID
plex_id = None [str] Plex unique item id, "ratingKey"
plex_type = None [str] Plex type, e.g. 'movie', 'clip'
kodi_id = None [int] Kodi unique kodi id (unique only within type!)
plex_uuid = None [str] Plex librarySectionUUID
kodi_id = None Kodi unique kodi id (unique only within type!)
kodi_type = None [str] Kodi type: 'movie'
file = None [str] Path to the item's file. STRING!!
uri = None [str] PMS path to item; will be auto-set with plex_id
uri = None [str] Weird Plex uri path involving plex_uuid. STRING!
guid = None [str] Weird Plex guid
api = None [API] API of xml 1 lvl below <MediaContainer>
playmethod = None [str] either 'DirectPath', 'DirectStream', 'Transcode'
xml = None [etree] XML from PMS, 1 lvl below <MediaContainer>
playmethod = None [str] either 'DirectPlay', 'DirectStream', 'Transcode'
playcount = None [int] how many times the item has already been played
offset = None [int] the item's view offset UPON START in Plex time
part = 0 [int] part number if Plex video consists of mult. parts
force_transcode [bool] defaults to False
"""
def __init__(self):
self.id = None
self._plex_id = None
self.plex_id = None
self.plex_type = None
self.plex_uuid = None
self.kodi_id = None
self.kodi_type = None
self.file = None
self._uri = None
self.uri = None
self.guid = None
self.api = None
self.xml = None
self.playmethod = None
self.playcount = None
self.offset = None
# Transcoding quality, if needed
self.quality = None
# If Plex video consists of several parts; part number
self.part = 0
self.force_transcode = False
# Shall we ask user to resume this item?
# None: ask user to resume
# False: do NOT resume, don't ask user
# True: do resume, don't ask user
self.resume = None
# Get the Plex audio and subtitle streams in the same order as Kodi
# uses them (Kodi uses indexes to activate them, not ids like Plex)
self._streams_have_been_processed = False
self._audio_streams = None
self._subtitle_streams = None
# Which Kodi streams are active?
self.current_kodi_audio_stream = None
# False means "deactivated", None means "we do not have a Kodi
# equivalent for this Plex subtitle"
self.current_kodi_sub_stream = None
@property
def plex_id(self):
return self._plex_id
@plex_id.setter
def plex_id(self, value):
self._plex_id = value
self._uri = ('server://%s/com.plexapp.plugins.library/library/metadata/%s' %
(app.CONN.machine_identifier, value))
@property
def uri(self):
return self._uri
@property
def audio_streams(self):
if not self._streams_have_been_processed:
self._process_streams()
return self._audio_streams
@property
def subtitle_streams(self):
if not self._streams_have_been_processed:
self._process_streams()
return self._subtitle_streams
def __unicode__(self):
return ("{{"
"'id': {self.id}, "
"'plex_id': {self.plex_id}, "
"'plex_type': '{self.plex_type}', "
"'kodi_id': {self.kodi_id}, "
"'kodi_type': '{self.kodi_type}', "
"'file': '{self.file}', "
"'guid': '{self.guid}', "
"'playmethod': '{self.playmethod}', "
"'playcount': {self.playcount}, "
"'resume': {self.resume},"
"'offset': {self.offset}, "
"'force_transcode': {self.force_transcode}, "
"'part': {self.part}".format(self=self))
def __repr__(self):
return self.__unicode__().encode('utf-8')
def _process_streams(self):
"""
Builds audio and subtitle streams and enables matching between Plex
and Kodi using self.audio_streams and self.subtitle_streams
Print the playlist item, e.g. to log. Returns utf-8 encoded string
"""
# The playqueue response from the PMS does not contain a stream filename
# thanks Plex
self._subtitle_streams = accessible_plex_subtitles(
self.playmethod,
self.file,
self.api.plex_media_streams())
# Audio streams are much easier - they're always available and sorted
# the same in Kodi and Plex
self._audio_streams = [x for x in self.api.plex_media_streams()
if x.get('streamType') == '2']
self._streams_have_been_processed = True
def _get_iterator(self, stream_type):
if stream_type == 'audio':
return self.audio_streams
elif stream_type == 'subtitle':
return self.subtitle_streams
answ = (u'{\'%s\': {\'id\': \'%s\', \'plex_id\': \'%s\', '
% (self.__class__.__name__, self.id, self.plex_id))
for key in self.__dict__:
if key in ('id', 'plex_id', 'xml'):
continue
if isinstance(getattr(self, key), str):
answ += '\'%s\': \'%s\', ' % (key,
try_decode(getattr(self, key)))
elif isinstance(getattr(self, key), unicode):
answ += '\'%s\': \'%s\', ' % (key, getattr(self, key))
else:
# e.g. int
answ += '\'%s\': %s, ' % (key, unicode(getattr(self, key)))
if self.xml is None:
answ += '\'xml\': None}}'
else:
answ += '\'xml\': \'%s\'}}' % self.xml.tag
return try_encode(answ)
def plex_stream_index(self, kodi_stream_index, stream_type):
"""
Pass in the kodi_stream_index [int] in order to receive the Plex stream
index [int].
index.
stream_type: 'video', 'audio', 'subtitle'
Returns None if unsuccessful
"""
if stream_type == 'audio':
return int(self.audio_streams[kodi_stream_index].get('id'))
elif stream_type == 'subtitle':
try:
return int(self.subtitle_streams[kodi_stream_index].get('id'))
except (IndexError, TypeError):
pass
stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type]
count = 0
# Kodi indexes differently than Plex
for stream in self.xml[0][self.part]:
if (stream.attrib['streamType'] == stream_type and
'key' in stream.attrib):
if count == kodi_stream_index:
return stream.attrib['id']
count += 1
for stream in self.xml[0][self.part]:
if (stream.attrib['streamType'] == stream_type and
'key' not in stream.attrib):
if count == kodi_stream_index:
return stream.attrib['id']
count += 1
def kodi_stream_index(self, plex_stream_index, stream_type):
"""
Pass in the plex_stream_index [int] in order to receive the Kodi stream
index [int].
Pass in the kodi_stream_index [int] in order to receive the Plex stream
index.
stream_type: 'video', 'audio', 'subtitle'
Returns None if unsuccessful
"""
if plex_stream_index is None:
return
for i, stream in enumerate(self._get_iterator(stream_type)):
if cast(int, stream.get('id')) == plex_stream_index:
return i
def active_plex_stream_index(self, stream_type):
"""
Returns the following tuple for the active stream on the Plex side:
(Plex stream id [int], languageTag [str] or None)
Returns None if no stream has been selected
"""
for i, stream in enumerate(self._get_iterator(stream_type)):
if stream.get('selected') == '1':
return (int(stream.get('id')), stream.get('languageTag'))
def on_kodi_subtitle_stream_change(self, kodi_stream_index, subs_enabled):
"""
Call this method if Kodi changed its subtitle and you want Plex to
know.
"""
if subs_enabled:
try:
plex_stream_index = int(self.subtitle_streams[kodi_stream_index].get('id'))
except (IndexError, TypeError):
LOG.debug('Kodi subtitle change detected to a sub %s that is '
'NOT available on the Plex side', kodi_stream_index)
self.current_kodi_sub_stream = None
return
LOG.debug('Kodi subtitle change detected: telling Plex about '
'switch to index %s, Plex stream id %s',
kodi_stream_index, plex_stream_index)
self.current_kodi_sub_stream = kodi_stream_index
else:
plex_stream_index = 0
LOG.debug('Kodi subtitle has been deactivated, telling Plex')
self.current_kodi_sub_stream = False
PF.change_subtitle(plex_stream_index, self.api.part_id())
def on_kodi_audio_stream_change(self, kodi_stream_index):
"""
Call this method if Kodi changed its audio stream and you want Plex to
know. kodi_stream_index [int]
"""
plex_stream_index = int(self.audio_streams[kodi_stream_index].get('id'))
LOG.debug('Changing Plex audio stream to %s, Kodi index %s',
plex_stream_index, kodi_stream_index)
PF.change_audio_stream(plex_stream_index, self.api.part_id())
self.current_kodi_audio_stream = kodi_stream_index
def switch_to_plex_streams(self):
self.switch_to_plex_stream('audio')
self.switch_to_plex_stream('subtitle')
def switch_to_plex_stream(self, typus):
try:
plex_index, language_tag = self.active_plex_stream_index(typus)
except TypeError:
LOG.debug('Deactivating Kodi subtitles because the PMS '
'told us to not show any subtitles')
app.APP.player.showSubtitles(False)
self.current_kodi_sub_stream = False
return
LOG.debug('The PMS wants to display %s stream with Plex id %s and '
'languageTag %s', typus, plex_index, language_tag)
kodi_index = self.kodi_stream_index(plex_index, typus)
if kodi_index is None:
LOG.debug('Leaving Kodi %s stream settings untouched since we '
'could not parse Plex %s stream with id %s to a Kodi'
' index', typus, typus, plex_index)
else:
LOG.debug('Switching to Kodi %s stream number %s because the '
'PMS told us to show stream with Plex id %s',
typus, kodi_index, plex_index)
# If we're choosing an "illegal" index, this function does
# need seem to fail nor log any errors
if typus == 'audio':
app.APP.player.setAudioStream(kodi_index)
else:
app.APP.player.setSubtitleStream(kodi_index)
app.APP.player.showSubtitles(True)
if typus == 'audio':
self.current_kodi_audio_stream = kodi_index
else:
self.current_kodi_sub_stream = kodi_index
def on_av_change(self, playerid):
kodi_audio_stream = js.get_current_audio_stream_index(playerid)
sub_enabled = js.get_subtitle_enabled(playerid)
kodi_sub_stream = js.get_current_subtitle_stream_index(playerid)
# Audio
if kodi_audio_stream != self.current_kodi_audio_stream:
self.on_kodi_audio_stream_change(kodi_audio_stream)
# Subtitles - CURRENTLY BROKEN ON THE KODI SIDE!
# current_kodi_sub_stream may also be zero
subs_off = (None, False)
if ((sub_enabled and self.current_kodi_sub_stream in subs_off)
or (not sub_enabled and self.current_kodi_sub_stream not in subs_off)
or (kodi_sub_stream is not None
and kodi_sub_stream != self.current_kodi_sub_stream)):
self.on_kodi_subtitle_stream_change(kodi_sub_stream, sub_enabled)
stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type]
count = 0
for stream in self.xml[0][self.part]:
if (stream.attrib['streamType'] == stream_type and
'key' in stream.attrib):
if stream.attrib['id'] == plex_stream_index:
return count
count += 1
for stream in self.xml[0][self.part]:
if (stream.attrib['streamType'] == stream_type and
'key' not in stream.attrib):
if stream.attrib['id'] == plex_stream_index:
return count
count += 1
def playlist_item_from_kodi(kodi_item):
@ -392,24 +257,30 @@ def playlist_item_from_kodi(kodi_item):
Supply with data['item'] as returned from Kodi JSON-RPC interface.
kodi_item dict contains keys 'id', 'type', 'file' (if applicable)
"""
item = PlaylistItem()
item = Playlist_Item()
item.kodi_id = kodi_item.get('id')
item.kodi_type = kodi_item.get('type')
if item.kodi_id:
with PlexDB(lock=False) as plexdb:
db_item = plexdb.item_by_kodi_id(kodi_item['id'], kodi_item['type'])
if db_item:
item.plex_id = db_item['plex_id']
item.plex_type = db_item['plex_type']
with plexdb.Get_Plex_DB() as plex_db:
plex_dbitem = plex_db.getItem_byKodiId(kodi_item['id'],
kodi_item['type'])
try:
item.plex_id = plex_dbitem[0]
item.plex_type = plex_dbitem[2]
item.plex_uuid = plex_dbitem[0] # we dont need the uuid yet :-)
except TypeError:
pass
item.file = kodi_item.get('file')
if item.plex_id is None and item.file is not None:
try:
query = item.file.split('?', 1)[1]
except IndexError:
query = ''
query = dict(utils.parse_qsl(query))
item.plex_id = cast(int, query.get('plex_id'))
query = dict(parse_qsl(urlsplit(item.file).query))
item.plex_id = query.get('plex_id')
item.plex_type = query.get('itemType')
if item.plex_id is None and item.file is not None:
item.uri = 'library://whatever/item/%s' % quote(item.file, safe='')
else:
# TO BE VERIFIED - PLEX DOESN'T LIKE PLAYLIST ADDS IN THIS MANNER
item.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' %
(item.plex_uuid, item.plex_id))
LOG.debug('Made playlist item from Kodi: %s', item)
return item
@ -423,39 +294,29 @@ def verify_kodi_item(plex_id, kodi_item):
set to None if unsuccessful.
Will raise a PlaylistError if plex_id is None and kodi_item['file'] starts
with either 'plugin' or 'http'.
Will raise KeyError if neither plex_id nor kodi_id are found
with either 'plugin' or 'http'
"""
if plex_id is not None or kodi_item.get('id') is not None:
# Got all the info we need
return kodi_item
# Special case playlist startup - got type but no id
if (not app.SYNC.direct_paths and app.SYNC.enable_music and
kodi_item.get('type') == v.KODI_TYPE_SONG and
kodi_item['file'].startswith('http')):
kodi_item['id'], _ = kodiid_from_filename(kodi_item['file'],
v.KODI_TYPE_SONG)
LOG.debug('Detected song. Research results: %s', kodi_item)
return kodi_item
# Need more info since we don't have kodi_id nor type. Use file path.
if ((kodi_item['file'].startswith('plugin') and
not kodi_item['file'].startswith('plugin://%s' % v.ADDON_ID)) or
if (kodi_item['file'].startswith('plugin') or
kodi_item['file'].startswith('http')):
LOG.debug('kodi_item cannot be used for Plex playback: %s', kodi_item)
raise PlaylistError('kodi_item cannot be used for Plex playback')
LOG.debug('Starting research for Kodi id since we didnt get one: %s',
kodi_item)
# Try the VIDEO DB first - will find both movies and episodes
kodi_id, kodi_type = kodiid_from_filename(kodi_item['file'],
db_type='video')
if not kodi_id:
# No movie or episode found - try MUSIC DB now for songs
kodi_id, kodi_type = kodiid_from_filename(kodi_item['file'],
db_type='music')
kodi_id = kodiid_from_filename(kodi_item['file'], v.KODI_TYPE_MOVIE)
kodi_item['type'] = v.KODI_TYPE_MOVIE
if kodi_id is None:
kodi_id = kodiid_from_filename(kodi_item['file'],
v.KODI_TYPE_EPISODE)
kodi_item['type'] = v.KODI_TYPE_EPISODE
if kodi_id is None:
kodi_id = kodiid_from_filename(kodi_item['file'],
v.KODI_TYPE_SONG)
kodi_item['type'] = v.KODI_TYPE_SONG
kodi_item['id'] = kodi_id
kodi_item['type'] = None if kodi_id is None else kodi_type
if plex_id is None and kodi_id is None:
raise KeyError('Neither Plex nor Kodi id found for %s' % kodi_item)
kodi_item['type'] = None if kodi_id is None else kodi_item['type']
LOG.debug('Research results for kodi_item: %s', kodi_item)
return kodi_item
@ -466,16 +327,19 @@ def playlist_item_from_plex(plex_id):
Returns a Playlist_Item
"""
item = PlaylistItem()
item = Playlist_Item()
item.plex_id = plex_id
with PlexDB(lock=False) as plexdb:
db_item = plexdb.item_by_id(plex_id)
if db_item:
item.plex_type = db_item['plex_type']
item.kodi_id = db_item['kodi_id']
item.kodi_type = db_item['kodi_type']
else:
with plexdb.Get_Plex_DB() as plex_db:
plex_dbitem = plex_db.getItem_byId(plex_id)
try:
item.plex_type = plex_dbitem[5]
item.kodi_id = plex_dbitem[0]
item.kodi_type = plex_dbitem[4]
except (TypeError, IndexError):
raise KeyError('Could not find plex_id %s in database' % plex_id)
item.plex_uuid = plex_id
item.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' %
(item.plex_uuid, plex_id))
LOG.debug('Made playlist item from plex: %s', item)
return item
@ -486,42 +350,41 @@ def playlist_item_from_xml(xml_video_element, kodi_id=None, kodi_type=None):
xml_video_element: etree xml piece 1 level underneath <MediaContainer>
"""
item = PlaylistItem()
item = Playlist_Item()
api = API(xml_video_element)
item.plex_id = api.plex_id
item.plex_type = api.plex_type
item.plex_id = api.plex_id()
item.plex_type = api.plex_type()
# item.id will only be set if you passed in an xml_video_element from e.g.
# a playQueue
item.id = api.item_id()
if kodi_id is not None:
item.kodi_id = kodi_id
item.kodi_type = kodi_type
elif item.plex_type != v.PLEX_TYPE_CLIP:
with PlexDB(lock=False) as plexdb:
db_element = plexdb.item_by_id(item.plex_id,
plex_type=item.plex_type)
if db_element:
item.kodi_id = db_element['kodi_id']
item.kodi_type = db_element['kodi_type']
elif item.plex_id is not None:
with plexdb.Get_Plex_DB() as plex_db:
db_element = plex_db.getItem_byId(item.plex_id)
try:
item.kodi_id, item.kodi_type = db_element[0], db_element[4]
except TypeError:
pass
item.guid = api.guid_html_escaped()
item.playcount = api.viewcount()
item.offset = api.resume_point()
item.api = api
item.xml = xml_video_element
LOG.debug('Created new playlist item from xml: %s', item)
return item
def _update_playlist_version(playlist, xml):
def _get_playListVersion_from_xml(playlist, xml):
"""
Takes a PMS xml (one level above the xml-depth where we're usually applying
API()) as input to overwrite the playlist version (e.g. Plex
Takes a PMS xml as input to overwrite the playlist version (e.g. Plex
playQueueVersion).
Raises PlaylistError if unsuccessful
"""
try:
playlist.version = int(xml.get('%sVersion' % playlist.kind))
except (AttributeError, TypeError):
playlist.version = int(xml.attrib['%sVersion' % playlist.kind])
except (TypeError, AttributeError, KeyError):
raise PlaylistError('Could not get new playlist Version for playlist '
'%s' % playlist)
@ -533,16 +396,18 @@ def get_playlist_details_from_xml(playlist, xml):
Raises PlaylistError if something went wrong.
"""
if xml is None:
raise PlaylistError('No playlist received for playlist %s' % playlist)
playlist.id = cast(int, xml.get('%sID' % playlist.kind))
playlist.version = cast(int, xml.get('%sVersion' % playlist.kind))
playlist.shuffled = cast(int, xml.get('%sShuffled' % playlist.kind))
playlist.selectedItemID = cast(int, xml.get('%sSelectedItemID'
% playlist.kind))
playlist.selectedItemOffset = cast(int, xml.get('%sSelectedItemOffset'
% playlist.kind))
LOG.debug('Updated playlist from xml: %s', playlist)
try:
playlist.id = xml.attrib['%sID' % playlist.kind]
playlist.version = xml.attrib['%sVersion' % playlist.kind]
playlist.shuffled = xml.attrib['%sShuffled' % playlist.kind]
playlist.selectedItemID = xml.attrib.get(
'%sSelectedItemID' % playlist.kind)
playlist.selectedItemOffset = xml.attrib.get(
'%sSelectedItemOffset' % playlist.kind)
LOG.debug('Updated playlist from xml: %s', playlist)
except (TypeError, KeyError, AttributeError) as msg:
raise PlaylistError('Could not get playlist details from xml: %s',
msg)
def update_playlist_from_PMS(playlist, playlist_id=None, xml=None):
@ -551,8 +416,6 @@ def update_playlist_from_PMS(playlist, playlist_id=None, xml=None):
need to fetch a new playqueue
If an xml is passed in, the playlist will be overwritten with its info
Raises PlaylistError if something went wront
"""
if xml is None:
xml = get_PMS_playlist(playlist, playlist_id)
@ -566,16 +429,16 @@ def update_playlist_from_PMS(playlist, playlist_id=None, xml=None):
playlist.items.append(playlist_item)
def init_plex_playqueue(playlist, plex_id=None, kodi_item=None):
def init_Plex_playlist(playlist, plex_id=None, kodi_item=None):
"""
Initializes the Plex side without changing the Kodi playlists
WILL ALSO UPDATE OUR PLAYLISTS.
Returns the first PKC playlist item or raises PlaylistError
"""
LOG.debug('Initializing the playqueue on the Plex side: %s', playlist)
verify_kodi_item(plex_id, kodi_item)
LOG.debug('Initializing the playlist on the Plex side: %s', playlist)
playlist.clear(kodi=False)
verify_kodi_item(plex_id, kodi_item)
try:
if plex_id:
item = playlist_item_from_plex(plex_id)
@ -584,23 +447,19 @@ def init_plex_playqueue(playlist, plex_id=None, kodi_item=None):
params = {
'next': 0,
'type': playlist.type,
'uri': item.uri,
'includeMarkers': 1, # e.g. start + stop of intros
'uri': item.uri
}
xml = DU().downloadUrl(url="{server}/%ss" % playlist.kind,
action_type="POST",
parameters=params)
if xml in (None, 401):
raise PlaylistError('Did not receive a valid xml from the PMS')
get_playlist_details_from_xml(playlist, xml)
# Need to get the details for the playlist item
item = playlist_item_from_xml(xml[0])
except (KeyError, IndexError, TypeError):
LOG.error('Could not init Plex playlist: plex_id %s, kodi_item %s',
plex_id, kodi_item)
raise PlaylistError
raise PlaylistError('Could not init Plex playlist with plex_id %s and '
'kodi_item %s' % (plex_id, kodi_item))
playlist.items.append(item)
LOG.debug('Initialized the playqueue on the Plex side: %s', playlist)
LOG.debug('Initialized the playlist on the Plex side: %s', playlist)
return item
@ -617,9 +476,9 @@ def add_listitem_to_playlist(playlist, pos, listitem, kodi_id=None,
'%s', pos, playlist)
kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file}
if playlist.id is None:
init_plex_playqueue(playlist, plex_id, kodi_item)
init_Plex_playlist(playlist, plex_id, kodi_item)
else:
add_item_to_plex_playqueue(playlist, pos, plex_id, kodi_item)
add_item_to_PMS_playlist(playlist, pos, plex_id, kodi_item)
if kodi_id is None and playlist.items[pos].kodi_id:
kodi_id = playlist.items[pos].kodi_id
kodi_type = playlist.items[pos].kodi_type
@ -646,9 +505,9 @@ def add_item_to_playlist(playlist, pos, kodi_id=None, kodi_type=None,
LOG.debug('add_item_to_playlist. Playlist before adding: %s', playlist)
kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file}
if playlist.id is None:
item = init_plex_playqueue(playlist, plex_id, kodi_item)
item = init_Plex_playlist(playlist, plex_id, kodi_item)
else:
item = add_item_to_plex_playqueue(playlist, pos, plex_id, kodi_item)
item = add_item_to_PMS_playlist(playlist, pos, plex_id, kodi_item)
params = {
'playlistid': playlist.playlistid,
'position': pos
@ -664,7 +523,7 @@ def add_item_to_playlist(playlist, pos, kodi_id=None, kodi_type=None,
return item
def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None):
def add_item_to_PMS_playlist(playlist, pos, plex_id=None, kodi_item=None):
"""
Adds a new item to the playlist at position pos [int] only on the Plex
side of things (e.g. because the user changed the Kodi side)
@ -677,22 +536,16 @@ def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None):
item = playlist_item_from_plex(plex_id)
else:
item = playlist_item_from_kodi(kodi_item)
url = "{server}/%ss/%s" % (playlist.kind, playlist.id)
parameters = {
'uri': item.uri,
'includeMarkers': 1, # e.g. start + stop of intros
}
url = "{server}/%ss/%s?uri=%s" % (playlist.kind, playlist.id, item.uri)
# Will always put the new item at the end of the Plex playlist
xml = DU().downloadUrl(url,
action_type="PUT",
parameters=parameters)
xml = DU().downloadUrl(url, action_type="PUT")
try:
xml[-1].attrib
except (TypeError, AttributeError, KeyError, IndexError):
raise PlaylistError('Could not add item %s to playlist %s'
% (kodi_item, playlist))
api = API(xml[-1])
item.api = api
item.xml = xml[-1]
item.id = api.item_id()
item.guid = api.guid_html_escaped()
item.offset = api.resume_point()
@ -700,7 +553,7 @@ def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None):
playlist.items.append(item)
if pos == len(playlist.items) - 1:
# Item was added at the end
_update_playlist_version(playlist, xml)
_get_playListVersion_from_xml(playlist, xml)
else:
# Move the new item to the correct position
move_playlist_item(playlist,
@ -743,8 +596,8 @@ def add_item_to_kodi_playlist(playlist, pos, kodi_id=None, kodi_type=None,
item = playlist_item_from_kodi(
{'id': kodi_id, 'type': kodi_type, 'file': file})
if item.plex_id is not None:
xml = PF.GetPlexMetadata(item.plex_id)
item.api = API(xml[-1])
xml = GetPlexMetadata(item.plex_id)
item.xml = xml[-1]
playlist.items.insert(pos, item)
return item
@ -768,10 +621,9 @@ def move_playlist_item(playlist, before_pos, after_pos):
playlist.id,
playlist.items[before_pos].id,
playlist.items[after_pos - 1].id)
# Tell the PMS that we're moving items around
xml = DU().downloadUrl(url, action_type="PUT")
# We need to increment the playlist version for communicating with the PMS
_update_playlist_version(playlist, xml)
# We need to increment the playlistVersion
_get_playListVersion_from_xml(
playlist, DU().downloadUrl(url, action_type="PUT"))
# Move our item's position in our internal playlist
playlist.items.insert(after_pos, playlist.items.pop(before_pos))
LOG.debug('Done moving for %s', playlist)
@ -782,20 +634,16 @@ def get_PMS_playlist(playlist, playlist_id=None):
Fetches the PMS playlist/playqueue as an XML. Pass in playlist_id if we
need to fetch a new playlist
Raises PlaylistError if something went wrong
Returns None if something went wrong
"""
playlist_id = playlist_id if playlist_id else playlist.id
parameters = {'includeMarkers': 1}
if playlist.kind == 'playList':
xml = DU().downloadUrl("{server}/playlists/%s/items" % playlist_id,
parameters=parameters)
else:
xml = DU().downloadUrl("{server}/playQueues/%s" % playlist_id,
parameters=parameters)
xml = DU().downloadUrl(
"{server}/%ss/%s" % (playlist.kind, playlist_id),
headerOptions={'Accept': 'application/xml'})
try:
xml.attrib
except AttributeError:
raise PlaylistError('Did not get a valid xml')
xml.attrib['%sID' % playlist.kind]
except (AttributeError, KeyError):
xml = None
return xml
@ -818,8 +666,8 @@ def delete_playlist_item_from_PMS(playlist, pos):
playlist.items[pos].id,
playlist.repeat),
action_type="DELETE")
_get_playListVersion_from_xml(playlist, xml)
del playlist.items[pos]
_update_playlist_version(playlist, xml)
# Functions operating on the Kodi playlist objects ##########
@ -890,10 +738,9 @@ def get_pms_playqueue(playqueue_id):
"""
Returns the Plex playqueue as an etree XML or None if unsuccessful
"""
parameters = {'includeMarkers': 1}
xml = DU().downloadUrl("{server}/playQueues/%s" % playqueue_id,
parameters=parameters,
headerOptions={'Accept': 'application/xml'})
xml = DU().downloadUrl(
"{server}/playQueues/%s" % playqueue_id,
headerOptions={'Accept': 'application/xml'})
try:
xml.attrib
except AttributeError:
@ -910,15 +757,14 @@ def get_plextype_from_xml(xml):
returns None if unsuccessful
"""
try:
plex_id = utils.REGEX_PLEX_ID_FROM_URL.findall(
xml.attrib['playQueueSourceURI'])[0]
plex_id = REGEX.findall(xml.attrib['playQueueSourceURI'])[0]
except IndexError:
LOG.error('Could not get plex_id from xml: %s', xml.attrib)
return
new_xml = PF.GetPlexMetadata(plex_id)
new_xml = GetPlexMetadata(plex_id)
try:
new_xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not get plex metadata for plex id %s', plex_id)
return
return new_xml[0].attrib.get('type').decode('utf-8')
return new_xml[0].attrib.get('type')

View file

@ -1,479 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
:module: plexkodiconnect.playlists
:synopsis: This module syncs Plex playlists to Kodi playlists and vice-versa
:author: Croneter
.. autoclass:: kodi_playlist_monitor
.. autoclass:: full_sync
.. autoclass:: websocket
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from sqlite3 import OperationalError
from .common import Playlist, PlaylistObserver, kodi_playlist_hash
from . import pms, db, kodi_pl, plex_pl
from ..watchdog import events
from ..plex_api import API
from .. import utils, path_ops, variables as v, app
from ..exceptions import PlaylistError
###############################################################################
LOG = getLogger('PLEX.playlists')
# Safety margin for playlist filesystem operations
FILESYSTEM_TIMEOUT = 1
# Which playlist formates are supported by PKC?
SUPPORTED_FILETYPES = (
'm3u',
# 'm3u8'
# 'pls',
# 'cue',
)
###############################################################################
def should_cancel():
return app.APP.stop_pkc or app.SYNC.stop_sync
def kodi_playlist_monitor():
"""
Monitor for the Kodi playlist folder special://profile/playlist
Monitors for all file changes and will thus catch all changes on the Kodi
side of things (as soon as the user saves a new or modified playlist). This
is accomplished by starting a PlaylistObserver with the
PlaylistEventhandler
Returns
-------
PlaylistObserver
Returns an already started PlaylistObserver instance
Notes
-----
Be sure to stop the returned PlaylistObserver with observer.stop()
(and maybe observer.join()) to shut down properly
"""
event_handler = PlaylistEventhandler()
observer = PlaylistObserver(timeout=FILESYSTEM_TIMEOUT)
observer.schedule(event_handler, v.PLAYLIST_PATH, recursive=True)
observer.start()
return observer
def remove_synced_playlists():
"""
Deletes all synched playlists on the Kodi side, not on the Plex side
"""
LOG.info('Removing all playlists that we synced to Kodi')
with app.APP.lock_playlists:
try:
paths = db.get_all_kodi_playlist_paths()
except OperationalError:
LOG.info('Playlists table has not yet been set-up')
return
kodi_pl.delete_kodi_playlists(paths)
db.wipe_table()
LOG.info('Done removing all synced playlists')
def websocket(plex_id, status):
"""
Call this function to process websocket messages from the PMS
Will use the playlist lock to process one single websocket message from
the PMS, and e.g. create or delete the corresponding Kodi playlist (if
applicable settings are set)
Parameters
----------
plex_id : unicode
The unqiue Plex id 'ratingKey' as received from the PMS
status : int
'state' as communicated by the PMS in the websocket message. This
function will then take the correct actions to process the message
* 0: 'created'
* 2: 'matching'
* 3: 'downloading'
* 4: 'loading'
* 5: 'finished'
* 6: 'analyzing'
* 9: 'deleted'
"""
create = False
plex_id = int(plex_id)
with app.APP.lock_playlists:
playlist = db.get_playlist(plex_id=plex_id)
if plex_id in plex_pl.IGNORE_PLEX_PLAYLIST_CHANGE:
LOG.debug('Ignoring detected Plex playlist change for %s',
playlist)
plex_pl.IGNORE_PLEX_PLAYLIST_CHANGE.remove(plex_id)
return
if playlist and status == 9:
# Won't be able to download metadata of the deleted playlist
if sync_plex_playlist(playlist=playlist):
LOG.debug('Plex deletion of playlist detected: %s', playlist)
try:
kodi_pl.delete(playlist)
except PlaylistError:
pass
return
xml = pms.metadata(plex_id)
if xml is None:
LOG.debug('Could not download playlist %s, probably deleted',
plex_id)
return
if not sync_plex_playlist(xml=xml[0]):
return
api = API(xml[0])
try:
if playlist:
if api.updated_at() == playlist.plex_updatedat:
LOG.debug('Playlist with id %s already synced: %s',
plex_id, playlist)
else:
LOG.debug('Change of Plex playlist detected: %s',
playlist)
kodi_pl.delete(playlist)
create = True
elif not playlist and not status == 9:
LOG.debug('Creation of new Plex playlist detected: %s',
plex_id)
create = True
# To the actual work
if create:
kodi_pl.create(plex_id)
except PlaylistError:
pass
def full_sync():
"""
Full sync of playlists between Kodi and Plex
Call to trigger a full sync both ways, e.g. on Kodi start-up. If issues
with a single playlist are encountered on either the Plex or Kodi side,
this particular playlist is omitted. Will use the playlist lock.
Returns
-------
bool
True if successful, False otherwise (actually only if we failed to
fetch the PMS playlists)
"""
LOG.info('Starting playlist full sync')
with app.APP.lock_playlists:
# Need to lock because we're messing with playlists
return _full_sync()
def _full_sync():
# Get all Plex playlists
xml = pms.all_playlists()
if xml is None:
return False
# For each playlist, check Plex database to see whether we already synced
# before. If yes, make sure that hashes are identical. If not, sync it.
old_plex_ids = db.plex_playlist_ids()
for xml_playlist in xml:
if should_cancel():
return False
api = API(xml_playlist)
try:
old_plex_ids.remove(api.plex_id)
except ValueError:
pass
if not sync_plex_playlist(xml=xml_playlist):
continue
playlist = db.get_playlist(plex_id=api.plex_id)
if not playlist:
LOG.debug('New Plex playlist %s discovered: %s',
api.plex_id, api.title())
try:
kodi_pl.create(api.plex_id)
except PlaylistError:
LOG.info('Skipping creation of playlist %s', api.plex_id)
elif playlist.plex_updatedat != api.updated_at():
LOG.debug('Detected changed Plex playlist %s: %s',
api.plex_id, api.title())
# Since we are DELETING a playlist, we need to catch with path!
try:
kodi_pl.delete(playlist)
except PlaylistError:
LOG.info('Skipping recreation of playlist %s', api.plex_id)
else:
try:
kodi_pl.create(api.plex_id)
except PlaylistError:
LOG.info('Could not recreate playlist %s', api.plex_id)
# Get rid of old Plex playlists that were deleted on the Plex side
for plex_id in old_plex_ids:
if should_cancel():
return False
playlist = db.get_playlist(plex_id=plex_id)
LOG.debug('Removing outdated Plex playlist from Kodi: %s', playlist)
if playlist is None:
continue
try:
kodi_pl.delete(playlist)
except PlaylistError:
LOG.debug('Skipping deletion of playlist: %s', playlist)
# Look at all supported Kodi playlists. Check whether they are in the DB.
old_kodi_paths = db.kodi_playlist_paths()
for root, _, files in path_ops.walk(v.PLAYLIST_PATH):
for f in files:
if should_cancel():
return False
path = path_ops.path.join(root, f)
try:
old_kodi_paths.remove(path)
except ValueError:
pass
if not sync_kodi_playlist(path):
continue
kodi_hash = kodi_playlist_hash(path)
playlist = db.get_playlist(path=path)
if playlist and playlist.kodi_hash == kodi_hash:
continue
if not playlist:
LOG.debug('New Kodi playlist detected: %s', path)
playlist = Playlist()
playlist.kodi_path = path
playlist.kodi_hash = kodi_hash
try:
plex_pl.create(playlist)
except PlaylistError:
LOG.info('Skipping Kodi playlist %s', path)
else:
LOG.debug('Changed Kodi playlist detected: %s', path)
plex_pl.delete(playlist)
playlist.kodi_hash = kodi_hash
try:
plex_pl.create(playlist)
except PlaylistError:
LOG.info('Skipping Kodi playlist %s', path)
for kodi_path in old_kodi_paths:
if should_cancel():
return False
playlist = db.get_playlist(path=kodi_path)
if not playlist:
continue
try:
plex_pl.delete(playlist)
except PlaylistError:
LOG.debug('Skipping deletion of Plex playlist: %s', playlist)
LOG.info('Playlist full sync done')
return True
def sync_kodi_playlist(path):
"""
Checks whether we should sync a specific Kodi playlist to Plex
Will check the following conditions for one single Kodi playlist:
* Kodi mixed playlists return False
* Support of the file type of the playlist, e.g. m3u
* Whether filename matches user settings to sync, if enabled
Parameters
----------
path : unicode
Absolute file path to the Kodi playlist in question
Returns
-------
bool
True if we should sync this Kodi playlist to Plex, False otherwise
"""
if path.startswith(v.PLAYLIST_PATH_MIXED):
return False
try:
extension = path.rsplit('.', 1)[1].lower()
except IndexError:
return False
if extension not in SUPPORTED_FILETYPES:
return False
if not app.SYNC.sync_specific_kodi_playlists:
return True
playlist = Playlist()
playlist.kodi_path = path
prefix = utils.settings('syncSpecificKodiPlaylistsPrefix').lower()
if playlist.kodi_filename.lower().startswith(prefix):
return True
LOG.debug('User chose to not sync Kodi playlist %s', path)
return False
def sync_plex_playlist(playlist=None, xml=None, plex_id=None):
"""
Checks whether we should sync a specific Plex playlist to Kodi
Will check the following conditions for one single Plex playlist:
* Plex music playlists return False if PKC audio sync is disabled
* Whether filename matches user settings to sync, if enabled
* False is returned if we could not retrieve more information about the
playlist if only the plex_id was given
Parameters
----------
Pass in either playlist, xml or plex_id (preferably in this order)
plex_id : unicode
Absolute file path to the Kodi playlist in question
xml : etree xml
PMS metadata for the Plex element in question. API(xml) instead of
the usual API(xml[0]) will be used!
playlist: PlayList
A PlayList instance with Playlist.plex_name and PlayList.kodi_type set
Returns
-------
bool
True if we should sync this Plex playlist to Kodi, False otherwise
"""
if playlist:
# Mainly once we DELETED a Plex playlist that we're NOT supposed
# to sync
name = playlist.plex_name
typus = playlist.kodi_type
else:
if xml is None:
xml = pms.metadata(plex_id)
if xml is None:
LOG.info('Could not get Plex metadata for playlist %s',
plex_id)
return False
api = API(xml[0])
else:
api = API(xml)
if api.playlist_type() == v.PLEX_TYPE_PHOTO_PLAYLIST:
# Not supported by Kodi
return False
elif api.playlist_type() is None:
# Encountered in logs, seems to be a malformed answer
LOG.error('Playlist type is missing: %s', api.xml.attrib)
return False
name = api.title()
typus = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()]
if (not app.SYNC.enable_music and typus == v.PLEX_PLAYLIST_TYPE_AUDIO):
LOG.debug('Not synching Plex audio playlist')
return False
if not app.SYNC.sync_specific_plex_playlists:
return True
prefix = utils.settings('syncSpecificPlexPlaylistsPrefix').lower()
if name and name.lower().startswith(prefix):
return True
LOG.debug('User chose to not sync Plex playlist %s', name)
return False
class PlaylistEventhandler(events.FileSystemEventHandler):
"""
PKC eventhandler to monitor Kodi playlists safed to disk
"""
def dispatch(self, event):
"""
Dispatches events to the appropriate methods.
Parameters
----------
:type event:
:class:`FileSystemEvent`
The event object representing the file system event.
"""
path = event.dest_path if event.event_type == events.EVENT_TYPE_MOVED \
else event.src_path
with app.APP.lock_playlists:
if not sync_kodi_playlist(path):
return
if path in kodi_pl.IGNORE_KODI_PLAYLIST_CHANGE:
LOG.debug('Ignoring event %s', event)
kodi_pl.IGNORE_KODI_PLAYLIST_CHANGE.remove(path)
return
_method_map = {
events.EVENT_TYPE_MODIFIED: self.on_modified,
events.EVENT_TYPE_MOVED: self.on_moved,
events.EVENT_TYPE_CREATED: self.on_created,
events.EVENT_TYPE_DELETED: self.on_deleted,
}
_method_map[event.event_type](event)
def on_created(self, event):
LOG.debug('on_created: %s', event.src_path)
old_playlist = db.get_playlist(path=event.src_path)
kodi_hash = kodi_playlist_hash(event.src_path)
if old_playlist and old_playlist.kodi_hash == kodi_hash:
LOG.debug('Playlist already in DB - skipping')
return
elif old_playlist:
LOG.debug('Playlist already in DB but it has been changed')
self.on_modified(event)
return
playlist = Playlist()
playlist.kodi_path = event.src_path
playlist.kodi_hash = kodi_hash
try:
plex_pl.create(playlist)
except PlaylistError:
pass
def on_modified(self, event):
LOG.debug('on_modified: %s', event.src_path)
old_playlist = db.get_playlist(path=event.src_path)
kodi_hash = kodi_playlist_hash(event.src_path)
if old_playlist and old_playlist.kodi_hash == kodi_hash:
LOG.debug('Nothing modified, playlist already in DB - skipping')
return
new_playlist = Playlist()
if old_playlist:
# Retain the name! Might've come from Plex
# (rename should fire on_moved)
new_playlist.plex_name = old_playlist.plex_name
plex_pl.delete(old_playlist)
new_playlist.kodi_path = event.src_path
new_playlist.kodi_hash = kodi_hash
try:
plex_pl.create(new_playlist)
except PlaylistError:
pass
def on_moved(self, event):
LOG.debug('on_moved: %s to %s', event.src_path, event.dest_path)
kodi_hash = kodi_playlist_hash(event.dest_path)
# First check whether we don't already have destination playlist in
# our DB. Just in case....
old_playlist = db.get_playlist(path=event.dest_path)
if old_playlist:
LOG.warning('Found target playlist already in our DB!')
new_event = events.FileModifiedEvent(event.dest_path)
self.on_modified(new_event)
return
# All good
old_playlist = db.get_playlist(path=event.src_path)
if not old_playlist:
LOG.debug('Did not have source path in the DB %s', event.src_path)
else:
plex_pl.delete(old_playlist)
new_playlist = Playlist()
new_playlist.kodi_path = event.dest_path
new_playlist.kodi_hash = kodi_hash
try:
plex_pl.create(new_playlist)
except PlaylistError:
pass
def on_deleted(self, event):
LOG.debug('on_deleted: %s', event.src_path)
playlist = db.get_playlist(path=event.src_path)
if not playlist:
LOG.debug('Playlist not found in DB for path %s', event.src_path)
else:
plex_pl.delete(playlist)

View file

@ -1,207 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import Queue
import time
import os
import hashlib
from ..watchdog import events
from ..watchdog.observers import Observer
from ..watchdog.utils.bricks import OrderedSetQueue
from .. import path_ops, variables as v, app
from ..exceptions import PlaylistError
###############################################################################
LOG = getLogger('PLEX.playlists.common')
# These filesystem events are considered similar
SIMILAR_EVENTS = (events.EVENT_TYPE_CREATED, events.EVENT_TYPE_MODIFIED)
###############################################################################
class Playlist(object):
"""
Class representing a synced Playlist with info for both Kodi and Plex.
Attributes:
Plex:
plex_id: unicode
plex_name: unicode
plex_updatedat: unicode
Kodi:
kodi_path: unicode
kodi_filename: unicode
kodi_extension: unicode
kodi_type: unicode
kodi_hash: unicode
Testing for a Playlist() returns ONLY True if all the following attributes
are set; 2 playlists are only equal if all attributes are equal:
plex_id
plex_name
plex_updatedat
kodi_path
kodi_filename
kodi_type
kodi_hash
"""
def __init__(self):
# Plex
self.plex_id = None
self.plex_name = None
self.plex_updatedat = None
# Kodi
self._kodi_path = None
self.kodi_filename = None
self.kodi_extension = None
self.kodi_type = None
self.kodi_hash = None
def __unicode__(self):
return ("{{"
"'plex_id': {self.plex_id}, "
"'plex_name': '{self.plex_name}', "
"'kodi_type': '{self.kodi_type}', "
"'kodi_filename': '{self.kodi_filename}', "
"'kodi_path': '{self._kodi_path}', "
"'plex_updatedat': {self.plex_updatedat}, "
"'kodi_hash': '{self.kodi_hash}'"
"}}").format(self=self)
def __repr__(self):
return self.__unicode__().encode('utf-8')
def __str__(self):
return self.__repr__()
def __bool__(self):
return (self.plex_id and self.plex_updatedat and self.plex_name and
self._kodi_path and self.kodi_filename and self.kodi_type and
self.kodi_hash)
# Used for comparison of playlists
@property
def key(self):
return (self.plex_id, self.plex_updatedat, self.plex_name,
self._kodi_path, self.kodi_filename, self.kodi_type,
self.kodi_hash)
def __eq__(self, playlist):
return self.key == playlist.key
def __ne__(self, playlist):
return self.key != playlist.key
@property
def kodi_path(self):
return self._kodi_path
@kodi_path.setter
def kodi_path(self, path):
f = path_ops.path.basename(path)
try:
self.kodi_filename, self.kodi_extension = f.rsplit('.', 1)
except ValueError:
LOG.error('Trying to set invalid path: %s', path)
raise PlaylistError('Invalid path: %s' % path)
if path.startswith(v.PLAYLIST_PATH_VIDEO):
self.kodi_type = v.KODI_TYPE_VIDEO_PLAYLIST
elif path.startswith(v.PLAYLIST_PATH_MUSIC):
self.kodi_type = v.KODI_TYPE_AUDIO_PLAYLIST
else:
LOG.error('Playlist type not supported for %s', path)
raise PlaylistError('Playlist type not supported: %s' % path)
if not self.plex_name:
self.plex_name = self.kodi_filename
self._kodi_path = path
def kodi_playlist_hash(path):
"""
Returns a md5 hash [unicode] using os.stat() st_size and st_mtime for the
playlist located at path [unicode]
(size of file in bytes and time of most recent content modification)
There are probably way more efficient ways out there to do this
"""
stat = os.stat(path_ops.encode_path(path))
# stat.st_size is of type long; stat.st_mtime is of type float - hash both
m = hashlib.md5()
m.update(repr(stat.st_size))
m.update(repr(stat.st_mtime))
return m.hexdigest().decode('utf-8')
class PlaylistQueue(OrderedSetQueue):
"""
OrderedSetQueue that drops all directory events immediately
"""
def _put(self, item):
if item[0].is_directory:
self.unfinished_tasks -= 1
else:
# Can't use super as OrderedSetQueue is old style class
OrderedSetQueue._put(self, item)
class PlaylistObserver(Observer):
"""
PKC implementation, overriding the dispatcher. PKC will wait for the
duration timeout (in seconds) AFTER receiving a filesystem event. A new
("non-similar") event will reset the timer.
Creating and modifying will be regarded as equal.
"""
def __init__(self, *args, **kwargs):
super(PlaylistObserver, self).__init__(*args, **kwargs)
# Drop the same events that get into the queue even if there are other
# events in between these similar events. Ignore directory events
# completely
self._event_queue = PlaylistQueue()
@staticmethod
def _pkc_similar_events(event1, event2):
if event1 == event2:
return True
elif (event1.src_path == event2.src_path and
event1.event_type in SIMILAR_EVENTS and
event2.event_type in SIMILAR_EVENTS):
# Set created and modified events to equal
return True
return False
def _dispatch_iterator(self, event_queue, timeout):
"""
This iterator will block for timeout (seconds) until an event is
received or raise Queue.Empty.
"""
event, watch = event_queue.get(block=True, timeout=timeout)
event_queue.task_done()
start = time.time()
while time.time() - start < timeout:
try:
new_event, new_watch = event_queue.get(block=False)
except Queue.Empty:
app.APP.monitor.waitForAbort(0.2)
else:
event_queue.task_done()
start = time.time()
if self._pkc_similar_events(new_event, event):
continue
else:
yield event, watch
event, watch = new_event, new_watch
yield event, watch
def dispatch_events(self, event_queue, timeout):
for event, watch in self._dispatch_iterator(event_queue, timeout):
# This is copy-paste of original code
with self._lock:
# To allow unschedule/stop and safe removal of event handlers
# within event handlers itself, check if the handler is still
# registered after every dispatch.
for handler in list(self._handlers.get(watch, [])):
if handler in self._handlers.get(watch, []):
handler.dispatch(event)

View file

@ -1,134 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Synced playlists are stored in our plex.db. Interact with it through this
module
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from .common import Playlist
from ..plex_db import PlexDB
from ..kodi_db import kodiid_from_filename
from .. import path_ops, utils, variables as v
from ..exceptions import PlaylistError
###############################################################################
LOG = getLogger('PLEX.playlists.db')
###############################################################################
def plex_playlist_ids():
"""
Returns a list of all Plex ids of the playlists already in our DB
"""
with PlexDB() as plexdb:
return list(plexdb.playlist_ids())
def kodi_playlist_paths():
"""
Returns a list of all Kodi playlist paths of the playlists already synced
"""
with PlexDB() as plexdb:
return list(plexdb.kodi_playlist_paths())
def update_playlist(playlist, delete=False):
"""
Assumes that all sync operations are over. Takes playlist [Playlist]
and creates/updates the corresponding Plex playlists table entry
Pass delete=True to delete the playlist entry
"""
with PlexDB() as plexdb:
if delete:
plexdb.delete_playlist(playlist)
else:
plexdb.add_playlist(playlist)
def get_playlist(path=None, plex_id=None):
"""
Returns the playlist as a Playlist for either the plex_id or path
"""
playlist = Playlist()
with PlexDB() as plexdb:
playlist = plexdb.playlist(playlist, plex_id, path)
return playlist
def get_all_kodi_playlist_paths():
"""
Returns a list with all paths for the playlists on the Kodi side
"""
with PlexDB() as plexdb:
paths = list(plexdb.all_kodi_paths())
return paths
def wipe_table():
"""
Deletes all playlists entries in the Plex DB
"""
with PlexDB() as plexdb:
plexdb.wipe_playlists()
def _m3u_iterator(text):
"""
Yields e.g. plugin://plugin.video.plexkodiconnect.movies/?plex_id=xxx
"""
lines = iter(text.split('\n'))
for line in lines:
if line.startswith('#EXTINF:'):
next_line = next(lines).strip()
if next_line.startswith('#EXT-KX-OFFSET:'):
yield next(lines).strip()
else:
yield next_line
def m3u_to_plex_ids(playlist):
"""
Adapter to process *.m3u playlist files. Encoding is not uniform!
"""
plex_ids = list()
with open(path_ops.encode_path(playlist.kodi_path), 'rb') as f:
text = f.read()
try:
text = text.decode(v.M3U_ENCODING)
except UnicodeDecodeError:
LOG.warning('Fallback to ISO-8859-1 decoding for %s', playlist)
text = text.decode('ISO-8859-1')
for entry in _m3u_iterator(text):
plex_id = utils.REGEX_PLEX_ID.search(entry)
if plex_id:
plex_id = plex_id.group(1)
plex_ids.append(plex_id)
else:
# Add-on paths not working, try direct
kodi_id, kodi_type = kodiid_from_filename(entry,
db_type=playlist.kodi_type)
if not kodi_id:
continue
with PlexDB() as plexdb:
item = plexdb.item_by_kodi_id(kodi_id, kodi_type)
if item:
plex_ids.append(item['plex_id'])
return plex_ids
def playlist_file_to_plex_ids(playlist):
"""
Takes the playlist file located at path [unicode] and parses it.
Returns a list of plex_ids (str) or raises PlaylistError if a single
item cannot be parsed from Kodi to Plex.
"""
if playlist.kodi_extension == 'm3u':
plex_ids = m3u_to_plex_ids(playlist)
else:
LOG.error('Unsupported playlist extension: %s',
playlist.kodi_extension)
raise PlaylistError
return plex_ids

View file

@ -1,175 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Create and delete playlists on the Kodi side of things
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import re
from .common import Playlist, kodi_playlist_hash
from . import db, pms
from ..plex_api import API
from .. import utils, path_ops, variables as v
from ..exceptions import PlaylistError
###############################################################################
LOG = getLogger('PLEX.playlists.kodi_pl')
REGEX_FILE_NUMBERING = re.compile(r'''_(\d\d)\.\w+$''')
# Avoid endless loops. Store the Kodi paths
IGNORE_KODI_PLAYLIST_CHANGE = list()
###############################################################################
def create(plex_id):
"""
Creates a new Kodi playlist file. Will also add (or modify an existing)
Plex playlist table entry.
Assumes that the Plex playlist is indeed new. A NEW Kodi playlist will be
created in any case (not replaced). Thus make sure that the "same" playlist
is deleted from both disk and the Plex database.
Returns the playlist or raises PlaylistError
"""
xml_metadata = pms.metadata(plex_id)
if xml_metadata is None:
LOG.error('Could not get Plex playlist metadata %s', plex_id)
raise PlaylistError('Could not get Plex playlist %s' % plex_id)
api = API(xml_metadata[0])
playlist = Playlist()
playlist.plex_id = api.plex_id
playlist.kodi_type = v.KODI_PLAYLIST_TYPE_FROM_PLEX[api.playlist_type()]
playlist.plex_name = api.title()
playlist.plex_updatedat = api.updated_at()
LOG.debug('Creating new Kodi playlist from Plex playlist: %s', playlist)
# Derive filename close to Plex playlist name
name = utils.valid_filename(playlist.plex_name)
path = path_ops.path.join(v.PLAYLIST_PATH, playlist.kodi_type,
'%s.m3u' % name)
while path_ops.exists(path) or db.get_playlist(path=path):
# In case the Plex playlist names are not unique
occurance = REGEX_FILE_NUMBERING.search(path)
if not occurance:
path = path_ops.path.join(v.PLAYLIST_PATH,
playlist.kodi_type,
'%s_01.m3u' % name[:min(len(name), 248)])
else:
number = int(occurance.group(1)) + 1
if number > 3:
LOG.warn('Detected spanning tree issue, abort sync for %s',
playlist)
raise PlaylistError('Spanning tree warning')
basename = re.sub(REGEX_FILE_NUMBERING, '', path)
path = '%s_%02d.m3u' % (basename, number)
LOG.debug('Kodi playlist path: %s', path)
playlist.kodi_path = path
xml_playlist = pms.get_playlist(plex_id)
if xml_playlist is None:
LOG.error('Could not get Plex playlist %s', plex_id)
raise PlaylistError('Could not get Plex playlist %s' % plex_id)
IGNORE_KODI_PLAYLIST_CHANGE.append(playlist.kodi_path)
try:
_write_playlist_to_file(playlist, xml_playlist)
except Exception:
IGNORE_KODI_PLAYLIST_CHANGE.remove(playlist.kodi_path)
raise
playlist.kodi_hash = kodi_playlist_hash(path)
db.update_playlist(playlist)
LOG.debug('Created Kodi playlist based on Plex playlist: %s', playlist)
def delete(playlist):
"""
Removes the corresponding Kodi file for playlist Playlist from
disk. Be sure that playlist.kodi_path is set. Will also delete the entry in
the Plex playlist table.
Returns None or raises PlaylistError
"""
if path_ops.exists(playlist.kodi_path):
IGNORE_KODI_PLAYLIST_CHANGE.append(playlist.kodi_path)
try:
path_ops.remove(playlist.kodi_path)
LOG.debug('Deleted Kodi playlist: %s', playlist)
except (OSError, IOError) as err:
LOG.error('Could not delete Kodi playlist file %s. Error:\n%s: %s',
playlist, err.errno, err.strerror)
IGNORE_KODI_PLAYLIST_CHANGE.remove(playlist.kodi_path)
raise PlaylistError('Could not delete %s' % playlist.kodi_path)
db.update_playlist(playlist, delete=True)
def delete_kodi_playlists(playlist_paths):
"""
Deletes all the the playlist files passed in; WILL IGNORE THIS CHANGE ON
THE PLEX SIDE!
"""
for path in playlist_paths:
try:
path_ops.remove(path)
# Ensure we're not deleting the playlists on the Plex side later
IGNORE_KODI_PLAYLIST_CHANGE.append(path)
LOG.info('Removed playlist %s', path)
except (OSError, IOError):
LOG.warn('Could not remove playlist %s', path)
def _write_playlist_to_file(playlist, xml):
"""
Feed with playlist Playlist. Will write the playlist to a m3u file
Returns None or raises PlaylistError
"""
text = '#EXTCPlayListM3U::M3U\n'
for xml_element in xml:
text += _m3u_element(xml_element)
text += '\n'
text = text.encode(v.M3U_ENCODING, 'ignore')
try:
with open(path_ops.encode_path(playlist.kodi_path), 'wb') as f:
f.write(text)
except EnvironmentError as err:
LOG.error('Could not write Kodi playlist file: %s', playlist)
LOG.error('Error message %s: %s', err.errno, err.strerror)
raise PlaylistError('Cannot write Kodi playlist to path for %s'
% playlist)
def _m3u_element(xml_element):
api = API(xml_element)
if api.plex_type == v.PLEX_TYPE_EPISODE:
if api.season_number() is not None and api.index() is not None:
return '#EXTINF:{},{} S{:2d}E{:2d} - {}\n{}\n'.format(
api.runtime(),
api.show_title(),
api.season_number(),
api.index(),
api.title(),
api.fullpath(force_addon=True)[0])
else:
# Only append the TV show name
return '#EXTINF:{},{} - {}\n{}\n'.format(
api.runtime(),
api.show_title(),
api.title(),
api.fullpath(force_addon=True)[0])
elif api.plex_type == v.PLEX_TYPE_SONG:
if api.index() is not None:
return '#EXTINF:{},{:02d}. {} - {}\n{}\n'.format(
api.runtime(),
api.index(),
api.grandparent_title(),
api.title(),
api.fullpath(force_first_media=True,
omit_check=True)[0])
else:
return '#EXTINF:{},{} - {}\n{}\n'.format(
api.runtime(),
api.grandparent_title(),
api.title(),
api.fullpath(force_first_media=True,
omit_check=True)[0])
else:
return '#EXTINF:{},{}\n{}\n'.format(
api.runtime(),
api.title(),
api.fullpath(force_first_media=True,
omit_check=True)[0])

View file

@ -1,49 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Create and delete playlists on the Plex side of things
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from . import pms, db
from ..exceptions import PlaylistError
###############################################################################
LOG = getLogger('PLEX.playlists.plex_pl')
# Used for updating Plex playlists due to Kodi changes - Plex playlist
# will have to be deleted first. Add Plex ids!
IGNORE_PLEX_PLAYLIST_CHANGE = list()
###############################################################################
def create(playlist):
"""
Adds the playlist Playlist to the PMS. If playlist.id is
not None the existing Plex playlist will be overwritten; otherwise a new
playlist will be generated and stored accordingly in the playlist object.
Will also add (or modify an existing) Plex playlist table entry.
Make sure that playlist.kodi_hash is set!
Returns None or raises PlaylistError
"""
LOG.debug('Creating Plex playlist from Kodi file: %s', playlist)
plex_ids = db.playlist_file_to_plex_ids(playlist)
if not plex_ids:
LOG.warning('No Plex ids found for playlist %s', playlist)
raise PlaylistError
pms.add_items(playlist, plex_ids)
IGNORE_PLEX_PLAYLIST_CHANGE.append(playlist.plex_id)
db.update_playlist(playlist)
LOG.debug('Done creating Plex playlist %s', playlist)
def delete(playlist):
"""
Removes the playlist Playlist from the PMS. Will also delete the
entry in the Plex playlist table.
Returns None or raises PlaylistError
"""
LOG.debug('Deleting playlist from PMS: %s', playlist)
IGNORE_PLEX_PLAYLIST_CHANGE.append(playlist.plex_id)
pms.delete(playlist)
db.update_playlist(playlist, delete=True)

View file

@ -1,145 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Functions to communicate with the currently connected PMS in order to
manipulate playlists
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from ..plex_api import API
from ..downloadutils import DownloadUtils as DU
from .. import utils, app, variables as v
from ..exceptions import PlaylistError
###############################################################################
LOG = getLogger('PLEX.playlists.pms')
###############################################################################
def all_playlists():
"""
Returns an XML with all Plex playlists or None
"""
xml = DU().downloadUrl('{server}/playlists')
try:
xml.attrib
except (AttributeError, TypeError):
LOG.error('Could not download a list of all playlists')
xml = None
return xml
def get_playlist(plex_id):
"""
Fetches the PMS playlist/playqueue as an XML. Pass in playlist id
Returns None if something went wrong
"""
xml = DU().downloadUrl("{server}/playlists/%s/items" % plex_id)
try:
xml.attrib
except AttributeError:
xml = None
return xml
def initialize(playlist, plex_id):
"""
Initializes a new playlist on the PMS side. Will set playlist.plex_id and
playlist.plex_updatedat. Will raise PlaylistError if something went wrong.
"""
LOG.debug('Initializing the playlist with Plex id %s on the Plex side: %s',
plex_id, playlist)
params = {
'type': v.PLEX_PLAYLIST_TYPE_FROM_KODI[playlist.kodi_type],
'title': playlist.plex_name,
'smart': 0,
'uri': ('library://None/item/%s' % (utils.quote('/library/metadata/%s'
% plex_id, safe='')))
}
xml = DU().downloadUrl(url='{server}/playlists',
action_type='POST',
parameters=params)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not initialize playlist on Plex side with plex id %s',
plex_id)
raise PlaylistError('Could not initialize Plex playlist %s', plex_id)
api = API(xml[0])
playlist.plex_id = api.plex_id
playlist.plex_updatedat = api.updated_at()
def add_item(playlist, plex_id):
"""
Adds the item with plex_id to the existing Plex playlist (at the end).
Will set playlist.plex_updatedat
Raises PlaylistError if that did not work out.
"""
params = {
'uri': ('library://None/item/%s' % (utils.quote('/library/metadata/%s'
% plex_id, safe='')))
}
xml = DU().downloadUrl(url='{server}/playlists/%s/items' % playlist.plex_id,
action_type='PUT',
parameters=params)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not initialize playlist on Plex side with plex id %s',
plex_id)
raise PlaylistError('Could not item %s to Plex playlist %s',
plex_id, playlist)
api = API(xml[0])
playlist.plex_updatedat = api.updated_at()
def add_items(playlist, plex_ids):
"""
Adds all plex_ids (a list of ints) to a new Plex playlist.
Will set playlist.plex_updatedat
Raises PlaylistError if that did not work out.
"""
params = {
'type': v.PLEX_PLAYLIST_TYPE_FROM_KODI[playlist.kodi_type],
'title': playlist.plex_name,
'smart': 0,
'uri': ('server://%s/com.plexapp.plugins.library/library/metadata/%s'
% (app.CONN.machine_identifier,
','.join(unicode(x) for x in plex_ids)))
}
xml = DU().downloadUrl(url='{server}/playlists/',
action_type='POST',
parameters=params)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not add items to a new playlist %s on Plex side',
playlist)
raise PlaylistError('Could not add items to a new Plex playlist %s' %
playlist)
api = API(xml[0])
playlist.plex_id = api.plex_id
playlist.plex_updatedat = api.updated_at()
def metadata(plex_id):
"""
Returns an xml with the entire metadata like updatedAt.
"""
xml = DU().downloadUrl('{server}/playlists/%s' % plex_id)
try:
xml.attrib
except AttributeError:
xml = None
return xml
def delete(playlist):
"""
Deletes the playlist from the PMS
"""
DU().downloadUrl('{server}/playlists/%s' % playlist.plex_id,
action_type="DELETE")

View file

@ -1,23 +1,27 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Monitors the Kodi playqueue and adjusts the Plex playqueue accordingly
"""
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import copy
from threading import Thread
from re import compile as re_compile
import xbmc
from xbmc import Player, PlayList, PLAYLIST_MUSIC, PLAYLIST_VIDEO, sleep
from .plex_api import API
from . import playlist_func as PL, plex_functions as PF
from . import backgroundthread, utils, json_rpc as js, app, variables as v
from . import exceptions
from utils import thread_methods
import playlist_func as PL
from PlexFunctions import GetAllPlexChildren
from PlexAPI import API
from plexbmchelper.subscribers import LOCK
from playback import play_xml
import json_rpc as js
import variables as v
import state
###############################################################################
LOG = getLogger('PLEX.playqueue')
LOG = getLogger("PLEX." + __name__)
PLUGIN = 'plugin://%s' % v.ADDON_ID
REGEX = re_compile(r'''plex_id=(\d+)''')
# Our PKC playqueues (3 instances of Playqueue_Object())
PLAYQUEUES = []
@ -33,7 +37,7 @@ def init_playqueues():
LOG.debug('Playqueues have already been initialized')
return
# Initialize Kodi playqueues
with app.APP.lock_playqueues:
with LOCK:
for i in (0, 1, 2):
# Just in case the Kodi response is not sorted correctly
for queue in js.get_playlists():
@ -44,12 +48,12 @@ def init_playqueues():
playqueue.type = queue['type']
# Initialize each Kodi playlist
if playqueue.type == v.KODI_TYPE_AUDIO:
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_MUSIC)
playqueue.kodi_pl = PlayList(PLAYLIST_MUSIC)
elif playqueue.type == v.KODI_TYPE_VIDEO:
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
playqueue.kodi_pl = PlayList(PLAYLIST_VIDEO)
else:
# Currently, only video or audio playqueues available
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
playqueue.kodi_pl = PlayList(PLAYLIST_VIDEO)
# Overwrite 'picture' with 'photo'
playqueue.type = v.KODI_TYPE_PHOTO
PLAYQUEUES.append(playqueue)
@ -61,22 +65,23 @@ def get_playqueue_from_type(kodi_playlist_type):
Returns the playqueue according to the kodi_playlist_type ('video',
'audio', 'picture') passed in
"""
for playqueue in PLAYQUEUES:
if playqueue.type == kodi_playlist_type:
break
else:
raise ValueError('Wrong playlist type passed in: %s',
kodi_playlist_type)
return playqueue
with LOCK:
for playqueue in PLAYQUEUES:
if playqueue.type == kodi_playlist_type:
break
else:
raise ValueError('Wrong playlist type passed in: %s',
kodi_playlist_type)
return playqueue
def init_playqueue_from_plex_children(plex_id, transient_token=None):
"""
Init a new playqueue e.g. from an album. Alexa does this
Returns the playqueue
Returns the Playlist_Object
"""
xml = PF.GetAllPlexChildren(plex_id)
xml = GetAllPlexChildren(plex_id)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
@ -87,31 +92,55 @@ def init_playqueue_from_plex_children(plex_id, transient_token=None):
playqueue.clear()
for i, child in enumerate(xml):
api = API(child)
try:
PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id)
except exceptions.PlaylistError:
LOG.error('Could not add Plex item to our playlist: %s, %s',
child.tag, child.attrib)
PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id())
playqueue.plex_transient_token = transient_token
LOG.debug('Firing up Kodi player')
app.APP.player.play(playqueue.kodi_pl, None, False, 0)
Player().play(playqueue.kodi_pl, None, False, 0)
return playqueue
class PlayqueueMonitor(backgroundthread.KillableThread):
def update_playqueue_from_PMS(playqueue,
playqueue_id=None,
repeat=None,
offset=None,
transient_token=None):
"""
Completely updates the Kodi playqueue with the new Plex playqueue. Pass
in playqueue_id if we need to fetch a new playqueue
repeat = 0, 1, 2
offset = time offset in Plextime (milliseconds)
"""
LOG.info('New playqueue %s received from Plex companion with offset '
'%s, repeat %s', playqueue_id, offset, repeat)
# Safe transient token from being deleted
if transient_token is None:
transient_token = playqueue.plex_transient_token
with LOCK:
xml = PL.get_PMS_playlist(playqueue, playqueue_id)
playqueue.clear()
try:
PL.get_playlist_details_from_xml(playqueue, xml)
except PL.PlaylistError:
LOG.error('Could not get playqueue ID %s', playqueue_id)
return
playqueue.repeat = 0 if not repeat else int(repeat)
playqueue.plex_transient_token = transient_token
play_xml(playqueue, xml, offset)
@thread_methods(add_suspends=['PMS_STATUS'])
class PlayqueueMonitor(Thread):
"""
Unfortunately, Kodi does not tell if items within a Kodi playqueue
(playlist) are swapped. This is what this monitor is for. Don't replace
this mechanism till Kodi's implementation of playlists has improved
"""
def _compare_playqueues(self, playqueue, new_kodi_playqueue):
def _compare_playqueues(self, playqueue, new):
"""
Used to poll the Kodi playqueue and update the Plex playqueue if needed
"""
old = list(playqueue.items)
# We might append to new_kodi_playqueue but will need the original
# still back in the main loop
new = copy.deepcopy(new_kodi_playqueue)
index = list(range(0, len(old)))
LOG.debug('Comparing new Kodi playqueue %s with our play queue %s',
new, old)
@ -121,7 +150,7 @@ class PlayqueueMonitor(backgroundthread.KillableThread):
# Ignore new media added by other addons
continue
for j, old_item in enumerate(old):
if self.should_suspend() or self.should_cancel():
if self.stopped():
# Chances are that we got an empty Kodi playlist due to
# Kodi exit
return
@ -138,7 +167,7 @@ class PlayqueueMonitor(backgroundthread.KillableThread):
old_item.kodi_type == new_item['type'])
else:
try:
plex_id = int(utils.REGEX_PLEX_ID.findall(new_item['file'])[0])
plex_id = REGEX.findall(new_item['file'])[0]
except IndexError:
LOG.debug('Comparing paths directly as a fallback')
identical = old_item.file == new_item['file']
@ -148,86 +177,75 @@ class PlayqueueMonitor(backgroundthread.KillableThread):
del old[j], index[j]
break
elif identical:
LOG.debug('Playqueue item %s moved to position %s',
LOG.debug('Detected playqueue item %s moved to position %s',
i + j, i)
try:
with LOCK:
PL.move_playlist_item(playqueue, i + j, i)
except exceptions.PlaylistError:
LOG.error('Could not modify playqueue positions')
LOG.error('This is likely caused by mixing audio and '
'video tracks in the Kodi playqueue')
del old[j], index[j]
break
else:
LOG.debug('Detected new Kodi element at position %s: %s ',
i, new_item)
try:
if playqueue.id is None:
PL.init_plex_playqueue(playqueue, kodi_item=new_item)
with LOCK:
try:
if playqueue.id is None:
PL.init_Plex_playlist(playqueue, kodi_item=new_item)
else:
PL.add_item_to_PMS_playlist(playqueue,
i,
kodi_item=new_item)
except PL.PlaylistError:
# Could not add the element
pass
except IndexError:
# This is really a hack - happens when using Addon Paths
# and repeatedly starting the same element. Kodi will
# then not pass kodi id nor file path AND will also not
# start-up playback. Hence kodimonitor kicks off
# playback. Also see kodimonitor.py - _playlist_onadd()
pass
else:
PL.add_item_to_plex_playqueue(playqueue,
i,
kodi_item=new_item)
except exceptions.PlaylistError:
# Could not add the element
pass
except KeyError:
# Catches KeyError from PL.verify_kodi_item()
# Hack: Kodi already started playback of a new item and we
# started playback already using kodimonitors
# PlayBackStart(), but the Kodi playlist STILL only shows
# the old element. Hence ignore playlist difference here
LOG.debug('Detected an outdated Kodi playlist - ignoring')
return
except IndexError:
# This is really a hack - happens when using Addon Paths
# and repeatedly starting the same element. Kodi will then
# not pass kodi id nor file path AND will also not
# start-up playback. Hence kodimonitor kicks off playback.
# Also see kodimonitor.py - _playlist_onadd()
pass
else:
for j in range(i, len(index)):
index[j] += 1
for j in range(i, len(index)):
index[j] += 1
for i in reversed(index):
if self.should_suspend() or self.should_cancel():
if self.stopped():
# Chances are that we got an empty Kodi playlist due to
# Kodi exit
return
LOG.debug('Detected deletion of playqueue element at pos %s', i)
try:
with LOCK:
PL.delete_playlist_item_from_PMS(playqueue, i)
except exceptions.PlaylistError:
LOG.error('Could not delete PMS element from position %s', i)
LOG.error('This is likely caused by mixing audio and '
'video tracks in the Kodi playqueue')
LOG.debug('Done comparing playqueues')
def run(self):
stopped = self.stopped
suspended = self.suspended
LOG.info("----===## Starting PlayqueueMonitor ##===----")
app.APP.register_thread(self)
try:
self._run()
finally:
app.APP.deregister_thread(self)
LOG.info("----===## PlayqueueMonitor stopped ##===----")
def _run(self):
while not self.should_cancel():
if self.should_suspend():
if self.wait_while_suspended():
return
with app.APP.lock_playqueues:
while not stopped():
while suspended():
if stopped():
break
sleep(1000)
work = []
# Detect changed playqueues first, do the work afterwards
with LOCK:
for playqueue in PLAYQUEUES:
kodi_pl = js.playlist_get_items(playqueue.playlistid)
if playqueue.old_kodi_pl != kodi_pl:
if playqueue.id is None and (not app.SYNC.direct_paths or
app.PLAYSTATE.context_menu_play):
if playqueue.id is None and (not state.DIRECT_PATHS or
state.CONTEXT_MENU_PLAY):
# Only initialize if directly fired up using direct
# paths. Otherwise let default.py do its magic
LOG.debug('Not yet initiating playback')
elif playqueue.pkc_edit:
playqueue.pkc_edit = False
LOG.debug('PKC edited the playqueue - skipping')
else:
# compare old and new playqueue
self._compare_playqueues(playqueue, kodi_pl)
playqueue.old_kodi_pl = list(kodi_pl)
self.sleep(0.2)
# We do need to update our playqueues
work.append((playqueue, kodi_pl))
playqueue.old_kodi_pl = kodi_pl
# Now do the work - LOCK individual playqueue edits
for playqueue, kodi_pl in work:
self._compare_playqueues(playqueue, kodi_pl)
sleep(200)
LOG.info("----===## PlayqueueMonitor stopped ##===----")

364
resources/lib/playutils.py Normal file
View file

@ -0,0 +1,364 @@
# -*- coding: utf-8 -*-
###############################################################################
from logging import getLogger
from downloadutils import DownloadUtils as DU
from utils import window, settings, language as lang, dialog, try_encode
import variables as v
###############################################################################
LOG = getLogger("PLEX." + __name__)
###############################################################################
class PlayUtils():
def __init__(self, api, playqueue_item):
"""
init with api (PlexAPI wrapper of the PMS xml element) and
playqueue_item (Playlist_Item())
"""
self.api = api
self.item = playqueue_item
def getPlayUrl(self):
"""
Returns the playurl for the part
(movie might consist of several files)
playurl is in unicode!
"""
self.api.mediastream_number()
playurl = self.isDirectPlay()
if playurl is not None:
LOG.info("File is direct playing.")
self.item.playmethod = 'DirectPlay'
elif self.isDirectStream():
LOG.info("File is direct streaming.")
playurl = self.api.transcode_video_path('DirectStream')
self.item.playmethod = 'DirectStream'
else:
LOG.info("File is transcoding.")
playurl = self.api.transcode_video_path(
'Transcode',
quality={
'maxVideoBitrate': self.get_bitrate(),
'videoResolution': self.get_resolution(),
'videoQuality': '100',
'mediaBufferSize': int(settings('kodi_video_cache'))/1024,
})
self.item.playmethod = 'Transcode'
LOG.info("The playurl is: %s", playurl)
self.item.file = playurl
return playurl
def isDirectPlay(self):
"""
Returns the path/playurl if we can direct play, None otherwise
"""
# True for e.g. plex.tv watch later
if self.api.should_stream() is True:
LOG.info("Plex item optimized for direct streaming")
return
# Check whether we have a strm file that we need to throw at Kodi 1:1
path = self.api.file_path()
if path is not None and path.endswith('.strm'):
LOG.info('.strm file detected')
playurl = self.api.validate_playurl(path,
self.api.plex_type(),
force_check=True)
return playurl
# set to either 'Direct Stream=1' or 'Transcode=2'
# and NOT to 'Direct Play=0'
if settings('playType') != "0":
# User forcing to play via HTTP
LOG.info("User chose to not direct play")
return
if self.mustTranscode():
return
return self.api.validate_playurl(path,
self.api.plex_type(),
force_check=True)
def mustTranscode(self):
"""
Returns True if we need to transcode because
- codec is in h265
- 10bit video codec
- HEVC codec
- playqueue_item force_transcode is set to True
- state variable FORCE_TRANSCODE set to True
(excepting trailers etc.)
- video bitrate above specified settings bitrate
if the corresponding file settings are set to 'true'
"""
if self.api.plex_type() in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_SONG):
LOG.info('Plex clip or music track, not transcoding')
return False
videoCodec = self.api.video_codec()
LOG.info("videoCodec: %s" % videoCodec)
if self.item.force_transcode is True:
LOG.info('User chose to force-transcode')
return True
codec = videoCodec['videocodec']
if codec is None:
# e.g. trailers. Avoids TypeError with "'h265' in codec"
LOG.info('No codec from PMS, not transcoding.')
return False
if ((settings('transcodeHi10P') == 'true' and
videoCodec['bitDepth'] == '10') and
('h264' in codec)):
LOG.info('Option to transcode 10bit h264 video content enabled.')
return True
try:
bitrate = int(videoCodec['bitrate'])
except (TypeError, ValueError):
LOG.info('No video bitrate from PMS, not transcoding.')
return False
if bitrate > self.get_max_bitrate():
LOG.info('Video bitrate of %s is higher than the maximal video'
'bitrate of %s that the user chose. Transcoding'
% (bitrate, self.get_max_bitrate()))
return True
try:
resolution = int(videoCodec['resolution'])
except (TypeError, ValueError):
LOG.info('No video resolution from PMS, not transcoding.')
return False
if 'h265' in codec or 'hevc' in codec:
if resolution >= self.getH265():
LOG.info("Option to transcode h265/HEVC enabled. Resolution "
"of the media: %s, transcoding limit resolution: %s"
% (str(resolution), str(self.getH265())))
return True
return False
def isDirectStream(self):
# Never transcode Music
if self.api.plex_type() == 'track':
return True
# set to 'Transcode=2'
if settings('playType') == "2":
# User forcing to play via HTTP
LOG.info("User chose to transcode")
return False
if self.mustTranscode():
return False
return True
def get_max_bitrate(self):
# get the addon video quality
videoQuality = settings('maxVideoQualities')
bitrate = {
'0': 320,
'1': 720,
'2': 1500,
'3': 2000,
'4': 3000,
'5': 4000,
'6': 8000,
'7': 10000,
'8': 12000,
'9': 20000,
'10': 40000,
'11': 99999999 # deactivated
}
# max bit rate supported by server (max signed 32bit integer)
return bitrate.get(videoQuality, 2147483)
def getH265(self):
"""
Returns the user settings for transcoding h265: boundary resolutions
of 480, 720 or 1080 as an int
OR 9999999 (int) if user chose not to transcode
"""
H265 = {
'0': 99999999,
'1': 480,
'2': 720,
'3': 1080
}
return H265[settings('transcodeH265')]
def get_bitrate(self):
"""
Get the desired transcoding bitrate from the settings
"""
videoQuality = settings('transcoderVideoQualities')
bitrate = {
'0': 320,
'1': 720,
'2': 1500,
'3': 2000,
'4': 3000,
'5': 4000,
'6': 8000,
'7': 10000,
'8': 12000,
'9': 20000,
'10': 40000,
}
# max bit rate supported by server (max signed 32bit integer)
return bitrate.get(videoQuality, 2147483)
def get_resolution(self):
"""
Get the desired transcoding resolutions from the settings
"""
chosen = settings('transcoderVideoQualities')
res = {
'0': '420x420',
'1': '576x320',
'2': '720x480',
'3': '1024x768',
'4': '1280x720',
'5': '1280x720',
'6': '1920x1080',
'7': '1920x1080',
'8': '1920x1080',
'9': '1920x1080',
'10': '1920x1080',
}
return res[chosen]
def audio_subtitle_prefs(self, listitem):
"""
For transcoding only
Called at the very beginning of play; used to change audio and subtitle
stream by a PUT request to the PMS
"""
# Set media and part where we're at
if self.api.mediastream is None:
self.api.mediastream_number()
try:
mediastreams = self.api.plex_media_streams()
except (TypeError, IndexError):
LOG.error('Could not get media %s, part %s',
self.api.mediastream, self.api.part)
return
part_id = mediastreams.attrib['id']
audio_streams_list = []
audio_streams = []
subtitle_streams_list = []
# No subtitles as an option
subtitle_streams = [lang(39706)]
downloadable_streams = []
download_subs = []
# selectAudioIndex = ""
select_subs_index = ""
audio_numb = 0
# Remember 'no subtitles'
sub_num = 1
default_sub = None
for stream in mediastreams:
# Since Plex returns all possible tracks together, have to sort
# them.
index = stream.attrib.get('id')
typus = stream.attrib.get('streamType')
# Audio
if typus == "2":
codec = stream.attrib.get('codec')
channellayout = stream.attrib.get('audioChannelLayout', "")
try:
track = "%s %s - %s %s" % (audio_numb+1,
stream.attrib['language'],
codec,
channellayout)
except KeyError:
track = "%s %s - %s %s" % (audio_numb+1,
lang(39707), # unknown
codec,
channellayout)
audio_streams_list.append(index)
audio_streams.append(try_encode(track))
audio_numb += 1
# Subtitles
elif typus == "3":
try:
track = "%s %s" % (sub_num+1, stream.attrib['language'])
except KeyError:
track = "%s %s (%s)" % (sub_num+1,
lang(39707), # unknown
stream.attrib.get('codec'))
default = stream.attrib.get('default')
forced = stream.attrib.get('forced')
downloadable = stream.attrib.get('key')
if default:
track = "%s - %s" % (track, lang(39708)) # Default
if forced:
track = "%s - %s" % (track, lang(39709)) # Forced
if downloadable:
# We do know the language - temporarily download
if 'language' in stream.attrib:
path = self.api.download_external_subtitles(
'{server}%s' % stream.attrib['key'],
"subtitle.%s.%s" % (stream.attrib['languageCode'],
stream.attrib['codec']))
# We don't know the language - no need to download
else:
path = self.api.attach_plex_token_to_url(
"%s%s" % (window('pms_server'),
stream.attrib['key']))
downloadable_streams.append(index)
download_subs.append(try_encode(path))
else:
track = "%s (%s)" % (track, lang(39710)) # burn-in
if stream.attrib.get('selected') == '1' and downloadable:
# Only show subs without asking user if they can be
# turned off
default_sub = index
subtitle_streams_list.append(index)
subtitle_streams.append(try_encode(track))
sub_num += 1
if audio_numb > 1:
resp = dialog('select', lang(33013), audio_streams)
if resp > -1:
# User selected some audio track
args = {
'audioStreamID': audio_streams_list[resp],
'allParts': 1
}
DU().downloadUrl('{server}/library/parts/%s' % part_id,
action_type='PUT',
parameters=args)
if sub_num == 1:
# No subtitles
return
select_subs_index = None
if (settings('pickPlexSubtitles') == 'true' and
default_sub is not None):
LOG.info('Using default Plex subtitle: %s', default_sub)
select_subs_index = default_sub
else:
resp = dialog('select', lang(33014), subtitle_streams)
if resp > 0:
select_subs_index = subtitle_streams_list[resp-1]
else:
# User selected no subtitles or backed out of dialog
select_subs_index = ''
LOG.debug('Adding external subtitles: %s', download_subs)
# Enable Kodi to switch autonomously to downloadable subtitles
if download_subs:
listitem.setSubtitles(download_subs)
# Don't additionally burn in subtitles
if select_subs_index in downloadable_streams:
select_subs_index = ''
# Now prep the PMS for our choice
args = {
'subtitleStreamID': select_subs_index,
'allParts': 1
}
DU().downloadUrl('{server}/library/parts/%s' % part_id,
action_type='PUT',
parameters=args)

View file

@ -1,32 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
plex_api interfaces with all Plex Media Server (and plex.tv) xml responses
"""
from __future__ import absolute_import, division, unicode_literals
from .base import Base
from .artwork import Artwork
from .file import File
from .media import Media
from .user import User
from .playback import Playback
from ..plex_db import PlexDB
class API(Base, Artwork, File, Media, User, Playback):
pass
def mass_api(xml):
"""
Pass in an entire XML PMS response with e.g. several movies or episodes
Will Look-up Kodi ids in the Plex.db for every element (thus speeding up
this process for several PMS items!)
"""
apis = [API(x) for x in xml]
with PlexDB(lock=False) as plexdb:
for api in apis:
api.check_db(plexdb=plexdb)
return apis

View file

@ -1,287 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from ..kodi_db import KodiVideoDB, KodiMusicDB
from ..downloadutils import DownloadUtils as DU
from .. import utils, variables as v, app
from . import fanart_lookup
LOG = getLogger('PLEX.api')
class Artwork(object):
def one_artwork(self, art_kind, aspect=None):
"""
aspect can be: 'square', '16:9', 'poster'. Defaults to 'poster'
"""
aspect = 'poster' if not aspect else aspect
if aspect == 'poster':
width = 1000
height = 1500
elif aspect == '16:9':
width = 1920
height = 1080
elif aspect == 'square':
width = 1000
height = 1000
else:
raise NotImplementedError('aspect ratio not yet implemented: %s'
% aspect)
artwork = self.xml.get(art_kind)
if not artwork or artwork.startswith('http'):
return artwork
if '/composite/' in artwork:
try:
# e.g. Plex collections where artwork already contains width and
# height. Need to upscale for better resolution
artwork, args = artwork.split('?')
args = dict(utils.parse_qsl(args))
width = int(args.get('width', 400))
height = int(args.get('height', 400))
# Adjust to 4k resolution 1920x1080
scaling = 1920.0 / float(max(width, height))
width = int(scaling * width)
height = int(scaling * height)
except ValueError:
# e.g. playlists
pass
artwork = '%s?width=%s&height=%s' % (artwork, width, height)
artwork = ('%s/photo/:/transcode?width=1920&height=1920&'
'minSize=1&upscale=0&url=%s'
% (app.CONN.server, utils.quote(artwork)))
artwork = self.attach_plex_token_to_url(artwork)
return artwork
def artwork_episode(self, full_artwork):
"""
Episodes are special, they only get the thumb, because all the other
artwork will be saved under season and show EXCEPT if you're
constructing a listitem and the item has NOT been synched to the Kodi db
"""
artworks = {}
# Item is currently NOT in the Kodi DB
art = self.one_artwork('thumb')
if art:
artworks['thumb'] = art
if not full_artwork:
# For episodes, only get the thumb. Everything else stemms from
# either the season or the show
return artworks
for kodi_artwork, plex_artwork in \
v.KODI_TO_PLEX_ARTWORK_EPISODE.iteritems():
art = self.one_artwork(plex_artwork)
if art:
artworks[kodi_artwork] = art
return artworks
def artwork(self, kodi_id=None, kodi_type=None, full_artwork=False):
"""
Gets the URLs to the Plex artwork. Dict keys will be missing if there
is no corresponding artwork.
Pass kodi_id and kodi_type to grab the artwork saved in the Kodi DB
(thus potentially more artwork, e.g. clearart, discart).
Output ('max' version)
{
'thumb'
'poster'
'banner'
'clearart'
'clearlogo'
'fanart'
}
'landscape' and 'icon' might be implemented later
Passing full_artwork=True returns ALL the artwork for the item, so not
just 'thumb' for episodes, but also season and show artwork
"""
if self.plex_type == v.PLEX_TYPE_EPISODE:
return self.artwork_episode(full_artwork)
artworks = {}
if kodi_id:
# in Kodi database, potentially with additional e.g. clearart
if self.plex_type in v.PLEX_VIDEOTYPES:
with KodiVideoDB(lock=False) as kodidb:
return kodidb.get_art(kodi_id, kodi_type)
else:
with KodiMusicDB(lock=False) as kodidb:
return kodidb.get_art(kodi_id, kodi_type)
for kodi_artwork, plex_artwork in v.KODI_TO_PLEX_ARTWORK.iteritems():
art = self.one_artwork(plex_artwork)
if art:
artworks[kodi_artwork] = art
if self.plex_type in (v.PLEX_TYPE_SONG, v.PLEX_TYPE_ALBUM):
# Get parent item artwork if the main item is missing artwork
if 'fanart' not in artworks:
art = self.one_artwork('parentArt')
if art:
artworks['fanart1'] = art
if 'poster' not in artworks:
art = self.one_artwork('parentThumb')
if art:
artworks['poster'] = art
if self.plex_type in (v.PLEX_TYPE_SONG,
v.PLEX_TYPE_ALBUM,
v.PLEX_TYPE_ARTIST):
# need to set poster also as thumb
art = self.one_artwork('thumb')
if art:
artworks['thumb'] = art
if self.plex_type == v.PLEX_TYPE_PLAYLIST:
art = self.one_artwork('composite')
if art:
artworks['thumb'] = art
return artworks
def fanart_artwork(self, artworks):
"""
Downloads additional fanart from third party sources (well, link to
fanart only).
"""
external_id = self.retrieve_external_item_id()
if external_id is not None:
artworks = self.lookup_fanart_tv(external_id[0], artworks)
return artworks
def set_artwork(self):
"""
Gets the URLs to the Plex artwork, or empty string if not found.
Only call on movies!
"""
artworks = {}
# Plex does not get much artwork - go ahead and get the rest from
# fanart tv only for movie or tv show
external_id = self.retrieve_external_item_id(collection=True)
if external_id is not None:
external_id, poster, background = external_id
if poster is not None:
artworks['poster'] = poster
if background is not None:
artworks['fanart'] = background
artworks = self.lookup_fanart_tv(external_id, artworks)
else:
LOG.info('Did not find a set/collection ID on TheMovieDB using %s.'
' Artwork will be missing.', self.title())
return artworks
def retrieve_external_item_id(self, collection=False):
"""
Returns the set
media_id [unicode]: the item's IMDB id for movies or tvdb id for
TV shows
poster [unicode]: path to the item's poster artwork
background [unicode]: path to the item's background artwork
The last two might be None if not found. Generally None is returned
if unsuccessful.
If not found in item's Plex metadata, check themovidedb.org.
"""
item = self.xml.attrib
media_type = self.plex_type
media_id = None
# Return the saved Plex id's, if applicable
# Always seek collection's ids since not provided by PMS
if collection is False:
if media_type == v.PLEX_TYPE_MOVIE:
media_id = self.guids.get('imdb')
elif media_type == v.PLEX_TYPE_SHOW:
media_id = self.guids.get('tvdb')
if media_id is not None:
return media_id, None, None
LOG.info('Plex did not provide ID for IMDB or TVDB. Start '
'lookup process')
else:
LOG.debug('Start movie set/collection lookup on themoviedb with %s',
item.get('title', ''))
return fanart_lookup.external_item_id(self.title(),
self.year(),
self.plex_type,
collection)
def lookup_fanart_tv(self, media_id, artworks):
"""
perform artwork lookup on fanart.tv
media_id: IMDB id for movies, tvdb id for TV shows
"""
api_key = utils.settings('FanArtTVAPIKey')
typus = self.plex_type
if typus == v.PLEX_TYPE_SHOW:
typus = 'tv'
if typus == v.PLEX_TYPE_MOVIE:
url = 'http://webservice.fanart.tv/v3/movies/%s?api_key=%s' \
% (media_id, api_key)
elif typus == 'tv':
url = 'http://webservice.fanart.tv/v3/tv/%s?api_key=%s' \
% (media_id, api_key)
else:
# Not supported artwork
return artworks
data = DU().downloadUrl(url,
authenticate=False,
timeout=15,
return_response=True)
if not data.ok:
LOG.debug('Could not download data from FanartTV')
return artworks
data = data.json()
fanart_tv_types = list(v.FANART_TV_TO_KODI_TYPE)
if typus == v.PLEX_TYPE_ARTIST:
fanart_tv_types.append(("thumb", "folder"))
else:
fanart_tv_types.append(("thumb", "thumb"))
prefixes = (
"hd" + typus,
"hd",
typus,
"",
)
for fanart_tv_type, kodi_type in fanart_tv_types:
# Skip the ones we already have
if kodi_type in artworks:
continue
for prefix in prefixes:
fanarttvimage = prefix + fanart_tv_type
if fanarttvimage not in data:
continue
# select image in preferred language
for entry in data[fanarttvimage]:
if entry.get("lang") == v.KODILANGUAGE:
artworks[kodi_type] = \
entry.get("url", "").replace(' ', '%20')
break
# just grab the first english OR undefinded one as fallback
# (so we're actually grabbing the more popular one)
if kodi_type not in artworks:
for entry in data[fanarttvimage]:
if entry.get("lang") in ("en", "00"):
artworks[kodi_type] = \
entry.get("url", "").replace(' ', '%20')
break
# grab extrafanarts in list
fanartcount = 1 if 'fanart' in artworks else ''
for prefix in prefixes:
fanarttvimage = prefix + 'background'
if fanarttvimage not in data:
continue
for entry in data[fanarttvimage]:
if entry.get("url") is None:
continue
artworks['fanart%s' % fanartcount] = \
entry['url'].replace(' ', '%20')
try:
fanartcount += 1
except TypeError:
fanartcount = 1
if fanartcount >= v.MAX_BACKGROUND_COUNT:
break
return artworks

View file

@ -1,697 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from re import sub
import xbmcgui
from ..utils import cast
from ..plex_db import PlexDB
from .. import utils, timing, variables as v, app, plex_functions as PF
from .. import widgets
LOG = getLogger('PLEX.api')
METADATA_PROVIDERS = (('imdb', utils.REGEX_IMDB),
('tvdb', utils.REGEX_TVDB),
('tmdb', utils.REGEX_TMDB),
('anidb', utils.REGEX_ANIDB))
class Base(object):
"""
Processes a Plex media server's XML response
xml: xml.etree.ElementTree element
"""
def __init__(self, xml):
self.xml = xml
# which media part in the XML response shall we look at if several
# media files are present for the SAME video? (e.g. a 4k and a 1080p
# version)
self.part = 0
self.mediastream = None
# Make sure we're only checking our Plex DB once
self._checked_db = False
# In order to run through the leaves of the xml only once
self._scanned_children = False
self._genres = []
self._countries = []
self._collections = []
self._people = []
self._cast = []
self._directors = []
self._writers = []
self._producers = []
self._locations = []
self._intro_markers = []
self._guids = {}
self._coll_match = None
# Plex DB attributes
self._section_id = None
self._kodi_id = None
self._last_sync = None
self._last_checksum = None
self._kodi_fileid = None
self._kodi_pathid = None
self._fanart_synced = None
@property
def tag(self):
"""
Returns the xml etree tag, e.g. 'Directory', 'Playlist', 'Hub', 'Video'
"""
return self.xml.tag
def tag_label(self):
"""
Returns the 'tag' attribute of the xml
"""
return self.xml.get('tag')
@property
def attrib(self):
"""
Returns the xml etree attrib dict
"""
return self.xml.attrib
@property
def plex_id(self):
"""
Returns the Plex ratingKey as an integer or None
"""
return cast(int, self.xml.get('ratingKey'))
@property
def fast_key(self):
"""
Returns the 'fastKey' as unicode or None
"""
return self.xml.get('fastKey')
@property
def plex_type(self):
"""
Returns the type of media, e.g. 'movie' or 'clip' for trailers as
Unicode or None.
"""
return self.xml.get('type')
@property
def section_id(self):
self.check_db()
return self._section_id
@property
def kodi_id(self):
self.check_db()
return self._kodi_id
@property
def kodi_type(self):
return v.KODITYPE_FROM_PLEXTYPE[self.plex_type]
@property
def last_sync(self):
self.check_db()
return self._last_sync
@property
def last_checksum(self):
self.check_db()
return self._last_checksum
@property
def kodi_fileid(self):
self.check_db()
return self._kodi_fileid
@property
def kodi_pathid(self):
self.check_db()
return self._kodi_pathid
@property
def fanart_synced(self):
self.check_db()
return self._fanart_synced
@property
def guids(self):
self._scan_children()
return self._guids
def check_db(self, plexdb=None):
"""
Check's whether we synched this item to Kodi. If so, then retrieve the
appropriate Kodi info like the kodi_id and kodi_fileid
Pass in a plexdb DB-connection for a faster lookup
"""
if self._checked_db:
return
self._checked_db = True
if self.plex_type == v.PLEX_TYPE_CLIP:
# Clips won't ever be synched to Kodi
return
if plexdb:
db_item = plexdb.item_by_id(self.plex_id, self.plex_type)
else:
with PlexDB(lock=False) as plexdb:
db_item = plexdb.item_by_id(self.plex_id, self.plex_type)
if not db_item:
return
self._section_id = db_item['section_id']
self._kodi_id = db_item['kodi_id']
self._last_sync = db_item['last_sync']
self._last_checksum = db_item['checksum']
if 'kodi_fileid' in db_item:
self._kodi_fileid = db_item['kodi_fileid']
if 'kodi_pathid' in db_item:
self._kodi_pathid = db_item['kodi_pathid']
if 'fanart_synced' in db_item:
self._fanart_synced = db_item['fanart_synced']
def path_and_plex_id(self):
"""
Returns the Plex key such as '/library/metadata/246922' or None
"""
return self.xml.get('key')
def item_id(self):
"""
Returns current playQueueItemID or if unsuccessful the playListItemID
as int.
If not found, None is returned
"""
return (cast(int, self.xml.get('playQueueItemID')) or
cast(int, self.xml.get('playListItemID')))
def playlist_type(self):
"""
Returns the playlist type ('video', 'audio') or None
"""
return self.xml.get('playlistType')
def library_section_id(self):
"""
Returns the id of the Plex library section (for e.g. a movies section)
as an int or None
"""
return cast(int, self.xml.get('librarySectionID'))
def guid_html_escaped(self):
"""
Returns the 'guid' attribute, e.g.
'com.plexapp.agents.thetvdb://76648/2/4?lang=en'
as an HTML-escaped string or None
"""
guid = self.xml.get('guid')
return utils.escape_html(guid) if guid else None
def date_created(self):
"""
Returns the date when this library item was created in Kodi-time as
unicode
If not found, returns 2000-01-01 10:00:00
"""
res = self.xml.get('addedAt')
return timing.plex_date_to_kodi(res) if res else '2000-01-01 10:00:00'
def updated_at(self):
"""
Returns the last time this item was updated as an int, e.g.
1524739868 or None
"""
return cast(int, self.xml.get('updatedAt'))
def checksum(self):
"""
Returns the unique int <ratingKey><updatedAt>. If updatedAt is not set,
addedAt is used.
"""
return int('%s%s' % (self.xml.get('ratingKey'),
abs(int(self.xml.get('updatedAt') or
self.xml.get('addedAt', '1541572987')))))
def title(self):
"""
Returns the title of the element as unicode or 'Missing Title'
"""
return self.xml.get('title', 'Missing Title')
def sorttitle(self):
"""
Returns an item's sorting name/title or the title itself if not found
"Missing Title" if both are not present
"""
return self.xml.get('titleSort',
self.xml.get('title', 'Missing Title'))
def plex_media_streams(self):
"""
Returns the media streams directly from the PMS xml.
Mind to set self.mediastream and self.part before calling this method!
"""
try:
return self.xml[self.mediastream][self.part]
except TypeError:
# Direct Paths when we don't set mediastream and part
return self.xml[0][0]
def part_id(self):
"""
Returns the unique id of the currently active part [int]
"""
try:
return int(self.xml[self.mediastream][self.part].attrib['id'])
except TypeError:
# Direct Paths when we don't set mediastream and part
return int(self.xml[0][0].attrib['id'])
def plot(self):
"""
Returns the plot or None.
"""
return self.xml.get('summary')
def tagline(self):
"""
Returns a shorter tagline of the plot or None
"""
return self.xml.get('tagline')
def shortplot(self):
"""
Not yet implemented - returns None
"""
pass
def premiere_date(self):
"""
Returns the "originallyAvailableAt", e.g. "2018-11-16" or None
"""
return self.xml.get('originallyAvailableAt')
def kodi_premiere_date(self):
"""
Takes Plex' originallyAvailableAt of the form "yyyy-mm-dd" and returns
Kodi's "dd.mm.yyyy" or None
"""
date = self.premiere_date()
if date is None:
return
try:
date = sub(r'(\d+)-(\d+)-(\d+)', r'\3.\2.\1', date)
except Exception:
date = None
return date
def year(self):
"""
Returns the production(?) year ("year") as Unicode or None
"""
return self.xml.get('year')
def studios(self):
"""
Returns a list of the 'studio' - currently only ever 1 entry.
Or returns an empty list
"""
return [self.xml.get('studio')] if self.xml.get('studio') else []
def content_rating(self):
"""
Get the content rating or None
"""
mpaa = self.xml.get('contentRating')
if not mpaa:
return
# Convert more complex cases
if mpaa in ('NR', 'UR'):
# Kodi seems to not like NR, but will accept Rated Not Rated
mpaa = 'Rated Not Rated'
elif mpaa.startswith('gb/'):
mpaa = mpaa.replace('gb/', 'UK:', 1)
return mpaa
def rating(self):
"""
Returns the rating [float] first from 'audienceRating', if that fails
from 'rating'.
Returns 0.0 if both are not found
"""
return cast(float, self.xml.get('audienceRating',
self.xml.get('rating'))) or 0.0
def votecount(self):
"""
Not implemented by Plex yet - returns None
"""
pass
def runtime(self):
"""
Returns the total duration of the element in seconds as int.
0 if not found
"""
runtime = cast(float, self.xml.get('duration')) or 0.0
return int(runtime * v.PLEX_TO_KODI_TIMEFACTOR)
def leave_count(self):
"""
Returns the following dict or None
{
'totalepisodes': unicode('leafCount'),
'watchedepisodes': unicode('viewedLeafCount'),
'unwatchedepisodes': unicode(totalepisodes - watchedepisodes)
}
"""
try:
total = int(self.xml.attrib['leafCount'])
watched = int(self.xml.attrib['viewedLeafCount'])
return {
'totalepisodes': unicode(total),
'watchedepisodes': unicode(watched),
'unwatchedepisodes': unicode(total - watched)
}
except (KeyError, TypeError):
pass
# Stuff having to do with parent and grandparent items
######################################################
def index(self):
"""
Returns the 'index' of the element [int]. Depicts e.g. season number of
the season or the track number of the song
"""
return cast(int, self.xml.get('index'))
def show_id(self):
"""
Returns the episode's tv show's Plex id [int] or None
"""
return self.grandparent_id()
def show_title(self):
"""
Returns the episode's tv show's name/title [unicode] or None
"""
return self.grandparent_title()
def season_id(self):
"""
Returns the episode's season's Plex id [int] or None
"""
return self.parent_id()
def season_number(self):
"""
Returns the episode's season number (e.g. season '2') as an int or None
"""
return self.parent_index()
def season_name(self):
"""
Returns the season's name/title or None
"""
return self.xml.get('title')
def artist_name(self):
"""
Returns the artist name for an album: first it attempts to return
'parentTitle', if that failes 'originalTitle'
"""
return self.xml.get('parentTitle', self.xml.get('originalTitle'))
def parent_id(self):
"""
Returns the 'parentRatingKey' as int or None
"""
return cast(int, self.xml.get('parentRatingKey'))
def parent_index(self):
"""
Returns the 'parentRatingKey' as int or None
"""
return cast(int, self.xml.get('parentIndex'))
def grandparent_id(self):
"""
Returns the ratingKey for the corresponding grandparent, e.g. a TV show
for episodes, or None
"""
return cast(int, self.xml.get('grandparentRatingKey'))
def grandparent_title(self):
"""
Returns the title for the corresponding grandparent, e.g. a TV show
name for episodes, or None
"""
return self.xml.get('grandparentTitle')
def disc_number(self):
"""
Returns the song's disc number as an int or None if not found
"""
return self.parent_index()
def _scan_children(self):
"""
Ensures that we're scanning the xml's subelements only once
"""
if self._scanned_children:
return
self._scanned_children = True
cast_order = 0
for child in self.xml:
if child.tag == 'Role':
self._cast.append((child.get('tag'),
child.get('thumb'),
child.get('role'),
cast_order))
cast_order += 1
elif child.tag == 'Genre':
self._genres.append(child.get('tag'))
elif child.tag == 'Country':
self._countries.append(child.get('tag'))
elif child.tag == 'Director':
self._directors.append(child.get('tag'))
elif child.tag == 'Writer':
self._writers.append(child.get('tag'))
elif child.tag == 'Producer':
self._producers.append(child.get('tag'))
elif child.tag == 'Location':
self._locations.append(child.get('path'))
elif child.tag == 'Collection':
self._collections.append((cast(int, child.get('id')),
child.get('tag')))
elif child.tag == 'Guid':
guid = child.get('id')
guid = guid.split('://', 1)
self._guids[guid[0]] = guid[1]
elif child.tag == 'Marker' and child.get('type') == 'intro':
intro = (cast(float, child.get('startTimeOffset')),
cast(float, child.get('endTimeOffset')))
if None in intro:
# Safety net if PMS xml is not as expected
continue
intro = (intro[0] / 1000.0, intro[1] / 1000.0)
self._intro_markers.append(intro)
# Plex Movie agent (legacy) or "normal" Plex tv show agent
if not self._guids:
guid = self.xml.get('guid')
if not guid:
return
for provider, regex in METADATA_PROVIDERS:
provider_id = regex.findall(guid)
try:
self._guids[provider] = provider_id[0]
except IndexError:
pass
else:
# There will only ever be one entry
break
def cast(self):
"""
Returns a list of tuples of the cast:
[(<name of actor [unicode]>,
<thumb url [unicode, may be None]>,
<role [unicode, may be None]>,
<order of appearance [int]>)]
"""
self._scan_children()
return self._cast
def genres(self):
"""
Returns a list of genres found
"""
self._scan_children()
return self._genres
def countries(self):
"""
Returns a list of all countries
"""
self._scan_children()
return self._countries
def directors(self):
"""
Returns a list of all directors
"""
self._scan_children()
return self._directors
def writers(self):
"""
Returns a list of all writers
"""
self._scan_children()
return self._writers
def producers(self):
"""
Returns a list of all producers
"""
self._scan_children()
return self._producers
def tv_show_path(self):
"""
Returns the direct path to the TV show, e.g. '\\NAS\tv\series'
or None
"""
self._scan_children()
if self._locations:
return self._locations[0]
def collections(self):
"""
Returns a list of tuples of the collection id and tags or an empty list
[(<collection id 1>, <collection name 1>), ...]
"""
self._scan_children()
return self._collections
def people(self):
"""
Returns a dict with lists of tuples:
{
'actor': [(<name of actor [unicode]>,
<thumb url [unicode, may be None]>,
<role [unicode, may be None]>,
<order of appearance [int]>)]
'director': [..., (<name>, ), ...],
'writer': [..., (<name>, ), ...]
}
Everything in unicode, except <cast order> which is an int.
Only <art-url> and <role> may be None if not found.
"""
self._scan_children()
return {
'actor': self._cast,
'director': [(x, ) for x in self._directors],
'writer': [(x, ) for x in self._writers]
}
def extras(self):
"""
Returns an iterator for etree elements for each extra, e.g. trailers
Returns None if no extras are found
"""
extras = self.xml.find('Extras')
if extras is None:
return
return (x for x in extras)
def trailer(self):
"""
Returns the URL for a single trailer (local trailer preferred; first
trailer found returned) or an add-on path to list all Plex extras
if the user setting showExtrasInsteadOfTrailer is set.
Returns None if nothing is found.
"""
url = None
for extras in self.xml.iterfind('Extras'):
# There will always be only 1 extras element
if (len(extras) > 0 and
app.SYNC.show_extras_instead_of_playing_trailer):
return ('plugin://%s?mode=route_to_extras&plex_id=%s'
% (v.ADDON_ID, self.plex_id))
for extra in extras:
typus = cast(int, extra.get('extraType'))
if typus != 1:
# Skip non-trailers
continue
if extra.get('guid', '').startswith('file:'):
url = extra.get('ratingKey')
# Always prefer local trailers (first one listed)
break
elif not url:
url = extra.get('ratingKey')
if url:
url = ('plugin://%s.movies/?plex_id=%s&plex_type=%s&mode=play'
% (v.ADDON_ID, url, v.PLEX_TYPE_CLIP))
return url
def listitem(self, listitem=xbmcgui.ListItem, resume=True):
"""
Returns a xbmcgui.ListItem() (or PKCListItem) for this Plex element
Pass resume=False in order to NOT set a resume point (but let Kodi
automatically handle it)
"""
item = widgets.generate_item(self)
if not resume and 'resume' in item:
del item['resume']
item = widgets.prepare_listitem(item)
return widgets.create_listitem(item, as_tuple=False, listitem=listitem)
def collections_match(self, section_id):
"""
Downloads one additional xml from the PMS in order to return a list of
tuples [(collection_id, plex_id), ...] for all collections of the
current item's Plex library sectin
Pass in the collection id of e.g. the movie's metadata
"""
if self._coll_match is None:
self._coll_match = PF.collections(section_id)
if self._coll_match is None:
LOG.error('Could not download collections for %s',
self.library_section_id())
self._coll_match = []
self._coll_match = \
[(utils.cast(int, x.get('index')),
utils.cast(int, x.get('ratingKey'))) for x in self._coll_match]
return self._coll_match
@staticmethod
def attach_plex_token_to_url(url):
"""
Returns an extended URL with the Plex token included as 'X-Plex-Token='
url may or may not already contain a '?'
"""
if not app.ACCOUNT.pms_token:
return url
if '?' not in url:
return "%s?X-Plex-Token=%s" % (url, app.ACCOUNT.pms_token)
else:
return "%s&X-Plex-Token=%s" % (url, app.ACCOUNT.pms_token)
@staticmethod
def list_to_string(input_list):
"""
Concatenates input_list (list of unicodes) with a separator ' / '
Returns None if the list was empty
"""
return ' / '.join(input_list) or None

View file

@ -1,182 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from re import sub
from string import punctuation
from ..downloadutils import DownloadUtils as DU
from .. import utils, variables as v
LOG = getLogger('PLEX.api.fanartlookup')
API_KEY = utils.settings('themoviedbAPIKey')
# How far apart can a video's airing date be (in years)
YEARS_APART = 1
# levenshtein_distance_ratio() returns a value between 0 (no match) and 1 (full
# match). What's the threshold?
LEVENSHTEIN_RATIO_THRESHOLD = 0.95
# Which character should we ignore when matching video titles?
EXCLUDE_CHARS = set(punctuation)
def external_item_id(title, year, plex_type, collection):
LOG.debug('Start identifying %s (%s, %s)', title, year, plex_type)
year = int(year) if year else None
media_type = 'tv' if plex_type == v.PLEX_TYPE_SHOW else plex_type
# if the title has the year in remove it as tmdb cannot deal with it...
# replace e.g. 'The Americans (2015)' with 'The Americans'
title = sub(r'\s*\(\d{4}\)$', '', title, count=1)
url = 'https://api.themoviedb.org/3/search/%s' % media_type
parameters = {
'api_key': API_KEY,
'language': v.KODILANGUAGE,
'query': title.encode('utf-8')
}
data = DU().downloadUrl(url,
authenticate=False,
parameters=parameters,
timeout=7)
try:
data = data['results']
except (AttributeError, KeyError, TypeError):
LOG.debug('No match found on themoviedb for %s (%s, %s)',
title, year, media_type)
return
LOG.debug('themoviedb returned results: %s', data)
# Some entries don't contain a title or id - get rid of them
data = [x for x in data if 'title' in x and 'id' in x]
# Get rid of all results that do NOT have a matching release year
if year:
data = [x for x in data if __year_almost_matches(year, x)]
if not data:
LOG.debug('Empty results returned by themoviedb for %s (%s, %s)',
title, year, media_type)
return
# Calculate how similar the titles are
title = sanitize_string(title)
for entry in data:
entry['match_score'] = levenshtein_distance_ratio(
sanitize_string(entry['title']), title)
# (one of the possibly many) best match using levenshtein distance ratio
entry = max(data, key=lambda x: x['match_score'])
if entry['match_score'] < LEVENSHTEIN_RATIO_THRESHOLD:
LOG.debug('Best themoviedb match not good enough: %s', entry)
return
# Check if we got several matches. If so, take the most popular one
best_matches = [x for x in data if
x['match_score'] == entry['match_score']
and 'popularity' in x]
entry = max(best_matches, key=lambda x: x['popularity'])
LOG.debug('Found themoviedb match: %s', entry)
# lookup external tmdb_id and perform artwork lookup on fanart.tv
tmdb_id = entry.get('id')
parameters = {'api_key': API_KEY}
if media_type == 'movie':
url = 'https://api.themoviedb.org/3/movie/%s' % tmdb_id
parameters['append_to_response'] = 'videos'
elif media_type == 'tv':
url = 'https://api.themoviedb.org/3/tv/%s' % tmdb_id
parameters['append_to_response'] = 'external_ids,videos'
media_id, poster, background = None, None, None
for language in (v.KODILANGUAGE, 'en'):
parameters['language'] = language
data = DU().downloadUrl(url,
authenticate=False,
parameters=parameters,
timeout=7)
try:
data.get('test')
except AttributeError:
LOG.warning('Could not download %s with parameters %s',
url, parameters)
continue
if collection is False:
if data.get('imdb_id'):
media_id = str(data.get('imdb_id'))
break
if (data.get('external_ids') and
data['external_ids'].get('tvdb_id')):
media_id = str(data['external_ids']['tvdb_id'])
break
else:
if not data.get('belongs_to_collection'):
continue
media_id = data.get('belongs_to_collection').get('id')
if not media_id:
continue
media_id = str(media_id)
LOG.debug('Retrieved collections tmdb id %s for %s',
media_id, title)
url = 'https://api.themoviedb.org/3/collection/%s' % media_id
data = DU().downloadUrl(url,
authenticate=False,
parameters=parameters,
timeout=7)
try:
data.get('poster_path')
except AttributeError:
LOG.debug('Could not find TheMovieDB poster paths for %s'
' in the language %s', title, language)
continue
if not poster and data.get('poster_path'):
poster = ('https://image.tmdb.org/t/p/original%s' %
data.get('poster_path'))
if not background and data.get('backdrop_path'):
background = ('https://image.tmdb.org/t/p/original%s' %
data.get('backdrop_path'))
return media_id, poster, background
def __year_almost_matches(year, entry):
try:
entry_year = int(entry['release_date'][0:4])
except (KeyError, ValueError):
return True
return abs(year - entry_year) <= YEARS_APART
def sanitize_string(s):
s = s.lower().strip()
# Get rid of chars in EXCLUDE_CHARS
s = ''.join(character for character in s if character not in EXCLUDE_CHARS)
# Get rid of multiple spaces
s = ' '.join(s.split())
return s
def levenshtein_distance_ratio(s, t):
"""
Calculates levenshtein distance ratio between two strings.
The more similar the strings, the closer the result will be to 1.
The farther disjunct the string, the closer the result to 0
https://www.datacamp.com/community/tutorials/fuzzy-string-python
"""
# Initialize matrix of zeros
rows = len(s) + 1
cols = len(t) + 1
distance = [[0 for x in range(cols)] for y in range(rows)]
# Populate matrix of zeros with the indeces of each character of both strings
for i in range(1, rows):
for k in range(1,cols):
distance[i][0] = i
distance[0][k] = k
# Iterate over the matrix to compute the cost of deletions,insertions and/or substitutions
for col in range(1, cols):
for row in range(1, rows):
if s[row-1] == t[col-1]:
cost = 0 # If the characters are the same in the two strings in a given position [i,j] then the cost is 0
else:
# In order to align the results with those of the Python Levenshtein package, if we choose to calculate the ratio
# the cost of a substitution is 2. If we calculate just distance, then the cost of a substitution is 1.
cost = 2
distance[row][col] = min(distance[row-1][col] + 1, # Cost of deletions
distance[row][col-1] + 1, # Cost of insertions
distance[row-1][col-1] + cost) # Cost of substitutions
return ((len(s)+len(t)) - distance[row][col]) / (len(s)+len(t))

View file

@ -1,207 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from .. import utils, variables as v, app
def _transcode_image_path(key, AuthToken, path, width, height):
"""
Transcode Image support
parameters:
key
AuthToken
path - source path of current XML: path[srcXML]
width
height
result:
final path to image file
"""
# external address - can we get a transcoding request for external images?
if key.startswith('http'):
path = key
elif key.startswith('/'): # internal full path.
path = 'http://127.0.0.1:32400' + key
else: # internal path, add-on
path = 'http://127.0.0.1:32400' + path + '/' + key
# This is bogus (note the extra path component) but ATV is stupid when it
# comes to caching images, it doesn't use querystrings. Fortunately PMS is
# lenient...
transcode_path = ('/photo/:/transcode/%sx%s/%s'
% (width, height, utils.quote_plus(path)))
args = {
'width': width,
'height': height,
'url': path
}
if AuthToken:
args['X-Plex-Token'] = AuthToken
return utils.extend_url(transcode_path, args)
class File(object):
def fullpath(self, force_first_media=True, force_addon=False,
direct_paths=None, omit_check=False, force_check=False):
"""
Returns a "fully qualified path" add-on paths or direct paths
depending on the current settings as the tupple
(fullpath, path, filename)
as unicode. Add-on paths are returned as a fallback. Returns None
if something went wrong.
firce_first_media=False prompts the user to choose which version of the
media should be returned, if several are present
force_addon=True will always return the add-on path
direct_path=True if you're calling from another Plex python
instance - because otherwise direct paths will
evaluate to False!
"""
direct_paths = app.SYNC.direct_paths if direct_paths is None \
else direct_paths
if (not direct_paths or force_addon or
self.plex_type == v.PLEX_TYPE_CLIP):
if self.plex_type == v.PLEX_TYPE_SONG:
return self._music_addon_paths(force_first_media)
if self.plex_type == v.PLEX_TYPE_EPISODE:
# need to include the plex show id in the path
path = ('plugin://plugin.video.plexkodiconnect.tvshows/%s/'
% self.grandparent_id())
else:
path = 'plugin://%s/' % v.ADDON_TYPE[self.plex_type]
# Filename in Kodi will end with actual filename - hopefully
# this is useful for other add-ons
filename = self.file_path(force_first_media=force_first_media)
if filename:
if '/' in filename:
filename = filename.rsplit('/', 1)[1]
else:
filename = filename.rsplit('\\', 1)[1]
entirepath = ('%s?mode=play&plex_id=%s&plex_type=%s&filename=%s'
% (path, self.plex_id, self.plex_type, filename))
else:
# E.g. clips or albums
entirepath = ('%s?mode=play&plex_id=%s&plex_type=%s'
% (path, self.plex_id, self.plex_type))
# For Kodi DB, we need to safe the ENTIRE path for filenames
filename = entirepath
else:
entirepath = self.validate_playurl(
self.file_path(force_first_media=force_first_media),
self.plex_type,
force_check=force_check,
omit_check=omit_check)
try:
if '/' in entirepath:
filename = entirepath.rsplit('/', 1)[1]
else:
filename = entirepath.rsplit('\\', 1)[1]
except (TypeError, IndexError):
# Fallback to add-on paths
return self.fullpath(force_first_media=force_first_media,
force_addon=True)
path = utils.rreplace(entirepath, filename, "", 1)
return entirepath, path, filename
def _music_addon_paths(self, force_first_media):
"""
For songs only. Normal add-on paths plugin://... don't work with the
Kodi music DB, hence use a "direct" url to the music file on the PMS.
"""
if self.mediastream is None and force_first_media is False:
if self.mediastream_number() is None:
return
streamno = 0 if force_first_media else self.mediastream
entirepath = "%s%s" % (app.CONN.server,
self.xml[streamno][self.part].get('key'))
entirepath = self.attach_plex_token_to_url(entirepath)
path, filename = entirepath.rsplit('/', 1)
return entirepath, path + '/', filename
def directory_path(self, section_id=None, plex_type=None, old_key=None,
synched=True):
key = self.xml.get('fastKey')
if not key:
key = self.xml.get('key')
if old_key:
key = '%s/%s' % (old_key, key)
elif not key.startswith('/'):
key = '/library/sections/%s/%s' % (section_id, key)
params = {
'mode': 'browseplex',
'key': key
}
if plex_type or self.plex_type:
params['plex_type'] = plex_type or self.plex_type
if not synched:
# No item to be found in the Kodi DB
params['synched'] = 'false'
if self.xml.get('prompt'):
# User input needed, e.g. search for a movie or episode
params['prompt'] = self.xml.get('prompt')
if section_id:
params['id'] = section_id
return utils.extend_url('plugin://%s/' % v.ADDON_ID, params)
def file_name(self, force_first_media=False):
"""
Returns only the filename, e.g. 'movie.mkv' as unicode or None if not
found
"""
ans = self.file_path(force_first_media=force_first_media)
if ans is None:
return
if "\\" in ans:
# Local path
filename = ans.rsplit("\\", 1)[1]
else:
try:
# Network share
filename = ans.rsplit("/", 1)[1]
except IndexError:
# E.g. certain Plex channels
filename = None
return filename
def file_path(self, force_first_media=False):
"""
Returns the direct path to this item, e.g. '\\NAS\movies\movie.mkv'
as unicode or None
force_first_media=True:
will always use 1st media stream, e.g. when several different
files are present for the same PMS item
"""
if self.mediastream is None and force_first_media is False:
if self.mediastream_number() is None:
return
try:
if force_first_media is False:
ans = self.xml[self.mediastream][self.part].attrib['file']
else:
ans = self.xml[0][self.part].attrib['file']
except (TypeError, AttributeError, IndexError, KeyError):
return
return ans
def get_picture_path(self):
"""
Returns the item's picture path (transcode, if necessary) as string.
Will always use addon paths, never direct paths
"""
path = self.xml[0][0].get('key')
extension = path[path.rfind('.'):].lower()
if app.SYNC.force_transcode_pix or extension not in v.KODI_SUPPORTED_IMAGES:
# Let Plex transcode
# max width/height supported by plex image transcoder is 1920x1080
path = app.CONN.server + _transcode_image_path(
path,
app.ACCOUNT.pms_token,
"%s%s" % (app.CONN.server, path),
1920,
1080)
else:
path = self.attach_plex_token_to_url('%s%s' % (app.CONN.server, path))
# Attach Plex id to url to let it be picked up by our playqueue agent
# later
return '%s&plex_id=%s' % (path, self.plex_id)

View file

@ -1,407 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from ..utils import cast
from ..downloadutils import DownloadUtils as DU
from .. import utils, variables as v, app, path_ops, clientinfo
from .. import plex_functions as PF
LOG = getLogger('PLEX.api')
class Media(object):
def optimized_for_streaming(self):
"""
Returns True if the item's 'optimizedForStreaming' is set, False other-
wise
"""
return cast(bool, self.xml[0].get('optimizedForStreaming')) or False
def _from_part_or_media(self, key):
"""
Retrieves XML data 'key' first from the active part. If unsuccessful,
tries to retrieve the data from the Media response part.
If all fails, None is returned.
"""
return self.xml[0][self.part].get(key, self.xml[0].get(key))
def intro_markers(self):
"""
Returns a list of tuples with floats (startTimeOffset, endTimeOffset)
in Koditime or an empty list.
Each entry represents an (episode) intro that Plex detected and that
can be skipped
"""
self._scan_children()
return self._intro_markers
def video_codec(self):
"""
Returns the video codec and resolution for the child and part selected.
If any data is not found on a part-level, the Media-level data is
returned.
If that also fails (e.g. for old trailers, None is returned)
Output:
{
'videocodec': xxx, e.g. 'h264'
'resolution': xxx, e.g. '720' or '1080'
'height': xxx, e.g. '816'
'width': xxx, e.g. '1920'
'aspectratio': xxx, e.g. '1.78'
'bitrate': xxx, e.g. '10642'
'container': xxx e.g. 'mkv',
'bitDepth': xxx e.g. '8', '10'
}
"""
answ = {
'videocodec': self._from_part_or_media('videoCodec'),
'resolution': self._from_part_or_media('videoResolution'),
'height': self._from_part_or_media('height'),
'width': self._from_part_or_media('width'),
'aspectratio': self._from_part_or_media('aspectratio'),
'bitrate': self._from_part_or_media('bitrate'),
'container': self._from_part_or_media('container'),
}
try:
answ['bitDepth'] = self.xml[0][self.part][self.mediastream].get('bitDepth')
except (TypeError, AttributeError, KeyError, IndexError):
answ['bitDepth'] = None
return answ
def picture_codec(self):
"""
Returns the exif metadata of pictures. This does NOT seem to be used
reliably by Kodi skins! (e.g. not at all)
"""
return {
'exif:CameraMake': self.xml[0].get('make'), # e.g. 'Canon'
'exif:CameraModel': self.xml[0].get('model'), # e.g. 'Canon XYZ'
'exif:DateTime': self.xml.get('originallyAvailableAt', '').replace('-', ':') or None, # e.g. '2017-11-05'
'exif:Height': self.xml[0].get('height'), # e.g. '2160'
'exif:Width': self.xml[0].get('width'), # e.g. '3240'
'exif:Orientation': self.xml[0][self.part].get('orientation'), # e.g. '1'
'exif:FocalLength': self.xml[0].get('focalLength'), # TO BE VALIDATED
'exif:ExposureTime': self.xml[0].get('exposure'), # e.g. '1/1000'
'exif:ApertureFNumber': self.xml[0].get('aperture'), # e.g. 'f/5.0'
'exif:ISOequivalent': self.xml[0].get('iso'), # e.g. '1600'
# missing on Kodi side: lens, e.g. "EF50mm f/1.8 II"
}
def mediastreams(self):
"""
Returns the media streams for metadata purposes
Output: each track contains a dictionaries
{
'video': videotrack-list, 'codec', 'height', 'width',
'aspect', 'video3DFormat'
'audio': audiotrack-list, 'codec', 'channels',
'language'
'subtitle': list of subtitle languages (or "Unknown")
}
"""
videotracks = []
audiotracks = []
subtitlelanguages = []
try:
# Sometimes, aspectratio is on the "toplevel"
aspect = cast(float, self.xml[0].get('aspectRatio'))
except IndexError:
# There is no stream info at all, returning empty
return {
'video': videotracks,
'audio': audiotracks,
'subtitle': subtitlelanguages
}
# Loop over parts
for child in self.xml[0]:
container = child.get('container')
# Loop over Streams
for stream in child:
media_type = int(stream.get('streamType', 999))
track = {}
if media_type == 1: # Video streams
if 'codec' in stream.attrib:
track['codec'] = stream.get('codec').lower()
if "msmpeg4" in track['codec']:
track['codec'] = "divx"
elif "mpeg4" in track['codec']:
pass
elif "h264" in track['codec']:
if container in ("mp4", "mov", "m4v"):
track['codec'] = "avc1"
track['height'] = cast(int, stream.get('height'))
track['width'] = cast(int, stream.get('width'))
# track['Video3DFormat'] = item.get('Video3DFormat')
track['aspect'] = cast(float,
stream.get('aspectRatio') or aspect)
track['duration'] = self.runtime()
track['video3DFormat'] = None
videotracks.append(track)
elif media_type == 2: # Audio streams
if 'codec' in stream.attrib:
track['codec'] = stream.get('codec').lower()
if ("dca" in track['codec'] and
"ma" in stream.get('profile', '').lower()):
track['codec'] = "dtshd_ma"
track['channels'] = cast(int, stream.get('channels'))
# 'unknown' if we cannot get language
track['language'] = stream.get('languageCode',
utils.lang(39310).lower())
audiotracks.append(track)
elif media_type == 3: # Subtitle streams
# 'unknown' if we cannot get language
subtitlelanguages.append(
stream.get('languageCode', utils.lang(39310)).lower())
return {
'video': videotracks,
'audio': audiotracks,
'subtitle': subtitlelanguages
}
def mediastream_number(self):
"""
Returns the Media stream as an int (mostly 0). Will let the user choose
if several media streams are present for a PMS item (if settings are
set accordingly)
Returns None if the user aborted selection (leaving self.mediastream at
its default of None)
"""
# How many streams do we have?
count = 0
for entry in self.xml.iterfind('./Media'):
count += 1
if (count > 1 and (
(self.plex_type != v.PLEX_TYPE_CLIP and
utils.settings('firstVideoStream') == 'false')
or
(self.plex_type == v.PLEX_TYPE_CLIP and
utils.settings('bestTrailer') == 'false'))):
# Several streams/files available.
dialoglist = []
for entry in self.xml.iterfind('./Media'):
# Get additional info (filename / languages)
if 'file' in entry[0].attrib:
option = entry[0].get('file')
option = path_ops.basename(option)
else:
option = self.title() or ''
# Languages of audio streams
languages = []
for stream in entry[0]:
if (cast(int, stream.get('streamType')) == 1 and
'language' in stream.attrib):
language = stream.get('language')
languages.append(language)
languages = ', '.join(languages)
if languages:
if option:
option = '%s (%s): ' % (option, languages)
else:
option = '%s: ' % languages
else:
option = '%s ' % option
if 'videoResolution' in entry.attrib:
res = entry.get('videoResolution')
option = '%s%sp ' % (option, res)
if 'videoCodec' in entry.attrib:
codec = entry.get('videoCodec')
option = '%s%s' % (option, codec)
option = option.strip() + ' - '
if 'audioProfile' in entry.attrib:
profile = entry.get('audioProfile')
option = '%s%s ' % (option, profile)
if 'audioCodec' in entry.attrib:
codec = entry.get('audioCodec')
option = '%s%s ' % (option, codec)
option = cast(str, option.strip())
dialoglist.append(option)
media = utils.dialog('select', 'Select stream', dialoglist)
LOG.info('User chose media stream number: %s', media)
if media == -1:
LOG.info('User cancelled media stream selection')
return
else:
media = 0
self.mediastream = media
return media
def transcode_video_path(self, action, quality=None):
"""
To be called on a VIDEO level of PMS xml response!
Transcode Video support; returns the URL to get a media started
Input:
action 'DirectPlay'
'DirectStream'
'Transcode'
quality: {
'videoResolution': e.g. '1024x768',
'videoQuality': e.g. '60',
'maxVideoBitrate': e.g. '2000' (in kbits)
}
(one or several of these options)
Output:
final URL to pull in PMS transcoder
TODO: mediaIndex
"""
if self.mediastream is None and self.mediastream_number() is None:
return
headers = clientinfo.getXArgsDeviceInfo()
if action == v.PLAYBACK_METHOD_DIRECT_PLAY:
path = self.xml[self.mediastream][self.part].get('key')
# e.g. Trailers already feature an '?'!
return utils.extend_url(app.CONN.server + path, headers)
# Direct Streaming and Transcoding
arguments = PF.transcoding_arguments(path=self.path_and_plex_id(),
media=self.mediastream,
part=self.part,
playmethod=action,
args=quality)
headers.update(arguments)
# Path/key to VIDEO item of xml PMS response is needed, not part
path = self.xml.get('key')
transcode_path = app.CONN.server + \
'/video/:/transcode/universal/start.m3u8'
return utils.extend_url(transcode_path, headers)
def cache_external_subs(self):
"""
Downloads external subtitles temporarily to Kodi and returns a list
of their paths
"""
externalsubs = []
try:
mediastreams = self.xml[0][self.part]
except (TypeError, KeyError, IndexError):
return externalsubs
for stream in mediastreams:
# Since plex returns all possible tracks together, have to pull
# only external subtitles - only for these a 'key' exists
if int(stream.get('streamType')) != 3 or 'key' not in stream.attrib:
# Not a subtitle or not not an external subtitle
continue
try:
path = self.download_external_subtitles(
'{server}%s' % stream.get('key'),
stream.get('displayTitle'),
stream.get('codec'))
except IOError:
# Catch "IOError: [Errno 22] invalid mode ('wb') or filename"
# Due to stream.get('displayTitle') returning chars that our
# OS is not supporting, e.g. "српски језик (SRT External)"
path = self.download_external_subtitles(
'{server}%s' % stream.get('key'),
stream.get('languageCode', 'Unknown'),
stream.get('codec'))
if path:
externalsubs.append(path)
LOG.info('Found external subs: %s', externalsubs)
return externalsubs
@staticmethod
def download_external_subtitles(url, filename, extension):
"""
One cannot pass the subtitle language for ListItems. Workaround; will
download the subtitle at url to the Kodi PKC directory in a temp dir
Returns the path to the downloaded subtitle or None
"""
path = path_ops.create_unique_path(v.EXTERNAL_SUBTITLE_TEMP_PATH,
filename,
extension)
response = DU().downloadUrl(url, return_response=True)
if not response.ok:
LOG.error('Could not temporarily download subtitle %s', url)
LOG.error('HTTP status: %s, message: %s',
response.status_code, response.text)
return
LOG.debug('Writing temp subtitle to %s', path)
with open(path_ops.encode_path(path), 'wb') as f:
f.write(response.content)
return path
def validate_playurl(self, path, typus, force_check=False, folder=False,
omit_check=False):
"""
Returns a valid path for Kodi, e.g. with '\' substituted to '\\' in
Unicode. Returns None if this is not possible
path : Unicode
typus : Plex type from PMS xml
force_check : Will always try to check validity of path
Will also skip confirmation dialog if path not found
folder : Set to True if path is a folder
omit_check : Will entirely omit validity check if True
"""
if path is None:
return
typus = v.REMAP_TYPE_FROM_PLEXTYPE[typus]
if app.SYNC.remap_path:
path = path.replace(getattr(app.SYNC, 'remapSMB%sOrg' % typus),
getattr(app.SYNC, 'remapSMB%sNew' % typus),
1)
# There might be backslashes left over:
path = path.replace('\\', '/')
elif app.SYNC.replace_smb_path:
if path.startswith('\\\\'):
path = 'smb:' + path.replace('\\', '/')
if app.SYNC.escape_path:
path = utils.escape_path(path, app.SYNC.escape_path_safe_chars)
if (app.SYNC.path_verified and not force_check) or omit_check:
return path
# exist() needs a / or \ at the end to work for directories
if not folder:
# files
check = path_ops.exists(path)
else:
# directories
if "\\" in path:
if not path.endswith('\\'):
# Add the missing backslash
check = path_ops.exists(path + "\\")
else:
check = path_ops.exists(path)
else:
if not path.endswith('/'):
check = path_ops.exists(path + "/")
else:
check = path_ops.exists(path)
if not check:
if force_check is False:
# Validate the path is correct with user intervention
if self.ask_to_validate(path):
app.APP.stop_threads(block=False)
path = None
app.SYNC.path_verified = True
else:
path = None
elif not force_check:
# Only set the flag if we were not force-checking the path
app.SYNC.path_verified = True
return path
@staticmethod
def ask_to_validate(url):
"""
Displays a YESNO dialog box:
Kodi can't locate file: <url>. Please verify the path.
You may need to verify your network credentials in the
add-on settings or use different Plex paths. Stop syncing?
Returns True if sync should stop, else False
"""
LOG.warn('Cannot access file: %s', url)
# Kodi cannot locate the file #s. Please verify your PKC settings. Stop
# syncing?
return utils.yesno_dialog(utils.lang(29999), utils.lang(39031) % url)

View file

@ -1,107 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from ..utils import cast
class Playback(object):
def decision_code(self):
"""
Returns the general_play_decision_code or mde_play_decision_code if
not available. Returns None if something went wrong
"""
return self.general_play_decision_code() or self.mde_play_decision_code()
def general_play_decision_code(self):
"""
Returns the 'generalDecisionCode' as an int or None
Generally, the 1xxx codes constitute a a success decision, 2xxx a
general playback error, 3xxx a direct play error, and 4xxx a transcode
error.
General decisions can include:
1000: Direct play OK.
1001: Direct play not available; Conversion OK.
2000: Neither direct play nor conversion is available.
2001: Not enough bandwidth for any playback of this item.
2002: Number of allowed streams has been reached. Stop a playback or ask
admin for more permissions.
2003: File is unplayable.
2004: Streaming Session doesnt exist or timed out.
2005: Client stopped playback.
2006: Admin Terminated Playback.
"""
return cast(int, self.xml.get('generalDecisionCode'))
def general_play_decision_text(self):
"""
Returns the text associated with the general_play_decision_code() as
text in unicode or None
"""
return self.xml.get('generalDecisionText')
def mde_play_decision_code(self):
return cast(int, self.xml.get('mdeDecisionCode'))
def mde_play_decision_text(self):
"""
Returns the text associated with the mde_play_decision_code() as
text in unicode or None
"""
return self.xml.get('mdeDecisionText')
def direct_play_decision_code(self):
return cast(int, self.xml.get('directPlayDecisionCode'))
def direct_play_decision_text(self):
"""
Returns the text associated with the mde_play_decision_code() as
text in unicode or None
"""
return self.xml.get('directPlayDecisionText')
def transcode_decision_code(self):
return cast(int, self.xml.get('directPlayDecisionCode'))
def transcode_decision_text(self):
"""
Returns the text associated with the mde_play_decision_code() as
text in unicode or None
"""
return self.xml.get('directPlayDecisionText')
def video_decision(self):
"""
Returns "copy" if PMS streaming brain decided to DirectStream, so copy
an existing video stream into a new container. Returns "transcode" if
the video stream will be transcoded.
Raises IndexError if something went wrong. Might also return None
"""
for stream in self.xml[0][0][0]:
if stream.get('streamType') == '1':
return stream.get('decision')
def audio_decision(self):
"""
Returns "copy" if PMS streaming brain decided to DirectStream, so copy
an existing audio stream into a new container. Returns "transcode" if
the audio stream will be transcoded.
Raises IndexError if something went wrong. Might also return None
"""
for stream in self.xml[0][0][0]:
if stream.get('streamType') == '2':
return stream.get('decision')
def subtitle_decision(self):
"""
Returns the PMS' decision on the subtitle stream.
Raises IndexError if something went wrong. Might also return None
"""
for stream in self.xml[0][0][0]:
if stream.get('streamType') == '3':
return stream.get('decision')

View file

@ -1,59 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from ..utils import cast
from .. import timing, variables as v, app
class User(object):
def viewcount(self):
"""
Returns the play count for the item as an int or the int 0 if not found
"""
return cast(int, self.xml.get('viewCount')) or 0
def resume_point(self):
"""
Returns the resume point of time in seconds as float. 0.0 if not found
"""
resume = cast(float, self.xml.get('viewOffset')) or 0.0
return resume * v.PLEX_TO_KODI_TIMEFACTOR
def resume_point_plex(self):
"""
Returns the resume point of time in microseconds as float.
0.0 if not found
"""
return cast(float, self.xml.get('viewOffset')) or 0.0
def userrating(self):
"""
Returns the userRating [int].
If the user chose to replace user ratings with the number of different
file versions for a specific video, that number is returned instead
(at most 10)
0 is returned if something goes wrong
"""
if (app.SYNC.indicate_media_versions is True and
self.plex_type in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_EPISODE)):
userrating = 0
for _ in self.xml.findall('./Media'):
userrating += 1
# Don't show a value of '1' - which we'll always have for normal
# Plex library items
return 0 if userrating == 1 else min(userrating, 10)
else:
return cast(int, self.xml.get('userRating')) or 0
def lastplayed(self):
"""
Returns the Kodi timestamp [unicode] for the last point of time, when
this item was played.
Returns None if this fails - item has never been played
"""
try:
return timing.plex_date_to_kodi(int(self.xml.get('lastViewedAt')))
except TypeError:
pass

View file

@ -1,14 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from .common import PlexDBBase, initialize, wipe, PLEXDB_LOCK
from .tvshows import TVShows
from .movies import Movies
from .music import Music
from .playlists import Playlists
from .sections import Sections
class PlexDB(PlexDBBase, TVShows, Movies, Music, Playlists, Sections):
pass

View file

@ -1,327 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from threading import Lock
from .. import db, variables as v
PLEXDB_LOCK = Lock()
SUPPORTED_KODI_TYPES = (
v.KODI_TYPE_MOVIE,
v.KODI_TYPE_SHOW,
v.KODI_TYPE_SEASON,
v.KODI_TYPE_EPISODE,
v.KODI_TYPE_ARTIST,
v.KODI_TYPE_ALBUM,
v.KODI_TYPE_SONG
)
class PlexDBBase(object):
"""
Plex database methods used for all types of items.
"""
def __init__(self, plexconn=None, lock=True, copy=False):
# Allows us to use this class with a cursor instead of context mgr
self.plexconn = plexconn
self.cursor = self.plexconn.cursor() if self.plexconn else None
self.lock = lock
self.copy = copy
def __enter__(self):
if self.lock:
PLEXDB_LOCK.acquire()
self.plexconn = db.connect('plex-copy' if self.copy else 'plex')
self.cursor = self.plexconn.cursor()
return self
def __exit__(self, e_typ, e_val, trcbak):
try:
if e_typ:
# re-raise any exception
return False
self.plexconn.commit()
finally:
self.plexconn.close()
if self.lock:
PLEXDB_LOCK.release()
def is_recorded(self, plex_id, plex_type):
"""
FAST method to check whether a plex_id has already been recorded
"""
self.cursor.execute('SELECT plex_id FROM %s WHERE plex_id = ?' % plex_type,
(plex_id, ))
return self.cursor.fetchone() is not None
def item_by_id(self, plex_id, plex_type=None):
"""
Returns the item for plex_id or None.
Supply with the correct plex_type to speed up lookup
"""
answ = None
if plex_type == v.PLEX_TYPE_MOVIE:
answ = self.movie(plex_id)
elif plex_type == v.PLEX_TYPE_EPISODE:
answ = self.episode(plex_id)
elif plex_type == v.PLEX_TYPE_SHOW:
answ = self.show(plex_id)
elif plex_type == v.PLEX_TYPE_SEASON:
answ = self.season(plex_id)
elif plex_type == v.PLEX_TYPE_SONG:
answ = self.song(plex_id)
elif plex_type == v.PLEX_TYPE_ALBUM:
answ = self.album(plex_id)
elif plex_type == v.PLEX_TYPE_ARTIST:
answ = self.artist(plex_id)
elif plex_type in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_PHOTO, v.PLEX_TYPE_PLAYLIST):
# Will never be synched to Kodi
pass
elif plex_type is None:
# SLOW - lookup plex_id in all our tables
for kind in (v.PLEX_TYPE_MOVIE,
v.PLEX_TYPE_EPISODE,
v.PLEX_TYPE_SHOW,
v.PLEX_TYPE_SEASON,
'song', # darn
v.PLEX_TYPE_ALBUM,
v.PLEX_TYPE_ARTIST):
method = getattr(self, kind)
answ = method(plex_id)
if answ:
break
return answ
def item_by_kodi_id(self, kodi_id, kodi_type):
"""
"""
if kodi_type not in SUPPORTED_KODI_TYPES:
return
self.cursor.execute('SELECT * from %s WHERE kodi_id = ? LIMIT 1'
% v.PLEX_TYPE_FROM_KODI_TYPE[kodi_type],
(kodi_id, ))
method = getattr(self, 'entry_to_%s' % v.PLEX_TYPE_FROM_KODI_TYPE[kodi_type])
return method(self.cursor.fetchone())
def plex_id_by_last_sync(self, plex_type, last_sync, limit):
"""
Returns an iterator for all items where the last_sync is NOT identical
"""
query = '''
SELECT plex_id FROM %s WHERE last_sync <> ? LIMIT %s
''' % (plex_type, limit)
return (x[0] for x in self.cursor.execute(query, (last_sync, )))
def checksum(self, plex_id, plex_type):
"""
Returns the checksum for plex_id
"""
self.cursor.execute('SELECT checksum FROM %s WHERE plex_id = ? LIMIT 1' % plex_type,
(plex_id, ))
try:
return self.cursor.fetchone()[0]
except TypeError:
pass
def update_last_sync(self, plex_id, plex_type, last_sync):
"""
Sets a new timestamp for plex_id
"""
self.cursor.execute('UPDATE %s SET last_sync = ? WHERE plex_id = ?' % plex_type,
(last_sync, plex_id))
def remove(self, plex_id, plex_type):
"""
Removes the item from our Plex db
"""
self.cursor.execute('DELETE FROM %s WHERE plex_id = ?' % plex_type, (plex_id, ))
def every_plex_id(self, plex_type, offset, limit):
"""
Returns an iterator for plex_type for every single plex_id
Will start with records at DB position offset [int] and return limit
[int] number of items
"""
return (x[0] for x in
self.cursor.execute('SELECT plex_id FROM %s LIMIT %s OFFSET %s'
% (plex_type, limit, offset)))
def missing_fanart(self, plex_type, offset, limit):
"""
Returns an iterator for plex_type for all plex_id, where fanart_synced
has not yet been set to 1
Will start with records at DB position offset [int] and return limit
[int] number of items
"""
query = '''
SELECT plex_id FROM %s WHERE fanart_synced = 0
LIMIT %s OFFSET %s
''' % (plex_type, limit, offset)
return (x[0] for x in self.cursor.execute(query))
def set_fanart_synced(self, plex_id, plex_type):
"""
Toggles fanart_synced to 1 for plex_id
"""
self.cursor.execute('UPDATE %s SET fanart_synced = 1 WHERE plex_id = ?' % plex_type,
(plex_id, ))
def plexid_by_sectionid(self, section_id, plex_type, limit):
query = '''
SELECT plex_id FROM %s WHERE section_id = ? LIMIT %s
''' % (plex_type, limit)
return (x[0] for x in self.cursor.execute(query, (section_id, )))
def kodiid_by_sectionid(self, section_id, plex_type):
return (x[0] for x in
self.cursor.execute('SELECT kodi_id FROM %s WHERE section_id = ?' % plex_type,
(section_id, )))
def initialize():
"""
Run once upon PKC startup to verify that plex db exists.
"""
with PlexDBBase() as plexdb:
plexdb.cursor.execute('''
CREATE TABLE IF NOT EXISTS version(
idVersion TEXT PRIMARY KEY)
''')
plexdb.cursor.execute('''
INSERT OR REPLACE INTO version(idVersion)
VALUES (?)
''', (v.ADDON_VERSION, ))
plexdb.cursor.execute('''
CREATE TABLE IF NOT EXISTS sections(
section_id INTEGER PRIMARY KEY,
section_name TEXT,
plex_type TEXT,
kodi_tagid INTEGER,
sync_to_kodi INTEGER,
last_sync INTEGER)
''')
plexdb.cursor.execute('''
CREATE TABLE IF NOT EXISTS movie(
plex_id INTEGER PRIMARY KEY,
checksum INTEGER UNIQUE,
section_id INTEGER,
kodi_id INTEGER,
kodi_fileid INTEGER,
kodi_pathid INTEGER,
fanart_synced INTEGER,
last_sync INTEGER)
''')
plexdb.cursor.execute('''
CREATE TABLE IF NOT EXISTS show(
plex_id INTEGER PRIMARY KEY,
checksum INTEGER UNIQUE,
section_id INTEGER,
kodi_id INTEGER,
kodi_pathid INTEGER,
fanart_synced INTEGER,
last_sync INTEGER)
''')
plexdb.cursor.execute('''
CREATE TABLE IF NOT EXISTS season(
plex_id INTEGER PRIMARY KEY,
checksum INTEGER UNIQUE,
section_id INTEGER,
show_id INTEGER,
parent_id INTEGER,
kodi_id INTEGER,
fanart_synced INTEGER,
last_sync INTEGER)
''')
plexdb.cursor.execute('''
CREATE TABLE IF NOT EXISTS episode(
plex_id INTEGER PRIMARY KEY,
checksum INTEGER UNIQUE,
section_id INTEGER,
show_id INTEGER,
grandparent_id INTEGER,
season_id INTEGER,
parent_id INTEGER,
kodi_id INTEGER,
kodi_fileid INTEGER,
kodi_fileid_2 INTEGER,
kodi_pathid INTEGER,
fanart_synced INTEGER,
last_sync INTEGER)
''')
plexdb.cursor.execute('''
CREATE TABLE IF NOT EXISTS artist(
plex_id INTEGER PRIMARY KEY,
checksum INTEGER UNIQUE,
section_id INTEGER,
kodi_id INTEGER,
last_sync INTEGER)
''')
plexdb.cursor.execute('''
CREATE TABLE IF NOT EXISTS album(
plex_id INTEGER PRIMARY KEY,
checksum INTEGER UNIQUE,
section_id INTEGER,
artist_id INTEGER,
parent_id INTEGER,
kodi_id INTEGER,
last_sync INTEGER)
''')
plexdb.cursor.execute('''
CREATE TABLE IF NOT EXISTS track(
plex_id INTEGER PRIMARY KEY,
checksum INTEGER UNIQUE,
section_id INTEGER,
artist_id INTEGER,
grandparent_id INTEGER,
album_id INTEGER,
parent_id INTEGER,
kodi_id INTEGER,
kodi_pathid INTEGER,
last_sync INTEGER)
''')
plexdb.cursor.execute('''
CREATE TABLE IF NOT EXISTS playlists(
plex_id INTEGER PRIMARY KEY,
plex_name TEXT,
plex_updatedat INTEGER,
kodi_path TEXT,
kodi_type TEXT,
kodi_hash TEXT)
''')
# DB indicees for faster lookups
commands = (
'CREATE INDEX IF NOT EXISTS ix_movie_1 ON movie (last_sync)',
'CREATE UNIQUE INDEX IF NOT EXISTS ix_movie_2 ON movie (kodi_id)',
'CREATE INDEX IF NOT EXISTS ix_show_1 ON show (last_sync)',
'CREATE UNIQUE INDEX IF NOT EXISTS ix_show_2 ON show (kodi_id)',
'CREATE INDEX IF NOT EXISTS ix_season_1 ON season (last_sync)',
'CREATE UNIQUE INDEX IF NOT EXISTS ix_season_2 ON season (kodi_id)',
'CREATE INDEX IF NOT EXISTS ix_episode_1 ON episode (last_sync)',
'CREATE UNIQUE INDEX IF NOT EXISTS ix_episode_2 ON episode (kodi_id)',
'CREATE INDEX IF NOT EXISTS ix_artist_1 ON artist (last_sync)',
'CREATE UNIQUE INDEX IF NOT EXISTS ix_artist_2 ON artist (kodi_id)',
'CREATE INDEX IF NOT EXISTS ix_album_1 ON album (last_sync)',
'CREATE UNIQUE INDEX IF NOT EXISTS ix_album_2 ON album (kodi_id)',
'CREATE INDEX IF NOT EXISTS ix_track_1 ON track (last_sync)',
'CREATE UNIQUE INDEX IF NOT EXISTS ix_track_2 ON track (kodi_id)',
'CREATE UNIQUE INDEX IF NOT EXISTS ix_playlists_2 ON playlists (kodi_path)',
'CREATE INDEX IF NOT EXISTS ix_playlists_3 ON playlists (kodi_hash)',
)
for cmd in commands:
plexdb.cursor.execute(cmd)
def wipe(table=None):
"""
Completely resets the Plex database.
If a table [unicode] name is provided, only that table will be dropped
"""
with PlexDBBase() as plexdb:
if table:
tables = [table]
else:
plexdb.cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table'")
tables = [i[0] for i in plexdb.cursor.fetchall()]
for table in tables:
plexdb.cursor.execute('DROP table IF EXISTS %s' % table)

View file

@ -1,69 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from .. import variables as v
class Movies(object):
def add_movie(self, plex_id, checksum, section_id, kodi_id, kodi_fileid,
kodi_pathid, last_sync):
"""
Appends or replaces an entry into the plex table for movies
"""
query = '''
INSERT OR REPLACE INTO movie(
plex_id,
checksum,
section_id,
kodi_id,
kodi_fileid,
kodi_pathid,
fanart_synced,
last_sync)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
'''
self.cursor.execute(
query,
(plex_id,
checksum,
section_id,
kodi_id,
kodi_fileid,
kodi_pathid,
0,
last_sync))
def movie(self, plex_id):
"""
Returns the show info as a tuple for the TV show with plex_id:
plex_id INTEGER PRIMARY KEY ASC,
checksum INTEGER UNIQUE,
section_id INTEGER,
kodi_id INTEGER,
kodi_fileid INTEGER,
kodi_pathid INTEGER,
fanart_synced INTEGER,
last_sync INTEGER
"""
if plex_id is None:
return
self.cursor.execute('SELECT * FROM movie WHERE plex_id = ? LIMIT 1',
(plex_id, ))
return self.entry_to_movie(self.cursor.fetchone())
@staticmethod
def entry_to_movie(entry):
if not entry:
return
return {
'plex_type': v.PLEX_TYPE_MOVIE,
'kodi_type': v.KODI_TYPE_MOVIE,
'plex_id': entry[0],
'checksum': entry[1],
'section_id': entry[2],
'kodi_id': entry[3],
'kodi_fileid': entry[4],
'kodi_pathid': entry[5],
'fanart_synced': entry[6],
'last_sync': entry[7]
}

View file

@ -1,245 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from .. import variables as v
class Music(object):
def add_artist(self, plex_id, checksum, section_id, kodi_id, last_sync):
"""
Appends or replaces music artist entry into the plex table
"""
query = '''
INSERT OR REPLACE INTO artist(
plex_id,
checksum,
section_id,
kodi_id,
last_sync)
VALUES (?, ?, ?, ?, ?)
'''
self.cursor.execute(
query,
(plex_id,
checksum,
section_id,
kodi_id,
last_sync))
def add_album(self, plex_id, checksum, section_id, artist_id, parent_id,
kodi_id, last_sync):
"""
Appends or replaces an entry into the plex table
"""
query = '''
INSERT OR REPLACE INTO album(
plex_id,
checksum,
section_id,
artist_id,
parent_id,
kodi_id,
last_sync)
VALUES (?, ?, ?, ?, ?, ?, ?)
'''
self.cursor.execute(
query,
(plex_id,
checksum,
section_id,
artist_id,
parent_id,
kodi_id,
last_sync))
def add_song(self, plex_id, checksum, section_id, artist_id, grandparent_id,
album_id, parent_id, kodi_id, kodi_pathid, last_sync):
"""
Appends or replaces an entry into the plex table
"""
query = '''
INSERT OR REPLACE INTO track(
plex_id,
checksum,
section_id,
artist_id,
grandparent_id,
album_id,
parent_id,
kodi_id,
kodi_pathid,
last_sync)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
'''
self.cursor.execute(
query,
(plex_id,
checksum,
section_id,
artist_id,
grandparent_id,
album_id,
parent_id,
kodi_id,
kodi_pathid,
last_sync))
def artist(self, plex_id):
"""
Returns the show info as a tuple for the TV show with plex_id:
plex_id INTEGER PRIMARY KEY,
checksum INTEGER UNIQUE,
section_id INTEGER,
kodi_id INTEGER,
last_sync INTEGER
"""
if plex_id is None:
return
self.cursor.execute('SELECT * FROM artist WHERE plex_id = ? LIMIT 1',
(plex_id, ))
return self.entry_to_artist(self.cursor.fetchone())
def album(self, plex_id):
"""
Returns the show info as a tuple for the TV show with plex_id:
plex_id INTEGER PRIMARY KEY,
checksum INTEGER UNIQUE,
section_id INTEGER,
artist_id INTEGER, # plex_id of the parent artist
parent_id INTEGER, # kodi_id of the parent artist
kodi_id INTEGER,
last_sync INTEGER
"""
if plex_id is None:
return
self.cursor.execute('SELECT * FROM album WHERE plex_id = ? LIMIT 1',
(plex_id, ))
return self.entry_to_album(self.cursor.fetchone())
def song(self, plex_id):
"""
Returns the show info as a tuple for the TV show with plex_id:
plex_id INTEGER PRIMARY KEY,
checksum INTEGER UNIQUE,
section_id INTEGER,
artist_id INTEGER, # plex_id of the parent artist
grandparent_id INTEGER, # kodi_id of the parent artist
album_id INTEGER, # plex_id of the parent album
parent_id INTEGER, # kodi_id of the parent album
kodi_id INTEGER,
kodi_pathid INTEGER,
last_sync INTEGER
"""
if plex_id is None:
return
self.cursor.execute('SELECT * FROM track WHERE plex_id = ? LIMIT 1',
(plex_id, ))
return self.entry_to_track(self.cursor.fetchone())
@staticmethod
def entry_to_track(entry):
if not entry:
return
return {
'plex_type': v.PLEX_TYPE_SONG,
'kodi_type': v.KODI_TYPE_SONG,
'plex_id': entry[0],
'checksum': entry[1],
'section_id': entry[2],
'artist_id': entry[3],
'grandparent_id': entry[4],
'album_id': entry[5],
'parent_id': entry[6],
'kodi_id': entry[7],
'kodi_pathid': entry[8],
'last_sync': entry[9]
}
@staticmethod
def entry_to_album(entry):
if not entry:
return
return {
'plex_type': v.PLEX_TYPE_ALBUM,
'kodi_type': v.KODI_TYPE_ALBUM,
'plex_id': entry[0],
'checksum': entry[1],
'section_id': entry[2],
'artist_id': entry[3],
'parent_id': entry[4],
'kodi_id': entry[5],
'last_sync': entry[6]
}
@staticmethod
def entry_to_artist(entry):
if not entry:
return
return {
'plex_type': v.PLEX_TYPE_ARTIST,
'kodi_type': v.KODI_TYPE_ARTIST,
'plex_id': entry[0],
'checksum': entry[1],
'section_id': entry[2],
'kodi_id': entry[3],
'last_sync': entry[4]
}
def album_has_songs(self, plex_id):
"""
Returns True if there are songs left for the album with plex_id
"""
self.cursor.execute('SELECT plex_id FROM track WHERE album_id = ? LIMIT 1',
(plex_id, ))
return self.cursor.fetchone() is not None
def artist_has_albums(self, plex_id):
"""
Returns True if there are albums left for the artist with plex_id
"""
self.cursor.execute('SELECT plex_id FROM album WHERE artist_id = ? LIMIT 1',
(plex_id, ))
return self.cursor.fetchone() is not None
def artist_has_songs(self, plex_id):
"""
Returns True if there are episodes left for the show with plex_id
"""
self.cursor.execute('SELECT plex_id FROM track WHERE artist_id = ? LIMIT 1',
(plex_id, ))
return self.cursor.fetchone() is not None
def song_by_album(self, plex_id):
"""
Returns an iterator for all songs that have a parent album_id with
a value of plex_id
"""
self.cursor.execute('SELECT * FROM track WHERE album_id = ?',
(plex_id, ))
return (self.entry_to_track(x) for x in self.cursor)
def song_by_artist(self, plex_id):
"""
Returns an iterator for all songs that have a grandparent artist_id
with a value of plex_id
"""
self.cursor.execute('SELECT * FROM track WHERE artist_id = ?',
(plex_id, ))
return (self.entry_to_track(x) for x in self.cursor)
def album_by_artist(self, plex_id):
"""
Returns an iterator for all albums that have a parent artist_id
with a value of plex_id
"""
self.cursor.execute('SELECT * FROM album WHERE artist_id = ?',
(plex_id, ))
return (self.entry_to_album(x) for x in self.cursor)
def songs_have_been_synced(self):
"""
Returns True if at least one song has been synced - indicating that
Plex Music sync has been active at some point
"""
self.cursor.execute('SELECT plex_id FROM track LIMIT 1')
return self.cursor.fetchone() is not None

View file

@ -1,96 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
class Playlists(object):
def playlist_ids(self):
"""
Returns an iterator of all Plex ids of playlists.
"""
self.cursor.execute('SELECT plex_id FROM playlists')
return (x[0] for x in self.cursor)
def kodi_playlist_paths(self):
"""
Returns an iterator of all Kodi playlist paths.
"""
self.cursor.execute('SELECT kodi_path FROM playlists')
return (x[0] for x in self.cursor)
def delete_playlist(self, playlist):
"""
Removes the entry for playlist [Playqueue_Object] from the Plex
playlists table.
Be sure to either set playlist.id or playlist.kodi_path
"""
if playlist.plex_id:
query = 'DELETE FROM playlists WHERE plex_id = ?'
var = playlist.plex_id
elif playlist.kodi_path:
query = 'DELETE FROM playlists WHERE kodi_path = ?'
var = playlist.kodi_path
else:
raise RuntimeError('Cannot delete playlist: %s' % playlist)
self.cursor.execute(query, (var, ))
def add_playlist(self, playlist):
"""
Inserts or modifies an existing entry in the Plex playlists table.
"""
query = '''
INSERT OR REPLACE INTO playlists(
plex_id,
plex_name,
plex_updatedat,
kodi_path,
kodi_type,
kodi_hash)
VALUES (?, ?, ?, ?, ?, ?)
'''
self.cursor.execute(
query,
(playlist.plex_id,
playlist.plex_name,
playlist.plex_updatedat,
playlist.kodi_path,
playlist.kodi_type,
playlist.kodi_hash))
def playlist(self, playlist, plex_id=None, path=None):
"""
Returns a complete Playlist (empty one passed in via playlist) for the
entry with plex_id OR kodi_path.
Returns None if not found
"""
query = 'SELECT * FROM playlists WHERE %s = ? LIMIT 1'
if plex_id:
query = query % 'plex_id'
var = plex_id
elif path:
query = query % 'kodi_path'
var = path
self.cursor.execute(query, (var, ))
answ = self.cursor.fetchone()
if not answ:
return
playlist.plex_id = answ[0]
playlist.plex_name = answ[1]
playlist.plex_updatedat = answ[2]
playlist.kodi_path = answ[3]
playlist.kodi_type = answ[4]
playlist.kodi_hash = answ[5]
return playlist
def all_kodi_paths(self):
"""
Returns a generator for all kodi_paths of all synched playlists
"""
self.cursor.execute('SELECT kodi_path FROM playlists')
return (x[0] for x in self.cursor)
def wipe_playlists(self):
"""
Deletes all entries in the playlists table
"""
self.cursor.execute('DELETE FROM playlists')

View file

@ -1,120 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
class Sections(object):
def all_sections(self):
"""
Returns an iterator for all sections
"""
self.cursor.execute('SELECT * FROM sections')
return (self.entry_to_section(x) for x in self.cursor)
def section(self, section_id):
"""
For section_id, returns the dict
section_id INTEGER PRIMARY KEY,
section_name TEXT,
plex_type TEXT,
kodi_tagid INTEGER,
sync_to_kodi BOOL,
last_sync INTEGER
"""
self.cursor.execute('SELECT * FROM sections WHERE section_id = ? LIMIT 1',
(section_id, ))
return self.entry_to_section(self.cursor.fetchone())
@staticmethod
def entry_to_section(entry):
if not entry:
return
return {
'section_id': entry[0],
'section_name': entry[1],
'plex_type': entry[2],
'kodi_tagid': entry[3],
'sync_to_kodi': entry[4] == 1,
'last_sync': entry[5]
}
def section_id_by_name(self, section_name):
"""
Returns the section_id for section_name (or None)
"""
self.cursor.execute('SELECT section_id FROM sections WHERE section_name = ? LIMIT 1',
(section_name, ))
try:
return self.cursor.fetchone()[0]
except TypeError:
pass
def add_section(self, section_id, section_name, plex_type, kodi_tagid,
sync_to_kodi, last_sync):
"""
Appends a Plex section to the Plex sections table
sync=False: Plex library won't be synced to Kodi
"""
query = '''
INSERT OR REPLACE INTO sections(
section_id,
section_name,
plex_type,
kodi_tagid,
sync_to_kodi,
last_sync)
VALUES (?, ?, ?, ?, ?, ?)
'''
self.cursor.execute(query,
(section_id,
section_name,
plex_type,
kodi_tagid,
sync_to_kodi,
last_sync))
def update_section(self, section_id, section_name):
"""
Updates the section with section_id
"""
query = 'UPDATE sections SET section_name = ? WHERE section_id = ?'
self.cursor.execute(query, (section_name, section_id))
def remove_section(self, section_id):
"""
Removes the Plex db entry for the section with section_id
"""
self.cursor.execute('DELETE FROM sections WHERE section_id = ?',
(section_id, ))
def update_section_sync(self, section_id, sync_to_kodi):
"""
Updates whether we should sync sections_id (sync=True) or not
"""
if sync_to_kodi:
query = '''
UPDATE sections
SET sync_to_kodi = ?
WHERE section_id = ?
'''
else:
# Set last_sync = 0 in order to force a full sync if reactivated
query = '''
UPDATE sections
SET sync_to_kodi = ?, last_sync = 0
WHERE section_id = ?
'''
self.cursor.execute(query, (sync_to_kodi, section_id))
def update_section_last_sync(self, section_id, last_sync):
"""
Updates the timestamp for the section
"""
self.cursor.execute('UPDATE sections SET last_sync = ? WHERE section_id = ?',
(last_sync, section_id))
def force_full_sync(self):
"""
Sets the last_sync flag to 0 for every section
"""
self.cursor.execute('UPDATE sections SET last_sync = 0')

View file

@ -1,244 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from .. import variables as v
class TVShows(object):
def add_show(self, plex_id, checksum, section_id, kodi_id, kodi_pathid,
last_sync):
"""
Appends or replaces tv show entry into the plex table
"""
self.cursor.execute(
'''
INSERT OR REPLACE INTO show(
plex_id,
checksum,
section_id,
kodi_id,
kodi_pathid,
fanart_synced,
last_sync)
VALUES (?, ?, ?, ?, ?, ?, ?)
''',
(plex_id,
checksum,
section_id,
kodi_id,
kodi_pathid,
0,
last_sync))
def add_season(self, plex_id, checksum, section_id, show_id, parent_id,
kodi_id, last_sync):
"""
Appends or replaces an entry into the plex table
"""
self.cursor.execute(
'''
INSERT OR REPLACE INTO season(
plex_id,
checksum,
section_id,
show_id,
parent_id,
kodi_id,
fanart_synced,
last_sync)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''',
(plex_id,
checksum,
section_id,
show_id,
parent_id,
kodi_id,
0,
last_sync))
def add_episode(self, plex_id, checksum, section_id, show_id,
grandparent_id, season_id, parent_id, kodi_id, kodi_fileid,
kodi_fileid_2, kodi_pathid, last_sync):
"""
Appends or replaces an entry into the plex table
"""
self.cursor.execute(
'''
INSERT OR REPLACE INTO episode(
plex_id,
checksum,
section_id,
show_id,
grandparent_id,
season_id,
parent_id,
kodi_id,
kodi_fileid,
kodi_fileid_2,
kodi_pathid,
fanart_synced,
last_sync)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(plex_id,
checksum,
section_id,
show_id,
grandparent_id,
season_id,
parent_id,
kodi_id,
kodi_fileid,
kodi_fileid_2,
kodi_pathid,
0,
last_sync))
def show(self, plex_id):
"""
Returns the show info as a tuple for the TV show with plex_id:
plex_id INTEGER PRIMARY KEY ASC,
checksum INTEGER UNIQUE,
section_id INTEGER,
kodi_id INTEGER,
kodi_pathid INTEGER,
fanart_synced INTEGER,
last_sync INTEGER
"""
if plex_id is None:
return
self.cursor.execute('SELECT * FROM show WHERE plex_id = ? LIMIT 1',
(plex_id, ))
return self.entry_to_show(self.cursor.fetchone())
def season(self, plex_id):
"""
Returns the show info as a tuple for the TV show with plex_id:
plex_id INTEGER PRIMARY KEY,
checksum INTEGER UNIQUE,
section_id INTEGER,
show_id INTEGER, # plex_id of the parent show
parent_id INTEGER, # kodi_id of the parent show
kodi_id INTEGER,
fanart_synced INTEGER,
last_sync INTEGER
"""
if plex_id is None:
return
self.cursor.execute('SELECT * FROM season WHERE plex_id = ? LIMIT 1',
(plex_id, ))
return self.entry_to_season(self.cursor.fetchone())
def episode(self, plex_id):
if plex_id is None:
return
self.cursor.execute('SELECT * FROM episode WHERE plex_id = ? LIMIT 1',
(plex_id, ))
return self.entry_to_episode(self.cursor.fetchone())
@staticmethod
def entry_to_episode(entry):
if not entry:
return
return {
'plex_type': v.PLEX_TYPE_EPISODE,
'kodi_type': v.KODI_TYPE_EPISODE,
'plex_id': entry[0],
'checksum': entry[1],
'section_id': entry[2],
'show_id': entry[3],
'grandparent_id': entry[4],
'season_id': entry[5],
'parent_id': entry[6],
'kodi_id': entry[7],
'kodi_fileid': entry[8],
'kodi_fileid_2': entry[9],
'kodi_pathid': entry[10],
'fanart_synced': entry[11],
'last_sync': entry[12]
}
@staticmethod
def entry_to_show(entry):
if not entry:
return
return {
'plex_type': v.PLEX_TYPE_SHOW,
'kodi_type': v.KODI_TYPE_SHOW,
'plex_id': entry[0],
'checksum': entry[1],
'section_id': entry[2],
'kodi_id': entry[3],
'kodi_pathid': entry[4],
'fanart_synced': entry[5],
'last_sync': entry[6]
}
@staticmethod
def entry_to_season(entry):
if not entry:
return
return {
'plex_type': v.PLEX_TYPE_SEASON,
'kodi_type': v.KODI_TYPE_SEASON,
'plex_id': entry[0],
'checksum': entry[1],
'section_id': entry[2],
'show_id': entry[3],
'parent_id': entry[4],
'kodi_id': entry[5],
'fanart_synced': entry[6],
'last_sync': entry[7]
}
def season_has_episodes(self, plex_id):
"""
Returns True if there are episodes left for the season with plex_id
"""
self.cursor.execute('SELECT plex_id FROM episode WHERE season_id = ? LIMIT 1',
(plex_id, ))
return self.cursor.fetchone() is not None
def show_has_seasons(self, plex_id):
"""
Returns True if there are seasons left for the show with plex_id
"""
self.cursor.execute('SELECT plex_id FROM season WHERE show_id = ? LIMIT 1',
(plex_id, ))
return self.cursor.fetchone() is not None
def show_has_episodes(self, plex_id):
"""
Returns True if there are episodes left for the show with plex_id
"""
self.cursor.execute('SELECT plex_id FROM episode WHERE show_id = ? LIMIT 1',
(plex_id, ))
return self.cursor.fetchone() is not None
def episode_by_season(self, plex_id):
"""
Returns an iterator for all episodes that have a parent season_id with
a value of plex_id
"""
return (self.entry_to_episode(x) for x in
self.cursor.execute('SELECT * FROM episode WHERE season_id = ?',
(plex_id, )))
def episode_by_show(self, plex_id):
"""
Returns an iterator for all episodes that have a grandparent show_id
with a value of plex_id
"""
return (self.entry_to_episode(x) for x in
self.cursor.execute('SELECT * FROM episode WHERE show_id = ?',
(plex_id, )))
def season_by_show(self, plex_id):
"""
Returns an iterator for all seasons that have a parent show_id
with a value of plex_id
"""
return (self.entry_to_season(x) for x in
self.cursor.execute('SELECT * FROM season WHERE show_id = ?',
(plex_id, )))

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more