Compare commits

..

157 commits

Author SHA1 Message Date
croneter
ee1eb14476
Merge pull request #1672 from croneter/beta-version
Bump Python 2 Master
2021-10-17 12:08:34 +02:00
croneter
9e54f59fd4
Merge pull request #1671 from croneter/version-bump
Beta and stable version bump 2.15.0
2021-10-17 12:07:54 +02:00
croneter
4cfcc4c1f8 Beta and stable version bump 2.15.0 2021-10-17 12:07:18 +02:00
croneter
5a6623a1dc
Merge pull request #1670 from croneter/update-translations
Update translations from Transifex [backport]
2021-10-17 11:56:31 +02:00
croneter
e5fa5de670 Update translations from Transifex 2021-10-17 11:55:04 +02:00
croneter
191a3131e3
Merge pull request #1669 from croneter/stream-option
Add playback settings to let the user choose whether Plex or Kodi provides the default audio or subtitle stream on playback start [backport]
2021-10-17 11:54:19 +02:00
croneter
f96c246244 Add playback settings to let the user choose whether Plex or Kodi provides the default audio and subtitle streams on playback start 2021-10-17 11:52:22 +02:00
croneter
a4bf3d061a
Merge pull request #1668 from croneter/fix-subs
Refactor stream code and fix Kodi not activating subtitle when it should [backport]
2021-10-17 11:51:04 +02:00
croneter
24c1ada5b1
Merge pull request #1667 from croneter/typeerror
Direct Paths: Fix TypeError: "element indices must be integers" on playback startup [backport]
2021-10-17 11:50:53 +02:00
croneter
61114e0d2e Refactor and fix Kodi not activating subtitle when it should 2021-10-17 11:47:41 +02:00
croneter
bdc98d0352 Fix whitespace 2021-10-17 11:47:27 +02:00
croneter
436d2e4391 Direct Paths: Fix TypeError: element indices must be integers for subtitles 2021-10-17 11:44:57 +02:00
croneter
2bc98f9ff1
Merge pull request #1639 from croneter/version-bump
Beta version bump 2.14.4
2021-09-24 17:41:16 +02:00
croneter
a4fba553f3
Merge pull request #1634 from croneter/fix-logging
Fix logging if fanart.tv lookup fails: be less verbose
2021-09-24 17:41:05 +02:00
croneter
097fd4cfa2
Merge pull request #1638 from croneter/streams
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
2021-09-24 17:40:52 +02:00
croneter
d80d3525b3 Beta version bump 2.14.4 2021-09-24 17:39:56 +02:00
croneter
d54307ffd5 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 2021-09-24 17:32:01 +02:00
croneter
53e3258517
Merge pull request #1637 from croneter/refactor-part
Refactor usage of a media part's id
2021-09-24 17:30:11 +02:00
croneter
dae123acee
Merge pull request #1635 from croneter/fix-subs
Transcoding: Fix Plex burning-in subtitles when it should not
2021-09-24 17:29:54 +02:00
croneter
f4a0789fc0
Merge pull request #1636 from croneter/fix-streams
Large refactoring of playlist and playqueue code
2021-09-24 17:27:21 +02:00
croneter
887f659b2f Refactor usage of a media part's id 2021-09-24 17:22:04 +02:00
croneter
2bd692e173 Refactoring: playlist and playqueue items to use API instead of xml 2021-09-24 17:19:50 +02:00
croneter
176fa07e80 Refactoring: move all exceptions in a single module 2021-09-24 17:19:50 +02:00
croneter
9da61a059f Disentangle and optimize some code
Rename method

Simplify some code

Clarify some code
2021-09-24 17:11:17 +02:00
croneter
11d06d909e Transcoding: Fix Plex burning-in subtitles when it should not 2021-09-24 17:07:56 +02:00
croneter
e96df700c1 Fix logging if fanart.tv lookup fails 2021-09-24 16:59:56 +02:00
croneter
057921b05e Fix download not always returning entire requests.response object 2021-09-24 16:59:41 +02:00
croneter
63bd85d5c8
Merge pull request #1620 from croneter/version-bump
Beta version bump 2.14.3
2021-09-09 15:04:33 +02:00
croneter
9d6bae3957
Merge pull request #1619 from croneter/reset-resume
Implement "Reset resume position" from the Kodi context menu
2021-09-09 15:04:16 +02:00
croneter
2432ce5ee6 Beta version bump 2.14.3 2021-09-09 14:59:02 +02:00
croneter
5a009b7ea0 Implement Kodi's "Reset resume position" 2021-09-09 14:54:37 +02:00
croneter
26073e5dac
Merge pull request #1616 from croneter/beta-version
Update README.md
2021-09-08 12:37:39 +02:00
croneter
47cd15baa0
Update README.md 2021-09-08 12:36:07 +02:00
croneter
560fc5b9c8
Merge pull request #1615 from croneter/beta-version
Bump Python2 Master
2021-09-08 11:46:53 +02:00
croneter
9495e1e27d
Merge pull request #1614 from croneter/version-bump
Stable and beta version bump 2.14.2
2021-09-08 11:46:01 +02:00
croneter
5720811a7e
Merge pull request #1613 from croneter/readme
Update readme
2021-09-08 11:43:50 +02:00
croneter
45afba1840 Stable and beta version bump 2.14.2 2021-09-08 11:41:35 +02:00
croneter
cb1a3e74e0 Update readme 2021-09-08 11:39:25 +02:00
croneter
76c4fba8e6
Merge pull request #1608 from croneter/version-bump
Beta version bump 2.14.1
2021-09-04 16:48:05 +02:00
croneter
516a09ce56
Merge pull request #1607 from croneter/pick-plex-subs
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
2021-09-04 16:47:43 +02:00
croneter
41855882ab
Merge pull request #1606 from croneter/fix-subs
Fix PlexKodiConnect setting the Plex subtitle to None
2021-09-04 16:46:50 +02:00
croneter
289266bb81
Merge pull request #1598 from geropan/beta-version
Download landscape artwork from fanart.tv
2021-09-04 16:46:38 +02:00
croneter
0490ce766e Beta version bump 2.14.1 2021-09-04 16:41:56 +02:00
croneter
c182b8f5f8 subtitles.py: Backport for Python 2 2021-09-04 16:37:56 +02:00
croneter
e6a0af4621 Use Plex settings for audio and subtitle stream selection 2021-09-04 16:17:47 +02:00
croneter
ea877b55d5
Merge pull request #1605 from croneter/revert-sub
Revert "Fix PlexKodiConnect changing subtitles for all videos on the PMS
2021-09-04 16:11:25 +02:00
croneter
7c2478a568 Fix PlexKodiConnect setting the Plex subtitle to None 2021-09-04 16:09:15 +02:00
croneter
c99db1edff Revert "Fix PlexKodiConnect changing subtitles for all videos on the PMS"
This reverts commit 4de0920bf5.
2021-09-04 16:06:30 +02:00
Antonio Martin
2f25ba2eae Used general term in fanart.tv mapping for prefix completion 2021-08-25 21:37:16 +01:00
Antonio Martin
7602f02bcd Download landscape artwork from fanart.tv 2021-08-25 18:17:04 +01:00
croneter
de9c935a40
Merge pull request #1597 from croneter/beta-version
Bump master
2021-08-22 20:43:02 +02:00
croneter
e049f37da9
Merge pull request #1596 from croneter/version-bump
Beta and stable version bump 2.14.0
2021-08-22 20:42:04 +02:00
croneter
ce6ab2c258 Beta and stable version bump 2.14.0 2021-08-22 20:41:32 +02:00
croneter
0f7410e0e3
Merge pull request #1595 from croneter/fix-subtitles
Fix PlexKodiConnect changing subtitles for all videos on the PMS
2021-08-22 20:39:32 +02:00
croneter
4de0920bf5 Fix PlexKodiConnect changing subtitles for all videos on the PMS 2021-08-22 20:38:51 +02:00
croneter
2b9594dd90
Merge pull request #1560 from croneter/version-bump
Beta version bump 2.13.2
2021-07-25 11:22:30 +02:00
croneter
4f75502a8a
Merge pull request #1559 from croneter/fix-websocket
Websocket Fix AttributeError: 'NoneType' object has no attribute 'is_ssl'
2021-07-25 11:22:12 +02:00
croneter
6bf41116cb
Merge pull request #1558 from croneter/fix-recursion
Fix RecursionError: maximum recursion depth exceeded
2021-07-25 11:21:54 +02:00
croneter
6201a04513
Merge pull request #1557 from croneter/fix-racing
Fix a racing condition that could lead to the sync getting stuck
2021-07-25 11:21:04 +02:00
croneter
74ec9eff97 Beta version bump 2.13.2 2021-07-25 11:20:21 +02:00
croneter
295f403c64 Websocket Fix AttributeError: 'NoneType' object has no attribute is_ssl 2021-07-25 11:17:07 +02:00
croneter
cb8dc30c7c Fix RecursionError: maximum recursion depth exceeded 2021-07-25 11:13:46 +02:00
croneter
262315c3e7 Fix Regression: AttributeError 2021-07-25 11:11:31 +02:00
croneter
a18b971564 Fix a racing condition that could lead to the sync getting stuck 2021-07-25 11:11:00 +02:00
croneter
1bd1da9f5a
Merge pull request #1549 from croneter/version-bump
Beta version bump 2.13.1
2021-07-23 14:48:04 +02:00
croneter
2fd91ff9d6
Merge pull request #1548 from croneter/fix-race
Fix a racing condition that could lead to the sync process getting stuck
2021-07-23 14:47:52 +02:00
croneter
1001df5e30
Merge pull request #1547 from croneter/locked-database
Fix likelyhood of `database is locked` error occuring
2021-07-23 14:47:30 +02:00
croneter
0f2fd110db Beta version bump 2.13.1 2021-07-23 14:46:45 +02:00
croneter
ada337c2c4 Fix a racing condition that could lead to the sync getting stuck
Fixup racing
2021-07-23 14:38:18 +02:00
croneter
1066f857a2 Switch to context manager 2021-07-23 14:38:06 +02:00
croneter
858a33f816 Remove obsolete methods 2021-07-23 14:37:30 +02:00
croneter
fce964cc7b Improve logging
fixup logging
2021-07-23 14:36:08 +02:00
croneter
7c903d0c94 Fix likelyhood of database is locked error occuring 2021-07-23 14:34:42 +02:00
croneter
3ff97d0669
Merge pull request #1536 from croneter/beta-version
Bump master
2021-07-18 12:04:58 +02:00
croneter
7553061945
Merge pull request #1533 from croneter/version-bump
Stable and beta version bump 2.13.0
2021-07-18 12:03:28 +02:00
croneter
6105a571c8
Merge pull request #1535 from croneter/hama
Support forced HAMA IDs when using tvdb uniqueID
2021-07-18 12:03:04 +02:00
croneter
2484cf10ac
Merge pull request #1534 from croneter/ani-db
Support for the Plex HAMA agent to let Kodi identify animes (using Kodi's uniqueID 'anidb')
2021-07-18 12:02:43 +02:00
croneter
f171785602 Stable and beta version bump 2.13.0
fixup
2021-07-18 12:01:56 +02:00
BrutuZ
3e9c8c6361 Support for the Plex HAMA agent to let Kodi identify animes (using Kodi's uniqueID 'anidb')
Support HAMA's forced AniDB IDs
2021-07-18 11:59:32 +02:00
BrutuZ
cac32cc66a Support forced HAMA IDs when using tvdb uniqueID 2021-07-18 11:52:25 +02:00
croneter
c4d14c02e2
Merge pull request #1518 from croneter/version-bump
Beta version bump 2.12.26
2021-06-05 15:50:32 +02:00
croneter
c6056b4efc
Merge pull request #1521 from croneter/update-translations
Update translations from Transifex
2021-06-05 15:50:14 +02:00
croneter
2c979fba57
Merge pull request #1516 from croneter/fix-versions
Fix auto-picking of video stream if several video versions are available
2021-06-05 15:50:00 +02:00
croneter
f877c37e76
Merge pull request #1514 from croneter/fix-continue-watching
Add an additional Plex Hub "PKC Continue Watching" that merges the Plex Continue Watching with On Deck
2021-06-05 15:49:43 +02:00
croneter
038960c538 Update translations from Transifex 2021-06-05 15:43:01 +02:00
croneter
cdf1514215 Beta version bump 2.12.26 2021-06-05 15:01:50 +02:00
croneter
f15ef8886a Fix auto-picking of video stream if several video versions are available 2021-06-05 14:55:26 +02:00
croneter
7f8339a753 Add an additional Plex Hub "PKC Continue Watching" that merges the Plex Continue Watching with On Deck 2021-06-05 14:11:43 +02:00
croneter
0cf35b7b87
Merge pull request #1510 from croneter/beta-version
Bump master
2021-05-30 10:58:31 +02:00
croneter
09b0c61f11
Merge pull request #1509 from croneter/version-bump
Stable and beta version bump 2.12.25
2021-05-30 10:56:46 +02:00
croneter
675a8150cc Merge branch 'beta-version' of https://github.com/croneter/PlexKodiConnect into beta-version 2021-05-30 10:53:47 +02:00
croneter
a2194a5ce8 Stable and beta version bump 2.12.25 2021-05-30 10:53:24 +02:00
croneter
166b94c4cd
Merge pull request #1491 from croneter/update-websockets
Update websocket client to 0.59.0. Fix threading issues and AttributeErrors
2021-05-30 10:51:37 +02:00
croneter
46f99901cc Attempt to fix websocket threading issues and AttributeError: 'NoneType' object has no attribute 'is_ssl' or 'settimeout' 2021-05-29 16:45:35 +02:00
croneter
36befcf46a Fix websockets and AttributeError: 'NoneType' object has no attribute 2021-05-29 16:43:56 +02:00
croneter
abd8b04ff9 Update websocket client to 0.59.0 2021-05-24 13:09:00 +02:00
croneter
dbf2117a30
Merge pull request #1484 from croneter/beta-version
Bump master
2021-05-23 08:05:07 +02:00
croneter
a2e08a30ec
Merge pull request #1483 from croneter/version-bump
Beta and stable version bump 2.12.24
2021-05-23 08:04:33 +02:00
croneter
d38fe789b3 Beta and stable version bump 2.12.24 2021-05-23 08:03:45 +02:00
croneter
f262fba18a
Merge pull request #1474 from croneter/version-bump
Beta version bump 2.12.23
2021-05-14 09:18:45 +02:00
croneter
29822db781
Merge pull request #1469 from croneter/py2-fix-dict
Fix Alexa and RuntimeError: dictionary keys changed during iteration
2021-05-14 09:18:32 +02:00
croneter
7c12b7aa36
Merge pull request #1460 from croneter/fix-attributeerror
Fix a rare AttributeError when using playlists
2021-05-14 09:18:11 +02:00
croneter
019bd1aeae Beta version bump 2.12.23 2021-05-14 09:17:26 +02:00
croneter
c29be48cac Fix Alexa and RuntimeError: dictionary keys changed during iteration 2021-05-02 14:36:34 +02:00
croneter
4916bbb46e Fix a rare AttributeError when using playlists 2021-04-30 10:22:11 +02:00
croneter
f7ae807167
Merge pull request #1454 from croneter/beta-version
Bump master
2021-04-17 14:30:38 +02:00
croneter
966cf6f526
Merge pull request #1453 from croneter/version-bump
Beta and stable version bump 2.12.22
2021-04-17 14:29:49 +02:00
croneter
ce14d394d4 Beta and stable version bump 2.12.22 2021-04-17 14:28:25 +02:00
croneter
46f115de68 Merge branch 'version-bump' into beta-version 2021-03-20 14:43:03 +01:00
croneter
e98aca1f00 Merge branch 'beta-version' into version-bump 2021-03-20 14:42:42 +01:00
croneter
cb6ba50904
Merge pull request #1420 from croneter/fix-settings
Hopefully fix RuntimeError: no add-on id "plugin.video.plexkodiconnect"
2021-03-20 14:40:36 +01:00
croneter
2d02f4af07
Merge pull request #1423 from croneter/update-translations
Update translations
2021-03-20 14:40:03 +01:00
croneter
1493ac0c58 Update readme 2021-03-20 14:36:09 +01:00
croneter
7c57dca0ec Update translations in addon.xml 2021-03-20 14:36:08 +01:00
croneter
04e2d09835 Update translations from Transifex 2021-03-20 14:36:08 +01:00
croneter
fbfcffbb0c Beta version bump 2.12.21 2021-03-20 14:16:14 +01:00
croneter
6b6464dac3 Hopefully fix RuntimeError: no add-on id "plugin.video.plexkodiconnect" 2021-03-20 10:33:59 +01:00
croneter
34045c0136
Merge pull request #1419 from croneter/new-websockets
Switch to new websocket implementation
2021-03-20 10:01:37 +01:00
croneter
1c4b15e357 Adapt websocket client logic 2021-03-20 09:48:23 +01:00
croneter
3d139b0929 Add new dependency on script.module.six 2021-03-20 09:26:45 +01:00
croneter
4c0634bc13 Add new Python websocket client 2021-03-20 09:25:18 +01:00
croneter
060880e754
Merge pull request #1406 from croneter/version-bump
Beta version bump 2.12.20
2021-03-14 14:36:45 +01:00
croneter
95758b5dc8
Merge pull request #1398 from croneter/add-websocket-info
Add information to PKC settings for background sync and Alexa whether a connection has been successfully made
2021-03-14 14:36:02 +01:00
croneter
3d7d2d0993 Beta version bump 2.12.20 2021-03-14 14:35:52 +01:00
croneter
808136bff8
Merge pull request #1405 from croneter/beta-version
Bump master for Phyton 2
2021-03-14 14:29:10 +01:00
croneter
98b6b681fd
Merge pull request #1404 from croneter/update-readme
Update readme
2021-03-14 14:28:20 +01:00
croneter
0a1edcd24a Update readme 2021-03-14 14:27:18 +01:00
croneter
c8caf2f11b
Merge pull request #1402 from croneter/version-bump
Beta and stable version bump 2.12.19
2021-03-14 14:21:14 +01:00
croneter
4bef20da32
Merge pull request #1403 from croneter/update-filename
Rename skip intro skin file
2021-03-14 14:20:57 +01:00
croneter
886d2e5df7 Rename skip intro skin file 2021-03-14 14:19:18 +01:00
croneter
f6c2a7c08f Beta and stable version bump 2.12.19 2021-03-14 14:18:02 +01:00
croneter
0fd7d11631 Add information to PKC settings about status of websocket and Alexa websocket connections 2021-03-14 13:58:12 +01:00
croneter
c69d131084
Merge pull request #1388 from croneter/versionbump
Beta version bump 2.12.18
2021-03-07 17:21:18 +01:00
croneter
dc5402abcc
Merge pull request #1385 from croneter/sync-playstate
Quickly sync recently watched items before synching the playstates of the entire Plex library
2021-03-07 17:20:47 +01:00
croneter
9d7d33c0d0
Merge pull request #1376 from croneter/fix-websocket
Improve logging for websocket JSON loads
2021-03-07 17:20:30 +01:00
croneter
1885d3fc94 Beta version bump 2.12.18 2021-03-07 17:17:37 +01:00
croneter
bb7b2de44b Sync recently watched items individually before synching every playstate 2021-03-07 15:21:38 +01:00
croneter
f134266efc Improve logging for websocket JSON loads 2021-03-01 10:48:12 +01:00
croneter
66771c53a2
Merge pull request #1366 from croneter/version-bump
Beta version bump 2.12.17
2021-02-24 17:42:28 +01:00
croneter
16cbe430af
Merge pull request #1365 from croneter/seasonnames
Sync name and user rating of a TV show season to Kodi
2021-02-24 17:42:15 +01:00
croneter
8aa5890e67
Merge pull request #1362 from croneter/fix-typeerror
Fix rare TypeError: expected string or buffer on playback start
2021-02-24 17:41:50 +01:00
croneter
12587a985c Beta version bump 2.12.17 2021-02-24 17:38:30 +01:00
croneter
9150e168f6 Sync name and user rating of a TV show season to Kodi 2021-02-24 17:22:12 +01:00
croneter
a12e07da6a Fix rare TypeError: expected string or buffer on playback start 2021-02-24 15:21:07 +01:00
croneter
fad755745a
Merge pull request #1349 from croneter/beta-version
Bump master
2021-02-20 16:25:17 +01:00
croneter
0051ed316e
Merge pull request #1332 from croneter/beta-version
Bump master
2021-02-07 13:15:44 +01:00
croneter
e5585aec44
Merge pull request #1322 from croneter/beta-version
Bump master
2021-01-31 17:53:32 +01:00
croneter
94a86b43c1
Merge pull request #1304 from croneter/beta-version
Bump master
2021-01-24 17:35:00 +01:00
croneter
22efe274a1
Merge pull request #1279 from croneter/beta-version
Bump master
2021-01-11 20:40:24 +01:00
croneter
acf446dcc0
Merge pull request #1275 from croneter/beta-version
Bump master
2021-01-11 16:27:33 +01:00
croneter
5eb1c2aacd
Merge pull request #1259 from croneter/beta-version
Bump master
2021-01-02 13:18:13 +01:00
croneter
e2ebe98fde
Update README.md 2021-01-02 10:29:08 +01:00
croneter
27202d2ab2
Merge pull request #1218 from croneter/beta-version
Bump master
2020-09-19 20:49:44 +02:00
croneter
ba6c46afac
Merge pull request #1201 from croneter/beta-version
Bump master
2020-07-11 15:50:16 +02:00
croneter
941ac4ef3b
Merge pull request #1158 from croneter/beta-version
Bump master
2020-03-25 16:40:25 +01:00
croneter
493ac7f49a
Merge pull request #1150 from croneter/beta-version
Bump master
2020-03-21 14:31:53 +01:00
croneter
d8dc959879
Merge pull request #1144 from croneter/beta-version
Bump master
2020-03-13 07:53:24 +01:00
102 changed files with 11835 additions and 2598 deletions

View file

@ -1,8 +1,8 @@
[![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 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 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) [![Kodi Matrix beta version](https://img.shields.io/badge/Kodi_Matrix_BETA-latest-red.svg?maxAge=60&style=flat) ](https://croneter.github.io/pkc-source/repository.plexkodiconnect.Kodi-Matrix.BETA.zip)
[![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation) [![Installation](https://img.shields.io/badge/wiki-installation-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/Installation)
[![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq) [![FAQ](https://img.shields.io/badge/wiki-FAQ-brightgreen.svg?maxAge=60&style=flat)](https://github.com/croneter/PlexKodiConnect/wiki/faq)
[![Forum](https://img.shields.io/badge/forum-plex-orange.svg?maxAge=60&style=flat)](https://forums.plex.tv/discussion/210023/plexkodiconnect-let-kodi-talk-to-your-plex) [![Forum](https://img.shields.io/badge/forum-plex-orange.svg?maxAge=60&style=flat)](https://forums.plex.tv/discussion/210023/plexkodiconnect-let-kodi-talk-to-your-plex)
@ -39,11 +39,7 @@ Unfortunately, the PKC Kodi repository had to move because it stopped working (t
### Download and Installation ### Download and Installation
Install PKC via the PlexKodiConnect Kodi repository download button just below (do NOT use the standard GitHub download!). Alternatively, add [https://croneter.github.io/pkc-source](https://croneter.github.io/pkc-source) as a new Kodi `Web server directory (HTTPS)` source. 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. 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.
| 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) |
### Warning ### Warning
Use at your own risk! This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases as this plugin directly changes them. Don't worry if you want Plex to manage all your media (like you should ;-)). Use at your own risk! This plugin assumes that you manage all your videos with Plex (and none with Kodi). You might lose data already stored in the Kodi video and music databases as this plugin directly changes them. Don't worry if you want Plex to manage all your media (like you should ;-)).
@ -53,10 +49,10 @@ Some people argue that PKC is 'hacky' because of the way it directly accesses th
### PKC Features ### PKC Features
- Support for Kodi 18 Leia and Kodi 19 Matrix - Support 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/) - [Skip intros](https://support.plex.tv/articles/skip-content/)
- [Amazon Alexa voice recognition](https://www.plex.tv/apps/streaming-devices/amazon-alexa) - [Amazon Alexa voice recognition](https://www.plex.tv/apps/streaming-devices/amazon-alexa)
- [Cinema Trailers & Extras](https://support.plex.tv/articles/202934883-cinema-trailers-extras/) - [Cinema Trailers & Extras](https://support.plex.tv/articles/202934883-cinema-trailers-extras/)
- If Plex did not provide a trailer, automatically get one using the Kodi add-on [The Movie Database](https://kodi.wiki/view/Add-on:The_Movie_Database)
- [Plex Watch Later / Plex It!](https://support.plex.tv/hc/en-us/sections/200211783-Plex-It-) - [Plex Watch Later / Plex It!](https://support.plex.tv/hc/en-us/sections/200211783-Plex-It-)
- [Plex Companion](https://support.plex.tv/hc/en-us/sections/200276908-Plex-Companion): fling Plex media (or anything else) from other Plex devices to PlexKodiConnect - [Plex Companion](https://support.plex.tv/hc/en-us/sections/200276908-Plex-Companion): fling Plex media (or anything else) from other Plex devices to PlexKodiConnect
- Automatically sync Plex playlists to Kodi playlists and vice-versa - Automatically sync Plex playlists to Kodi playlists and vice-versa
@ -82,6 +78,7 @@ Some people argue that PKC is 'hacky' because of the way it directly accesses th
+ Hungarian, thanks @savage93 + Hungarian, thanks @savage93
+ Ukrainian, thanks @uniss + Ukrainian, thanks @uniss
+ Lithuanian, thanks @egidusm + Lithuanian, thanks @egidusm
+ Korean, thanks @so-o-bima
### Additional Artwork ### Additional Artwork
PKC uses additional artwork for free from [TheMovieDB](https://www.themoviedb.org). Many thanks for lettings us use the API, guys! PKC uses additional artwork for free from [TheMovieDB](https://www.themoviedb.org). Many thanks for lettings us use the API, guys!

859
addon.xml
View file

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="2.12.16" provider-name="croneter"> <addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="2.15.0" provider-name="croneter">
<requires> <requires>
<import addon="xbmc.python" version="2.1.0"/> <import addon="xbmc.python" version="2.1.0"/>
<import addon="script.module.requests" version="2.9.1" /> <import addon="script.module.requests" version="2.9.1" />
<import addon="script.module.defusedxml" version="0.5.0"/> <import addon="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.movies" version="2.1.3" />
<import addon="plugin.video.plexkodiconnect.tvshows" version="2.1.3" /> <import addon="plugin.video.plexkodiconnect.tvshows" version="2.1.3" />
<import addon="metadata.themoviedb.org.python" version="1.3.1" />
</requires> </requires>
<extension point="xbmc.python.pluginsource" library="default.py"> <extension point="xbmc.python.pluginsource" library="default.py">
<provides>video audio image</provides> <provides>video audio image</provides>
@ -78,13 +78,99 @@
<summary lang="uk_UA">Нативна інтеграція Plex в Kodi</summary> <summary lang="uk_UA">Нативна інтеграція Plex в Kodi</summary>
<description lang="uk_UA">Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик!</description> <description lang="uk_UA">Підключає Kodi до серверу Plex. Цей плагін передбачає, що ви керуєте всіма своїми відео за допомогою Plex (і ніяк не Kodi). Ви можете втратити дані, які вже зберігаються у відео та музичних БД Kodi (оскільки цей плагін безпосередньо їх змінює). Використовуйте на свій страх і ризик!</description>
<disclaimer lang="uk_UA">Використовуйте на свій ризик</disclaimer> <disclaimer lang="uk_UA">Використовуйте на свій ризик</disclaimer>
<disclaimer lang="lv_LV">Lieto uz savu atbildību</disclaimer>
<summary lang="sv_SE">Inbyggd integrering av Plex i Kodi</summary> <summary lang="sv_SE">Inbyggd integrering av Plex i Kodi</summary>
<description lang="sv_SE">Anslut Kodi till din Plex Media Server. Detta tillägg antar att du hanterar alla dina filmer med Plex (och ingen med Kodi). Du kan förlora data redan sparad i Kodis video och musik databaser (eftersom detta tillägg direkt ändrar dem). Använd på egen risk!</description> <description lang="sv_SE">Anslut Kodi till din Plex Media Server. Detta tillägg antar att du hanterar alla dina filmer med Plex (och ingen med Kodi). Du kan förlora data redan sparad i Kodis video och musik databaser (eftersom detta tillägg direkt ändrar dem). Använd på egen risk!</description>
<disclaimer lang="sv_SE">Använd på egen risk</disclaimer> <disclaimer lang="sv_SE">Använd på egen risk</disclaimer>
<summary lang="lt_LT">Natūralioji „Plex“ integracija į „Kodi“</summary> <summary lang="lt_LT">Natūralioji „Plex“ integracija į „Kodi“</summary>
<description lang="lt_LT">Prijunkite „Kodi“ prie „Plex Medija Serverio“. Šiame papildinyje daroma prielaida, kad valdote visus savo vaizdo įrašus naudodami „Plex“ (ir nė vieno su „Kodi“). Galite prarasti jau saugomus „Kodi“ vaizdo įrašų ir muzikos duomenų bazių duomenis (kadangi šis papildinys juos tiesiogiai pakeičia). Naudokite savo pačių rizika!</description> <description lang="lt_LT">Prijunkite „Kodi“ prie „Plex Medija Serverio“. Šiame papildinyje daroma prielaida, kad valdote visus savo vaizdo įrašus naudodami „Plex“ (ir nė vieno su „Kodi“). Galite prarasti jau saugomus „Kodi“ vaizdo įrašų ir muzikos duomenų bazių duomenis (kadangi šis papildinys juos tiesiogiai pakeičia). Naudokite savo pačių rizika!</description>
<disclaimer lang="lt_LT">Naudokite savo pačių rizika</disclaimer> <disclaimer lang="lt_LT">Naudokite savo pačių rizika</disclaimer>
<news>version 2.12.16: <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
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 - versions 2.12.14 and 2.12.15 for everyone
version 2.12.15 (beta only): version 2.12.15 (beta only):
@ -189,771 +275,6 @@ version 2.11.0 (beta only):
- Ensure that our only video transcoding target is h264 - Ensure that our only video transcoding target is h264
- Fix adjusted subtitle size not working when burning in subtitles - 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 - Fix regression: burn-in subtitles picking up the last user setting instead of the current one
</news>
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&amp;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:
- 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...</news>
</extension> </extension>
</addon> </addon>

View file

@ -1,3 +1,85 @@
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: version 2.12.16:
- versions 2.12.14 and 2.12.15 for everyone - versions 2.12.14 and 2.12.15 for everyone

View file

@ -168,6 +168,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "Stahování obrázků PKC dokončeno" msgstr "Stahování obrázků PKC dokončeno"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Číslo portu" msgstr "Číslo portu"
@ -694,6 +702,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Vynutit překódování obrázků" msgstr "Vynutit překódování obrázků"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1150,6 +1173,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Současný stav plex.tv:" msgstr "Současný stav plex.tv:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1160,6 +1188,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Seriály" msgstr "Seriály"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1225,6 +1258,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "Znovu načíst Kodi pro aplikování nastavení níže" msgstr "Znovu načíst Kodi pro aplikování nastavení níže"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Odhlásit uživatele Plex Home " msgstr "Odhlásit uživatele Plex Home "

View file

@ -168,6 +168,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "PKC billede caching er færdiggjort" msgstr "PKC billede caching er færdiggjort"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Portnummer" msgstr "Portnummer"
@ -694,6 +702,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Transcode billeder" msgstr "Transcode billeder"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1153,6 +1176,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Nuværende plex.tv status:" msgstr "Nuværende plex.tv status:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1163,6 +1191,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "TV-udsendelser" msgstr "TV-udsendelser"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1230,6 +1263,31 @@ msgstr ""
"Reload Kodi node filer for alle indstillinger\n" "Reload Kodi node filer for alle indstillinger\n"
"nedeunder" "nedeunder"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Log ud Plex hjemme bruger " msgstr "Log ud Plex hjemme bruger "

View file

@ -170,6 +170,17 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "PKC Bilder-Caching beendet" msgstr "PKC Bilder-Caching beendet"
# 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 ""
"Um ein reibungsloses PlexKodiConnect-Erlebnis zu gewährleisten, wird "
"DRINGEND empfohlen, für die Ersteinrichtung und für mögliche Datenbank-"
"Resets den Standard-Skin \"Estuary\" von Kodi zu verwenden. Weiterfahren?"
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Portnummer" msgstr "Portnummer"
@ -704,6 +715,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Bilder immer transkodieren" msgstr "Bilder immer transkodieren"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr "Ersten Videostream wählen, wenn mehrere Versionen vorhanden sind"
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr "Wer wählt den Audiotrack beim Start der Wiedergabe?"
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr "Wer wählt Untertitel beim Start der Wiedergabe?"
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1171,6 +1197,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Aktueller plex.tv Status:" msgstr "Aktueller plex.tv Status:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr "Verbindungsstatus Hintergrund-Synchronisation:"
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1181,6 +1212,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Serien" msgstr "Serien"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr "Zugriff auf Mediendateien während der Synchronisierung überprüfen"
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1246,6 +1282,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "Kodi neu laden um Einstellungen unten zu übernehmen" msgstr "Kodi neu laden um Einstellungen unten zu übernehmen"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr "Alexa Verbindungsstatus:"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr "Timeout - nicht verbunden"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr "IOError - nicht verbunden"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr "Angehalten - nicht verbunden"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr "Managed Plex User - nicht verbunden"
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Plex Home Benutzer abmelden: " msgstr "Plex Home Benutzer abmelden: "

File diff suppressed because it is too large Load diff

View file

@ -660,6 +660,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "" msgstr ""
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1063,6 +1078,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "" msgstr ""
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1124,6 +1144,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "" msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "" msgstr ""

View file

@ -171,6 +171,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "El caché de imágenes solo-PKC fue completado" msgstr "El caché de imágenes solo-PKC fue completado"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Número de puerto" msgstr "Número de puerto"
@ -702,6 +710,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Obligar transcodificar fotografías" msgstr "Obligar transcodificar fotografías"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1166,6 +1189,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Estado actual de plex.tv:" msgstr "Estado actual de plex.tv:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1176,6 +1204,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Series" msgstr "Series"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1243,6 +1276,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "Recargar Kodi para aplicar todos los ajustes." msgstr "Recargar Kodi para aplicar todos los ajustes."
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Terminar sesión del usuario de Plex Home " msgstr "Terminar sesión del usuario de Plex Home "

View file

@ -173,6 +173,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "El caché de imágenes solo-PKC fue completado" msgstr "El caché de imágenes solo-PKC fue completado"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Número de puerto" msgstr "Número de puerto"
@ -704,6 +712,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Obligar transcodificar fotografías" msgstr "Obligar transcodificar fotografías"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1168,6 +1191,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Estado actual de plex.tv:" msgstr "Estado actual de plex.tv:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1178,6 +1206,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Series" msgstr "Series"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1245,6 +1278,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "Recargar Kodi para aplicar todos los ajustes." msgstr "Recargar Kodi para aplicar todos los ajustes."
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Terminar sesión del usuario de Plex Home " msgstr "Terminar sesión del usuario de Plex Home "

View file

@ -171,6 +171,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "El caché de imágenes solo-PKC fue completado" msgstr "El caché de imágenes solo-PKC fue completado"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Número de puerto" msgstr "Número de puerto"
@ -702,6 +710,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Obligar transcodificar fotografías" msgstr "Obligar transcodificar fotografías"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1166,6 +1189,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Estado actual de plex.tv:" msgstr "Estado actual de plex.tv:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1176,6 +1204,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Series" msgstr "Series"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1243,6 +1276,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "Recargar Kodi para aplicar todos los ajustes." msgstr "Recargar Kodi para aplicar todos los ajustes."
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Terminar sesión del usuario de Plex Home " msgstr "Terminar sesión del usuario de Plex Home "

View file

@ -172,6 +172,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "Mise en cache de images pour PKC-seulement terminée" msgstr "Mise en cache de images pour PKC-seulement terminée"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Numéro de port" msgstr "Numéro de port"
@ -707,6 +715,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Forcer le transcodage des images" msgstr "Forcer le transcodage des images"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1180,6 +1203,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "État actuel de plex.tv: " msgstr "État actuel de plex.tv: "
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1190,6 +1218,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Séries TV" msgstr "Séries TV"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1257,6 +1290,31 @@ msgid "Reload Kodi node files to apply all the settings below"
msgstr "" msgstr ""
"Recharger les fichiers de Kodi pour appliquer tous les paramètres ci-dessous" "Recharger les fichiers de Kodi pour appliquer tous les paramètres ci-dessous"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Log-out Plex Home User " msgstr "Log-out Plex Home User "

View file

@ -176,6 +176,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "Mise en cache de images pour PKC-seulement terminée" msgstr "Mise en cache de images pour PKC-seulement terminée"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Numéro de port" msgstr "Numéro de port"
@ -711,6 +719,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Forcer le transcodage des images" msgstr "Forcer le transcodage des images"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1184,6 +1207,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "État actuel de plex.tv: " msgstr "État actuel de plex.tv: "
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1194,6 +1222,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Séries TV" msgstr "Séries TV"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1261,6 +1294,31 @@ msgid "Reload Kodi node files to apply all the settings below"
msgstr "" msgstr ""
"Recharger les fichiers de Kodi pour appliquer tous les paramètres ci-dessous" "Recharger les fichiers de Kodi pour appliquer tous les paramètres ci-dessous"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Log-out Plex Home User " msgstr "Log-out Plex Home User "

View file

@ -1,7 +1,7 @@
# XBMC Media Center language file # XBMC Media Center language file
# Translators: # Translators:
# Croneter None <croneter@gmail.com>, 2019 # Croneter None <croneter@gmail.com>, 2019
# Savage93 <savageistheking@gmail.com>, 2020 # Savage93 <savageistheking@gmail.com>, 2021
# #
msgid "" msgid ""
msgstr "" msgstr ""
@ -9,7 +9,7 @@ msgstr ""
"Report-Msgid-Bugs-To: croneter@gmail.com\n" "Report-Msgid-Bugs-To: croneter@gmail.com\n"
"POT-Creation-Date: 2017-04-15 13:13+0000\n" "POT-Creation-Date: 2017-04-15 13:13+0000\n"
"PO-Revision-Date: 2017-04-30 08:30+0000\n" "PO-Revision-Date: 2017-04-30 08:30+0000\n"
"Last-Translator: Savage93 <savageistheking@gmail.com>, 2020\n" "Last-Translator: Savage93 <savageistheking@gmail.com>, 2021\n"
"Language-Team: Hungarian (Hungary) (https://www.transifex.com/croneter/teams/73837/hu_HU/)\n" "Language-Team: Hungarian (Hungary) (https://www.transifex.com/croneter/teams/73837/hu_HU/)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@ -51,6 +51,10 @@ msgid ""
"random password automatically if you haven't done so already. Please confirm" "random password automatically if you haven't done so already. Please confirm"
" the next dialog that you want to enable the webserver now with Yes." " the next dialog that you want to enable the webserver now with Yes."
msgstr "" msgstr ""
"A művészképek gyorsítótárazásához szükség van a Kodi webszerverének "
"bekapcsolására. A PKC beállított egy erős, véletlenszerű jelszót ehhez, "
"amennyiben ezt korábban nem tette meg. Kérem erősítse meg a következő "
"dialógusablakban, hogy be kívánja kapcsolni a webszervert."
msgctxt "#30005" msgctxt "#30005"
msgid "Username: " msgid "Username: "
@ -169,6 +173,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "PKC képek gyorsítótárazása befejeződött" msgstr "PKC képek gyorsítótárazása befejeződött"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Portszám" msgstr "Portszám"
@ -616,7 +628,7 @@ msgstr "Szinkronizálni kívánt Plex könyvtárak kiválasztása"
# PKC Settings - Playback # PKC Settings - Playback
msgctxt "#30525" msgctxt "#30525"
msgid "Skip intro" msgid "Skip intro"
msgstr "" msgstr "Bevezető kihagyása"
# PKC Settings - Playback # PKC Settings - Playback
msgctxt "#30527" msgctxt "#30527"
@ -684,6 +696,8 @@ msgstr "Film-szett/kollekció képek letöltése a FanArtTV-ről"
msgctxt "#30541" msgctxt "#30541"
msgid "Transcoding: Auto-pick audio and subtitle stream using Plex defaults" msgid "Transcoding: Auto-pick audio and subtitle stream using Plex defaults"
msgstr "" msgstr ""
"Transzkódolás: hang- és feliratsávok automatikus kiválasztása a Plex "
"alapértelmezések alapján"
# PKC Settings - Playback # PKC Settings - Playback
msgctxt "#30542" msgctxt "#30542"
@ -704,6 +718,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Képek transzkódolásának kényszerítése" msgstr "Képek transzkódolásának kényszerítése"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -983,7 +1012,7 @@ msgstr ""
# PKC Settings - Customize Paths # PKC Settings - Customize Paths
msgctxt "#39090" msgctxt "#39090"
msgid "Safe characters for http(s), dav(s) and (s)ftp urls" msgid "Safe characters for http(s), dav(s) and (s)ftp urls"
msgstr "" msgstr "Biztonságos karakterek http(s), dav(s) és (s)ftp elérési utakhoz"
# PKC Settings - Customize Paths # PKC Settings - Customize Paths
msgctxt "#39037" msgctxt "#39037"
@ -1168,6 +1197,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Jelenlegi plex.tv állapot:" msgstr "Jelenlegi plex.tv állapot:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1178,6 +1212,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "TV sorozatok" msgstr "TV sorozatok"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1246,6 +1285,31 @@ msgid "Reload Kodi node files to apply all the settings below"
msgstr "" msgstr ""
"Kodi csomópont fájlok újratöltése az alábbi beállítások alkalmazásához" "Kodi csomópont fájlok újratöltése az alábbi beállítások alkalmazásához"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Kijelentkezés az otthoni Plex felhasználó fiókból: " msgstr "Kijelentkezés az otthoni Plex felhasználó fiókból: "

View file

@ -171,6 +171,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "Cache delle immagini di PKC completato" msgstr "Cache delle immagini di PKC completato"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Porta" msgstr "Porta"
@ -702,6 +710,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Forza transcodifica immagini" msgstr "Forza transcodifica immagini"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1169,6 +1192,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Stato attuale di plex.tv:" msgstr "Stato attuale di plex.tv:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1179,6 +1207,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Serie TV" msgstr "Serie TV"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1246,6 +1279,31 @@ msgstr ""
"Ricarica i nodi di file di Kodi per applicare tutte le impostazioni di " "Ricarica i nodi di file di Kodi per applicare tutte le impostazioni di "
"sotto" "sotto"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Logout utente Plex " msgstr "Logout utente Plex "

File diff suppressed because it is too large Load diff

View file

@ -171,6 +171,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "„PKC“-baigtas tik atvaizdžių podėliavimas" msgstr "„PKC“-baigtas tik atvaizdžių podėliavimas"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Prievado numeris" msgstr "Prievado numeris"
@ -698,6 +706,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Priverstinai perkoduoti nuotraukas" msgstr "Priverstinai perkoduoti nuotraukas"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1162,6 +1185,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Dabartinė „plex.tv“ būsena:" msgstr "Dabartinė „plex.tv“ būsena:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1172,6 +1200,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "TV Laidos" msgstr "TV Laidos"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1239,6 +1272,31 @@ msgstr ""
"Atnaujinkite „Kodi“ mazgų failus, kad galėtumėte taikyti visus toliau " "Atnaujinkite „Kodi“ mazgų failus, kad galėtumėte taikyti visus toliau "
"pateiktus nustatymus" "pateiktus nustatymus"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Atjungti „Plex“ namų vartotoją" msgstr "Atjungti „Plex“ namų vartotoją"

View file

@ -166,6 +166,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "Tikai-PKC attēlu kešošana pabeigta" msgstr "Tikai-PKC attēlu kešošana pabeigta"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Porta Numurs" msgstr "Porta Numurs"
@ -691,6 +699,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Uzspiest attēlu pārkodēšanu" msgstr "Uzspiest attēlu pārkodēšanu"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1144,6 +1167,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Pašreizējais plex.tv statuss:" msgstr "Pašreizējais plex.tv statuss:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1154,6 +1182,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Seriāli" msgstr "Seriāli"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1212,6 +1245,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "" msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "" msgstr ""

View file

@ -170,6 +170,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "PKC afbeelding caching voltooid" msgstr "PKC afbeelding caching voltooid"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Poortnummer" msgstr "Poortnummer"
@ -699,6 +707,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Forceer transcoden van foto's" msgstr "Forceer transcoden van foto's"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1156,6 +1179,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Huidige status van de plex.tv:" msgstr "Huidige status van de plex.tv:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1166,6 +1194,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "TV series" msgstr "TV series"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1234,6 +1267,31 @@ msgstr ""
"Herlaad de Kodi node bestanden om alles onderstaande instellingen door te " "Herlaad de Kodi node bestanden om alles onderstaande instellingen door te "
"voeren" "voeren"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Log-out Plex Home gebruiker " msgstr "Log-out Plex Home gebruiker "

View file

@ -172,6 +172,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "PKC mellomlagring av bilder gjennomført" msgstr "PKC mellomlagring av bilder gjennomført"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Portnummer" msgstr "Portnummer"
@ -696,6 +704,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Tving transkoding av bilde" msgstr "Tving transkoding av bilde"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1149,6 +1172,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Aktuell plex.tv statys:" msgstr "Aktuell plex.tv statys:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1159,6 +1187,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "TV-show" msgstr "TV-show"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1224,6 +1257,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "" msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Logg av Plex Home User" msgstr "Logg av Plex Home User"

File diff suppressed because it is too large Load diff

View file

@ -169,6 +169,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "Armazenamento PKC somente imagens finalizado" msgstr "Armazenamento PKC somente imagens finalizado"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Número da Porta" msgstr "Número da Porta"
@ -686,6 +694,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Forçar transcodificação de imagens" msgstr "Forçar transcodificação de imagens"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1145,6 +1168,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Estado atual da plex.tv:" msgstr "Estado atual da plex.tv:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1155,6 +1183,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Programas de TV" msgstr "Programas de TV"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1217,6 +1250,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "" msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Sair da sessão do Utilizador Caseiro Plex" msgstr "Sair da sessão do Utilizador Caseiro Plex"

View file

@ -170,6 +170,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "" msgstr ""
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Número da Porta" msgstr "Número da Porta"
@ -689,6 +697,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Forçar transcodificação de imagens" msgstr "Forçar transcodificação de imagens"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1148,6 +1171,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Estado atual da plex.tv:" msgstr "Estado atual da plex.tv:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1158,6 +1186,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Programas de TV" msgstr "Programas de TV"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1220,6 +1253,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "" msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Sair da sessão do Utilizador Caseiro Plex" msgstr "Sair da sessão do Utilizador Caseiro Plex"

View file

@ -173,6 +173,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "Кеширование изображений PKC завершено" msgstr "Кеширование изображений PKC завершено"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Порт" msgstr "Порт"
@ -703,6 +711,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Принудительно транскодировать изображения" msgstr "Принудительно транскодировать изображения"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1161,6 +1184,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Текущий статус на plex.tv:" msgstr "Текущий статус на plex.tv:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1171,6 +1199,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Сериалы" msgstr "Сериалы"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1236,6 +1269,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "Перезаписать узлы БД Kodi, чтобы применить следующие настройки" msgstr "Перезаписать узлы БД Kodi, чтобы применить следующие настройки"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Выйти из Plex" msgstr "Выйти из Plex"

View file

@ -173,6 +173,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "Cachelagring av PKC-bilder färdig" msgstr "Cachelagring av PKC-bilder färdig"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Portnummer" msgstr "Portnummer"
@ -698,6 +706,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Tvinga omkodning av bilder" msgstr "Tvinga omkodning av bilder"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1156,6 +1179,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Nuvarande plex.tv status:" msgstr "Nuvarande plex.tv status:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1166,6 +1194,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "TV-Serier" msgstr "TV-Serier"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1230,6 +1263,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "Ladda om Kodi-nodfiler för att applicera alla inställningar nedan" msgstr "Ladda om Kodi-nodfiler för att applicera alla inställningar nedan"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Logga ut Plex Home-användare" msgstr "Logga ut Plex Home-användare"

View file

@ -167,6 +167,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "Кешування зображень PKC завершено" msgstr "Кешування зображень PKC завершено"
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "Номер порту" msgstr "Номер порту"
@ -698,6 +706,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "Примусове перекодування зображень" msgstr "Примусове перекодування зображень"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1158,6 +1181,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "Поточний plex.tv статус:" msgstr "Поточний plex.tv статус:"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1168,6 +1196,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "Серіали" msgstr "Серіали"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1235,6 +1268,31 @@ msgid "Reload Kodi node files to apply all the settings below"
msgstr "" msgstr ""
"Перезавантажити файли вузла Kodi для застосування всіх наступних налаштувань" "Перезавантажити файли вузла Kodi для застосування всіх наступних налаштувань"
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "Вийти з профілю користувача Plex Home" msgstr "Вийти з профілю користувача Plex Home"

View file

@ -166,6 +166,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "" msgstr ""
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "端口号" msgstr "端口号"
@ -681,6 +689,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "强制图片转码" msgstr "强制图片转码"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1114,6 +1137,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "当前plex.tv状态" msgstr "当前plex.tv状态"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1124,6 +1152,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "电视节目" msgstr "电视节目"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1182,6 +1215,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "" msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "退出Plex家庭用户 " msgstr "退出Plex家庭用户 "

View file

@ -164,6 +164,14 @@ msgctxt "#30028"
msgid "PKC-only image caching completed" msgid "PKC-only image caching completed"
msgstr "" msgstr ""
# Warning shown when PKC switches to the Kodi default skin Estuary
msgctxt "#30029"
msgid ""
"To ensure a smooth PlexKodiConnect experience, it is HIGHLY recommended to "
"use Kodi's default skin \"Estuary\" for initial set-up and for possible "
"database resets. Continue?"
msgstr ""
msgctxt "#30030" msgctxt "#30030"
msgid "Port Number" msgid "Port Number"
msgstr "埠號" msgstr "埠號"
@ -679,6 +687,21 @@ msgctxt "#30545"
msgid "Force transcode pictures" msgid "Force transcode pictures"
msgstr "強制圖片轉碼" msgstr "強制圖片轉碼"
# PKC Settings - Playback
msgctxt "#30546"
msgid "Pick the first video if several versions are present"
msgstr ""
# PKC Settings - Playback
msgctxt "#30547"
msgid "Who picks the audio stream on playback start?"
msgstr ""
# PKC Settings - Playback
msgctxt "#30548"
msgid "Who picks subtitles on playback start?"
msgstr ""
# Welcome to Plex notification # Welcome to Plex notification
msgctxt "#33000" msgctxt "#33000"
msgid "Welcome" msgid "Welcome"
@ -1110,6 +1133,11 @@ msgctxt "#39071"
msgid "Current plex.tv status:" msgid "Current plex.tv status:"
msgstr "plex.tv 狀態︰" msgstr "plex.tv 狀態︰"
# PKC Settings - Connection
msgctxt "#39072"
msgid "Background sync connection:"
msgstr ""
# PKC Settings, category name # PKC Settings, category name
msgctxt "#39073" msgctxt "#39073"
msgid "Appearance Tweaks" msgid "Appearance Tweaks"
@ -1120,6 +1148,11 @@ msgctxt "#39074"
msgid "TV Shows" msgid "TV Shows"
msgstr "電視節目" msgstr "電視節目"
# PKC Settings - Sync
msgctxt "#39075"
msgid "Verify access to media files while synching"
msgstr ""
# Pop-up during initial sync # Pop-up during initial sync
msgctxt "#39076" msgctxt "#39076"
msgid "" msgid ""
@ -1178,6 +1211,31 @@ msgctxt "#39085"
msgid "Reload Kodi node files to apply all the settings below" msgid "Reload Kodi node files to apply all the settings below"
msgstr "" msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39089"
msgid "Alexa connection status:"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39091"
msgid "Timeout - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39092"
msgid "IOError - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39093"
msgid "Suspended - not connected"
msgstr ""
# PKC Settings - Connection - Background sync connection status
msgctxt "#39094"
msgid "Managed Plex User - not connected"
msgstr ""
msgctxt "#39200" msgctxt "#39200"
msgid "Log-out Plex Home User " msgid "Log-out Plex Home User "
msgstr "登出Plex Home用戶 " msgstr "登出Plex Home用戶 "

View file

@ -47,8 +47,8 @@ class App(object):
self.monitor = None self.monitor = None
# xbmc.Player() instance # xbmc.Player() instance
self.player = None self.player = None
# Instance of MetadataThread() # Instance of FanartThread()
self.metadata_thread = None self.fanart_thread = None
# Instance of ImageCachingThread() # Instance of ImageCachingThread()
self.caching_thread = None self.caching_thread = None
# Dialog to skip intro # Dialog to skip intro
@ -62,24 +62,24 @@ class App(object):
def is_playing_video(self): def is_playing_video(self):
return self.player.isPlayingVideo() == 1 return self.player.isPlayingVideo() == 1
def register_metadata_thread(self, thread): def register_fanart_thread(self, thread):
self.metadata_thread = thread self.fanart_thread = thread
self.threads.append(thread) self.threads.append(thread)
def deregister_metadata_thread(self, thread): def deregister_fanart_thread(self, thread):
self.metadata_thread.unblock_callers() self.fanart_thread.unblock_callers()
self.metadata_thread = None self.fanart_thread = None
self.threads.remove(thread) self.threads.remove(thread)
def suspend_metadata_thread(self, block=True): def suspend_fanart_thread(self, block=True):
try: try:
self.metadata_thread.suspend(block=block) self.fanart_thread.suspend(block=block)
except AttributeError: except AttributeError:
pass pass
def resume_metadata_thread(self): def resume_fanart_thread(self):
try: try:
self.metadata_thread.resume() self.fanart_thread.resume()
except AttributeError: except AttributeError:
pass pass

View file

@ -57,8 +57,6 @@ class Sync(object):
# How often shall we sync? # How often shall we sync?
self.full_sync_intervall = None self.full_sync_intervall = None
# Background Sync disabled?
self.background_sync_disabled = None
# How long shall we wait with synching a new item to make sure Plex got all # How long shall we wait with synching a new item to make sure Plex got all
# metadata? # metadata?
self.backgroundsync_saftymargin = None self.backgroundsync_saftymargin = None
@ -81,7 +79,6 @@ class Sync(object):
# List of section_ids we're synching to Kodi - will be automatically # List of section_ids we're synching to Kodi - will be automatically
# re-built if sections are set a-new # re-built if sections are set a-new
self.section_ids = set() self.section_ids = set()
self.enable_alexa = None
self.load() self.load()
@ -122,8 +119,6 @@ class Sync(object):
Any settings unrelated to syncs to the Kodi database - can thus be Any settings unrelated to syncs to the Kodi database - can thus be
safely reset without a Kodi reboot safely reset without a Kodi reboot
""" """
self.background_sync_disabled = utils.settings('enableBackgroundSync') == 'false'
self.enable_alexa = utils.settings('enable_alexa') == 'true'
self.sync_dialog = utils.settings('dbSyncIndicator') == 'true' self.sync_dialog = utils.settings('dbSyncIndicator') == 'true'
self.full_sync_intervall = int(utils.settings('fullSyncInterval')) * 60 self.full_sync_intervall = int(utils.settings('fullSyncInterval')) * 60
self.backgroundsync_saftymargin = int(utils.settings('backgroundsync_saftyMargin')) self.backgroundsync_saftymargin = int(utils.settings('backgroundsync_saftyMargin'))

View file

@ -135,43 +135,6 @@ class ProcessingQueue(Queue.Queue, object):
def _qsize(self): def _qsize(self):
return self._current_queue._qsize() if self._current_queue else 0 return self._current_queue._qsize() if self._current_queue else 0
def _total_qsize(self):
"""
This method is BROKEN as it can lead to a deadlock when a single item
from the current section takes longer to download then any new items
coming in
"""
return sum(q._qsize() for q in self._queues) if self._queues else 0
def put(self, item, block=True, timeout=None):
"""
PKC customization of Queue.put. item needs to be the tuple
(count [int], {'section': [Section], 'xml': [etree xml]})
"""
self.not_full.acquire()
try:
if self.maxsize > 0:
if not block:
if self._qsize() == self.maxsize:
raise Queue.Full
elif timeout is None:
while self._qsize() == self.maxsize:
self.not_full.wait()
elif timeout < 0:
raise ValueError("'timeout' must be a non-negative number")
else:
endtime = _time() + timeout
while self._qsize() == self.maxsize:
remaining = endtime - _time()
if remaining <= 0.0:
raise Queue.Full
self.not_full.wait(remaining)
self._put(item)
self.unfinished_tasks += 1
self.not_empty.notify()
finally:
self.not_full.release()
def _put(self, item): def _put(self, item):
for i, section in enumerate(self._sections): for i, section in enumerate(self._sections):
if item[1]['section'] == section: if item[1]['section'] == section:
@ -188,16 +151,13 @@ class ProcessingQueue(Queue.Queue, object):
Once the get()-method returns None, you've received the sentinel and Once the get()-method returns None, you've received the sentinel and
you've thus exhausted the queue you've thus exhausted the queue
""" """
self.not_full.acquire() with self.not_full:
try:
section.number_of_items = 1 section.number_of_items = 1
self._add_section(section) self._add_section(section)
# Add the actual sentinel to the queue we just added # Add the actual sentinel to the queue we just added
self._queues[-1]._put((None, None)) self._queues[-1]._put((None, None))
self.unfinished_tasks += 1 self.unfinished_tasks += 1
self.not_empty.notify() self.not_empty.notify()
finally:
self.not_full.release()
def add_section(self, section): def add_section(self, section):
""" """
@ -207,11 +167,26 @@ class ProcessingQueue(Queue.Queue, object):
Be sure to set section.number_of_items correctly as it will signal Be sure to set section.number_of_items correctly as it will signal
when processing is completely done for a specific section! when processing is completely done for a specific section!
""" """
self.mutex.acquire() with self.mutex:
try:
self._add_section(section) self._add_section(section)
finally:
self.mutex.release() 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): def _add_section(self, section):
self._sections.append(section) self._sections.append(section)

View file

@ -49,7 +49,7 @@ def convert_alexa_to_companion(dictionary):
""" """
The params passed by Alexa must first be converted to Companion talk The params passed by Alexa must first be converted to Companion talk
""" """
for key in dictionary: for key in list(dictionary):
if key in v.ALEXA_TO_COMPANION: if key in v.ALEXA_TO_COMPANION:
dictionary[v.ALEXA_TO_COMPANION[key]] = dictionary[key] dictionary[v.ALEXA_TO_COMPANION[key]] = dictionary[key]
del dictionary[key] del dictionary[key]

View file

@ -4,18 +4,13 @@ import sqlite3
from functools import wraps from functools import wraps
from . import variables as v, app from . import variables as v, app
from .exceptions import LockedDatabase
DB_WRITE_ATTEMPTS = 100 DB_WRITE_ATTEMPTS = 100
DB_WRITE_ATTEMPTS_TIMEOUT = 1 # in seconds
DB_CONNECTION_TIMEOUT = 10 DB_CONNECTION_TIMEOUT = 10
class LockedDatabase(Exception):
"""
Dedicated class to make sure we're not silently catching locked DBs.
"""
pass
def catch_operationalerrors(method): def catch_operationalerrors(method):
""" """
sqlite.OperationalError is raised immediately if another DB connection sqlite.OperationalError is raised immediately if another DB connection
@ -43,7 +38,7 @@ def catch_operationalerrors(method):
self.kodiconn.commit() self.kodiconn.commit()
if self.artconn: if self.artconn:
self.artconn.commit() self.artconn.commit()
if app.APP.monitor.waitForAbort(0.1): if app.APP.monitor.waitForAbort(DB_WRITE_ATTEMPTS_TIMEOUT):
# PKC needs to quit # PKC needs to quit
return return
# Start new transactions # Start new transactions

View file

@ -224,7 +224,11 @@ class DownloadUtils():
if r.status_code != 401: if r.status_code != 401:
self.count_unauthorized = 0 self.count_unauthorized = 0
if r.status_code == 204: if return_response is True:
# return the entire response object
return r
elif r.status_code == 204:
# No body in the response # No body in the response
# But read (empty) content to release connection back to pool # But read (empty) content to release connection back to pool
# (see requests: keep-alive documentation) # (see requests: keep-alive documentation)
@ -258,9 +262,6 @@ class DownloadUtils():
elif r.status_code in (200, 201): elif r.status_code in (200, 201):
# 200: OK # 200: OK
# 201: Created # 201: Created
if return_response is True:
# return the entire response object
return r
try: try:
# xml response # xml response
r = utils.defused_etree.fromstring(r.content) r = utils.defused_etree.fromstring(r.content)

View file

@ -7,6 +7,7 @@ e.g. plugin://... calls. Hence be careful to only rely on window variables.
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
from logging import getLogger from logging import getLogger
import sys import sys
import copy
import xbmc import xbmc
import xbmcplugin import xbmcplugin
@ -423,6 +424,7 @@ def hub(content_type):
# We need to make sure that only entries that WORK are displayed # We need to make sure that only entries that WORK are displayed
# WARNING: using xml.remove(child) in for-loop requires traversing from # WARNING: using xml.remove(child) in for-loop requires traversing from
# the end! # the end!
pkc_cont_watching = None
for entry in reversed(xml): for entry in reversed(xml):
api = API(entry) api = API(entry)
append = False append = False
@ -439,6 +441,21 @@ def hub(content_type):
append = True append = True
if not append: if not append:
xml.remove(entry) xml.remove(entry)
# HACK ##################
# Merge Plex's "Continue watching" with "On deck"
if entry.get('key') == '/hubs/home/continueWatching':
pkc_cont_watching = copy.deepcopy(entry)
pkc_cont_watching.set('key', '/hubs/continueWatching')
title = pkc_cont_watching.get('title') or 'Continue Watching'
pkc_cont_watching.set('title', 'PKC %s' % title)
if pkc_cont_watching:
for i, entry in enumerate(xml):
if entry.get('key') == '/hubs/home/continueWatching':
xml.insert(i + 1, pkc_cont_watching)
break
# END HACK ##################
show_listing(xml) show_listing(xml)

View file

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

View file

@ -133,7 +133,6 @@ class Movie(ItemBase):
kodi_id=kodi_id, kodi_id=kodi_id,
kodi_fileid=file_id, kodi_fileid=file_id,
kodi_pathid=kodi_pathid, kodi_pathid=kodi_pathid,
trailer_synced=bool(api.trailer()),
last_sync=self.last_sync) last_sync=self.last_sync)
def remove(self, plex_id, plex_type=None): def remove(self, plex_id, plex_type=None):

View file

@ -1,40 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
import logging
import os
import sys
import xbmcvfs
import xbmcaddon
# Import the existing Kodi add-on metadata.themoviedb.org.python
__ADDON__ = xbmcaddon.Addon(id='metadata.themoviedb.org.python')
__TEMP_PATH__ = os.path.join(__ADDON__.getAddonInfo('path').decode('utf-8'),
'python',
'lib')
__BASE__ = xbmcvfs.translatePath(__TEMP_PATH__.encode('utf-8')).decode('utf-8')
sys.path.append(__BASE__)
import tmdbscraper.tmdb as tmdb
logger = logging.getLogger('PLEX.movies_tmdb')
def get_tmdb_scraper(settings):
language = settings.getSettingString('language').decode('utf-8')
certcountry = settings.getSettingString('tmdbcertcountry').decode('utf-8')
return tmdb.TMDBMovieScraper(__ADDON__, language, certcountry)
# Instantiate once in order to prevent having to re-read the add-on settings
# for every single movie
__SCRAPER__ = get_tmdb_scraper(__ADDON__)
def get_tmdb_details(unique_ids):
details = __SCRAPER__.get_details(unique_ids)
LOG.error('details type. %s', type(details))
if 'error' in details:
logger.debug('Could not get tmdb details for %s. Error: %s',
unique_ids, details)
return details

View file

@ -270,6 +270,7 @@ class Show(TvShowMixin, ItemBase):
unique_ids.get('imdb', unique_ids.get('imdb',
unique_ids.get('tmdb'))) unique_ids.get('tmdb')))
class Season(TvShowMixin, ItemBase): class Season(TvShowMixin, ItemBase):
def add_update(self, xml, section_name=None, section_id=None, def add_update(self, xml, section_name=None, section_id=None,
children=None): children=None):
@ -279,7 +280,7 @@ class Season(TvShowMixin, ItemBase):
api = API(xml) api = API(xml)
if not self.sync_this_item(section_id or api.library_section_id()): 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 ' LOG.debug('Skipping sync of %s %s: %s - section %s not synched to '
'Kodi', api.plex_type, api.plex_id, api.title(), 'Kodi', api.plex_type, api.plex_id, api.season_name(),
section_id or api.library_section_id()) section_id or api.library_section_id())
return return
plex_id = api.plex_id plex_id = api.plex_id
@ -317,15 +318,24 @@ class Season(TvShowMixin, ItemBase):
if key in artwork and artwork[key] == parent_artwork[key]: if key in artwork and artwork[key] == parent_artwork[key]:
del artwork[key] del artwork[key]
if update_item: if update_item:
LOG.info('UPDATE season plex_id %s - %s', plex_id, api.title()) LOG.info('UPDATE season plex_id %s - %s',
plex_id, api.season_name())
kodi_id = season['kodi_id'] 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: if app.SYNC.artwork:
self.kodidb.modify_artwork(artwork, self.kodidb.modify_artwork(artwork,
kodi_id, kodi_id,
v.KODI_TYPE_SEASON) v.KODI_TYPE_SEASON)
else: else:
LOG.info('ADD season plex_id %s - %s', plex_id, api.title()) LOG.info('ADD season plex_id %s - %s', plex_id, api.season_name())
kodi_id = self.kodidb.add_season(parent_id, api.index()) kodi_id = self.kodidb.add_season(parent_id,
api.index(),
api.season_name(),
api.userrating() or None)
if app.SYNC.artwork: if app.SYNC.artwork:
self.kodidb.add_artwork(artwork, self.kodidb.add_artwork(artwork,
kodi_id, kodi_id,

View file

@ -421,6 +421,41 @@ def get_item(playerid):
'properties': ['title', 'file']})['result']['item'] 'properties': ['title', 'file']})['result']['item']
def get_current_audio_stream_index(playerid):
"""
Returns the currently active audio stream index [int]
"""
return JsonRPC('Player.GetProperties').execute({
'playerid': playerid,
'properties': ['currentaudiostream']})['result']['currentaudiostream']['index']
def get_current_subtitle_stream_index(playerid):
"""
Returns the currently active subtitle stream index [int] or None if there
are no subs
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE! The
JSON reply won't change even though subtitles are changed :-(
"""
try:
return JsonRPC('Player.GetProperties').execute({
'playerid': playerid,
'properties': ['currentsubtitle', ]})['result']['currentsubtitle']['index']
except KeyError:
pass
def get_subtitle_enabled(playerid):
"""
Returns True if a subtitle is currently enabled, False otherwise.
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE! The
JSON reply won't change even though subtitles are changed :-(
"""
return JsonRPC('Player.GetProperties').execute({
'playerid': playerid,
'properties': ['subtitleenabled', ]})['result']['subtitleenabled']
def get_player_props(playerid): def get_player_props(playerid):
""" """
Returns a dict for the active Kodi player with the following values: Returns a dict for the active Kodi player with the following values:

View file

@ -479,33 +479,6 @@ class KodiVideoDB(common.KodiDBBase):
(kodi_id, kodi_type)) (kodi_id, kodi_type))
return dict(self.cursor.fetchall()) return dict(self.cursor.fetchall())
def get_trailer(self, kodi_id, kodi_type):
"""
Returns the trailer's URL for kodi_type from the Kodi database or None
"""
if kodi_type == v.KODI_TYPE_MOVIE:
self.cursor.execute('SELECT c19 FROM movie WHERE idMovie=?',
(kodi_id, ))
else:
raise NotImplementedError('trailers for %s not implemented'
% kodi_type)
try:
return self.cursor.fetchone()[0]
except TypeError:
pass
@db.catch_operationalerrors
def set_trailer(self, kodi_id, kodi_type, url):
"""
Writes the trailer's url to the Kodi DB
"""
if kodi_type == v.KODI_TYPE_MOVIE:
self.cursor.execute('UPDATE movie SET c19=? WHERE idMovie=?',
(url, kodi_id))
else:
raise NotImplementedError('trailers for not implemented'
% kodi_type)
@db.catch_operationalerrors @db.catch_operationalerrors
def modify_streams(self, fileid, streamdetails=None, runtime=None): def modify_streams(self, fileid, streamdetails=None, runtime=None):
""" """
@ -602,6 +575,22 @@ class KodiVideoDB(common.KodiDBBase):
return return
return movie_id, typus return movie_id, typus
def file_id_from_id(self, kodi_id, kodi_type):
"""
Returns the Kodi file_id for the item with kodi_id and kodi_type or
None
"""
if kodi_type == v.KODI_TYPE_MOVIE:
identifier = 'idMovie'
elif kodi_type == v.KODI_TYPE_EPISODE:
identifier = 'idEpisode'
self.cursor.execute('SELECT idFile FROM %s WHERE %s = ? LIMIT 1'
% (kodi_type, identifier), (kodi_id, ))
try:
return self.cursor.fetchone()[0]
except TypeError:
pass
def get_resume(self, file_id): def get_resume(self, file_id):
""" """
Returns the first resume point in seconds (int) if found, else None for Returns the first resume point in seconds (int) if found, else None for
@ -745,15 +734,32 @@ class KodiVideoDB(common.KodiDBBase):
self.cursor.execute('DELETE FROM sets WHERE idSet = ?', (set_id,)) self.cursor.execute('DELETE FROM sets WHERE idSet = ?', (set_id,))
@db.catch_operationalerrors @db.catch_operationalerrors
def add_season(self, showid, seasonnumber): def add_season(self, showid, seasonnumber, name, userrating):
""" """
Adds a TV show season to the Kodi video DB or simply returns the ID, Adds a TV show season to the Kodi video DB or simply returns the ID,
if there already is an entry in the DB if there already is an entry in the DB
""" """
self.cursor.execute('INSERT INTO seasons(idShow, season) VALUES (?, ?)', self.cursor.execute('''
(showid, seasonnumber)) INSERT INTO seasons(
idShow, season, name, userrating)
VALUES (?, ?, ?, ?)
''', (showid, seasonnumber, name, userrating))
return self.cursor.lastrowid return self.cursor.lastrowid
@db.catch_operationalerrors
def update_season(self, seasonid, showid, seasonnumber, name, userrating):
"""
Updates a TV show season with a certain seasonid
"""
self.cursor.execute('''
UPDATE seasons
SET idShow = ?,
season = ?,
name = ?,
userrating = ?
WHERE idSeason = ?
''', (showid, seasonnumber, name, userrating, seasonid))
@db.catch_operationalerrors @db.catch_operationalerrors
def add_uniqueid(self, *args): def add_uniqueid(self, *args):
""" """

View file

@ -14,11 +14,13 @@ import xbmc
from .plex_api import API from .plex_api import API
from .plex_db import PlexDB from .plex_db import PlexDB
from .kodi_db import KodiVideoDB
from . import kodi_db from . import kodi_db
from .downloadutils import DownloadUtils as DU from .downloadutils import DownloadUtils as DU
from . import utils, timing, plex_functions as PF from . import utils, timing, plex_functions as PF
from . import json_rpc as js, playqueue as PQ, playlist_func as PL from . import json_rpc as js, playqueue as PQ, playlist_func as PL
from . import backgroundthread, app, variables as v from . import backgroundthread, app, variables as v
from . import exceptions
LOG = getLogger('PLEX.kodimonitor') LOG = getLogger('PLEX.kodimonitor')
@ -27,8 +29,10 @@ class KodiMonitor(xbmc.Monitor):
""" """
PKC implementation of the Kodi Monitor class. Invoke only once. PKC implementation of the Kodi Monitor class. Invoke only once.
""" """
def __init__(self): def __init__(self):
self._already_slept = False self._already_slept = False
self._switched_to_plex_streams = True
xbmc.Monitor.__init__(self) xbmc.Monitor.__init__(self)
for playerid in app.PLAYSTATE.player_states: for playerid in app.PLAYSTATE.player_states:
app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template) app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
@ -64,6 +68,9 @@ class KodiMonitor(xbmc.Monitor):
if method == "Player.OnPlay": if method == "Player.OnPlay":
with app.APP.lock_playqueues: with app.APP.lock_playqueues:
self.PlayBackStart(data) self.PlayBackStart(data)
elif method == 'Player.OnAVChange':
with app.APP.lock_playqueues:
self._on_av_change(data)
elif method == "Player.OnStop": elif method == "Player.OnStop":
with app.APP.lock_playqueues: with app.APP.lock_playqueues:
_playback_cleanup(ended=data.get('end')) _playback_cleanup(ended=data.get('end'))
@ -82,6 +89,7 @@ class KodiMonitor(xbmc.Monitor):
with app.APP.lock_playqueues: with app.APP.lock_playqueues:
self._playlist_onclear(data) self._playlist_onclear(data)
elif method == "VideoLibrary.OnUpdate": elif method == "VideoLibrary.OnUpdate":
with app.APP.lock_playqueues:
_videolibrary_onupdate(data) _videolibrary_onupdate(data)
elif method == "VideoLibrary.OnRemove": elif method == "VideoLibrary.OnRemove":
pass pass
@ -175,7 +183,7 @@ class KodiMonitor(xbmc.Monitor):
try: try:
for i, item in enumerate(items): for i, item in enumerate(items):
PL.add_item_to_plex_playqueue(playqueue, i + 1, kodi_item=item) PL.add_item_to_plex_playqueue(playqueue, i + 1, kodi_item=item)
except PL.PlaylistError: except exceptions.PlaylistError:
LOG.info('Could not build Plex playlist for: %s', items) LOG.info('Could not build Plex playlist for: %s', items)
def _json_item(self, playerid): def _json_item(self, playerid):
@ -289,7 +297,7 @@ class KodiMonitor(xbmc.Monitor):
LOG.debug('Detected different path') LOG.debug('Detected different path')
try: try:
tmp_plex_id = int(utils.REGEX_PLEX_ID.findall(path)[0]) tmp_plex_id = int(utils.REGEX_PLEX_ID.findall(path)[0])
except IndexError: except (IndexError, TypeError):
LOG.debug('No Plex id in path, need to init playqueue') LOG.debug('No Plex id in path, need to init playqueue')
initialize = True initialize = True
else: else:
@ -312,7 +320,7 @@ class KodiMonitor(xbmc.Monitor):
return return
try: try:
item = PL.init_plex_playqueue(playqueue, plex_id=plex_id) item = PL.init_plex_playqueue(playqueue, plex_id=plex_id)
except PL.PlaylistError: except exceptions.PlaylistError:
LOG.info('Could not initialize the Plex playlist') LOG.info('Could not initialize the Plex playlist')
return return
item.file = path item.file = path
@ -337,8 +345,7 @@ class KodiMonitor(xbmc.Monitor):
container_key = '/library/metadata/%s' % plex_id container_key = '/library/metadata/%s' % plex_id
# Mechanik for Plex skip intro feature # Mechanik for Plex skip intro feature
if utils.settings('enableSkipIntro') == 'true': if utils.settings('enableSkipIntro') == 'true':
api = API(item.xml) status['intro_markers'] = item.api.intro_markers()
status['intro_markers'] = api.intro_markers()
# Remember the currently playing item # Remember the currently playing item
app.PLAYSTATE.item = item app.PLAYSTATE.item = item
# Remember that this player has been active # Remember that this player has been active
@ -353,14 +360,44 @@ class KodiMonitor(xbmc.Monitor):
status['plex_type'] = plex_type status['plex_type'] = plex_type
status['playmethod'] = item.playmethod status['playmethod'] = item.playmethod
status['playcount'] = item.playcount status['playcount'] = item.playcount
try:
status['external_player'] = app.APP.player.isExternalPlayer() == 1 status['external_player'] = app.APP.player.isExternalPlayer() == 1
except AttributeError:
# Kodi version < 17
pass
LOG.debug('Set the player state: %s', status) LOG.debug('Set the player state: %s', status)
# Workaround for the Kodi add-on Up Next
if not app.SYNC.direct_paths: if not app.SYNC.direct_paths:
_notify_upnext(item) _notify_upnext(item)
self._switched_to_plex_streams = False
def _on_av_change(self, data):
"""
Will be called when Kodi has a video, audio or subtitle stream. Also
happens when the stream changes.
Example data as returned by Kodi:
{'item': {'id': 5, 'type': 'movie'},
'player': {'playerid': 1, 'speed': 1}}
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE!
Kodi subs will never change. Also see json_rpc.py
"""
playerid = data['player']['playerid']
if not playerid == v.KODI_VIDEO_PLAYER_ID:
# We're just messing with Kodi's videoplayer
return
item = app.PLAYSTATE.item
if item is None:
# Player might've quit
return
if not self._switched_to_plex_streams:
# We need to switch to the Plex streams ONCE upon playback start
# after onavchange has been fired
if utils.settings('audioStreamPick') == '0':
item.switch_to_plex_stream('audio')
if utils.settings('subtitleStreamPick') == '0':
item.switch_to_plex_stream('subtitle')
self._switched_to_plex_streams = True
else:
item.on_av_change(playerid)
def _playback_cleanup(ended=False): def _playback_cleanup(ended=False):
@ -536,11 +573,10 @@ def _next_episode(current_api):
current_api.grandparent_title()) current_api.grandparent_title())
return return
try: try:
next_api = API(xml[counter + 1]) return API(xml[counter + 1])
except IndexError: except IndexError:
# Was the last episode # Was the last episode
return pass
return next_api
def _complete_artwork_keys(info): def _complete_artwork_keys(info):
@ -566,7 +602,7 @@ def _notify_upnext(item):
""" """
if not item.plex_type == v.PLEX_TYPE_EPISODE: if not item.plex_type == v.PLEX_TYPE_EPISODE:
return return
this_api = API(item.xml) this_api = item.api
next_api = _next_episode(this_api) next_api = _next_episode(this_api)
if next_api is None: if next_api is None:
return return
@ -602,17 +638,34 @@ def _videolibrary_onupdate(data):
A specific Kodi library item has been updated. This seems to happen if the 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 user marks an item as watched/unwatched or if playback of the item just
stopped 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}
""" """
playcount = data.get('playcount') item = data.get('item') if 'item' in data else data
item = data.get('item')
if playcount is None or item is None:
return
try: try:
kodi_id = item['id'] kodi_id = item['id']
kodi_type = item['type'] kodi_type = item['type']
except (KeyError, TypeError): except (KeyError, TypeError):
LOG.info("Item is invalid for playstate update.") LOG.debug("Item is invalid for a Plex playstate update")
return 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 \ if app.PLAYSTATE.item and kodi_id == app.PLAYSTATE.item.kodi_id and \
kodi_type == app.PLAYSTATE.item.kodi_type: kodi_type == app.PLAYSTATE.item.kodi_type:
# Kodi updates an item immediately after playback. Hence we do NOT # Kodi updates an item immediately after playback. Hence we do NOT

View file

@ -5,5 +5,5 @@ from .full_sync import start
from .websocket import store_websocket_message, process_websocket_messages, \ from .websocket import store_websocket_message, process_websocket_messages, \
WEBSOCKET_MESSAGES, PLAYSTATE_SESSIONS WEBSOCKET_MESSAGES, PLAYSTATE_SESSIONS
from .common import update_kodi_library, PLAYLIST_SYNC_ENABLED from .common import update_kodi_library, PLAYLIST_SYNC_ENABLED
from .additional_metadata import MetadataThread, ProcessMetadataTask from .fanart import FanartThread, FanartTask
from .sections import force_full_sync, delete_files, clear_window_vars from .sections import force_full_sync, delete_files, clear_window_vars

View file

@ -1,118 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
from . import additional_metadata_tmdb
from ..plex_db import PlexDB
from .. import backgroundthread, utils
from .. import variables as v, app
logger = getLogger('PLEX.sync.metadata')
BATCH_SIZE = 500
SUPPORTED_METADATA = {
v.PLEX_TYPE_MOVIE: (
('missing_trailers', additional_metadata_tmdb.process_trailers),
('missing_fanart', additional_metadata_tmdb.process_fanart),
),
v.PLEX_TYPE_SHOW: (
('missing_fanart', additional_metadata_tmdb.process_fanart),
),
}
class ProcessingNotDone(Exception):
"""Exception to detect whether we've completed our sync and did not have to
abort or suspend."""
pass
def processing_is_activated(item_getter):
"""Checks the PKC settings whether processing is even activated."""
if item_getter == 'missing_fanart':
return utils.settings('FanartTV') == 'true'
return True
class MetadataThread(backgroundthread.KillableThread):
"""This will potentially take hours!"""
def __init__(self, callback, refresh=False):
self.callback = callback
self.refresh = refresh
super(MetadataThread, self).__init__()
def should_suspend(self):
return self._suspended or app.APP.is_playing_video
def _process_in_batches(self, item_getter, processor, plex_type):
offset = 0
while True:
with PlexDB() as plexdb:
# Keep DB connection open only for a short period of time!
if self.refresh:
# Simply grab every single item if we want to refresh
func = plexdb.every_plex_id
else:
func = getattr(plexdb, item_getter)
batch = list(func(plex_type, offset, BATCH_SIZE))
for plex_id in batch:
# Do the actual, time-consuming processing
if self.should_suspend() or self.should_cancel():
raise ProcessingNotDone()
processor(plex_id, plex_type, self.refresh)
if len(batch) < BATCH_SIZE:
break
offset += BATCH_SIZE
def _loop(self):
for plex_type in SUPPORTED_METADATA:
for item_getter, processor in SUPPORTED_METADATA[plex_type]:
if not processing_is_activated(item_getter):
continue
self._process_in_batches(item_getter, processor, plex_type)
def _run(self):
finished = False
while not finished:
try:
self._loop()
except ProcessingNotDone:
finished = False
else:
finished = True
if self.wait_while_suspended():
break
logger.info('MetadataThread finished completely: %s', finished)
self.callback(finished)
def run(self):
logger.info('Starting MetadataThread')
app.APP.register_metadata_thread(self)
try:
self._run()
except Exception:
utils.ERROR(notify=True)
finally:
app.APP.deregister_metadata_thread(self)
class ProcessMetadataTask(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):
if self.plex_type not in SUPPORTED_METADATA:
return
for item_getter, processor in SUPPORTED_METADATA[self.plex_type]:
if self.should_cancel():
# Just don't process this item at all. Next full sync will
# take care of it
return
if not processing_is_activated(item_getter):
continue
processor(self.plex_id, self.plex_type, self.refresh)

View file

@ -1,156 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
import logging
import os
import sys
import xbmcvfs
import xbmcaddon
from ..plex_api import API
from ..kodi_db import KodiVideoDB
from ..plex_db import PlexDB
from .. import itemtypes, plex_functions as PF, utils, variables as v
# Import the existing Kodi add-on metadata.themoviedb.org.python
__ADDON__ = xbmcaddon.Addon(id='metadata.themoviedb.org.python')
__TEMP_PATH__ = os.path.join(__ADDON__.getAddonInfo('path').decode('utf-8'),
'python',
'lib')
__BASE__ = xbmcvfs.translatePath(__TEMP_PATH__.encode('utf-8')).decode('utf-8')
sys.path.append(__BASE__)
import tmdbscraper.tmdb as tmdb
logger = logging.getLogger('PLEX.metadata_movies')
PREFER_KODI_COLLECTION_ART = utils.settings('PreferKodiCollectionArt') == 'false'
TMDB_SUPPORTED_IDS = ('tmdb', 'imdb')
def get_tmdb_scraper(settings):
language = settings.getSettingString('language').decode('utf-8')
certcountry = settings.getSettingString('tmdbcertcountry').decode('utf-8')
return tmdb.TMDBMovieScraper(settings, language, certcountry)
def get_tmdb_details(unique_ids):
settings = xbmcaddon.Addon(id='metadata.themoviedb.org.python')
details = get_tmdb_scraper(settings).get_details(unique_ids)
if 'error' in details:
logger.debug('Could not get tmdb details for %s. Error: %s',
unique_ids, details)
return details
def process_trailers(plex_id, plex_type, refresh=False):
done = True
try:
with PlexDB() as plexdb:
db_item = plexdb.item_by_id(plex_id, plex_type)
if not db_item:
logger.error('Could not get Kodi id for %s %s', plex_type, plex_id)
done = False
return
with KodiVideoDB() as kodidb:
trailer = kodidb.get_trailer(db_item['kodi_id'],
db_item['kodi_type'])
if trailer and (trailer.startswith('plugin://' + v.ADDON_ID) or
not refresh):
# No need to get a trailer
return
logger.debug('Processing trailer for %s %s', plex_type, plex_id)
xml = PF.GetPlexMetadata(plex_id)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
logger.warn('Could not get metadata for %s. Skipping that %s '
'for now', plex_id, plex_type)
done = False
return
api = API(xml[0])
if (not api.guids or
not [x for x in api.guids if x in TMDB_SUPPORTED_IDS]):
logger.debug('No unique ids found for %s %s, cannot get a trailer',
plex_type, api.title())
return
trailer = get_tmdb_details(api.guids)
trailer = trailer.get('info', {}).get('trailer')
if trailer:
with KodiVideoDB() as kodidb:
kodidb.set_trailer(db_item['kodi_id'],
db_item['kodi_type'],
trailer)
logger.debug('Found a new trailer for %s %s: %s',
plex_type, api.title(), trailer)
else:
logger.debug('No trailer found for %s %s', plex_type, api.title())
finally:
if done is True:
with PlexDB() as plexdb:
plexdb.set_trailer_synced(plex_id, plex_type)
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 = True
try:
artworks = None
with PlexDB() as plexdb:
db_item = plexdb.item_by_id(plex_id, plex_type)
if not db_item:
logger.error('Could not get Kodi id for %s %s', plex_type, plex_id)
done = False
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:
return
xml = PF.GetPlexMetadata(plex_id)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
logger.debug('Could not get metadata for %s %s. Skipping that '
'item for now', plex_type, plex_id)
done = False
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():
logger.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)
finally:
if done is True:
with PlexDB() as plexdb:
plexdb.set_fanart_synced(plex_id, plex_type)

View file

@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
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
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):
"""
This will potentially take hours!
"""
def __init__(self, callback, refresh=False):
self.callback = callback
self.refresh = refresh
super(FanartThread, self).__init__()
def should_suspend(self):
return self._suspended or app.APP.is_playing_video
def run(self):
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)
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)

View file

@ -46,6 +46,10 @@ class FillMetadataQueue(common.LibrarySyncMixin,
if (not self.repair and if (not self.repair and
plexdb.checksum(plex_id, section.plex_type) == checksum): plexdb.checksum(plex_id, section.plex_type) == checksum):
continue 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: try:
self.get_metadata_queue.put((count, plex_id, section), self.get_metadata_queue.put((count, plex_id, section),
timeout=QUEUE_TIMEOUT) timeout=QUEUE_TIMEOUT)
@ -54,16 +58,14 @@ class FillMetadataQueue(common.LibrarySyncMixin,
'aborting sync now', plex_id) 'aborting sync now', plex_id)
section.sync_successful = False section.sync_successful = False
break break
else:
count += 1 count += 1
if not do_process_section:
do_process_section = True
self.processing_queue.add_section(section)
LOG.debug('Put section in queue with %s items: %s',
section.number_of_items, section)
# We might have received LESS items from the PMS than anticipated. # We might have received LESS items from the PMS than anticipated.
# Ensures that our queues finish # Ensures that our queues finish
LOG.debug('%s items to process for section %s', count, section) self.processing_queue.change_section_number_of_items(section,
section.number_of_items = count count)
LOG.debug('%s items to process for section %s',
section.number_of_items, section)
def _run(self): def _run(self):
while not self.should_cancel(): while not self.should_cancel():

View file

@ -137,7 +137,7 @@ class FullSync(common.LibrarySyncMixin, bg.KillableThread):
LOG.error('Could not entirely process section %s', section) LOG.error('Could not entirely process section %s', section)
self.successful = False self.successful = False
def threaded_get_generators(self, kinds, section_queue, all_items): def threaded_get_generators(self, kinds, section_queue, items):
""" """
Getting iterators is costly, so let's do it in a dedicated thread Getting iterators is costly, so let's do it in a dedicated thread
""" """
@ -154,17 +154,28 @@ class FullSync(common.LibrarySyncMixin, bg.KillableThread):
continue continue
section = sections.get_sync_section(section, section = sections.get_sync_section(section,
plex_type=kind[0]) plex_type=kind[0])
if self.repair or all_items: timestamp = section.last_sync - UPDATED_AT_SAFETY \
updated_at = None
else:
updated_at = section.last_sync - UPDATED_AT_SAFETY \
if section.last_sync else None 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: try:
section.iterator = PF.get_section_iterator( section.iterator = PF.get_section_iterator(
section.section_id, section.section_id,
plex_type=section.plex_type, plex_type=section.plex_type,
updated_at=updated_at, updated_at=updated_at,
last_viewed_at=None) last_viewed_at=last_viewed_at)
except RuntimeError: except RuntimeError:
LOG.error('Sync at least partially unsuccessful!') LOG.error('Sync at least partially unsuccessful!')
LOG.error('Error getting section iterator %s', section) LOG.error('Error getting section iterator %s', section)
@ -195,19 +206,42 @@ class FullSync(common.LibrarySyncMixin, bg.KillableThread):
(v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_ARTIST), (v.PLEX_TYPE_ARTIST, v.PLEX_TYPE_ARTIST),
(v.PLEX_TYPE_ALBUM, v.PLEX_TYPE_ARTIST), (v.PLEX_TYPE_ALBUM, v.PLEX_TYPE_ARTIST),
]) ])
# ADD NEW ITEMS # ADD NEW ITEMS
# We need to enforce syncing e.g. show before season before episode # We need to enforce syncing e.g. show before season before episode
bg.FunctionAsTask(self.threaded_get_generators, bg.FunctionAsTask(self.threaded_get_generators,
None, None,
kinds, section_queue, False).start() kinds,
section_queue,
items='all' if self.repair else 'updated').start()
# Do the heavy lifting # Do the heavy lifting
self.process_new_and_changed_items(section_queue, processing_queue) self.process_new_and_changed_items(section_queue, processing_queue)
common.update_kodi_library(video=True, music=True) common.update_kodi_library(video=True, music=True)
if self.should_cancel() or not self.successful: if self.should_cancel() or not self.successful:
return 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 # Sync Plex playlists to Kodi and vice-versa
if common.PLAYLIST_SYNC_ENABLED: if common.PLAYLIST_SYNC_ENABLED:
LOG.debug('Start playlist sync')
if self.show_dialog: if self.show_dialog:
if self.dialog: if self.dialog:
self.dialog.close() self.dialog.close()
@ -218,14 +252,9 @@ class FullSync(common.LibrarySyncMixin, bg.KillableThread):
return return
# SYNC PLAYSTATE of ALL items (otherwise we won't pick up on items that # SYNC PLAYSTATE of ALL items (otherwise we won't pick up on items that
# were set to unwatched). Also mark all items on the PMS to be able # were set to unwatched or changed user ratings). Also mark all items on
# to delete the ones still in Kodi # the PMS to be able to delete the ones still in Kodi
LOG.debug('Start synching playstate and userdata for every item') LOG.debug('Start synching playstate and userdata for every item')
if app.SYNC.enable_music:
# In order to not delete all your songs again
kinds.extend([
(v.PLEX_TYPE_SONG, v.PLEX_TYPE_ARTIST),
])
# Make sure we're not showing an item's title in the sync dialog # Make sure we're not showing an item's title in the sync dialog
if not self.show_dialog_userdata and self.dialog: if not self.show_dialog_userdata and self.dialog:
# Close the progress indicator dialog # Close the progress indicator dialog
@ -233,7 +262,9 @@ class FullSync(common.LibrarySyncMixin, bg.KillableThread):
self.dialog = None self.dialog = None
bg.FunctionAsTask(self.threaded_get_generators, bg.FunctionAsTask(self.threaded_get_generators,
None, None,
kinds, section_queue, True).start() kinds,
section_queue,
items='all').start()
self.processing_loop_playstates(section_queue) self.processing_loop_playstates(section_queue)
if self.should_cancel() or not self.successful: if self.should_cancel() or not self.successful:
return return

View file

@ -93,6 +93,7 @@ class Section(object):
"'name': '{self.name}', " "'name': '{self.name}', "
"'section_id': {self.section_id}, " "'section_id': {self.section_id}, "
"'section_type': '{self.section_type}', " "'section_type': '{self.section_type}', "
"'plex_type': '{self.plex_type}', "
"'sync_to_kodi': {self.sync_to_kodi}, " "'sync_to_kodi': {self.sync_to_kodi}, "
"'last_sync': {self.last_sync}" "'last_sync': {self.last_sync}"
"}}").format(self=self).encode('utf-8') "}}").format(self=self).encode('utf-8')
@ -108,6 +109,8 @@ class Section(object):
Sections compare equal if their section_id, name and plex_type (first 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 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 return (self.section_id == section.section_id and
self.name == section.name and self.name == section.name and
(self.plex_type == section.plex_type if self.plex_type else (self.plex_type == section.plex_type if self.plex_type else

View file

@ -4,7 +4,7 @@ from __future__ import absolute_import, division, unicode_literals
from logging import getLogger from logging import getLogger
from .common import update_kodi_library, PLAYLIST_SYNC_ENABLED from .common import update_kodi_library, PLAYLIST_SYNC_ENABLED
from .additional_metadata import ProcessMetadataTask from .fanart import SYNC_FANART, FanartTask
from ..plex_api import API from ..plex_api import API
from ..plex_db import PlexDB from ..plex_db import PlexDB
from .. import kodi_db from .. import kodi_db
@ -85,8 +85,9 @@ def process_websocket_messages():
continue continue
else: else:
successful, video, music = process_new_item_message(message) successful, video, music = process_new_item_message(message)
if successful: if (successful and SYNC_FANART and
task = ProcessMetadataTask() message['plex_type'] in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_SHOW)):
task = FanartTask()
task.setup(message['plex_id'], task.setup(message['plex_id'],
message['plex_type'], message['plex_type'],
refresh=False) refresh=False)

View file

@ -99,15 +99,4 @@ def check_migration():
utils.settings('accessToken', value='') utils.settings('accessToken', value='')
utils.settings('plexAvatar', value='') utils.settings('plexAvatar', value='')
# Need to delete the UNIQUE index that prevents creating several
# playlist entries with the same kodi_hash
if not utils.compare_version(last_migration, '2.12.17'):
LOG.info('Migrating to version 2.12.16')
# Add an additional column `trailer_synced` in the Plex movie table
from .plex_db import PlexDB
with PlexDB() as plexdb:
query = 'ALTER TABLE movie ADD trailer_synced BOOLEAN'
plexdb.cursor.execute(query)
# Index will be automatically recreated on next PKC startup
utils.settings('last_migrated_PKC_version', value=v.ADDON_VERSION) utils.settings('last_migrated_PKC_version', value=v.ADDON_VERSION)

View file

@ -16,6 +16,7 @@ from .kodi_db import KodiVideoDB
from . import plex_functions as PF, playlist_func as PL, playqueue as PQ 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 json_rpc as js, variables as v, utils, transfer
from . import playback_decision, app from . import playback_decision, app
from . import exceptions
############################################################################### ###############################################################################
LOG = getLogger('PLEX.playback') LOG = getLogger('PLEX.playback')
@ -192,7 +193,7 @@ def _playback_init(plex_id, plex_type, playqueue, pos, resume):
# Special case - we already got a filled Kodi playqueue # Special case - we already got a filled Kodi playqueue
try: try:
_init_existing_kodi_playlist(playqueue, pos) _init_existing_kodi_playlist(playqueue, pos)
except PL.PlaylistError: except exceptions.PlaylistError:
LOG.error('Playback_init for existing Kodi playlist failed') LOG.error('Playback_init for existing Kodi playlist failed')
_ensure_resolve(abort=True) _ensure_resolve(abort=True)
return return
@ -312,7 +313,7 @@ def _init_existing_kodi_playlist(playqueue, pos):
kodi_items = js.playlist_get_items(playqueue.playlistid) kodi_items = js.playlist_get_items(playqueue.playlistid)
if not kodi_items: if not kodi_items:
LOG.error('No Kodi items returned') LOG.error('No Kodi items returned')
raise PL.PlaylistError('No Kodi items returned') raise exceptions.PlaylistError('No Kodi items returned')
item = PL.init_plex_playqueue(playqueue, kodi_item=kodi_items[pos]) item = PL.init_plex_playqueue(playqueue, kodi_item=kodi_items[pos])
item.force_transcode = app.PLAYSTATE.force_transcode item.force_transcode = app.PLAYSTATE.force_transcode
# playqueue.py will add the rest - this will likely put the PMS under # playqueue.py will add the rest - this will likely put the PMS under
@ -444,27 +445,26 @@ def _conclude_playback(playqueue, pos):
""" """
LOG.debug('Concluding playback for playqueue position %s', pos) LOG.debug('Concluding playback for playqueue position %s', pos)
item = playqueue.items[pos] item = playqueue.items[pos]
api = API(item.xml) if item.api.mediastream_number() is None:
if api.mediastream_number() is None:
# E.g. user could choose between several media streams and cancelled # E.g. user could choose between several media streams and cancelled
LOG.debug('Did not get a mediastream_number') LOG.debug('Did not get a mediastream_number')
_ensure_resolve() _ensure_resolve()
return return
api.part = item.part or 0 item.api.part = item.part or 0
playback_decision.set_pkc_playmethod(api, item) playback_decision.set_pkc_playmethod(item.api, item)
if not playback_decision.audio_subtitle_prefs(api, item): if not playback_decision.audio_subtitle_prefs(item.api, item):
LOG.info('Did not set audio subtitle prefs, aborting silently') LOG.info('Did not set audio subtitle prefs, aborting silently')
_ensure_resolve() _ensure_resolve()
return return
playback_decision.set_playurl(api, item) playback_decision.set_playurl(item.api, item)
if not item.file: if not item.file:
LOG.info('Did not get a playurl, aborting playback silently') LOG.info('Did not get a playurl, aborting playback silently')
_ensure_resolve() _ensure_resolve()
return return
listitem = api.listitem(listitem=transfer.PKCListItem, resume=False) listitem = item.api.listitem(listitem=transfer.PKCListItem, resume=False)
listitem.setPath(item.file.encode('utf-8')) listitem.setPath(item.file.encode('utf-8'))
if item.playmethod != v.PLAYBACK_METHOD_DIRECT_PATH: if item.playmethod != v.PLAYBACK_METHOD_DIRECT_PATH:
listitem.setSubtitles(api.cache_external_subs()) listitem.setSubtitles(item.api.cache_external_subs())
transfer.send(listitem) transfer.send(listitem)
LOG.debug('Done concluding playback') LOG.debug('Done concluding playback')

View file

@ -329,27 +329,12 @@ def audio_subtitle_prefs(api, item):
Returns None if user cancelled or we need to abort, True otherwise Returns None if user cancelled or we need to abort, True otherwise
""" """
# Set media and part where we're at # Set media and part where we're at
if (api.mediastream is None and if api.mediastream is None and api.mediastream_number() is None:
api.mediastream_number() is None):
return return
try:
mediastreams = api.plex_media_streams()
except (TypeError, IndexError):
LOG.error('Could not get media %s, part %s',
api.mediastream, api.part)
return
part_id = mediastreams.attrib['id']
if item.playmethod != v.PLAYBACK_METHOD_TRANSCODE: if item.playmethod != v.PLAYBACK_METHOD_TRANSCODE:
LOG.debug('Telling PMS we are not burning in any subtitles')
args = {
'subtitleStreamID': 0,
'allParts': 1
}
DU().downloadUrl('{server}/library/parts/%s' % part_id,
action_type='PUT',
parameters=args)
return True return True
return setup_transcoding_audio_subtitle_prefs(mediastreams, part_id) return setup_transcoding_audio_subtitle_prefs(api.plex_media_streams(),
api.part_id())
def setup_transcoding_audio_subtitle_prefs(mediastreams, part_id): def setup_transcoding_audio_subtitle_prefs(mediastreams, part_id):
@ -434,7 +419,8 @@ def setup_transcoding_audio_subtitle_prefs(mediastreams, part_id):
action_type='PUT', action_type='PUT',
parameters=args) parameters=args)
select_subs_index = '' # Zero telling the PMS to deactivate subs altogether
select_subs_index = 0
if sub_num == 1: if sub_num == 1:
# Note: we DO need to tell the PMS that we DONT want any sub # Note: we DO need to tell the PMS that we DONT want any sub
# Otherwise, the PMS might pick-up the last one # Otherwise, the PMS might pick-up the last one
@ -457,11 +443,5 @@ def setup_transcoding_audio_subtitle_prefs(mediastreams, part_id):
subtitle_streams[resp].decode('utf-8')) subtitle_streams[resp].decode('utf-8'))
select_subs_index = subtitle_streams_list[resp - 1] select_subs_index = subtitle_streams_list[resp - 1]
# Now prep the PMS for our choice # Now prep the PMS for our choice
args = { PF.change_subtitle(select_subs_index, part_id)
'subtitleStreamID': select_subs_index,
'allParts': 1
}
DU().downloadUrl('{server}/library/parts/%s' % part_id,
action_type='PUT',
parameters=args)
return True return True

View file

@ -12,23 +12,16 @@ from . import plex_functions as PF
from .kodi_db import kodiid_from_filename from .kodi_db import kodiid_from_filename
from .downloadutils import DownloadUtils as DU from .downloadutils import DownloadUtils as DU
from . import utils from . import utils
from .utils import cast
from . import json_rpc as js from . import json_rpc as js
from . import variables as v from . import variables as v
from . import app from . import app
from .exceptions import PlaylistError
from .subtitles import accessible_plex_subtitles
###############################################################################
LOG = getLogger('PLEX.playlist_func') LOG = getLogger('PLEX.playlist_func')
###############################################################################
class PlaylistError(Exception):
"""
Exception for our playlist constructs
"""
pass
class Playqueue_Object(object): class Playqueue_Object(object):
""" """
@ -157,13 +150,14 @@ class PlaylistItem(object):
file = None [str] Path to the item's file. STRING!! file = None [str] Path to the item's file. STRING!!
uri = None [str] PMS path to item; will be auto-set with plex_id uri = None [str] PMS path to item; will be auto-set with plex_id
guid = None [str] Weird Plex guid guid = None [str] Weird Plex guid
xml = None [etree] XML from PMS, 1 lvl below <MediaContainer> api = None [API] API of xml 1 lvl below <MediaContainer>
playmethod = None [str] either 'DirectPath', 'DirectStream', 'Transcode' playmethod = None [str] either 'DirectPath', 'DirectStream', 'Transcode'
playcount = None [int] how many times the item has already been played playcount = None [int] how many times the item has already been played
offset = None [int] the item's view offset UPON START in Plex time offset = None [int] the item's view offset UPON START in Plex time
part = 0 [int] part number if Plex video consists of mult. parts part = 0 [int] part number if Plex video consists of mult. parts
force_transcode [bool] defaults to False force_transcode [bool] defaults to False
""" """
def __init__(self): def __init__(self):
self.id = None self.id = None
self._plex_id = None self._plex_id = None
@ -173,7 +167,7 @@ class PlaylistItem(object):
self.file = None self.file = None
self._uri = None self._uri = None
self.guid = None self.guid = None
self.xml = None self.api = None
self.playmethod = None self.playmethod = None
self.playcount = None self.playcount = None
self.offset = None self.offset = None
@ -187,6 +181,16 @@ class PlaylistItem(object):
# False: do NOT resume, don't ask user # False: do NOT resume, don't ask user
# True: do resume, don't ask user # True: do resume, don't ask user
self.resume = None 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 @property
def plex_id(self): def plex_id(self):
@ -202,6 +206,18 @@ class PlaylistItem(object):
def uri(self): def uri(self):
return self._uri 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): def __unicode__(self):
return ("{{" return ("{{"
"'id': {self.id}, " "'id': {self.id}, "
@ -221,60 +237,152 @@ class PlaylistItem(object):
def __repr__(self): def __repr__(self):
return self.__unicode__().encode('utf-8') 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
"""
# 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
def plex_stream_index(self, kodi_stream_index, stream_type): def plex_stream_index(self, kodi_stream_index, stream_type):
""" """
Pass in the kodi_stream_index [int] in order to receive the Plex stream Pass in the kodi_stream_index [int] in order to receive the Plex stream
index. index [int].
stream_type: 'video', 'audio', 'subtitle' stream_type: 'video', 'audio', 'subtitle'
Returns None if unsuccessful Returns None if unsuccessful
""" """
stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type] if stream_type == 'audio':
count = 0 return int(self.audio_streams[kodi_stream_index].get('id'))
if kodi_stream_index == -1: elif stream_type == 'subtitle':
# Kodi telling us "it's the last one" try:
iterator = list(reversed(self.xml[0][self.part])) return int(self.subtitle_streams[kodi_stream_index].get('id'))
kodi_stream_index = 0 except (IndexError, TypeError):
else: pass
iterator = self.xml[0][self.part]
# Kodi indexes differently than Plex
for stream in iterator:
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 iterator:
if (stream.attrib['streamType'] == stream_type and
'key' not in stream.attrib):
if count == kodi_stream_index:
return stream.attrib['id']
count += 1
def kodi_stream_index(self, plex_stream_index, stream_type): def kodi_stream_index(self, plex_stream_index, stream_type):
""" """
Pass in the kodi_stream_index [int] in order to receive the Plex stream Pass in the plex_stream_index [int] in order to receive the Kodi stream
index. index [int].
stream_type: 'video', 'audio', 'subtitle' stream_type: 'video', 'audio', 'subtitle'
Returns None if unsuccessful Returns None if unsuccessful
""" """
stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type] if plex_stream_index is None:
count = 0 return
for stream in self.xml[0][self.part]: for i, stream in enumerate(self._get_iterator(stream_type)):
if (stream.attrib['streamType'] == stream_type and if cast(int, stream.get('id')) == plex_stream_index:
'key' in stream.attrib): return i
if stream.attrib['id'] == plex_stream_index:
return count def active_plex_stream_index(self, stream_type):
count += 1 """
for stream in self.xml[0][self.part]: Returns the following tuple for the active stream on the Plex side:
if (stream.attrib['streamType'] == stream_type and (Plex stream id [int], languageTag [str] or None)
'key' not in stream.attrib): Returns None if no stream has been selected
if stream.attrib['id'] == plex_stream_index: """
return count for i, stream in enumerate(self._get_iterator(stream_type)):
count += 1 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)
def playlist_item_from_kodi(kodi_item): def playlist_item_from_kodi(kodi_item):
@ -300,7 +408,7 @@ def playlist_item_from_kodi(kodi_item):
except IndexError: except IndexError:
query = '' query = ''
query = dict(utils.parse_qsl(query)) query = dict(utils.parse_qsl(query))
item.plex_id = utils.cast(int, query.get('plex_id')) item.plex_id = cast(int, query.get('plex_id'))
item.plex_type = query.get('itemType') item.plex_type = query.get('itemType')
LOG.debug('Made playlist item from Kodi: %s', item) LOG.debug('Made playlist item from Kodi: %s', item)
return item return item
@ -398,21 +506,22 @@ def playlist_item_from_xml(xml_video_element, kodi_id=None, kodi_type=None):
item.guid = api.guid_html_escaped() item.guid = api.guid_html_escaped()
item.playcount = api.viewcount() item.playcount = api.viewcount()
item.offset = api.resume_point() item.offset = api.resume_point()
item.xml = xml_video_element item.api = api
LOG.debug('Created new playlist item from xml: %s', item) LOG.debug('Created new playlist item from xml: %s', item)
return item return item
def _get_playListVersion_from_xml(playlist, xml): def _update_playlist_version(playlist, xml):
""" """
Takes a PMS xml as input to overwrite the playlist version (e.g. Plex 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
playQueueVersion). playQueueVersion).
Raises PlaylistError if unsuccessful Raises PlaylistError if unsuccessful
""" """
playlist.version = utils.cast(int, try:
xml.get('%sVersion' % playlist.kind)) playlist.version = int(xml.get('%sVersion' % playlist.kind))
if playlist.version is None: except (AttributeError, TypeError):
raise PlaylistError('Could not get new playlist Version for playlist ' raise PlaylistError('Could not get new playlist Version for playlist '
'%s' % playlist) '%s' % playlist)
@ -424,17 +533,14 @@ def get_playlist_details_from_xml(playlist, xml):
Raises PlaylistError if something went wrong. Raises PlaylistError if something went wrong.
""" """
playlist.id = utils.cast(int, if xml is None:
xml.get('%sID' % playlist.kind)) raise PlaylistError('No playlist received for playlist %s' % playlist)
playlist.version = utils.cast(int, playlist.id = cast(int, xml.get('%sID' % playlist.kind))
xml.get('%sVersion' % playlist.kind)) playlist.version = cast(int, xml.get('%sVersion' % playlist.kind))
playlist.shuffled = utils.cast(int, playlist.shuffled = cast(int, xml.get('%sShuffled' % playlist.kind))
xml.get('%sShuffled' % playlist.kind)) playlist.selectedItemID = cast(int, xml.get('%sSelectedItemID'
playlist.selectedItemID = utils.cast(int,
xml.get('%sSelectedItemID'
% playlist.kind)) % playlist.kind))
playlist.selectedItemOffset = utils.cast(int, playlist.selectedItemOffset = cast(int, xml.get('%sSelectedItemOffset'
xml.get('%sSelectedItemOffset'
% playlist.kind)) % playlist.kind))
LOG.debug('Updated playlist from xml: %s', playlist) LOG.debug('Updated playlist from xml: %s', playlist)
@ -586,7 +692,7 @@ def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None):
raise PlaylistError('Could not add item %s to playlist %s' raise PlaylistError('Could not add item %s to playlist %s'
% (kodi_item, playlist)) % (kodi_item, playlist))
api = API(xml[-1]) api = API(xml[-1])
item.xml = xml[-1] item.api = api
item.id = api.item_id() item.id = api.item_id()
item.guid = api.guid_html_escaped() item.guid = api.guid_html_escaped()
item.offset = api.resume_point() item.offset = api.resume_point()
@ -594,7 +700,7 @@ def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None):
playlist.items.append(item) playlist.items.append(item)
if pos == len(playlist.items) - 1: if pos == len(playlist.items) - 1:
# Item was added at the end # Item was added at the end
_get_playListVersion_from_xml(playlist, xml) _update_playlist_version(playlist, xml)
else: else:
# Move the new item to the correct position # Move the new item to the correct position
move_playlist_item(playlist, move_playlist_item(playlist,
@ -638,7 +744,7 @@ def add_item_to_kodi_playlist(playlist, pos, kodi_id=None, kodi_type=None,
{'id': kodi_id, 'type': kodi_type, 'file': file}) {'id': kodi_id, 'type': kodi_type, 'file': file})
if item.plex_id is not None: if item.plex_id is not None:
xml = PF.GetPlexMetadata(item.plex_id) xml = PF.GetPlexMetadata(item.plex_id)
item.xml = xml[-1] item.api = API(xml[-1])
playlist.items.insert(pos, item) playlist.items.insert(pos, item)
return item return item
@ -662,9 +768,10 @@ def move_playlist_item(playlist, before_pos, after_pos):
playlist.id, playlist.id,
playlist.items[before_pos].id, playlist.items[before_pos].id,
playlist.items[after_pos - 1].id) playlist.items[after_pos - 1].id)
# We need to increment the playlistVersion # Tell the PMS that we're moving items around
_get_playListVersion_from_xml( xml = DU().downloadUrl(url, action_type="PUT")
playlist, DU().downloadUrl(url, action_type="PUT")) # We need to increment the playlist version for communicating with the PMS
_update_playlist_version(playlist, xml)
# Move our item's position in our internal playlist # Move our item's position in our internal playlist
playlist.items.insert(after_pos, playlist.items.pop(before_pos)) playlist.items.insert(after_pos, playlist.items.pop(before_pos))
LOG.debug('Done moving for %s', playlist) LOG.debug('Done moving for %s', playlist)
@ -711,8 +818,8 @@ def delete_playlist_item_from_PMS(playlist, pos):
playlist.items[pos].id, playlist.items[pos].id,
playlist.repeat), playlist.repeat),
action_type="DELETE") action_type="DELETE")
_get_playListVersion_from_xml(playlist, xml)
del playlist.items[pos] del playlist.items[pos]
_update_playlist_version(playlist, xml)
# Functions operating on the Kodi playlist objects ########## # Functions operating on the Kodi playlist objects ##########

View file

@ -15,13 +15,13 @@ from __future__ import absolute_import, division, unicode_literals
from logging import getLogger from logging import getLogger
from sqlite3 import OperationalError from sqlite3 import OperationalError
from .common import Playlist, PlaylistError, PlaylistObserver, \ from .common import Playlist, PlaylistObserver, kodi_playlist_hash
kodi_playlist_hash
from . import pms, db, kodi_pl, plex_pl from . import pms, db, kodi_pl, plex_pl
from ..watchdog import events from ..watchdog import events
from ..plex_api import API from ..plex_api import API
from .. import utils, path_ops, variables as v, app from .. import utils, path_ops, variables as v, app
from ..exceptions import PlaylistError
############################################################################### ###############################################################################
LOG = getLogger('PLEX.playlists') LOG = getLogger('PLEX.playlists')

View file

@ -12,6 +12,8 @@ from ..watchdog.observers import Observer
from ..watchdog.utils.bricks import OrderedSetQueue from ..watchdog.utils.bricks import OrderedSetQueue
from .. import path_ops, variables as v, app from .. import path_ops, variables as v, app
from ..exceptions import PlaylistError
############################################################################### ###############################################################################
LOG = getLogger('PLEX.playlists.common') LOG = getLogger('PLEX.playlists.common')
@ -20,13 +22,6 @@ SIMILAR_EVENTS = (events.EVENT_TYPE_CREATED, events.EVENT_TYPE_MODIFIED)
############################################################################### ###############################################################################
class PlaylistError(Exception):
"""
The one main exception thrown if anything goes awry
"""
pass
class Playlist(object): class Playlist(object):
""" """
Class representing a synced Playlist with info for both Kodi and Plex. Class representing a synced Playlist with info for both Kodi and Plex.

View file

@ -7,10 +7,11 @@ module
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
from logging import getLogger from logging import getLogger
from .common import Playlist, PlaylistError from .common import Playlist
from ..plex_db import PlexDB from ..plex_db import PlexDB
from ..kodi_db import kodiid_from_filename from ..kodi_db import kodiid_from_filename
from .. import path_ops, utils, variables as v from .. import path_ops, utils, variables as v
from ..exceptions import PlaylistError
############################################################################### ###############################################################################
LOG = getLogger('PLEX.playlists.db') LOG = getLogger('PLEX.playlists.db')
@ -121,7 +122,7 @@ def m3u_to_plex_ids(playlist):
def playlist_file_to_plex_ids(playlist): def playlist_file_to_plex_ids(playlist):
""" """
Takes the playlist file located at path [unicode] and parses it. Takes the playlist file located at path [unicode] and parses it.
Returns a list of plex_ids (str) or raises PL.PlaylistError if a single Returns a list of plex_ids (str) or raises PlaylistError if a single
item cannot be parsed from Kodi to Plex. item cannot be parsed from Kodi to Plex.
""" """
if playlist.kodi_extension == 'm3u': if playlist.kodi_extension == 'm3u':

View file

@ -7,11 +7,13 @@ from __future__ import absolute_import, division, unicode_literals
from logging import getLogger from logging import getLogger
import re import re
from .common import Playlist, PlaylistError, kodi_playlist_hash from .common import Playlist, kodi_playlist_hash
from . import db, pms from . import db, pms
from ..plex_api import API from ..plex_api import API
from .. import utils, path_ops, variables as v from .. import utils, path_ops, variables as v
from ..exceptions import PlaylistError
############################################################################### ###############################################################################
LOG = getLogger('PLEX.playlists.kodi_pl') LOG = getLogger('PLEX.playlists.kodi_pl')
REGEX_FILE_NUMBERING = re.compile(r'''_(\d\d)\.\w+$''') REGEX_FILE_NUMBERING = re.compile(r'''_(\d\d)\.\w+$''')

View file

@ -6,8 +6,9 @@ Create and delete playlists on the Plex side of things
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
from logging import getLogger from logging import getLogger
from .common import PlaylistError
from . import pms, db from . import pms, db
from ..exceptions import PlaylistError
############################################################################### ###############################################################################
LOG = getLogger('PLEX.playlists.plex_pl') LOG = getLogger('PLEX.playlists.plex_pl')
# Used for updating Plex playlists due to Kodi changes - Plex playlist # Used for updating Plex playlists due to Kodi changes - Plex playlist

View file

@ -7,11 +7,11 @@ manipulate playlists
from __future__ import absolute_import, division, unicode_literals from __future__ import absolute_import, division, unicode_literals
from logging import getLogger from logging import getLogger
from .common import PlaylistError
from ..plex_api import API from ..plex_api import API
from ..downloadutils import DownloadUtils as DU from ..downloadutils import DownloadUtils as DU
from .. import utils, app, variables as v from .. import utils, app, variables as v
from ..exceptions import PlaylistError
############################################################################### ###############################################################################
LOG = getLogger('PLEX.playlists.pms') LOG = getLogger('PLEX.playlists.pms')

View file

@ -12,6 +12,7 @@ import xbmc
from .plex_api import API from .plex_api import API
from . import playlist_func as PL, plex_functions as PF from . import playlist_func as PL, plex_functions as PF
from . import backgroundthread, utils, json_rpc as js, app, variables as v from . import backgroundthread, utils, json_rpc as js, app, variables as v
from . import exceptions
############################################################################### ###############################################################################
LOG = getLogger('PLEX.playqueue') LOG = getLogger('PLEX.playqueue')
@ -88,7 +89,7 @@ def init_playqueue_from_plex_children(plex_id, transient_token=None):
api = API(child) api = API(child)
try: try:
PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id) PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id)
except PL.PlaylistError: except exceptions.PlaylistError:
LOG.error('Could not add Plex item to our playlist: %s, %s', LOG.error('Could not add Plex item to our playlist: %s, %s',
child.tag, child.attrib) child.tag, child.attrib)
playqueue.plex_transient_token = transient_token playqueue.plex_transient_token = transient_token
@ -151,7 +152,7 @@ class PlayqueueMonitor(backgroundthread.KillableThread):
i + j, i) i + j, i)
try: try:
PL.move_playlist_item(playqueue, i + j, i) PL.move_playlist_item(playqueue, i + j, i)
except PL.PlaylistError: except exceptions.PlaylistError:
LOG.error('Could not modify playqueue positions') LOG.error('Could not modify playqueue positions')
LOG.error('This is likely caused by mixing audio and ' LOG.error('This is likely caused by mixing audio and '
'video tracks in the Kodi playqueue') 'video tracks in the Kodi playqueue')
@ -167,7 +168,7 @@ class PlayqueueMonitor(backgroundthread.KillableThread):
PL.add_item_to_plex_playqueue(playqueue, PL.add_item_to_plex_playqueue(playqueue,
i, i,
kodi_item=new_item) kodi_item=new_item)
except PL.PlaylistError: except exceptions.PlaylistError:
# Could not add the element # Could not add the element
pass pass
except KeyError: except KeyError:
@ -196,7 +197,7 @@ class PlayqueueMonitor(backgroundthread.KillableThread):
LOG.debug('Detected deletion of playqueue element at pos %s', i) LOG.debug('Detected deletion of playqueue element at pos %s', i)
try: try:
PL.delete_playlist_item_from_PMS(playqueue, i) PL.delete_playlist_item_from_PMS(playqueue, i)
except PL.PlaylistError: except exceptions.PlaylistError:
LOG.error('Could not delete PMS element from position %s', i) LOG.error('Could not delete PMS element from position %s', i)
LOG.error('This is likely caused by mixing audio and ' LOG.error('This is likely caused by mixing audio and '
'video tracks in the Kodi playqueue') 'video tracks in the Kodi playqueue')

View file

@ -222,12 +222,14 @@ class Artwork(object):
else: else:
# Not supported artwork # Not supported artwork
return artworks return artworks
data = DU().downloadUrl(url, authenticate=False, timeout=15) data = DU().downloadUrl(url,
try: authenticate=False,
data.get('test') timeout=15,
except AttributeError: return_response=True)
LOG.error('Could not download data from FanartTV') if not data.ok:
LOG.debug('Could not download data from FanartTV')
return artworks return artworks
data = data.json()
fanart_tv_types = list(v.FANART_TV_TO_KODI_TYPE) fanart_tv_types = list(v.FANART_TV_TO_KODI_TYPE)

View file

@ -15,7 +15,9 @@ LOG = getLogger('PLEX.api')
METADATA_PROVIDERS = (('imdb', utils.REGEX_IMDB), METADATA_PROVIDERS = (('imdb', utils.REGEX_IMDB),
('tvdb', utils.REGEX_TVDB), ('tvdb', utils.REGEX_TVDB),
('tmdb', utils.REGEX_TMDB)) ('tmdb', utils.REGEX_TMDB),
('anidb', utils.REGEX_ANIDB))
class Base(object): class Base(object):
""" """
@ -23,6 +25,7 @@ class Base(object):
xml: xml.etree.ElementTree element xml: xml.etree.ElementTree element
""" """
def __init__(self, xml): def __init__(self, xml):
self.xml = xml self.xml = xml
# which media part in the XML response shall we look at if several # which media part in the XML response shall we look at if several
@ -254,7 +257,21 @@ class Base(object):
Returns the media streams directly from the PMS xml. Returns the media streams directly from the PMS xml.
Mind to set self.mediastream and self.part before calling this method! Mind to set self.mediastream and self.part before calling this method!
""" """
try:
return self.xml[self.mediastream][self.part] 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): def plot(self):
""" """
@ -398,6 +415,12 @@ class Base(object):
""" """
return self.parent_index() 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): def artist_name(self):
""" """
Returns the artist name for an album: first it attempts to return Returns the artist name for an album: first it attempts to return

View file

@ -178,7 +178,7 @@ class Media(object):
count += 1 count += 1
if (count > 1 and ( if (count > 1 and (
(self.plex_type != v.PLEX_TYPE_CLIP and (self.plex_type != v.PLEX_TYPE_CLIP and
utils.settings('bestQuality') == 'false') utils.settings('firstVideoStream') == 'false')
or or
(self.plex_type == v.PLEX_TYPE_CLIP and (self.plex_type == v.PLEX_TYPE_CLIP and
utils.settings('bestTrailer') == 'false'))): utils.settings('bestTrailer') == 'false'))):
@ -320,12 +320,11 @@ class Media(object):
filename, filename,
extension) extension)
response = DU().downloadUrl(url, return_response=True) response = DU().downloadUrl(url, return_response=True)
try: if not response.ok:
response.status_code
except AttributeError:
LOG.error('Could not temporarily download subtitle %s', url) LOG.error('Could not temporarily download subtitle %s', url)
LOG.error('HTTP status: %s, message: %s',
response.status_code, response.text)
return return
else:
LOG.debug('Writing temp subtitle to %s', path) LOG.debug('Writing temp subtitle to %s', path)
with open(path_ops.encode_path(path), 'wb') as f: with open(path_ops.encode_path(path), 'wb') as f:
f.write(response.content) f.write(response.content)

View file

@ -21,6 +21,7 @@ from . import playqueue as PQ
from . import variables as v from . import variables as v
from . import backgroundthread from . import backgroundthread
from . import app from . import app
from . import exceptions
############################################################################### ###############################################################################
@ -51,7 +52,7 @@ def update_playqueue_from_PMS(playqueue,
with app.APP.lock_playqueues: with app.APP.lock_playqueues:
try: try:
xml = PL.get_PMS_playlist(playqueue, playqueue_id) xml = PL.get_PMS_playlist(playqueue, playqueue_id)
except PL.PlaylistError: except exceptions.PlaylistError:
LOG.error('Could now download playqueue %s', playqueue_id) LOG.error('Could now download playqueue %s', playqueue_id)
return return
if playqueue.id == playqueue_id: if playqueue.id == playqueue_id:
@ -64,7 +65,7 @@ def update_playqueue_from_PMS(playqueue,
# Get new metadata for the playqueue first # Get new metadata for the playqueue first
try: try:
PL.get_playlist_details_from_xml(playqueue, xml) PL.get_playlist_details_from_xml(playqueue, xml)
except PL.PlaylistError: except exceptions.PlaylistError:
LOG.error('Could not get playqueue ID %s', playqueue_id) LOG.error('Could not get playqueue ID %s', playqueue_id)
return return
playqueue.repeat = 0 if not repeat else int(repeat) playqueue.repeat = 0 if not repeat else int(repeat)

View file

@ -160,19 +160,6 @@ class PlexDBBase(object):
''' % (plex_type, limit, offset) ''' % (plex_type, limit, offset)
return (x[0] for x in self.cursor.execute(query)) return (x[0] for x in self.cursor.execute(query))
def missing_trailers(self, plex_type, offset, limit):
"""
Returns an iterator for plex_type for all plex_id, where trailer_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 trailer_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): def set_fanart_synced(self, plex_id, plex_type):
""" """
Toggles fanart_synced to 1 for plex_id Toggles fanart_synced to 1 for plex_id
@ -180,13 +167,6 @@ class PlexDBBase(object):
self.cursor.execute('UPDATE %s SET fanart_synced = 1 WHERE plex_id = ?' % plex_type, self.cursor.execute('UPDATE %s SET fanart_synced = 1 WHERE plex_id = ?' % plex_type,
(plex_id, )) (plex_id, ))
def set_trailer_synced(self, plex_id, plex_type):
"""
Toggles fanart_synced to 1 for plex_id
"""
self.cursor.execute('UPDATE %s SET trailer_synced = 1 WHERE plex_id = ?' % plex_type,
(plex_id, ))
def plexid_by_sectionid(self, section_id, plex_type, limit): def plexid_by_sectionid(self, section_id, plex_type, limit):
query = ''' query = '''
SELECT plex_id FROM %s WHERE section_id = ? LIMIT %s SELECT plex_id FROM %s WHERE section_id = ? LIMIT %s
@ -230,7 +210,6 @@ def initialize():
kodi_fileid INTEGER, kodi_fileid INTEGER,
kodi_pathid INTEGER, kodi_pathid INTEGER,
fanart_synced INTEGER, fanart_synced INTEGER,
trailer_synced BOOLEAN,
last_sync INTEGER) last_sync INTEGER)
''') ''')
plexdb.cursor.execute(''' plexdb.cursor.execute('''

View file

@ -6,7 +6,7 @@ from .. import variables as v
class Movies(object): class Movies(object):
def add_movie(self, plex_id, checksum, section_id, kodi_id, kodi_fileid, def add_movie(self, plex_id, checksum, section_id, kodi_id, kodi_fileid,
kodi_pathid, trailer_synced, last_sync): kodi_pathid, last_sync):
""" """
Appends or replaces an entry into the plex table for movies Appends or replaces an entry into the plex table for movies
""" """
@ -19,9 +19,8 @@ class Movies(object):
kodi_fileid, kodi_fileid,
kodi_pathid, kodi_pathid,
fanart_synced, fanart_synced,
trailer_synced,
last_sync) last_sync)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''' '''
self.cursor.execute( self.cursor.execute(
query, query,
@ -32,7 +31,6 @@ class Movies(object):
kodi_fileid, kodi_fileid,
kodi_pathid, kodi_pathid,
0, 0,
trailer_synced,
last_sync)) last_sync))
def movie(self, plex_id): def movie(self, plex_id):

View file

@ -1117,3 +1117,35 @@ def playback_decision(path, media, part, playmethod, video=True, args=None):
return DU().downloadUrl(utils.extend_url(url, arguments), return DU().downloadUrl(utils.extend_url(url, arguments),
headerOptions=v.STREAMING_HEADERS, headerOptions=v.STREAMING_HEADERS,
reraise=True) reraise=True)
def change_subtitle(plex_stream_id, part_id):
"""
Tell the PMS to display/burn-in the subtitle stream with id plex_stream_id
for the Part (PMS XML etree tag "Part") with unique id part_id.
- plex_stream_id = 0 will deactivate the subtitle
- We always do this for ALL parts of a video
"""
arguments = {
'subtitleStreamID': plex_stream_id,
'allParts': 1
}
url = '{server}/library/parts/%s' % part_id
return DU().downloadUrl(utils.extend_url(url, arguments),
action_type='PUT')
def change_audio_stream(plex_stream_id, part_id):
"""
Tell the PMS to display/burn-in the subtitle stream with id plex_stream_id
for the Part (PMS XML etree tag "Part") with unique id part_id.
- plex_stream_id = 0 will deactivate the subtitle
- We always do this for ALL parts of a video
"""
arguments = {
'audioStreamID': plex_stream_id,
'allParts': 1
}
url = '{server}/library/parts/%s' % part_id
return DU().downloadUrl(utils.extend_url(url, arguments),
action_type='PUT')

View file

@ -98,7 +98,8 @@ class Service(object):
self.welcome_msg = True self.welcome_msg = True
self.connection_check_counter = 0 self.connection_check_counter = 0
self.setup = None self.setup = None
self.alexa = None self.pms_ws = None
self.alexa_ws = None
self.playqueue = None self.playqueue = None
# Flags for other threads # Flags for other threads
self.connection_check_running = False self.connection_check_running = False
@ -446,8 +447,8 @@ class Service(object):
self.setup.setup() self.setup.setup()
# Initialize important threads # Initialize important threads
self.ws = websocket_client.PMS_Websocket() self.pms_ws = websocket_client.get_pms_websocketapp()
self.alexa = websocket_client.Alexa_Websocket() self.alexa_ws = websocket_client.get_alexa_websocketapp()
self.sync = sync.Sync() self.sync = sync.Sync()
self.plexcompanion = plex_companion.PlexCompanion() self.plexcompanion = plex_companion.PlexCompanion()
self.playqueue = playqueue.PlayqueueMonitor() self.playqueue = playqueue.PlayqueueMonitor()
@ -547,11 +548,11 @@ class Service(object):
continue continue
elif not self.startup_completed: elif not self.startup_completed:
self.startup_completed = True self.startup_completed = True
self.ws.start() self.pms_ws.start()
self.sync.start() self.sync.start()
self.plexcompanion.start() self.plexcompanion.start()
self.playqueue.start() self.playqueue.start()
self.alexa.start() self.alexa_ws.start()
elif app.APP.is_playing: elif app.APP.is_playing:
skip_plex_intro.check() skip_plex_intro.check()

View file

@ -16,7 +16,7 @@ def skip_intro(intros):
if start <= progress < end: if start <= progress < end:
in_intro = True in_intro = True
if in_intro and app.APP.skip_intro_dialog is None: if in_intro and app.APP.skip_intro_dialog is None:
app.APP.skip_intro_dialog = SkipIntroDialog('skip_intro.xml', app.APP.skip_intro_dialog = SkipIntroDialog('script-plex-skip_intro.xml',
v.ADDON_PATH, v.ADDON_PATH,
'default', 'default',
'1080i', '1080i',

470
resources/lib/subtitles.py Normal file
View file

@ -0,0 +1,470 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger
import re
from os import path
import xml.etree.ElementTree as etree
from . import app
from . import path_ops
from . import variables as v
from .exceptions import SubtitleError
LOG = getLogger('PLEX.subtitles')
# See https://kodi.wiki/view/Subtitles
SUBTITLE_LANGUAGE = re.compile(r'''(?i)[\. -]*(.*?)([\. -]forced)?$''')
# Plex support for external subtitles: srt, smi, ssa, aas, vtt
# https://support.plex.tv/articles/200471133-adding-local-subtitles-to-your-media/
# Which subtitles files are picked up by Kodi, what extensions do they need?
KODI_SUBTITLE_EXTENSIONS = ('srt', 'ssa', 'ass', 'usf', 'cdg', 'idx', 'sub',
'utf', 'aqt', 'jss', 'psb', 'rt', 'smi', 'txt',
'smil', 'stl', 'dks', 'pjs', 'mpl2', 'mks')
# Official language designations. Tuples consist of
# (ISO language name, ISO 639-1, ISO 639-2, ISO 639-2/B)
# source: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
LANGUAGE_ISO_CODES = (
('abkhazian', 'ab', 'abk', 'abk'),
('afar', 'aa', 'aar', 'aar'),
('afrikaans', 'af', 'afr', 'afr'),
('akan', 'ak', 'aka', 'aka'),
('albanian', 'sq', 'sqi', 'alb'),
('amharic', 'am', 'amh', 'amh'),
('arabic', 'ar', 'ara', 'ara'),
('aragonese', 'an', 'arg', 'arg'),
('armenian', 'hy', 'hye', 'arm'),
('assamese', 'as', 'asm', 'asm'),
('avaric', 'av', 'ava', 'ava'),
('avestan', 'ae', 'ave', 'ave'),
('aymara', 'ay', 'aym', 'aym'),
('azerbaijani', 'az', 'aze', 'aze'),
('bambara', 'bm', 'bam', 'bam'),
('bashkir', 'ba', 'bak', 'bak'),
('basque', 'eu', 'eus', 'baq'),
('belarusian', 'be', 'bel', 'bel'),
('bengali', 'bn', 'ben', 'ben'),
('bislama', 'bi', 'bis', 'bis'),
('bosnian', 'bs', 'bos', 'bos'),
('breton', 'br', 'bre', 'bre'),
('bulgarian', 'bg', 'bul', 'bul'),
('burmese', 'my', 'mya', 'bur'),
('catalan', 'ca', 'cat', 'cat'),
('chamorro', 'ch', 'cha', 'cha'),
('chechen', 'ce', 'che', 'che'),
('chichewa', 'ny', 'nya', 'nya'),
('chinese', 'zh', 'zho', 'chi'),
('chuvash', 'cv', 'chv', 'chv'),
('cornish', 'kw', 'cor', 'cor'),
('corsican', 'co', 'cos', 'cos'),
('cree', 'cr', 'cre', 'cre'),
('croatian', 'hr', 'hrv', 'hrv'),
('czech', 'cs', 'ces', 'cze'),
('danish', 'da', 'dan', 'dan'),
('divehi', 'dv', 'div', 'div'),
('dutch', 'nl', 'nld', 'dut'),
('dzongkha', 'dz', 'dzo', 'dzo'),
('english', 'en', 'eng', 'eng'),
('esperanto', 'eo', 'epo', 'epo'),
('estonian', 'et', 'est', 'est'),
('ewe', 'ee', 'ewe', 'ewe'),
('faroese', 'fo', 'fao', 'fao'),
('fijian', 'fj', 'fij', 'fij'),
('finnish', 'fi', 'fin', 'fin'),
('french', 'fr', 'fra', 'fre'),
('fulah', 'ff', 'ful', 'ful'),
('galician', 'gl', 'glg', 'glg'),
('georgian', 'ka', 'kat', 'geo'),
('german', 'de', 'deu', 'ger'),
('greek', 'el', 'ell', 'gre'),
('guarani', 'gn', 'grn', 'grn'),
('gujarati', 'gu', 'guj', 'guj'),
('haitian', 'ht', 'hat', 'hat'),
('hausa', 'ha', 'hau', 'hau'),
('hebrew', 'he', 'heb', 'heb'),
('herero', 'hz', 'her', 'her'),
('hindi', 'hi', 'hin', 'hin'),
('hiri motu', 'ho', 'hmo', 'hmo'),
('hungarian', 'hu', 'hun', 'hun'),
('interlingua', 'ia', 'ina', 'ina'),
('indonesian', 'id', 'ind', 'ind'),
('interlingue', 'ie', 'ile', 'ile'),
('irish', 'ga', 'gle', 'gle'),
('igbo', 'ig', 'ibo', 'ibo'),
('inupiaq', 'ik', 'ipk', 'ipk'),
('ido', 'io', 'ido', 'ido'),
('icelandic', 'is', 'isl', 'ice'),
('italian', 'it', 'ita', 'ita'),
('inuktitut', 'iu', 'iku', 'iku'),
('japanese', 'ja', 'jpn', 'jpn'),
('javanese', 'jv', 'jav', 'jav'),
('kalaallisut', 'kl', 'kal', 'kal'),
('kannada', 'kn', 'kan', 'kan'),
('kanuri', 'kr', 'kau', 'kau'),
('kashmiri', 'ks', 'kas', 'kas'),
('kazakh', 'kk', 'kaz', 'kaz'),
('central khmer', 'km', 'khm', 'khm'),
('kikuyu', 'ki', 'kik', 'kik'),
('kinyarwanda', 'rw', 'kin', 'kin'),
('kirghiz', 'ky', 'kir', 'kir'),
('komi', 'kv', 'kom', 'kom'),
('kongo', 'kg', 'kon', 'kon'),
('korean', 'ko', 'kor', 'kor'),
('kurdish', 'ku', 'kur', 'kur'),
('kuanyama', 'kj', 'kua', 'kua'),
('latin', 'la', 'lat', 'lat'),
('luxembourgish', 'lb', 'ltz', 'ltz'),
('ganda', 'lg', 'lug', 'lug'),
('limburgan', 'li', 'lim', 'lim'),
('lingala', 'ln', 'lin', 'lin'),
('lao', 'lo', 'lao', 'lao'),
('lithuanian', 'lt', 'lit', 'lit'),
('luba-katanga', 'lu', 'lub', 'lub'),
('latvian', 'lv', 'lav', 'lav'),
('manx', 'gv', 'glv', 'glv'),
('macedonian', 'mk', 'mkd', 'mac'),
('malagasy', 'mg', 'mlg', 'mlg'),
('malay', 'ms', 'msa', 'may'),
('malayalam', 'ml', 'mal', 'mal'),
('maltese', 'mt', 'mlt', 'mlt'),
('maori', 'mi', 'mri', 'mao'),
('marathi', 'mr', 'mar', 'mar'),
('marshallese', 'mh', 'mah', 'mah'),
('mongolian', 'mn', 'mon', 'mon'),
('nauru', 'na', 'nau', 'nau'),
('navajo', 'nv', 'nav', 'nav'),
('north ndebele', 'nd', 'nde', 'nde'),
('nepali', 'ne', 'nep', 'nep'),
('ndonga', 'ng', 'ndo', 'ndo'),
('norwegian bokmål', 'nb', 'nob', 'nob'),
('norwegian nynorsk', 'nn', 'nno', 'nno'),
('norwegian', 'no', 'nor', 'nor'),
('sichuan yi', 'ii', 'iii', 'iii'),
('south ndebele', 'nr', 'nbl', 'nbl'),
('occitan', 'oc', 'oci', 'oci'),
('ojibwa', 'oj', 'oji', 'oji'),
('church slavic', 'cu', 'chu', 'chu'),
('oromo', 'om', 'orm', 'orm'),
('oriya', 'or', 'ori', 'ori'),
('ossetian', 'os', 'oss', 'oss'),
('punjabi', 'pa', 'pan', 'pan'),
('pali', 'pi', 'pli', 'pli'),
('persian', 'fa', 'fas', 'per'),
('polish', 'pl', 'pol', 'pol'),
('pashto', 'ps', 'pus', 'pus'),
('portuguese', 'pt', 'por', 'por'),
('quechua', 'qu', 'que', 'que'),
('romansh', 'rm', 'roh', 'roh'),
('rundi', 'rn', 'run', 'run'),
('romanian', 'ro', 'ron', 'rum'),
('russian', 'ru', 'rus', 'rus'),
('sanskrit', 'sa', 'san', 'san'),
('sardinian', 'sc', 'srd', 'srd'),
('sindhi', 'sd', 'snd', 'snd'),
('northern sami', 'se', 'sme', 'sme'),
('samoan', 'sm', 'smo', 'smo'),
('sango', 'sg', 'sag', 'sag'),
('serbian', 'sr', 'srp', 'srp'),
('gaelic', 'gd', 'gla', 'gla'),
('shona', 'sn', 'sna', 'sna'),
('sinhala', 'si', 'sin', 'sin'),
('slovak', 'sk', 'slk', 'slo'),
('slovenian', 'sl', 'slv', 'slv'),
('somali', 'so', 'som', 'som'),
('southern sotho', 'st', 'sot', 'sot'),
('spanish', 'es', 'spa', 'spa'),
('sundanese', 'su', 'sun', 'sun'),
('swahili', 'sw', 'swa', 'swa'),
('swati', 'ss', 'ssw', 'ssw'),
('swedish', 'sv', 'swe', 'swe'),
('tamil', 'ta', 'tam', 'tam'),
('telugu', 'te', 'tel', 'tel'),
('tajik', 'tg', 'tgk', 'tgk'),
('thai', 'th', 'tha', 'tha'),
('tigrinya', 'ti', 'tir', 'tir'),
('tibetan', 'bo', 'bod', 'tib'),
('turkmen', 'tk', 'tuk', 'tuk'),
('tagalog', 'tl', 'tgl', 'tgl'),
('tswana', 'tn', 'tsn', 'tsn'),
('tonga', 'to', 'ton', 'ton'),
('turkish', 'tr', 'tur', 'tur'),
('tsonga', 'ts', 'tso', 'tso'),
('tatar', 'tt', 'tat', 'tat'),
('twi', 'tw', 'twi', 'twi'),
('tahitian', 'ty', 'tah', 'tah'),
('uighur', 'ug', 'uig', 'uig'),
('ukrainian', 'uk', 'ukr', 'ukr'),
('urdu', 'ur', 'urd', 'urd'),
('uzbek', 'uz', 'uzb', 'uzb'),
('venda', 've', 'ven', 'ven'),
('vietnamese', 'vi', 'vie', 'vie'),
('volapük', 'vo', 'vol', 'vol'),
('walloon', 'wa', 'wln', 'wln'),
('welsh', 'cy', 'cym', 'wel'),
('wolof', 'wo', 'wol', 'wol'),
('western frisian', 'fy', 'fry', 'fry'),
('xhosa', 'xh', 'xho', 'xho'),
('yiddish', 'yi', 'yid', 'yid'),
('yoruba', 'yo', 'yor', 'yor'),
('zhuang', 'za', 'zha', 'zha'),
('zulu', 'zu', 'zul', 'zul'),
)
def accessible_plex_subtitles(playmethod, playing_file, xml_streams):
if not playmethod == v.PLAYBACK_METHOD_DIRECT_PATH:
# We can access all subtitles because we're downloading additional
# external ones into the Kodi PKC add-on directory
streams = []
# Kodi ennumerates EXTERNAL subtitles first, then internal ones
for stream in xml_streams:
if stream.get('streamType') == '3' and 'key' in stream.attrib:
streams.append(stream)
for stream in xml_streams:
if stream.get('streamType') == '3' and 'key' not in stream.attrib:
streams.append(stream)
if streams:
LOG.debug('Working with the following Plex subtitle streams:')
log_plex_streams(streams)
return streams
kodi_subs = kodi_subs_from_player()
plex_streams_int, plex_streams_ext = accessible_plex_sub_streams(xml_streams)
# Kodi appends internal streams at the end of its list
kodi_subs_ext = kodi_subs[:len(kodi_subs) - len(plex_streams_int)]
LOG.debug('Kodi list of external subs: %s', kodi_subs_ext)
LOG.debug('Kodi has %s external subs, Plex %s, trying to match them',
len(kodi_subs_ext), len(plex_streams_ext))
dirname, basename = path.split(playing_file)
filename, _ = path.splitext(basename)
try:
kodi_subs_file = kodi_external_subs(dirname, filename, kodi_subs_ext)
reordered_plex_streams_ext = reorder_plex_streams(plex_streams_ext,
kodi_subs_file)
except SubtitleError:
# Add dummy subtitles so we won't match against Plex subtitles that
# are in an incorrect order - keeps Kodi order of subs intact
reordered_plex_streams_ext = [DummySub()
for _ in range(len(kodi_subs_ext))]
reordered_plex_streams_ext.extend(plex_streams_int)
return reordered_plex_streams_ext
def reorder_plex_streams(plex_streams_ext, kodi_subs_file):
"""
Returns the Plex streams in a "best-guess" order as indicated by the
Kodi external subtitles kodi_subs_file
"""
order = [None for i in range(len(kodi_subs_file))]
# Pick subtitles with known language, extension and "forced" True first
for i, kodi_sub in enumerate(kodi_subs_file):
if not kodi_sub['iso'] or not kodi_sub['forced']:
continue
for plex_stream in plex_streams_ext:
if not plex_stream.get('forced'):
continue
elif not kodi_sub['iso'][1] == plex_stream.get('languageTag'):
continue
elif not kodi_sub['codec'] == plex_stream.get('codec').lower():
continue
# Pick the first matching result - even though it's a best guess
order[i] = plex_stream
plex_streams_ext.remove(plex_stream)
break
# Pick non-forced
for i, kodi_sub in enumerate(kodi_subs_file):
if order[i] is not None or not kodi_sub['iso']:
continue
for plex_stream in plex_streams_ext:
if not kodi_sub['iso'][1] == plex_stream.get('languageTag'):
continue
elif not kodi_sub['codec'] == plex_stream.get('codec').lower():
continue
elif not (kodi_sub['forced'] is (plex_stream.get('forced') == '1')):
continue
# Pick the first matching result - even though it's a best guess
order[i] = plex_stream
plex_streams_ext.remove(plex_stream)
break
# Pick subs irrelevant of forced flag
for i, kodi_sub in enumerate(kodi_subs_file):
if order[i] is not None or not kodi_sub['iso']:
continue
for plex_stream in plex_streams_ext:
if not kodi_sub['iso'][1] == plex_stream.get('languageTag'):
continue
elif not kodi_sub['codec'] == plex_stream.get('codec').lower():
continue
# Pick the first matching result - even though it's a best guess
order[i] = plex_stream
plex_streams_ext.remove(plex_stream)
break
# Pick subs based on codec (Plex does not detect "English" as en). Forced
# ones first
for i, kodi_sub in enumerate(kodi_subs_file):
if order[i] is not None or not kodi_sub['forced']:
continue
for plex_stream in plex_streams_ext:
if not kodi_sub['codec'] == plex_stream.get('codec').lower():
continue
elif not plex_stream.get('forced'):
continue
elif plex_stream.get('languageTag') and kodi_sub['iso'] \
and not plex_stream.get('languageTag') == kodi_sub['iso'][1]:
continue
# Pick the first matching result - even though it's a best guess
order[i] = plex_stream
plex_streams_ext.remove(plex_stream)
break
# Pick subs based on codec alone (Plex does not detect "English" as en).
# Non-forced
for i, kodi_sub in enumerate(kodi_subs_file):
if order[i] is not None:
continue
for plex_stream in plex_streams_ext:
if not kodi_sub['codec'] == plex_stream.get('codec').lower():
continue
elif not (kodi_sub['forced'] is (plex_stream.get('forced') == '1')):
continue
elif plex_stream.get('languageTag') and kodi_sub['iso'] \
and not plex_stream.get('languageTag') == kodi_sub['iso'][1]:
continue
# Pick the first matching result - even though it's a best guess
order[i] = plex_stream
plex_streams_ext.remove(plex_stream)
break
# Pick subs based on codec alone (Plex does not detect "English" as en).
# Even with miss-matching forced flag
for i, kodi_sub in enumerate(kodi_subs_file):
if order[i] is not None:
continue
for plex_stream in plex_streams_ext:
if not kodi_sub['codec'] == plex_stream.get('codec').lower():
continue
elif plex_stream.get('languageTag') and kodi_sub['iso'] \
and not plex_stream.get('languageTag') == kodi_sub['iso'][1]:
continue
# Pick the first matching result - even though it's a best guess
order[i] = plex_stream
plex_streams_ext.remove(plex_stream)
break
# Now lets add dummies for Kodi subs we could not match
for i, kodi_sub in enumerate(kodi_subs_file):
if order[i] is not None:
continue
LOG.debug('Could not match Kodi sub number %s %s, adding a dummy',
i, kodi_sub)
order[i] = DummySub()
if plex_streams_ext:
LOG.debug('We could not match the following Plex subtitles:')
log_plex_streams(plex_streams_ext)
if order:
LOG.debug('Derived order of external subtitle streams:')
log_plex_streams(order)
return order
def log_plex_streams(plex_streams):
for i, stream in enumerate(plex_streams):
LOG.debug('Number %s: %s: %s', i, stream.tag, stream.attrib)
def accessible_plex_sub_streams(xml):
# Any additionally downloaded subtitles are not accessible for Kodi
# We're identifying them by the additional key 'providerTitle'
plex_streams = [stream for stream in xml
if stream.get('streamType') == '3'
and not stream.get('providerTitle')]
LOG.debug('Available Plex subtitle streams for currently playing item:')
log_plex_streams(plex_streams)
# Kodi can display internal subtitle streams for sure
plex_streams_int = [x for x in plex_streams if 'key' not in x.attrib]
# We need to check external ones
# If the movie name is 'The Dark Knight (2008).mkv', Kodi finds
# subtitles 'The Dark Knight (2008)*.*.<ext>'
plex_streams_ext = [x for x in plex_streams if 'key' in x.attrib]
return plex_streams_int, plex_streams_ext
def kodi_subs_from_player():
"""
Kodi can only play subtitles that it pickes up itself: They lie in the
same folder as the video file and are named similarly
"""
kodi_subs = app.APP.player.getAvailableSubtitleStreams()
LOG.debug('Kodi list of available subtitles: %s', kodi_subs)
return kodi_subs
def kodi_external_subs(dirname, filename, kodi_subs_ext):
file_subs = external_subs_from_filesystem(dirname, filename)
if len(file_subs) != len(kodi_subs_ext):
LOG.warn('Unexpected missmatch of number of Kodi subtitles')
LOG.warn('Kodi subs: %s', kodi_subs_ext)
LOG.warn('Subs from the filesystem: %s', file_subs)
raise SubtitleError()
for i, sub in enumerate(file_subs):
if sub['iso'] and kodi_subs_ext[i].lower() not in sub['iso']:
LOG.warn('Unexpected Kodi external subtitle language combo')
LOG.warn('Kodi subs: %s', kodi_subs_ext)
LOG.warn('Subs from the filesystem: %s', file_subs)
raise SubtitleError()
return file_subs
def external_subs_from_filesystem(dirname, filename):
"""
Returns a list of dicts of subtitles lying within the directory dirname:
{'iso': tuple of detected ISO language (see LANGUAGE_ISO_CODES) or None,
'language': language string that Kodi might show in its GUI,
'forced': has '[. -]forced' been appended to the filename?
'file': subtitle file name}
Supply with the currently playing filename as Kodi uses that to search
for subtitles. See https://kodi.wiki/view/Subtitles
"""
file_subs = []
for root, dirs, files in path_ops.walk(dirname):
for file in files:
name, extension = path.splitext(file)
# Get rid of the dot and force lowercase
extension = extension[1:].lower()
if extension not in KODI_SUBTITLE_EXTENSIONS:
# Not an extension Kodi supports
continue
elif not name.startswith(filename):
# Naming not up to standards, Kodi won't pick up this file
# (but Plex might!!)
continue
regex = SUBTITLE_LANGUAGE.search(name.replace(filename, '', 1))
language = (regex.group(1) if regex.group(1) else '').lower()
forced = True if regex.group(2) else False
iso = None
if len(language) == 2:
language_searchgrid = (1, )
elif len(language) == 3:
language_searchgrid = (2, 3)
else:
language_searchgrid = (0, )
for lang in LANGUAGE_ISO_CODES:
for i in language_searchgrid:
if lang[i] == language:
iso = lang
break
else:
continue
break
file_subs.append({'iso': iso,
'language': language,
'forced': forced,
'codec': extension,
'file': '%s.%s' % (name, extension)})
LOG.debug('Detected these external subtitles while scanning the file '
'system: %s', file_subs)
return file_subs
class DummySub(etree.Element):
def __init__(self):
super(DummySub, self).__init__('Stream-subtitle-dummy')

View file

@ -22,7 +22,7 @@ class Sync(backgroundthread.KillableThread):
def __init__(self): def __init__(self):
self.sync_successful = False self.sync_successful = False
self.last_full_sync = 0 self.last_full_sync = 0
self.metadata_thread = None self.fanart_thread = None
self.image_cache_thread = None self.image_cache_thread = None
# Lock used to wait on a full sync, e.g. on initial sync # Lock used to wait on a full sync, e.g. on initial sync
# self.lock = backgroundthread.threading.Lock() # self.lock = backgroundthread.threading.Lock()
@ -52,7 +52,7 @@ class Sync(backgroundthread.KillableThread):
utils.lang(39223), utils.lang(39223),
utils.lang(39224), # refresh all utils.lang(39224), # refresh all
utils.lang(39225)) == 0 utils.lang(39225)) == 0
if not self.start_additional_metadata(refresh=refresh): if not self.start_fanart_download(refresh=refresh):
# Fanart download already running # Fanart download already running
utils.dialog('notification', utils.dialog('notification',
heading='{plex}', heading='{plex}',
@ -76,31 +76,33 @@ class Sync(backgroundthread.KillableThread):
self.last_full_sync = timing.unix_timestamp() self.last_full_sync = timing.unix_timestamp()
if not successful: if not successful:
LOG.warn('Could not finish scheduled full sync') LOG.warn('Could not finish scheduled full sync')
app.APP.resume_metadata_thread() app.APP.resume_fanart_thread()
app.APP.resume_caching_thread() app.APP.resume_caching_thread()
def start_library_sync(self, show_dialog=None, repair=False, block=False): def start_library_sync(self, show_dialog=None, repair=False, block=False):
app.APP.suspend_metadata_thread(block=True) app.APP.suspend_fanart_thread(block=True)
app.APP.suspend_caching_thread(block=True) app.APP.suspend_caching_thread(block=True)
show_dialog = show_dialog if show_dialog is not None else app.SYNC.sync_dialog show_dialog = show_dialog if show_dialog is not None else app.SYNC.sync_dialog
library_sync.start(show_dialog, repair, self.on_library_scan_finished) library_sync.start(show_dialog, repair, self.on_library_scan_finished)
def start_additional_metadata(self, refresh): def start_fanart_download(self, refresh):
if not utils.settings('FanartTV') == 'true':
LOG.info('Additional fanart download is deactivated')
return False
if not app.SYNC.artwork: if not app.SYNC.artwork:
LOG.info('Not synching Plex PMS artwork, not getting artwork') LOG.info('Not synching Plex PMS artwork, not getting artwork')
return False return False
elif self.metadata_thread is None or not self.metadata_thread.is_alive(): elif self.fanart_thread is None or not self.fanart_thread.is_alive():
LOG.info('Start downloading additional metadata with refresh %s', LOG.info('Start downloading additional fanart with refresh %s',
refresh) refresh)
self.metadata_thread = library_sync.MetadataThread(self.on_metadata_finished, refresh) self.fanart_thread = library_sync.FanartThread(self.on_fanart_download_finished, refresh)
self.metadata_thread.start() self.fanart_thread.start()
return True return True
else: else:
LOG.info('Still downloading metadata') LOG.info('Still downloading fanart')
return False return False
@staticmethod def on_fanart_download_finished(self, successful):
def on_metadata_finished(successful):
# FanartTV lookup completed # FanartTV lookup completed
if successful: if successful:
# Toggled to "Yes" # Toggled to "Yes"
@ -187,7 +189,7 @@ class Sync(backgroundthread.KillableThread):
xbmc.executebuiltin('ReloadSkin()') xbmc.executebuiltin('ReloadSkin()')
if library_sync.PLAYLIST_SYNC_ENABLED: if library_sync.PLAYLIST_SYNC_ENABLED:
playlist_monitor = playlists.kodi_playlist_monitor() playlist_monitor = playlists.kodi_playlist_monitor()
self.start_additional_metadata(refresh=False) self.start_fanart_download(refresh=False)
self.start_image_cache_thread() self.start_image_cache_thread()
else: else:
LOG.error('Initial start-up full sync unsuccessful') LOG.error('Initial start-up full sync unsuccessful')
@ -204,7 +206,7 @@ class Sync(backgroundthread.KillableThread):
LOG.info('Done initial sync on Kodi startup') LOG.info('Done initial sync on Kodi startup')
if library_sync.PLAYLIST_SYNC_ENABLED: if library_sync.PLAYLIST_SYNC_ENABLED:
playlist_monitor = playlists.kodi_playlist_monitor() playlist_monitor = playlists.kodi_playlist_monitor()
self.start_additional_metadata(refresh=False) self.start_fanart_download(refresh=False)
self.start_image_cache_thread() self.start_image_cache_thread()
else: else:
LOG.info('Startup sync has not yet been successful') LOG.info('Startup sync has not yet been successful')
@ -225,7 +227,7 @@ class Sync(backgroundthread.KillableThread):
not app.APP.is_playing_video): not app.APP.is_playing_video):
LOG.info('Doing scheduled full library scan') LOG.info('Doing scheduled full library scan')
self.start_library_sync() self.start_library_sync()
elif not app.SYNC.background_sync_disabled: else:
# Check back whether we should process something Only do # Check back whether we should process something Only do
# this once a while (otherwise, potentially many screen # this once a while (otherwise, potentially many screen
# refreshes lead to flickering) # refreshes lead to flickering)

View file

@ -34,8 +34,8 @@ def unix_date_to_kodi(unix_kodi_time):
""" """
try: try:
return strftime('%Y-%m-%d %H:%M:%S', localtime(float(unix_kodi_time))) return strftime('%Y-%m-%d %H:%M:%S', localtime(float(unix_kodi_time)))
except Exception: except:
LOG.exception('Received an illegal timestamp from Plex: %s. ' LOG.error('Received an illegal timestamp from Plex: %s. '
'Using 1970-01-01 12:00:00', 'Using 1970-01-01 12:00:00',
unix_kodi_time) unix_kodi_time)
return '1970-01-01 12:00:00' return '1970-01-01 12:00:00'

View file

@ -48,8 +48,9 @@ REGEX_END_DIGITS = re.compile(r'''/(.+)/(\d+)$''')
REGEX_PLEX_DIRECT = re.compile(r'''\.plex\.direct:\d+$''') REGEX_PLEX_DIRECT = re.compile(r'''\.plex\.direct:\d+$''')
# Plex API # Plex API
REGEX_IMDB = re.compile(r'''/(tt\d+)''') REGEX_IMDB = re.compile(r'''/(tt\d+)''')
REGEX_TVDB = re.compile(r'''thetvdb:\/\/(.+?)\?''') REGEX_TVDB = re.compile(r'''(?:the)?tvdb(?::\/\/|[2-5]?-)(\d+?)\?''')
REGEX_TMDB = re.compile(r'''themoviedb:\/\/(.+?)\?''') REGEX_TMDB = re.compile(r'''themoviedb:\/\/(.+?)\?''')
REGEX_ANIDB = re.compile(r'''anidb[2-4]?-(\d+?)\?''')
# Plex music # Plex music
REGEX_MUSICPATH = re.compile(r'''^\^(.+)\$$''') REGEX_MUSICPATH = re.compile(r'''^\^(.+)\$$''')
# Grab Plex id from an URL-encoded string # Grab Plex id from an URL-encoded string
@ -118,7 +119,7 @@ def settings(setting, value=None):
""" """
# We need to instantiate every single time to read changed variables! # We need to instantiate every single time to read changed variables!
with SETTINGS_LOCK: with SETTINGS_LOCK:
addon = xbmcaddon.Addon(id='plugin.video.plexkodiconnect') addon = xbmcaddon.Addon()
if value is not None: if value is not None:
# Takes string or unicode by default! # Takes string or unicode by default!
addon.setSetting(try_encode(setting), try_encode(value)) addon.setSetting(try_encode(setting), try_encode(value))

View file

@ -45,6 +45,10 @@ ADDON_PATH = try_decode(_ADDON.getAddonInfo('path'))
ADDON_FOLDER = try_decode(xbmc.translatePath('special://home')) ADDON_FOLDER = try_decode(xbmc.translatePath('special://home'))
ADDON_PROFILE = try_decode(xbmc.translatePath(_ADDON.getAddonInfo('profile'))) ADDON_PROFILE = try_decode(xbmc.translatePath(_ADDON.getAddonInfo('profile')))
# Used e.g. for json_rpc
KODI_VIDEO_PLAYER_ID = 1
KODI_AUDIO_PLAYER_ID = 0
KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1) KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1)
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2]) KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
KODILONGVERSION = xbmc.getInfoLabel('System.BuildVersion') KODILONGVERSION = xbmc.getInfoLabel('System.BuildVersion')
@ -542,7 +546,8 @@ ALL_KODI_ARTWORK = (
'clearart', 'clearart',
'clearlogo', 'clearlogo',
'fanart', 'fanart',
'discart' 'discart',
'landscape'
) )
# we need to use a little mapping between fanart.tv arttypes and kodi artttypes # we need to use a little mapping between fanart.tv arttypes and kodi artttypes
@ -556,7 +561,8 @@ FANART_TV_TO_KODI_TYPE = [
('clearlogo', 'clearlogo'), ('clearlogo', 'clearlogo'),
('background', 'fanart'), ('background', 'fanart'),
('showbackground', 'fanart'), ('showbackground', 'fanart'),
('characterart', 'characterart') ('characterart', 'characterart'),
('thumb', 'landscape')
] ]
# How many different backgrounds do we want to load from fanart.tv? # How many different backgrounds do we want to load from fanart.tv?
MAX_BACKGROUND_COUNT = 10 MAX_BACKGROUND_COUNT = 10

View file

@ -1,917 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
"""
import socket
try:
import ssl
from ssl import SSLError
HAVE_SSL = True
except ImportError:
class SSLError(Exception):
"""
Dummy class of SSLError for ssl none-support environment.
"""
pass
HAVE_SSL = False
from urlparse import urlparse
import os
import array
import struct
import uuid
import hashlib
import base64
import threading
import logging
import traceback
import sys
from . import utils, app
###############################################################################
LOG = logging.getLogger('PLEX.websocket')
###############################################################################
"""
websocket python client.
=========================
This version support only hybi-13.
Please see http://tools.ietf.org/html/rfc6455 for protocol.
"""
# websocket supported version.
VERSION = 13
# closing frame status codes.
STATUS_NORMAL = 1000
STATUS_GOING_AWAY = 1001
STATUS_PROTOCOL_ERROR = 1002
STATUS_UNSUPPORTED_DATA_TYPE = 1003
STATUS_STATUS_NOT_AVAILABLE = 1005
STATUS_ABNORMAL_CLOSED = 1006
STATUS_INVALID_PAYLOAD = 1007
STATUS_POLICY_VIOLATION = 1008
STATUS_MESSAGE_TOO_BIG = 1009
STATUS_INVALID_EXTENSION = 1010
STATUS_UNEXPECTED_CONDITION = 1011
STATUS_TLS_HANDSHAKE_ERROR = 1015
class WebSocketException(Exception):
"""
websocket exeception class.
"""
pass
class WebSocketConnectionClosedException(WebSocketException):
"""
If remote host closed the connection or some network error happened,
this exception will be raised.
"""
pass
class WebSocketTimeoutException(WebSocketException):
"""
WebSocketTimeoutException will be raised at socket timeout during read and
write data.
"""
pass
class WebsocketRedirect(WebSocketException):
"""
WebsocketRedirect will be raised if a status code 301 is returned
The Exception will be instantiated with a dict containing all response
headers; which should contain the redirect address under the key 'location'
Access the headers via the attribute headers
"""
def __init__(self, headers):
self.headers = headers
super(WebsocketRedirect, self).__init__()
DEFAULT_TIMEOUT = None
TRACE_ENABLED = False
def enable_trace(tracable):
"""
turn on/off the tracability.
tracable: boolean value. if set True, tracability is enabled.
"""
global TRACE_ENABLED
TRACE_ENABLED = tracable
if tracable:
if not LOG.handlers:
LOG.addHandler(logging.StreamHandler())
LOG.setLevel(logging.DEBUG)
def setdefaulttimeout(timeout):
"""
Set the global timeout setting to connect.
timeout: default socket timeout time. This value is second.
"""
global DEFAULT_TIMEOUT
DEFAULT_TIMEOUT = timeout
def getdefaulttimeout():
"""
Return the global timeout setting(second) to connect.
"""
return DEFAULT_TIMEOUT
def _parse_url(url):
"""
parse url and the result is tuple of
(hostname, port, resource path and the flag of secure mode)
url: url string.
"""
if ":" not in url:
raise ValueError("url is invalid")
scheme, url = url.split(":", 1)
parsed = urlparse(url, scheme="http")
if parsed.hostname:
hostname = parsed.hostname
else:
raise ValueError("hostname is invalid")
port = 0
if parsed.port:
port = parsed.port
is_secure = False
if scheme == "ws" or scheme == 'http':
if not port:
port = 80
elif scheme == "wss" or scheme == 'https':
is_secure = True
if not port:
port = 443
else:
raise ValueError("scheme %s is invalid" % scheme)
if parsed.path:
resource = parsed.path
else:
resource = "/"
if parsed.query:
resource += "?" + parsed.query
return (hostname, port, resource, is_secure)
def create_connection(url, timeout=None, **options):
"""
connect to url and return websocket object.
Connect to url and return the WebSocket object.
Passing optional timeout parameter will set the timeout on the socket.
If no timeout is supplied, the global default timeout setting returned by
getdefauttimeout() is used.
You can customize using 'options'.
If you set "header" list object, you can set your own custom header.
>>> conn = create_connection("ws://echo.websocket.org/",
... header=["User-Agent: MyProgram",
... "x-custom: header"])
timeout: socket timeout time. This value is integer.
if you set None for this value, it means "use DEFAULT_TIMEOUT
value"
options: current support option is only "header".
if you set header as dict value, the custom HTTP headers are added
"""
sockopt = options.get("sockopt", [])
sslopt = options.get("sslopt", {})
websock = WebSocket(sockopt=sockopt, sslopt=sslopt)
websock.settimeout(timeout if timeout is not None else DEFAULT_TIMEOUT)
websock.connect(url, **options)
return websock
_MAX_INTEGER = (1 << 32) - 1
_AVAILABLE_KEY_CHARS = range(0x21, 0x2f + 1) + range(0x3a, 0x7e + 1)
_MAX_CHAR_BYTE = (1 << 8) - 1
# ref. Websocket gets an update, and it breaks stuff.
# http://axod.blogspot.com/2010/06/websocket-gets-update-and-it-breaks.html
def _create_sec_websocket_key():
uid = uuid.uuid4()
return base64.encodestring(uid.bytes).strip()
_HEADERS_TO_CHECK = {"upgrade": "websocket", "connection": "upgrade"}
class ABNF(object):
"""
ABNF frame class.
see http://tools.ietf.org/html/rfc5234
and http://tools.ietf.org/html/rfc6455#section-5.2
"""
# operation code values.
OPCODE_CONT = 0x0
OPCODE_TEXT = 0x1
OPCODE_BINARY = 0x2
OPCODE_CLOSE = 0x8
OPCODE_PING = 0x9
OPCODE_PONG = 0xa
# available operation code value tuple
OPCODES = (OPCODE_CONT, OPCODE_TEXT, OPCODE_BINARY, OPCODE_CLOSE,
OPCODE_PING, OPCODE_PONG)
# opcode human readable string
OPCODE_MAP = {
OPCODE_CONT: "cont",
OPCODE_TEXT: "text",
OPCODE_BINARY: "binary",
OPCODE_CLOSE: "close",
OPCODE_PING: "ping",
OPCODE_PONG: "pong"
}
# data length threashold.
LENGTH_7 = 0x7d
LENGTH_16 = 1 << 16
LENGTH_63 = 1 << 63
def __init__(self, fin=0, rsv1=0, rsv2=0, rsv3=0,
opcode=OPCODE_TEXT, mask=1, data=""):
"""
Constructor for ABNF.
please check RFC for arguments.
"""
self.fin = fin
self.rsv1 = rsv1
self.rsv2 = rsv2
self.rsv3 = rsv3
self.opcode = opcode
self.mask = mask
self.data = data
self.get_mask_key = os.urandom
def __str__(self):
return "fin=" + str(self.fin) \
+ " opcode=" + str(self.opcode) \
+ " data=" + str(self.data)
@staticmethod
def create_frame(data, opcode):
"""
create frame to send text, binary and other data.
data: data to send. This is string value(byte array).
if opcode is OPCODE_TEXT and this value is uniocde,
data value is conveted into unicode string, automatically.
opcode: operation code. please see OPCODE_XXX.
"""
if opcode == ABNF.OPCODE_TEXT and isinstance(data, unicode):
data = utils.try_encode(data)
# mask must be set if send data from client
return ABNF(1, 0, 0, 0, opcode, 1, data)
def format(self):
"""
format this object to string(byte array) to send data to server.
"""
if any(x not in (0, 1) for x in [self.fin, self.rsv1, self.rsv2, self.rsv3]):
raise ValueError("not 0 or 1")
if self.opcode not in ABNF.OPCODES:
raise ValueError("Invalid OPCODE")
length = len(self.data)
if length >= ABNF.LENGTH_63:
raise ValueError("data is too long")
frame_header = chr(self.fin << 7 |
self.rsv1 << 6 | self.rsv2 << 5 | self.rsv3 << 4 |
self.opcode)
if length < ABNF.LENGTH_7:
frame_header += chr(self.mask << 7 | length)
elif length < ABNF.LENGTH_16:
frame_header += chr(self.mask << 7 | 0x7e)
frame_header += struct.pack("!H", length)
else:
frame_header += chr(self.mask << 7 | 0x7f)
frame_header += struct.pack("!Q", length)
if not self.mask:
return frame_header + self.data
else:
mask_key = self.get_mask_key(4)
return frame_header + self._get_masked(mask_key)
def _get_masked(self, mask_key):
s = ABNF.mask(mask_key, self.data)
return mask_key + "".join(s)
@staticmethod
def mask(mask_key, data):
"""
mask or unmask data. Just do xor for each byte
mask_key: 4 byte string(byte).
data: data to mask/unmask.
"""
_m = array.array("B", mask_key)
_d = array.array("B", data)
for i in xrange(len(_d)):
_d[i] ^= _m[i % 4]
return _d.tostring()
class WebSocket(object):
"""
Low level WebSocket interface.
This class is based on
The WebSocket protocol draft-hixie-thewebsocketprotocol-76
http://tools.ietf.org/html/draft-hixie-thewebsocketprotocol-76
We can connect to the websocket server and send/recieve data.
The following example is a echo client.
>>> import websocket
>>> ws = websocket.WebSocket()
>>> ws.connect("ws://echo.websocket.org")
>>> ws.send("Hello, Server")
>>> ws.recv()
'Hello, Server'
>>> ws.close()
get_mask_key: a callable to produce new mask keys, see the set_mask_key
function's docstring for more details
sockopt: values for socket.setsockopt.
sockopt must be tuple and each element is argument of sock.setscokopt.
sslopt: dict object for ssl socket option.
"""
def __init__(self, get_mask_key=None, sockopt=None, sslopt=None):
"""
Initalize WebSocket object.
"""
if sockopt is None:
sockopt = []
if sslopt is None:
sslopt = {}
self.connected = False
self.sock = socket.socket()
for opts in sockopt:
self.sock.setsockopt(*opts)
self.sslopt = sslopt
self.get_mask_key = get_mask_key
# Buffers over the packets from the layer beneath until desired amount
# bytes of bytes are received.
self._recv_buffer = []
# These buffer over the build-up of a single frame.
self._frame_header = None
self._frame_length = None
self._frame_mask = None
self._cont_data = None
def fileno(self):
"""
Returns sock.fileno()
"""
return self.sock.fileno()
def set_mask_key(self, func):
"""
set function to create musk key. You can custumize mask key generator.
Mainly, this is for testing purpose.
func: callable object. the fuct must 1 argument as integer.
The argument means length of mask key.
This func must be return string(byte array),
which length is argument specified.
"""
self.get_mask_key = func
def gettimeout(self):
"""
Get the websocket timeout(second).
"""
return self.sock.gettimeout()
def settimeout(self, timeout):
"""
Set the timeout to the websocket.
timeout: timeout time(second).
"""
self.sock.settimeout(timeout)
timeout = property(gettimeout, settimeout)
def connect(self, url, **options):
"""
Connect to url. url is websocket url scheme. ie. ws://host:port/resource
You can customize using 'options'.
If you set "header" dict object, you can set your own custom header.
>>> ws = WebSocket()
>>> ws.connect("ws://echo.websocket.org/",
... header={"User-Agent: MyProgram",
... "x-custom: header"})
timeout: socket timeout time. This value is integer.
if you set None for this value,
it means "use DEFAULT_TIMEOUT value"
options: current support option is only "header".
if you set header as dict value,
the custom HTTP headers are added.
"""
hostname, port, resource, is_secure = _parse_url(url)
# TODO: we need to support proxy
self.sock.connect((hostname, port))
if is_secure:
if HAVE_SSL:
if self.sslopt is None:
sslopt = {}
else:
sslopt = self.sslopt
self.sock = ssl.wrap_socket(self.sock, **sslopt)
else:
raise WebSocketException("SSL not available.")
self._handshake(hostname, port, resource, **options)
def _handshake(self, host, port, resource, **options):
headers = []
headers.append("GET %s HTTP/1.1" % resource)
headers.append("Upgrade: websocket")
headers.append("Connection: Upgrade")
if port == 80:
hostport = host
else:
hostport = "%s:%d" % (host, port)
headers.append("Host: %s" % hostport)
if "origin" in options:
headers.append("Origin: %s" % options["origin"])
else:
headers.append("Origin: http://%s" % hostport)
key = _create_sec_websocket_key()
headers.append("Sec-WebSocket-Key: %s" % key)
headers.append("Sec-WebSocket-Version: %s" % VERSION)
if "header" in options:
headers.extend(options["header"])
headers.append("")
headers.append("")
header_str = "\r\n".join(headers)
self._send(header_str)
if TRACE_ENABLED:
LOG.debug("--- request header ---")
LOG.debug(header_str)
LOG.debug("-----------------------")
status, resp_headers = self._read_headers()
if status == 301:
# Redirect
raise WebsocketRedirect(resp_headers)
if status != 101:
self.close()
raise WebSocketException("Handshake Status %d" % status)
success = self._validate_header(resp_headers, key)
if not success:
self.close()
raise WebSocketException("Invalid WebSocket Header")
self.connected = True
@staticmethod
def _validate_header(headers, key):
for k, v in _HEADERS_TO_CHECK.iteritems():
r = headers.get(k, None)
if not r:
return False
r = r.lower()
if v != r:
return False
result = headers.get("sec-websocket-accept", None)
if not result:
return False
result = result.lower()
value = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
hashed = base64.encodestring(hashlib.sha1(value).digest()).strip().lower()
return hashed == result
def _read_headers(self):
status = None
headers = {}
if TRACE_ENABLED:
LOG.debug("--- response header ---")
while True:
line = self._recv_line()
if line == "\r\n":
break
line = line.strip()
if TRACE_ENABLED:
LOG.debug(line)
if not status:
status_info = line.split(" ", 2)
status = int(status_info[1])
else:
kv = line.split(":", 1)
if len(kv) == 2:
key, value = kv
headers[key.lower()] = value.strip().lower()
else:
raise WebSocketException("Invalid header")
if TRACE_ENABLED:
LOG.debug("-----------------------")
return status, headers
def send(self, payload, opcode=ABNF.OPCODE_TEXT):
"""
Send the data as string.
payload: Payload must be utf-8 string or unicoce,
if the opcode is OPCODE_TEXT.
Otherwise, it must be string(byte array)
opcode: operation code to send. Please see OPCODE_XXX.
"""
frame = ABNF.create_frame(payload, opcode)
if self.get_mask_key:
frame.get_mask_key = self.get_mask_key
data = frame.format()
length = len(data)
if TRACE_ENABLED:
LOG.debug("send: %s", repr(data))
while data:
l = self._send(data)
data = data[l:]
return length
def send_binary(self, payload):
"""
send the payload
"""
return self.send(payload, ABNF.OPCODE_BINARY)
def ping(self, payload=""):
"""
send ping data.
payload: data payload to send server.
"""
self.send(payload, ABNF.OPCODE_PING)
def pong(self, payload):
"""
send pong data.
payload: data payload to send server.
"""
self.send(payload, ABNF.OPCODE_PONG)
def recv(self):
"""
Receive string data(byte array) from the server.
return value: string(byte array) value.
"""
_, data = self.recv_data()
return data
def recv_data(self):
"""
Recieve data with operation code.
return value: tuple of operation code and string(byte array) value.
"""
while True:
frame = self.recv_frame()
if not frame:
# handle error:
# 'NoneType' object has no attribute 'opcode'
raise WebSocketException("Not a valid frame %s" % frame)
elif frame.opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY, ABNF.OPCODE_CONT):
if frame.opcode == ABNF.OPCODE_CONT and not self._cont_data:
raise WebSocketException("Illegal frame")
if self._cont_data:
self._cont_data[1] += frame.data
else:
self._cont_data = [frame.opcode, frame.data]
if frame.fin:
data = self._cont_data
self._cont_data = None
return data
elif frame.opcode == ABNF.OPCODE_CLOSE:
self.send_close()
return (frame.opcode, None)
elif frame.opcode == ABNF.OPCODE_PING:
self.pong(frame.data)
def recv_frame(self):
"""
recieve data as frame from server.
return value: ABNF frame object.
"""
# Header
if self._frame_header is None:
self._frame_header = self._recv_strict(2)
b1 = ord(self._frame_header[0])
fin = b1 >> 7 & 1
rsv1 = b1 >> 6 & 1
rsv2 = b1 >> 5 & 1
rsv3 = b1 >> 4 & 1
opcode = b1 & 0xf
b2 = ord(self._frame_header[1])
has_mask = b2 >> 7 & 1
# Frame length
if self._frame_length is None:
length_bits = b2 & 0x7f
if length_bits == 0x7e:
length_data = self._recv_strict(2)
self._frame_length = struct.unpack("!H", length_data)[0]
elif length_bits == 0x7f:
length_data = self._recv_strict(8)
self._frame_length = struct.unpack("!Q", length_data)[0]
else:
self._frame_length = length_bits
# Mask
if self._frame_mask is None:
self._frame_mask = self._recv_strict(4) if has_mask else ""
# Payload
payload = self._recv_strict(self._frame_length)
if has_mask:
payload = ABNF.mask(self._frame_mask, payload)
# Reset for next frame
self._frame_header = None
self._frame_length = None
self._frame_mask = None
return ABNF(fin, rsv1, rsv2, rsv3, opcode, has_mask, payload)
def send_close(self, status=STATUS_NORMAL, reason=""):
"""
send close data to the server.
status: status code to send. see STATUS_XXX.
reason: the reason to close. This must be string.
"""
if status < 0 or status >= ABNF.LENGTH_16:
raise ValueError("code is invalid range")
self.send(struct.pack('!H', status) + reason, ABNF.OPCODE_CLOSE)
def close(self, status=STATUS_NORMAL, reason=""):
"""
Close Websocket object
status: status code to send. see STATUS_XXX.
reason: the reason to close. This must be string.
"""
try:
self.sock.shutdown(socket.SHUT_RDWR)
except Exception:
pass
self._closeInternal()
def _closeInternal(self):
self.connected = False
self.sock.close()
def _send(self, data):
try:
return self.sock.send(data)
except socket.timeout as e:
raise WebSocketTimeoutException(e.args[0])
except Exception as e:
if "timed out" in e.args[0]:
raise WebSocketTimeoutException(e.args[0])
else:
raise e
def _recv(self, bufsize):
try:
bytes_ = self.sock.recv(bufsize)
except socket.timeout as e:
raise WebSocketTimeoutException(e.args[0])
except SSLError as e:
if e.args[0] == "The read operation timed out":
raise WebSocketTimeoutException(e.args[0])
else:
raise
if not bytes_:
raise WebSocketConnectionClosedException()
return bytes_
def _recv_strict(self, bufsize):
shortage = bufsize - sum(len(x) for x in self._recv_buffer)
while shortage > 0:
bytes_ = self._recv(shortage)
self._recv_buffer.append(bytes_)
shortage -= len(bytes_)
unified = "".join(self._recv_buffer)
if shortage == 0:
self._recv_buffer = []
return unified
else:
self._recv_buffer = [unified[bufsize:]]
return unified[:bufsize]
def _recv_line(self):
line = []
while True:
c = self._recv(1)
line.append(c)
if c == "\n":
break
return "".join(line)
class WebSocketApp(object):
"""
Higher level of APIs are provided.
The interface is like JavaScript WebSocket object.
"""
def __init__(self, url, header=None,
on_open=None, on_message=None, on_error=None,
on_close=None, keep_running=True, get_mask_key=None):
"""
url: websocket url.
header: custom header for websocket handshake.
on_open: callable object which is called at opening websocket.
this function has one argument. The arugment is this class object.
on_message: callbale object which is called when recieved data.
on_message has 2 arguments.
The 1st arugment is this class object.
The passing 2nd arugment is utf-8 string which we get from the server.
on_error: callable object which is called when we get error.
on_error has 2 arguments.
The 1st arugment is this class object.
The passing 2nd arugment is exception object.
on_close: callable object which is called when closed the connection.
this function has one argument. The arugment is this class object.
keep_running: a boolean flag indicating whether the app's main loop should
keep running, defaults to True
get_mask_key: a callable to produce new mask keys, see the WebSocket.set_mask_key's
docstring for more information
"""
self.url = url
self.header = [] if header is None else header
self.on_open = on_open
self.on_message = on_message
self.on_error = on_error
self.on_close = on_close
self.keep_running = keep_running
self.get_mask_key = get_mask_key
self.sock = None
def send(self, data, opcode=ABNF.OPCODE_TEXT):
"""
send message.
data: message to send. If you set opcode to OPCODE_TEXT, data must be utf-8 string or unicode.
opcode: operation code of data. default is OPCODE_TEXT.
"""
if self.sock.send(data, opcode) == 0:
raise WebSocketConnectionClosedException()
def close(self):
"""
close websocket connection.
"""
self.keep_running = False
if self.sock != None:
self.sock.close()
def _send_ping(self, interval):
while True:
for _ in range(interval):
app.APP.monitor.waitForAbort(1)
if not self.keep_running:
return
self.sock.ping()
def run_forever(self, sockopt=None, sslopt=None, ping_interval=0):
"""
run event loop for WebSocket framework.
This loop is infinite loop and is alive during websocket is available.
sockopt: values for socket.setsockopt.
sockopt must be tuple and each element is argument of
sock.setscokopt.
sslopt: ssl socket optional dict.
ping_interval: automatically send "ping" command every specified
period(second)
if set to 0, not send automatically.
"""
if sockopt is None:
sockopt = []
if sslopt is None:
sslopt = {}
if self.sock:
raise WebSocketException("socket is already opened")
thread = None
self.keep_running = True
try:
self.sock = WebSocket(self.get_mask_key,
sockopt=sockopt,
sslopt=sslopt)
self.sock.settimeout(DEFAULT_TIMEOUT)
self.sock.connect(self.url, header=self.header)
self._callback(self.on_open)
if ping_interval:
thread = threading.Thread(target=self._send_ping,
args=(ping_interval,))
thread.setDaemon(True)
thread.start()
while self.keep_running:
try:
data = self.sock.recv()
if data is None or self.keep_running is False:
break
self._callback(self.on_message, data)
except Exception, e:
if "timed out" not in e.args[0]:
raise e
except Exception, e:
self._callback(self.on_error, e)
finally:
if thread:
self.keep_running = False
self.sock.close()
self._callback(self.on_close)
self.sock = None
def _callback(self, callback, *args):
if callback:
try:
callback(self, *args)
except Exception, e:
LOG.error(e)
_, _, tb = sys.exc_info()
traceback.print_tb(tb)
if __name__ == "__main__":
enable_trace(True)
WEBSOCKET = create_connection("ws://echo.websocket.org/")
LOG.info("Sending 'Hello, World'...")
WEBSOCKET.send("Hello, World")
LOG.info("Sent")
LOG.info("Receiving...")
RESULT = WEBSOCKET.recv()
LOG.info("Received '%s'", RESULT)
WEBSOCKET.close()

View file

@ -0,0 +1,28 @@
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
from ._abnf import *
from ._app import WebSocketApp
from ._core import *
from ._exceptions import *
from ._logging import *
from ._socket import *
__version__ = "0.59.0"

View file

@ -0,0 +1,458 @@
"""
"""
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import array
import os
import struct
import six
from ._exceptions import *
from ._utils import validate_utf8
from threading import Lock
try:
if six.PY3:
import numpy
else:
numpy = None
except ImportError:
numpy = None
try:
# If wsaccel is available we use compiled routines to mask data.
if not numpy:
from wsaccel.xormask import XorMaskerSimple
def _mask(_m, _d):
return XorMaskerSimple(_m).process(_d)
except ImportError:
# wsaccel is not available, we rely on python implementations.
def _mask(_m, _d):
for i in range(len(_d)):
_d[i] ^= _m[i % 4]
if six.PY3:
return _d.tobytes()
else:
return _d.tostring()
__all__ = [
'ABNF', 'continuous_frame', 'frame_buffer',
'STATUS_NORMAL',
'STATUS_GOING_AWAY',
'STATUS_PROTOCOL_ERROR',
'STATUS_UNSUPPORTED_DATA_TYPE',
'STATUS_STATUS_NOT_AVAILABLE',
'STATUS_ABNORMAL_CLOSED',
'STATUS_INVALID_PAYLOAD',
'STATUS_POLICY_VIOLATION',
'STATUS_MESSAGE_TOO_BIG',
'STATUS_INVALID_EXTENSION',
'STATUS_UNEXPECTED_CONDITION',
'STATUS_BAD_GATEWAY',
'STATUS_TLS_HANDSHAKE_ERROR',
]
# closing frame status codes.
STATUS_NORMAL = 1000
STATUS_GOING_AWAY = 1001
STATUS_PROTOCOL_ERROR = 1002
STATUS_UNSUPPORTED_DATA_TYPE = 1003
STATUS_STATUS_NOT_AVAILABLE = 1005
STATUS_ABNORMAL_CLOSED = 1006
STATUS_INVALID_PAYLOAD = 1007
STATUS_POLICY_VIOLATION = 1008
STATUS_MESSAGE_TOO_BIG = 1009
STATUS_INVALID_EXTENSION = 1010
STATUS_UNEXPECTED_CONDITION = 1011
STATUS_BAD_GATEWAY = 1014
STATUS_TLS_HANDSHAKE_ERROR = 1015
VALID_CLOSE_STATUS = (
STATUS_NORMAL,
STATUS_GOING_AWAY,
STATUS_PROTOCOL_ERROR,
STATUS_UNSUPPORTED_DATA_TYPE,
STATUS_INVALID_PAYLOAD,
STATUS_POLICY_VIOLATION,
STATUS_MESSAGE_TOO_BIG,
STATUS_INVALID_EXTENSION,
STATUS_UNEXPECTED_CONDITION,
STATUS_BAD_GATEWAY,
)
class ABNF(object):
"""
ABNF frame class.
See http://tools.ietf.org/html/rfc5234
and http://tools.ietf.org/html/rfc6455#section-5.2
"""
# operation code values.
OPCODE_CONT = 0x0
OPCODE_TEXT = 0x1
OPCODE_BINARY = 0x2
OPCODE_CLOSE = 0x8
OPCODE_PING = 0x9
OPCODE_PONG = 0xa
# available operation code value tuple
OPCODES = (OPCODE_CONT, OPCODE_TEXT, OPCODE_BINARY, OPCODE_CLOSE,
OPCODE_PING, OPCODE_PONG)
# opcode human readable string
OPCODE_MAP = {
OPCODE_CONT: "cont",
OPCODE_TEXT: "text",
OPCODE_BINARY: "binary",
OPCODE_CLOSE: "close",
OPCODE_PING: "ping",
OPCODE_PONG: "pong"
}
# data length threshold.
LENGTH_7 = 0x7e
LENGTH_16 = 1 << 16
LENGTH_63 = 1 << 63
def __init__(self, fin=0, rsv1=0, rsv2=0, rsv3=0,
opcode=OPCODE_TEXT, mask=1, data=""):
"""
Constructor for ABNF. Please check RFC for arguments.
"""
self.fin = fin
self.rsv1 = rsv1
self.rsv2 = rsv2
self.rsv3 = rsv3
self.opcode = opcode
self.mask = mask
if data is None:
data = ""
self.data = data
self.get_mask_key = os.urandom
def validate(self, skip_utf8_validation=False):
"""
Validate the ABNF frame.
Parameters
----------
skip_utf8_validation: skip utf8 validation.
"""
if self.rsv1 or self.rsv2 or self.rsv3:
raise WebSocketProtocolException("rsv is not implemented, yet")
if self.opcode not in ABNF.OPCODES:
raise WebSocketProtocolException("Invalid opcode %r", self.opcode)
if self.opcode == ABNF.OPCODE_PING and not self.fin:
raise WebSocketProtocolException("Invalid ping frame.")
if self.opcode == ABNF.OPCODE_CLOSE:
l = len(self.data)
if not l:
return
if l == 1 or l >= 126:
raise WebSocketProtocolException("Invalid close frame.")
if l > 2 and not skip_utf8_validation and not validate_utf8(self.data[2:]):
raise WebSocketProtocolException("Invalid close frame.")
code = 256 * \
six.byte2int(self.data[0:1]) + six.byte2int(self.data[1:2])
if not self._is_valid_close_status(code):
raise WebSocketProtocolException("Invalid close opcode.")
@staticmethod
def _is_valid_close_status(code):
return code in VALID_CLOSE_STATUS or (3000 <= code < 5000)
def __str__(self):
return "fin=" + str(self.fin) \
+ " opcode=" + str(self.opcode) \
+ " data=" + str(self.data)
@staticmethod
def create_frame(data, opcode, fin=1):
"""
Create frame to send text, binary and other data.
Parameters
----------
data: <type>
data to send. This is string value(byte array).
If opcode is OPCODE_TEXT and this value is unicode,
data value is converted into unicode string, automatically.
opcode: <type>
operation code. please see OPCODE_XXX.
fin: <type>
fin flag. if set to 0, create continue fragmentation.
"""
if opcode == ABNF.OPCODE_TEXT and isinstance(data, six.text_type):
data = data.encode("utf-8")
# mask must be set if send data from client
return ABNF(fin, 0, 0, 0, opcode, 1, data)
def format(self):
"""
Format this object to string(byte array) to send data to server.
"""
if any(x not in (0, 1) for x in [self.fin, self.rsv1, self.rsv2, self.rsv3]):
raise ValueError("not 0 or 1")
if self.opcode not in ABNF.OPCODES:
raise ValueError("Invalid OPCODE")
length = len(self.data)
if length >= ABNF.LENGTH_63:
raise ValueError("data is too long")
frame_header = chr(self.fin << 7 |
self.rsv1 << 6 | self.rsv2 << 5 | self.rsv3 << 4 |
self.opcode)
if length < ABNF.LENGTH_7:
frame_header += chr(self.mask << 7 | length)
frame_header = six.b(frame_header)
elif length < ABNF.LENGTH_16:
frame_header += chr(self.mask << 7 | 0x7e)
frame_header = six.b(frame_header)
frame_header += struct.pack("!H", length)
else:
frame_header += chr(self.mask << 7 | 0x7f)
frame_header = six.b(frame_header)
frame_header += struct.pack("!Q", length)
if not self.mask:
return frame_header + self.data
else:
mask_key = self.get_mask_key(4)
return frame_header + self._get_masked(mask_key)
def _get_masked(self, mask_key):
s = ABNF.mask(mask_key, self.data)
if isinstance(mask_key, six.text_type):
mask_key = mask_key.encode('utf-8')
return mask_key + s
@staticmethod
def mask(mask_key, data):
"""
Mask or unmask data. Just do xor for each byte
Parameters
----------
mask_key: <type>
4 byte string(byte).
data: <type>
data to mask/unmask.
"""
if data is None:
data = ""
if isinstance(mask_key, six.text_type):
mask_key = six.b(mask_key)
if isinstance(data, six.text_type):
data = six.b(data)
if numpy:
origlen = len(data)
_mask_key = mask_key[3] << 24 | mask_key[2] << 16 | mask_key[1] << 8 | mask_key[0]
# We need data to be a multiple of four...
data += bytes(" " * (4 - (len(data) % 4)), "us-ascii")
a = numpy.frombuffer(data, dtype="uint32")
masked = numpy.bitwise_xor(a, [_mask_key]).astype("uint32")
if len(data) > origlen:
return masked.tobytes()[:origlen]
return masked.tobytes()
else:
_m = array.array("B", mask_key)
_d = array.array("B", data)
return _mask(_m, _d)
class frame_buffer(object):
_HEADER_MASK_INDEX = 5
_HEADER_LENGTH_INDEX = 6
def __init__(self, recv_fn, skip_utf8_validation):
self.recv = recv_fn
self.skip_utf8_validation = skip_utf8_validation
# Buffers over the packets from the layer beneath until desired amount
# bytes of bytes are received.
self.recv_buffer = []
self.clear()
self.lock = Lock()
def clear(self):
self.header = None
self.length = None
self.mask = None
def has_received_header(self):
return self.header is None
def recv_header(self):
header = self.recv_strict(2)
b1 = header[0]
if six.PY2:
b1 = ord(b1)
fin = b1 >> 7 & 1
rsv1 = b1 >> 6 & 1
rsv2 = b1 >> 5 & 1
rsv3 = b1 >> 4 & 1
opcode = b1 & 0xf
b2 = header[1]
if six.PY2:
b2 = ord(b2)
has_mask = b2 >> 7 & 1
length_bits = b2 & 0x7f
self.header = (fin, rsv1, rsv2, rsv3, opcode, has_mask, length_bits)
def has_mask(self):
if not self.header:
return False
return self.header[frame_buffer._HEADER_MASK_INDEX]
def has_received_length(self):
return self.length is None
def recv_length(self):
bits = self.header[frame_buffer._HEADER_LENGTH_INDEX]
length_bits = bits & 0x7f
if length_bits == 0x7e:
v = self.recv_strict(2)
self.length = struct.unpack("!H", v)[0]
elif length_bits == 0x7f:
v = self.recv_strict(8)
self.length = struct.unpack("!Q", v)[0]
else:
self.length = length_bits
def has_received_mask(self):
return self.mask is None
def recv_mask(self):
self.mask = self.recv_strict(4) if self.has_mask() else ""
def recv_frame(self):
with self.lock:
# Header
if self.has_received_header():
self.recv_header()
(fin, rsv1, rsv2, rsv3, opcode, has_mask, _) = self.header
# Frame length
if self.has_received_length():
self.recv_length()
length = self.length
# Mask
if self.has_received_mask():
self.recv_mask()
mask = self.mask
# Payload
payload = self.recv_strict(length)
if has_mask:
payload = ABNF.mask(mask, payload)
# Reset for next frame
self.clear()
frame = ABNF(fin, rsv1, rsv2, rsv3, opcode, has_mask, payload)
frame.validate(self.skip_utf8_validation)
return frame
def recv_strict(self, bufsize):
shortage = bufsize - sum(len(x) for x in self.recv_buffer)
while shortage > 0:
# Limit buffer size that we pass to socket.recv() to avoid
# fragmenting the heap -- the number of bytes recv() actually
# reads is limited by socket buffer and is relatively small,
# yet passing large numbers repeatedly causes lots of large
# buffers allocated and then shrunk, which results in
# fragmentation.
bytes_ = self.recv(min(16384, shortage))
self.recv_buffer.append(bytes_)
shortage -= len(bytes_)
unified = six.b("").join(self.recv_buffer)
if shortage == 0:
self.recv_buffer = []
return unified
else:
self.recv_buffer = [unified[bufsize:]]
return unified[:bufsize]
class continuous_frame(object):
def __init__(self, fire_cont_frame, skip_utf8_validation):
self.fire_cont_frame = fire_cont_frame
self.skip_utf8_validation = skip_utf8_validation
self.cont_data = None
self.recving_frames = None
def validate(self, frame):
if not self.recving_frames and frame.opcode == ABNF.OPCODE_CONT:
raise WebSocketProtocolException("Illegal frame")
if self.recving_frames and \
frame.opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY):
raise WebSocketProtocolException("Illegal frame")
def add(self, frame):
if self.cont_data:
self.cont_data[1] += frame.data
else:
if frame.opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY):
self.recving_frames = frame.opcode
self.cont_data = [frame.opcode, frame.data]
if frame.fin:
self.recving_frames = None
def is_fire(self, frame):
return frame.fin or self.fire_cont_frame
def extract(self, frame):
data = self.cont_data
self.cont_data = None
frame.data = data[1]
if not self.fire_cont_frame and data[0] == ABNF.OPCODE_TEXT and not self.skip_utf8_validation and not validate_utf8(frame.data):
raise WebSocketPayloadException(
"cannot decode: " + repr(frame.data))
return [data[0], frame]

View file

@ -0,0 +1,400 @@
"""
"""
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import inspect
import select
import sys
import threading
import time
import traceback
import six
from ._abnf import ABNF
from ._core import WebSocket, getdefaulttimeout
from ._exceptions import *
from . import _logging
__all__ = ["WebSocketApp"]
class Dispatcher:
"""
Dispatcher
"""
def __init__(self, app, ping_timeout):
self.app = app
self.ping_timeout = ping_timeout
def read(self, sock, read_callback, check_callback):
while self.app.keep_running:
r, w, e = select.select(
(self.app.sock.sock, ), (), (), self.ping_timeout)
if r:
if not read_callback():
break
check_callback()
class SSLDispatcher:
"""
SSLDispatcher
"""
def __init__(self, app, ping_timeout):
self.app = app
self.ping_timeout = ping_timeout
def read(self, sock, read_callback, check_callback):
while self.app.keep_running:
r = self.select()
if r:
if not read_callback():
break
check_callback()
def select(self):
sock = self.app.sock.sock
if sock.pending():
return [sock,]
r, w, e = select.select((sock, ), (), (), self.ping_timeout)
return r
class WebSocketApp(object):
"""
Higher level of APIs are provided. The interface is like JavaScript WebSocket object.
"""
def __init__(self, url, header=None,
on_open=None, on_message=None, on_error=None,
on_close=None, on_ping=None, on_pong=None,
on_cont_message=None,
keep_running=True, get_mask_key=None, cookie=None,
subprotocols=None,
on_data=None):
"""
WebSocketApp initialization
Parameters
----------
url: <type>
websocket url.
header: list or dict
custom header for websocket handshake.
on_open: <type>
callable object which is called at opening websocket.
this function has one argument. The argument is this class object.
on_message: <type>
callable object which is called when received data.
on_message has 2 arguments.
The 1st argument is this class object.
The 2nd argument is utf-8 string which we get from the server.
on_error: <type>
callable object which is called when we get error.
on_error has 2 arguments.
The 1st argument is this class object.
The 2nd argument is exception object.
on_close: <type>
callable object which is called when closed the connection.
this function has one argument. The argument is this class object.
on_cont_message: <type>
callback object which is called when receive continued
frame data.
on_cont_message has 3 arguments.
The 1st argument is this class object.
The 2nd argument is utf-8 string which we get from the server.
The 3rd argument is continue flag. if 0, the data continue
to next frame data
on_data: <type>
callback object which is called when a message received.
This is called before on_message or on_cont_message,
and then on_message or on_cont_message is called.
on_data has 4 argument.
The 1st argument is this class object.
The 2nd argument is utf-8 string which we get from the server.
The 3rd argument is data type. ABNF.OPCODE_TEXT or ABNF.OPCODE_BINARY will be came.
The 4th argument is continue flag. if 0, the data continue
keep_running: <type>
this parameter is obsolete and ignored.
get_mask_key: func
a callable to produce new mask keys,
see the WebSocket.set_mask_key's docstring for more information
cookie: str
cookie value.
subprotocols: <type>
array of available sub protocols. default is None.
"""
self.url = url
self.header = header if header is not None else []
self.cookie = cookie
self.on_open = on_open
self.on_message = on_message
self.on_data = on_data
self.on_error = on_error
self.on_close = on_close
self.on_ping = on_ping
self.on_pong = on_pong
self.on_cont_message = on_cont_message
self.keep_running = False
self.get_mask_key = get_mask_key
self.sock = None
self.last_ping_tm = 0
self.last_pong_tm = 0
self.subprotocols = subprotocols
def send(self, data, opcode=ABNF.OPCODE_TEXT):
"""
send message
Parameters
----------
data: <type>
Message to send. If you set opcode to OPCODE_TEXT,
data must be utf-8 string or unicode.
opcode: <type>
Operation code of data. default is OPCODE_TEXT.
"""
if not self.sock or self.sock.send(data, opcode) == 0:
raise WebSocketConnectionClosedException(
"Connection is already closed.")
def close(self, **kwargs):
"""
Close websocket connection.
"""
self.keep_running = False
if self.sock:
self.sock.close(**kwargs)
self.sock = None
def _send_ping(self, interval, event, payload):
while not event.wait(interval):
self.last_ping_tm = time.time()
if self.sock:
try:
self.sock.ping(payload)
except Exception as ex:
_logging.warning("send_ping routine terminated: {}".format(ex))
break
def run_forever(self, sockopt=None, sslopt=None,
ping_interval=0, ping_timeout=None,
ping_payload="",
http_proxy_host=None, http_proxy_port=None,
http_no_proxy=None, http_proxy_auth=None,
skip_utf8_validation=False,
host=None, origin=None, dispatcher=None,
suppress_origin=False, proxy_type=None,
enable_multithread=True):
"""
Run event loop for WebSocket framework.
This loop is an infinite loop and is alive while websocket is available.
Parameters
----------
sockopt: tuple
values for socket.setsockopt.
sockopt must be tuple
and each element is argument of sock.setsockopt.
sslopt: dict
optional dict object for ssl socket option.
ping_interval: int or float
automatically send "ping" command
every specified period (in seconds)
if set to 0, not send automatically.
ping_timeout: int or float
timeout (in seconds) if the pong message is not received.
ping_payload: str
payload message to send with each ping.
http_proxy_host: <type>
http proxy host name.
http_proxy_port: <type>
http proxy port. If not set, set to 80.
http_no_proxy: <type>
host names, which doesn't use proxy.
skip_utf8_validation: bool
skip utf8 validation.
host: str
update host header.
origin: str
update origin header.
dispatcher: <type>
customize reading data from socket.
suppress_origin: bool
suppress outputting origin header.
Returns
-------
teardown: bool
False if caught KeyboardInterrupt, True if other exception was raised during a loop
"""
if ping_timeout is not None and ping_timeout <= 0:
ping_timeout = None
if ping_timeout and ping_interval and ping_interval <= ping_timeout:
raise WebSocketException("Ensure ping_interval > ping_timeout")
if not sockopt:
sockopt = []
if not sslopt:
sslopt = {}
if self.sock:
raise WebSocketException("socket is already opened")
thread = None
self.keep_running = True
self.last_ping_tm = 0
self.last_pong_tm = 0
def teardown(close_frame=None):
"""
Tears down the connection.
If close_frame is set, we will invoke the on_close handler with the
statusCode and reason from there.
"""
if thread and thread.is_alive():
event.set()
thread.join()
self.keep_running = False
if self.sock:
self.sock.close()
close_args = self._get_close_args(
close_frame.data if close_frame else None)
self._callback(self.on_close, *close_args)
self.sock = None
try:
self.sock = WebSocket(
self.get_mask_key, sockopt=sockopt, sslopt=sslopt,
fire_cont_frame=self.on_cont_message is not None,
skip_utf8_validation=skip_utf8_validation,
enable_multithread=enable_multithread)
self.sock.settimeout(getdefaulttimeout())
self.sock.connect(
self.url, header=self.header, cookie=self.cookie,
http_proxy_host=http_proxy_host,
http_proxy_port=http_proxy_port, http_no_proxy=http_no_proxy,
http_proxy_auth=http_proxy_auth, subprotocols=self.subprotocols,
host=host, origin=origin, suppress_origin=suppress_origin,
proxy_type=proxy_type)
if not dispatcher:
dispatcher = self.create_dispatcher(ping_timeout)
self._callback(self.on_open)
if ping_interval:
event = threading.Event()
thread = threading.Thread(
target=self._send_ping, args=(ping_interval, event, ping_payload))
thread.daemon = True
thread.start()
def read():
if not self.keep_running:
return teardown()
op_code, frame = self.sock.recv_data_frame(True)
if op_code == ABNF.OPCODE_CLOSE:
return teardown(frame)
elif op_code == ABNF.OPCODE_PING:
self._callback(self.on_ping, frame.data)
elif op_code == ABNF.OPCODE_PONG:
self.last_pong_tm = time.time()
self._callback(self.on_pong, frame.data)
elif op_code == ABNF.OPCODE_CONT and self.on_cont_message:
self._callback(self.on_data, frame.data,
frame.opcode, frame.fin)
self._callback(self.on_cont_message,
frame.data, frame.fin)
else:
data = frame.data
if six.PY3 and op_code == ABNF.OPCODE_TEXT:
data = data.decode("utf-8")
self._callback(self.on_data, data, frame.opcode, True)
self._callback(self.on_message, data)
return True
def check():
if (ping_timeout):
has_timeout_expired = time.time() - self.last_ping_tm > ping_timeout
has_pong_not_arrived_after_last_ping = self.last_pong_tm - self.last_ping_tm < 0
has_pong_arrived_too_late = self.last_pong_tm - self.last_ping_tm > ping_timeout
if (self.last_ping_tm and
has_timeout_expired and
(has_pong_not_arrived_after_last_ping or has_pong_arrived_too_late)):
raise WebSocketTimeoutException("ping/pong timed out")
return True
dispatcher.read(self.sock.sock, read, check)
except (Exception, KeyboardInterrupt, SystemExit) as e:
self._callback(self.on_error, e)
if isinstance(e, SystemExit):
# propagate SystemExit further
raise
teardown()
return not isinstance(e, KeyboardInterrupt)
def create_dispatcher(self, ping_timeout):
timeout = ping_timeout or 10
if self.sock.is_ssl():
return SSLDispatcher(self, timeout)
return Dispatcher(self, timeout)
def _get_close_args(self, data):
"""
_get_close_args extracts the code, reason from the close body
if they exists, and if the self.on_close except three arguments
"""
# if the on_close callback is "old", just return empty list
if sys.version_info < (3, 0):
if not self.on_close or len(inspect.getargspec(self.on_close).args) != 3:
return []
else:
if not self.on_close or len(inspect.getfullargspec(self.on_close).args) != 3:
return []
if data and len(data) >= 2:
code = 256 * six.byte2int(data[0:1]) + six.byte2int(data[1:2])
reason = data[2:].decode('utf-8')
return [code, reason]
return [None, None]
def _callback(self, callback, *args):
if callback:
try:
callback(self, *args)
except Exception as e:
_logging.error("error from callback {}: {}".format(callback, e))
if _logging.isEnabledForDebug():
_, _, tb = sys.exc_info()
traceback.print_tb(tb)

View file

@ -0,0 +1,78 @@
"""
"""
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
try:
import Cookie
except:
import http.cookies as Cookie
class SimpleCookieJar(object):
def __init__(self):
self.jar = dict()
def add(self, set_cookie):
if set_cookie:
try:
simpleCookie = Cookie.SimpleCookie(set_cookie)
except:
simpleCookie = Cookie.SimpleCookie(set_cookie.encode('ascii', 'ignore'))
for k, v in simpleCookie.items():
domain = v.get("domain")
if domain:
if not domain.startswith("."):
domain = "." + domain
cookie = self.jar.get(domain) if self.jar.get(domain) else Cookie.SimpleCookie()
cookie.update(simpleCookie)
self.jar[domain.lower()] = cookie
def set(self, set_cookie):
if set_cookie:
try:
simpleCookie = Cookie.SimpleCookie(set_cookie)
except:
simpleCookie = Cookie.SimpleCookie(set_cookie.encode('ascii', 'ignore'))
for k, v in simpleCookie.items():
domain = v.get("domain")
if domain:
if not domain.startswith("."):
domain = "." + domain
self.jar[domain.lower()] = simpleCookie
def get(self, host):
if not host:
return ""
cookies = []
for domain, simpleCookie in self.jar.items():
host = host.lower()
if host.endswith(domain) or host == domain[1:]:
cookies.append(self.jar.get(domain))
return "; ".join(filter(
None, sorted(
["%s=%s" % (k, v.value) for cookie in filter(None, cookies) for k, v in cookie.items()]
)))

View file

@ -0,0 +1,598 @@
from __future__ import print_function
"""
_core.py
====================================
WebSocket Python client
"""
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import socket
import struct
import threading
import time
import six
# websocket modules
from ._abnf import *
from ._exceptions import *
from ._handshake import *
from ._http import *
from ._logging import *
from ._socket import *
from ._ssl_compat import *
from ._utils import *
__all__ = ['WebSocket', 'create_connection']
class WebSocket(object):
"""
Low level WebSocket interface.
This class is based on the WebSocket protocol `draft-hixie-thewebsocketprotocol-76 <http://tools.ietf.org/html/draft-hixie-thewebsocketprotocol-76>`_
We can connect to the websocket server and send/receive data.
The following example is an echo client.
>>> import websocket
>>> ws = websocket.WebSocket()
>>> ws.connect("ws://echo.websocket.org")
>>> ws.send("Hello, Server")
>>> ws.recv()
'Hello, Server'
>>> ws.close()
Parameters
----------
get_mask_key: func
a callable to produce new mask keys, see the set_mask_key
function's docstring for more details
sockopt: tuple
values for socket.setsockopt.
sockopt must be tuple and each element is argument of sock.setsockopt.
sslopt: dict
optional dict object for ssl socket option.
fire_cont_frame: bool
fire recv event for each cont frame. default is False
enable_multithread: bool
if set to True, lock send method.
skip_utf8_validation: bool
skip utf8 validation.
"""
def __init__(self, get_mask_key=None, sockopt=None, sslopt=None,
fire_cont_frame=False, enable_multithread=False,
skip_utf8_validation=False, **_):
"""
Initialize WebSocket object.
Parameters
----------
sslopt: specify ssl certification verification options
"""
self.sock_opt = sock_opt(sockopt, sslopt)
self.handshake_response = None
self.sock = None
self.connected = False
self.get_mask_key = get_mask_key
# These buffer over the build-up of a single frame.
self.frame_buffer = frame_buffer(self._recv, skip_utf8_validation)
self.cont_frame = continuous_frame(
fire_cont_frame, skip_utf8_validation)
if enable_multithread:
self.lock = threading.Lock()
self.readlock = threading.Lock()
else:
self.lock = NoLock()
self.readlock = NoLock()
def __iter__(self):
"""
Allow iteration over websocket, implying sequential `recv` executions.
"""
while True:
yield self.recv()
def __next__(self):
return self.recv()
def next(self):
return self.__next__()
def fileno(self):
return self.sock.fileno()
def set_mask_key(self, func):
"""
Set function to create mask key. You can customize mask key generator.
Mainly, this is for testing purpose.
Parameters
----------
func: func
callable object. the func takes 1 argument as integer.
The argument means length of mask key.
This func must return string(byte array),
which length is argument specified.
"""
self.get_mask_key = func
def gettimeout(self):
"""
Get the websocket timeout (in seconds) as an int or float
Returns
----------
timeout: int or float
returns timeout value (in seconds). This value could be either float/integer.
"""
return self.sock_opt.timeout
def settimeout(self, timeout):
"""
Set the timeout to the websocket.
Parameters
----------
timeout: int or float
timeout time (in seconds). This value could be either float/integer.
"""
self.sock_opt.timeout = timeout
if self.sock:
self.sock.settimeout(timeout)
timeout = property(gettimeout, settimeout)
def getsubprotocol(self):
"""
Get subprotocol
"""
if self.handshake_response:
return self.handshake_response.subprotocol
else:
return None
subprotocol = property(getsubprotocol)
def getstatus(self):
"""
Get handshake status
"""
if self.handshake_response:
return self.handshake_response.status
else:
return None
status = property(getstatus)
def getheaders(self):
"""
Get handshake response header
"""
if self.handshake_response:
return self.handshake_response.headers
else:
return None
def is_ssl(self):
try:
return isinstance(self.sock, ssl.SSLSocket)
except:
return False
headers = property(getheaders)
def connect(self, url, **options):
"""
Connect to url. url is websocket url scheme.
ie. ws://host:port/resource
You can customize using 'options'.
If you set "header" list object, you can set your own custom header.
>>> ws = WebSocket()
>>> ws.connect("ws://echo.websocket.org/",
... header=["User-Agent: MyProgram",
... "x-custom: header"])
timeout: <type>
socket timeout time. This value is an integer or float.
if you set None for this value, it means "use default_timeout value"
Parameters
----------
options:
- header: list or dict
custom http header list or dict.
- cookie: str
cookie value.
- origin: str
custom origin url.
- suppress_origin: bool
suppress outputting origin header.
- host: str
custom host header string.
- http_proxy_host: <type>
http proxy host name.
- http_proxy_port: <type>
http proxy port. If not set, set to 80.
- http_no_proxy: <type>
host names, which doesn't use proxy.
- http_proxy_auth: <type>
http proxy auth information. tuple of username and password. default is None
- redirect_limit: <type>
number of redirects to follow.
- subprotocols: <type>
array of available sub protocols. default is None.
- socket: <type>
pre-initialized stream socket.
"""
self.sock_opt.timeout = options.get('timeout', self.sock_opt.timeout)
self.sock, addrs = connect(url, self.sock_opt, proxy_info(**options),
options.pop('socket', None))
try:
self.handshake_response = handshake(self.sock, *addrs, **options)
for attempt in range(options.pop('redirect_limit', 3)):
if self.handshake_response.status in SUPPORTED_REDIRECT_STATUSES:
url = self.handshake_response.headers['location']
self.sock.close()
self.sock, addrs = connect(url, self.sock_opt, proxy_info(**options),
options.pop('socket', None))
self.handshake_response = handshake(self.sock, *addrs, **options)
self.connected = True
except:
if self.sock:
self.sock.close()
self.sock = None
raise
def send(self, payload, opcode=ABNF.OPCODE_TEXT):
"""
Send the data as string.
Parameters
----------
payload: <type>
Payload must be utf-8 string or unicode,
if the opcode is OPCODE_TEXT.
Otherwise, it must be string(byte array)
opcode: <type>
operation code to send. Please see OPCODE_XXX.
"""
frame = ABNF.create_frame(payload, opcode)
return self.send_frame(frame)
def send_frame(self, frame):
"""
Send the data frame.
>>> ws = create_connection("ws://echo.websocket.org/")
>>> frame = ABNF.create_frame("Hello", ABNF.OPCODE_TEXT)
>>> ws.send_frame(frame)
>>> cont_frame = ABNF.create_frame("My name is ", ABNF.OPCODE_CONT, 0)
>>> ws.send_frame(frame)
>>> cont_frame = ABNF.create_frame("Foo Bar", ABNF.OPCODE_CONT, 1)
>>> ws.send_frame(frame)
Parameters
----------
frame: <type>
frame data created by ABNF.create_frame
"""
if self.get_mask_key:
frame.get_mask_key = self.get_mask_key
data = frame.format()
length = len(data)
if (isEnabledForTrace()):
trace("send: " + repr(data))
with self.lock:
while data:
l = self._send(data)
data = data[l:]
return length
def send_binary(self, payload):
return self.send(payload, ABNF.OPCODE_BINARY)
def ping(self, payload=""):
"""
Send ping data.
Parameters
----------
payload: <type>
data payload to send server.
"""
if isinstance(payload, six.text_type):
payload = payload.encode("utf-8")
self.send(payload, ABNF.OPCODE_PING)
def pong(self, payload=""):
"""
Send pong data.
Parameters
----------
payload: <type>
data payload to send server.
"""
if isinstance(payload, six.text_type):
payload = payload.encode("utf-8")
self.send(payload, ABNF.OPCODE_PONG)
def recv(self):
"""
Receive string data(byte array) from the server.
Returns
----------
data: string (byte array) value.
"""
with self.readlock:
opcode, data = self.recv_data()
if six.PY3 and opcode == ABNF.OPCODE_TEXT:
return data.decode("utf-8")
elif opcode == ABNF.OPCODE_TEXT or opcode == ABNF.OPCODE_BINARY:
return data
else:
return ''
def recv_data(self, control_frame=False):
"""
Receive data with operation code.
Parameters
----------
control_frame: bool
a boolean flag indicating whether to return control frame
data, defaults to False
Returns
-------
opcode, frame.data: tuple
tuple of operation code and string(byte array) value.
"""
opcode, frame = self.recv_data_frame(control_frame)
return opcode, frame.data
def recv_data_frame(self, control_frame=False):
"""
Receive data with operation code.
Parameters
----------
control_frame: bool
a boolean flag indicating whether to return control frame
data, defaults to False
Returns
-------
frame.opcode, frame: tuple
tuple of operation code and string(byte array) value.
"""
while True:
frame = self.recv_frame()
if not frame:
# handle error:
# 'NoneType' object has no attribute 'opcode'
raise WebSocketProtocolException(
"Not a valid frame %s" % frame)
elif frame.opcode in (ABNF.OPCODE_TEXT, ABNF.OPCODE_BINARY, ABNF.OPCODE_CONT):
self.cont_frame.validate(frame)
self.cont_frame.add(frame)
if self.cont_frame.is_fire(frame):
return self.cont_frame.extract(frame)
elif frame.opcode == ABNF.OPCODE_CLOSE:
self.send_close()
return frame.opcode, frame
elif frame.opcode == ABNF.OPCODE_PING:
if len(frame.data) < 126:
self.pong(frame.data)
else:
raise WebSocketProtocolException(
"Ping message is too long")
if control_frame:
return frame.opcode, frame
elif frame.opcode == ABNF.OPCODE_PONG:
if control_frame:
return frame.opcode, frame
def recv_frame(self):
"""
Receive data as frame from server.
Returns
-------
self.frame_buffer.recv_frame(): ABNF frame object
"""
return self.frame_buffer.recv_frame()
def send_close(self, status=STATUS_NORMAL, reason=six.b("")):
"""
Send close data to the server.
Parameters
----------
status: <type>
status code to send. see STATUS_XXX.
reason: str or bytes
the reason to close. This must be string or bytes.
"""
if status < 0 or status >= ABNF.LENGTH_16:
raise ValueError("code is invalid range")
self.connected = False
self.send(struct.pack('!H', status) + reason, ABNF.OPCODE_CLOSE)
def close(self, status=STATUS_NORMAL, reason=six.b(""), timeout=3):
"""
Close Websocket object
Parameters
----------
status: <type>
status code to send. see STATUS_XXX.
reason: <type>
the reason to close. This must be string.
timeout: int or float
timeout until receive a close frame.
If None, it will wait forever until receive a close frame.
"""
if self.connected:
if status < 0 or status >= ABNF.LENGTH_16:
raise ValueError("code is invalid range")
try:
self.connected = False
self.send(struct.pack('!H', status) + reason, ABNF.OPCODE_CLOSE)
sock_timeout = self.sock.gettimeout()
self.sock.settimeout(timeout)
start_time = time.time()
while timeout is None or time.time() - start_time < timeout:
try:
frame = self.recv_frame()
if frame.opcode != ABNF.OPCODE_CLOSE:
continue
if isEnabledForError():
recv_status = struct.unpack("!H", frame.data[0:2])[0]
if recv_status >= 3000 and recv_status <= 4999:
debug("close status: " + repr(recv_status))
elif recv_status != STATUS_NORMAL:
error("close status: " + repr(recv_status))
break
except:
break
self.sock.settimeout(sock_timeout)
self.sock.shutdown(socket.SHUT_RDWR)
except:
pass
self.shutdown()
def abort(self):
"""
Low-level asynchronous abort, wakes up other threads that are waiting in recv_*
"""
if self.connected:
self.sock.shutdown(socket.SHUT_RDWR)
def shutdown(self):
"""
close socket, immediately.
"""
if self.sock:
self.sock.close()
self.sock = None
self.connected = False
def _send(self, data):
return send(self.sock, data)
def _recv(self, bufsize):
try:
return recv(self.sock, bufsize)
except WebSocketConnectionClosedException:
if self.sock:
self.sock.close()
self.sock = None
self.connected = False
raise
def create_connection(url, timeout=None, class_=WebSocket, **options):
"""
Connect to url and return websocket object.
Connect to url and return the WebSocket object.
Passing optional timeout parameter will set the timeout on the socket.
If no timeout is supplied,
the global default timeout setting returned by getdefaulttimeout() is used.
You can customize using 'options'.
If you set "header" list object, you can set your own custom header.
>>> conn = create_connection("ws://echo.websocket.org/",
... header=["User-Agent: MyProgram",
... "x-custom: header"])
Parameters
----------
timeout: int or float
socket timeout time. This value could be either float/integer.
if you set None for this value,
it means "use default_timeout value"
class_: <type>
class to instantiate when creating the connection. It has to implement
settimeout and connect. It's __init__ should be compatible with
WebSocket.__init__, i.e. accept all of it's kwargs.
options: <type>
- header: list or dict
custom http header list or dict.
- cookie: str
cookie value.
- origin: str
custom origin url.
- suppress_origin: bool
suppress outputting origin header.
- host: <type>
custom host header string.
- http_proxy_host: <type>
http proxy host name.
- http_proxy_port: <type>
http proxy port. If not set, set to 80.
- http_no_proxy: <type>
host names, which doesn't use proxy.
- http_proxy_auth: <type>
http proxy auth information. tuple of username and password. default is None
- enable_multithread: bool
enable lock for multithread.
- redirect_limit: <type>
number of redirects to follow.
- sockopt: <type>
socket options
- sslopt: <type>
ssl option
- subprotocols: <type>
array of available sub protocols. default is None.
- skip_utf8_validation: bool
skip utf8 validation.
- socket: <type>
pre-initialized stream socket.
"""
sockopt = options.pop("sockopt", [])
sslopt = options.pop("sslopt", {})
fire_cont_frame = options.pop("fire_cont_frame", False)
enable_multithread = options.pop("enable_multithread", False)
skip_utf8_validation = options.pop("skip_utf8_validation", False)
websock = class_(sockopt=sockopt, sslopt=sslopt,
fire_cont_frame=fire_cont_frame,
enable_multithread=enable_multithread,
skip_utf8_validation=skip_utf8_validation, **options)
websock.settimeout(timeout if timeout is not None else getdefaulttimeout())
websock.connect(url, **options)
return websock

View file

@ -0,0 +1,86 @@
"""
Define WebSocket exceptions
"""
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
class WebSocketException(Exception):
"""
WebSocket exception class.
"""
pass
class WebSocketProtocolException(WebSocketException):
"""
If the WebSocket protocol is invalid, this exception will be raised.
"""
pass
class WebSocketPayloadException(WebSocketException):
"""
If the WebSocket payload is invalid, this exception will be raised.
"""
pass
class WebSocketConnectionClosedException(WebSocketException):
"""
If remote host closed the connection or some network error happened,
this exception will be raised.
"""
pass
class WebSocketTimeoutException(WebSocketException):
"""
WebSocketTimeoutException will be raised at socket timeout during read/write data.
"""
pass
class WebSocketProxyException(WebSocketException):
"""
WebSocketProxyException will be raised when proxy error occurred.
"""
pass
class WebSocketBadStatusException(WebSocketException):
"""
WebSocketBadStatusException will be raised when we get bad handshake status code.
"""
def __init__(self, message, status_code, status_message=None, resp_headers=None):
msg = message % (status_code, status_message)
super(WebSocketBadStatusException, self).__init__(msg)
self.status_code = status_code
self.resp_headers = resp_headers
class WebSocketAddressException(WebSocketException):
"""
If the websocket address info cannot be found, this exception will be raised.
"""
pass

View file

@ -0,0 +1,212 @@
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import hashlib
import hmac
import os
import six
from ._cookiejar import SimpleCookieJar
from ._exceptions import *
from ._http import *
from ._logging import *
from ._socket import *
if hasattr(six, 'PY3') and six.PY3:
from base64 import encodebytes as base64encode
else:
from base64 import encodestring as base64encode
if hasattr(six, 'PY3') and six.PY3:
if hasattr(six, 'PY34') and six.PY34:
from http import client as HTTPStatus
else:
from http import HTTPStatus
else:
import httplib as HTTPStatus
__all__ = ["handshake_response", "handshake", "SUPPORTED_REDIRECT_STATUSES"]
if hasattr(hmac, "compare_digest"):
compare_digest = hmac.compare_digest
else:
def compare_digest(s1, s2):
return s1 == s2
# websocket supported version.
VERSION = 13
SUPPORTED_REDIRECT_STATUSES = (HTTPStatus.MOVED_PERMANENTLY, HTTPStatus.FOUND, HTTPStatus.SEE_OTHER,)
SUCCESS_STATUSES = SUPPORTED_REDIRECT_STATUSES + (HTTPStatus.SWITCHING_PROTOCOLS,)
CookieJar = SimpleCookieJar()
class handshake_response(object):
def __init__(self, status, headers, subprotocol):
self.status = status
self.headers = headers
self.subprotocol = subprotocol
CookieJar.add(headers.get("set-cookie"))
def handshake(sock, hostname, port, resource, **options):
headers, key = _get_handshake_headers(resource, hostname, port, options)
header_str = "\r\n".join(headers)
send(sock, header_str)
dump("request header", header_str)
status, resp = _get_resp_headers(sock)
if status in SUPPORTED_REDIRECT_STATUSES:
return handshake_response(status, resp, None)
success, subproto = _validate(resp, key, options.get("subprotocols"))
if not success:
raise WebSocketException("Invalid WebSocket Header")
return handshake_response(status, resp, subproto)
def _pack_hostname(hostname):
# IPv6 address
if ':' in hostname:
return '[' + hostname + ']'
return hostname
def _get_handshake_headers(resource, host, port, options):
headers = [
"GET %s HTTP/1.1" % resource,
"Upgrade: websocket"
]
if port == 80 or port == 443:
hostport = _pack_hostname(host)
else:
hostport = "%s:%d" % (_pack_hostname(host), port)
if "host" in options and options["host"] is not None:
headers.append("Host: %s" % options["host"])
else:
headers.append("Host: %s" % hostport)
if "suppress_origin" not in options or not options["suppress_origin"]:
if "origin" in options and options["origin"] is not None:
headers.append("Origin: %s" % options["origin"])
else:
headers.append("Origin: http://%s" % hostport)
key = _create_sec_websocket_key()
# Append Sec-WebSocket-Key & Sec-WebSocket-Version if not manually specified
if 'header' not in options or 'Sec-WebSocket-Key' not in options['header']:
key = _create_sec_websocket_key()
headers.append("Sec-WebSocket-Key: %s" % key)
else:
key = options['header']['Sec-WebSocket-Key']
if 'header' not in options or 'Sec-WebSocket-Version' not in options['header']:
headers.append("Sec-WebSocket-Version: %s" % VERSION)
if 'connection' not in options or options['connection'] is None:
headers.append('Connection: Upgrade')
else:
headers.append(options['connection'])
subprotocols = options.get("subprotocols")
if subprotocols:
headers.append("Sec-WebSocket-Protocol: %s" % ",".join(subprotocols))
if "header" in options:
header = options["header"]
if isinstance(header, dict):
header = [
": ".join([k, v])
for k, v in header.items()
if v is not None
]
headers.extend(header)
server_cookie = CookieJar.get(host)
client_cookie = options.get("cookie", None)
cookie = "; ".join(filter(None, [server_cookie, client_cookie]))
if cookie:
headers.append("Cookie: %s" % cookie)
headers.append("")
headers.append("")
return headers, key
def _get_resp_headers(sock, success_statuses=SUCCESS_STATUSES):
status, resp_headers, status_message = read_headers(sock)
if status not in success_statuses:
raise WebSocketBadStatusException("Handshake status %d %s", status, status_message, resp_headers)
return status, resp_headers
_HEADERS_TO_CHECK = {
"upgrade": "websocket",
"connection": "upgrade",
}
def _validate(headers, key, subprotocols):
subproto = None
for k, v in _HEADERS_TO_CHECK.items():
r = headers.get(k, None)
if not r:
return False, None
r = [x.strip().lower() for x in r.split(',')]
if v not in r:
return False, None
if subprotocols:
subproto = headers.get("sec-websocket-protocol", None)
if not subproto or subproto.lower() not in [s.lower() for s in subprotocols]:
error("Invalid subprotocol: " + str(subprotocols))
return False, None
subproto = subproto.lower()
result = headers.get("sec-websocket-accept", None)
if not result:
return False, None
result = result.lower()
if isinstance(result, six.text_type):
result = result.encode('utf-8')
value = (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode('utf-8')
hashed = base64encode(hashlib.sha1(value).digest()).strip().lower()
success = compare_digest(hashed, result)
if success:
return True, subproto
else:
return False, None
def _create_sec_websocket_key():
randomness = os.urandom(16)
return base64encode(randomness).decode('utf-8').strip()

View file

@ -0,0 +1,335 @@
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import errno
import os
import socket
import sys
import six
from ._exceptions import *
from ._logging import *
from ._socket import*
from ._ssl_compat import *
from ._url import *
if six.PY3:
from base64 import encodebytes as base64encode
else:
from base64 import encodestring as base64encode
__all__ = ["proxy_info", "connect", "read_headers"]
try:
import socks
ProxyConnectionError = socks.ProxyConnectionError
HAS_PYSOCKS = True
except:
class ProxyConnectionError(BaseException):
pass
HAS_PYSOCKS = False
class proxy_info(object):
def __init__(self, **options):
self.type = options.get("proxy_type") or "http"
if not(self.type in ['http', 'socks4', 'socks5', 'socks5h']):
raise ValueError("proxy_type must be 'http', 'socks4', 'socks5' or 'socks5h'")
self.host = options.get("http_proxy_host", None)
if self.host:
self.port = options.get("http_proxy_port", 0)
self.auth = options.get("http_proxy_auth", None)
self.no_proxy = options.get("http_no_proxy", None)
else:
self.port = 0
self.auth = None
self.no_proxy = None
def _open_proxied_socket(url, options, proxy):
hostname, port, resource, is_secure = parse_url(url)
if not HAS_PYSOCKS:
raise WebSocketException("PySocks module not found.")
ptype = socks.SOCKS5
rdns = False
if proxy.type == "socks4":
ptype = socks.SOCKS4
if proxy.type == "http":
ptype = socks.HTTP
if proxy.type[-1] == "h":
rdns = True
sock = socks.create_connection(
(hostname, port),
proxy_type=ptype,
proxy_addr=proxy.host,
proxy_port=proxy.port,
proxy_rdns=rdns,
proxy_username=proxy.auth[0] if proxy.auth else None,
proxy_password=proxy.auth[1] if proxy.auth else None,
timeout=options.timeout,
socket_options=DEFAULT_SOCKET_OPTION + options.sockopt
)
if is_secure:
if HAVE_SSL:
sock = _ssl_socket(sock, options.sslopt, hostname)
else:
raise WebSocketException("SSL not available.")
return sock, (hostname, port, resource)
def connect(url, options, proxy, socket):
if proxy.host and not socket and not (proxy.type == 'http'):
return _open_proxied_socket(url, options, proxy)
hostname, port, resource, is_secure = parse_url(url)
if socket:
return socket, (hostname, port, resource)
addrinfo_list, need_tunnel, auth = _get_addrinfo_list(
hostname, port, is_secure, proxy)
if not addrinfo_list:
raise WebSocketException(
"Host not found.: " + hostname + ":" + str(port))
sock = None
try:
sock = _open_socket(addrinfo_list, options.sockopt, options.timeout)
if need_tunnel:
sock = _tunnel(sock, hostname, port, auth)
if is_secure:
if HAVE_SSL:
sock = _ssl_socket(sock, options.sslopt, hostname)
else:
raise WebSocketException("SSL not available.")
return sock, (hostname, port, resource)
except:
if sock:
sock.close()
raise
def _get_addrinfo_list(hostname, port, is_secure, proxy):
phost, pport, pauth = get_proxy_info(
hostname, is_secure, proxy.host, proxy.port, proxy.auth, proxy.no_proxy)
try:
# when running on windows 10, getaddrinfo without socktype returns a socktype 0.
# This generates an error exception: `_on_error: exception Socket type must be stream or datagram, not 0`
# or `OSError: [Errno 22] Invalid argument` when creating socket. Force the socket type to SOCK_STREAM.
if not phost:
addrinfo_list = socket.getaddrinfo(
hostname, port, 0, socket.SOCK_STREAM, socket.SOL_TCP)
return addrinfo_list, False, None
else:
pport = pport and pport or 80
# when running on windows 10, the getaddrinfo used above
# returns a socktype 0. This generates an error exception:
# _on_error: exception Socket type must be stream or datagram, not 0
# Force the socket type to SOCK_STREAM
addrinfo_list = socket.getaddrinfo(phost, pport, 0, socket.SOCK_STREAM, socket.SOL_TCP)
return addrinfo_list, True, pauth
except socket.gaierror as e:
raise WebSocketAddressException(e)
def _open_socket(addrinfo_list, sockopt, timeout):
err = None
for addrinfo in addrinfo_list:
family, socktype, proto = addrinfo[:3]
sock = socket.socket(family, socktype, proto)
sock.settimeout(timeout)
for opts in DEFAULT_SOCKET_OPTION:
sock.setsockopt(*opts)
for opts in sockopt:
sock.setsockopt(*opts)
address = addrinfo[4]
err = None
while not err:
try:
sock.connect(address)
except ProxyConnectionError as error:
err = WebSocketProxyException(str(error))
err.remote_ip = str(address[0])
continue
except socket.error as error:
error.remote_ip = str(address[0])
try:
eConnRefused = (errno.ECONNREFUSED, errno.WSAECONNREFUSED)
except:
eConnRefused = (errno.ECONNREFUSED, )
if error.errno == errno.EINTR:
continue
elif error.errno in eConnRefused:
err = error
continue
else:
raise error
else:
break
else:
continue
break
else:
if err:
raise err
return sock
def _can_use_sni():
return six.PY2 and sys.version_info >= (2, 7, 9) or sys.version_info >= (3, 2)
def _wrap_sni_socket(sock, sslopt, hostname, check_hostname):
context = ssl.SSLContext(sslopt.get('ssl_version', ssl.PROTOCOL_SSLv23))
if sslopt.get('cert_reqs', ssl.CERT_NONE) != ssl.CERT_NONE:
cafile = sslopt.get('ca_certs', None)
capath = sslopt.get('ca_cert_path', None)
if cafile or capath:
context.load_verify_locations(cafile=cafile, capath=capath)
elif hasattr(context, 'load_default_certs'):
context.load_default_certs(ssl.Purpose.SERVER_AUTH)
if sslopt.get('certfile', None):
context.load_cert_chain(
sslopt['certfile'],
sslopt.get('keyfile', None),
sslopt.get('password', None),
)
# see
# https://github.com/liris/websocket-client/commit/b96a2e8fa765753e82eea531adb19716b52ca3ca#commitcomment-10803153
context.verify_mode = sslopt['cert_reqs']
if HAVE_CONTEXT_CHECK_HOSTNAME:
context.check_hostname = check_hostname
if 'ciphers' in sslopt:
context.set_ciphers(sslopt['ciphers'])
if 'cert_chain' in sslopt:
certfile, keyfile, password = sslopt['cert_chain']
context.load_cert_chain(certfile, keyfile, password)
if 'ecdh_curve' in sslopt:
context.set_ecdh_curve(sslopt['ecdh_curve'])
return context.wrap_socket(
sock,
do_handshake_on_connect=sslopt.get('do_handshake_on_connect', True),
suppress_ragged_eofs=sslopt.get('suppress_ragged_eofs', True),
server_hostname=hostname,
)
def _ssl_socket(sock, user_sslopt, hostname):
sslopt = dict(cert_reqs=ssl.CERT_REQUIRED)
sslopt.update(user_sslopt)
certPath = os.environ.get('WEBSOCKET_CLIENT_CA_BUNDLE')
if certPath and os.path.isfile(certPath) \
and user_sslopt.get('ca_certs', None) is None \
and user_sslopt.get('ca_cert', None) is None:
sslopt['ca_certs'] = certPath
elif certPath and os.path.isdir(certPath) \
and user_sslopt.get('ca_cert_path', None) is None:
sslopt['ca_cert_path'] = certPath
check_hostname = sslopt["cert_reqs"] != ssl.CERT_NONE and sslopt.pop(
'check_hostname', True)
if _can_use_sni():
sock = _wrap_sni_socket(sock, sslopt, hostname, check_hostname)
else:
sslopt.pop('check_hostname', True)
sock = ssl.wrap_socket(sock, **sslopt)
if not HAVE_CONTEXT_CHECK_HOSTNAME and check_hostname:
match_hostname(sock.getpeercert(), hostname)
return sock
def _tunnel(sock, host, port, auth):
debug("Connecting proxy...")
connect_header = "CONNECT %s:%d HTTP/1.1\r\n" % (host, port)
connect_header += "Host: %s:%d\r\n" % (host, port)
# TODO: support digest auth.
if auth and auth[0]:
auth_str = auth[0]
if auth[1]:
auth_str += ":" + auth[1]
encoded_str = base64encode(auth_str.encode()).strip().decode().replace('\n', '')
connect_header += "Proxy-Authorization: Basic %s\r\n" % encoded_str
connect_header += "\r\n"
dump("request header", connect_header)
send(sock, connect_header)
try:
status, resp_headers, status_message = read_headers(sock)
except Exception as e:
raise WebSocketProxyException(str(e))
if status != 200:
raise WebSocketProxyException(
"failed CONNECT via proxy status: %r" % status)
return sock
def read_headers(sock):
status = None
status_message = None
headers = {}
trace("--- response header ---")
while True:
line = recv_line(sock)
line = line.decode('utf-8').strip()
if not line:
break
trace(line)
if not status:
status_info = line.split(" ", 2)
status = int(status_info[1])
if len(status_info) > 2:
status_message = status_info[2]
else:
kv = line.split(":", 1)
if len(kv) == 2:
key, value = kv
if key.lower() == "set-cookie" and headers.get("set-cookie"):
headers["set-cookie"] = headers.get("set-cookie") + "; " + value.strip()
else:
headers[key.lower()] = value.strip()
else:
raise WebSocketException("Invalid header")
trace("-----------------------")
return status, headers, status_message

View file

@ -0,0 +1,92 @@
"""
"""
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import logging
_logger = logging.getLogger('websocket')
try:
from logging import NullHandler
except ImportError:
class NullHandler(logging.Handler):
def emit(self, record):
pass
_logger.addHandler(NullHandler())
_traceEnabled = False
__all__ = ["enableTrace", "dump", "error", "warning", "debug", "trace",
"isEnabledForError", "isEnabledForDebug", "isEnabledForTrace"]
def enableTrace(traceable, handler=logging.StreamHandler()):
"""
Turn on/off the traceability.
Parameters
----------
traceable: bool
If set to True, traceability is enabled.
"""
global _traceEnabled
_traceEnabled = traceable
if traceable:
_logger.addHandler(handler)
_logger.setLevel(logging.DEBUG)
def dump(title, message):
if _traceEnabled:
_logger.debug("--- " + title + " ---")
_logger.debug(message)
_logger.debug("-----------------------")
def error(msg):
_logger.error(msg)
def warning(msg):
_logger.warning(msg)
def debug(msg):
_logger.debug(msg)
def trace(msg):
if _traceEnabled:
_logger.debug(msg)
def isEnabledForError():
return _logger.isEnabledFor(logging.ERROR)
def isEnabledForDebug():
return _logger.isEnabledFor(logging.DEBUG)
def isEnabledForTrace():
return _traceEnabled

View file

@ -0,0 +1,176 @@
"""
"""
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import errno
import select
import socket
import six
from ._exceptions import *
from ._ssl_compat import *
from ._utils import *
DEFAULT_SOCKET_OPTION = [(socket.SOL_TCP, socket.TCP_NODELAY, 1)]
if hasattr(socket, "SO_KEEPALIVE"):
DEFAULT_SOCKET_OPTION.append((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1))
if hasattr(socket, "TCP_KEEPIDLE"):
DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPIDLE, 30))
if hasattr(socket, "TCP_KEEPINTVL"):
DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPINTVL, 10))
if hasattr(socket, "TCP_KEEPCNT"):
DEFAULT_SOCKET_OPTION.append((socket.SOL_TCP, socket.TCP_KEEPCNT, 3))
_default_timeout = None
__all__ = ["DEFAULT_SOCKET_OPTION", "sock_opt", "setdefaulttimeout", "getdefaulttimeout",
"recv", "recv_line", "send"]
class sock_opt(object):
def __init__(self, sockopt, sslopt):
if sockopt is None:
sockopt = []
if sslopt is None:
sslopt = {}
self.sockopt = sockopt
self.sslopt = sslopt
self.timeout = None
def setdefaulttimeout(timeout):
"""
Set the global timeout setting to connect.
Parameters
----------
timeout: int or float
default socket timeout time (in seconds)
"""
global _default_timeout
_default_timeout = timeout
def getdefaulttimeout():
"""
Get default timeout
Returns
----------
_default_timeout: int or float
Return the global timeout setting (in seconds) to connect.
"""
return _default_timeout
def recv(sock, bufsize):
if not sock:
raise WebSocketConnectionClosedException("socket is already closed.")
def _recv():
try:
return sock.recv(bufsize)
except SSLWantReadError:
pass
except socket.error as exc:
error_code = extract_error_code(exc)
if error_code is None:
raise
if error_code != errno.EAGAIN or error_code != errno.EWOULDBLOCK:
raise
r, w, e = select.select((sock, ), (), (), sock.gettimeout())
if r:
return sock.recv(bufsize)
try:
if sock.gettimeout() == 0:
bytes_ = sock.recv(bufsize)
else:
bytes_ = _recv()
except socket.timeout as e:
message = extract_err_message(e)
raise WebSocketTimeoutException(message)
except SSLError as e:
message = extract_err_message(e)
if isinstance(message, str) and 'timed out' in message:
raise WebSocketTimeoutException(message)
else:
raise
if not bytes_:
raise WebSocketConnectionClosedException(
"Connection is already closed.")
return bytes_
def recv_line(sock):
line = []
while True:
c = recv(sock, 1)
line.append(c)
if c == six.b("\n"):
break
return six.b("").join(line)
def send(sock, data):
if isinstance(data, six.text_type):
data = data.encode('utf-8')
if not sock:
raise WebSocketConnectionClosedException("socket is already closed.")
def _send():
try:
return sock.send(data)
except SSLWantWriteError:
pass
except socket.error as exc:
error_code = extract_error_code(exc)
if error_code is None:
raise
if error_code != errno.EAGAIN or error_code != errno.EWOULDBLOCK:
raise
r, w, e = select.select((), (sock, ), (), sock.gettimeout())
if w:
return sock.send(data)
try:
if sock.gettimeout() == 0:
return sock.send(data)
else:
return _send()
except socket.timeout as e:
message = extract_err_message(e)
raise WebSocketTimeoutException(message)
except Exception as e:
message = extract_err_message(e)
if isinstance(message, str) and "timed out" in message:
raise WebSocketTimeoutException(message)
else:
raise

View file

@ -0,0 +1,53 @@
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
__all__ = ["HAVE_SSL", "ssl", "SSLError", "SSLWantReadError", "SSLWantWriteError"]
try:
import ssl
from ssl import SSLError
from ssl import SSLWantReadError
from ssl import SSLWantWriteError
if hasattr(ssl, 'SSLContext') and hasattr(ssl.SSLContext, 'check_hostname'):
HAVE_CONTEXT_CHECK_HOSTNAME = True
else:
HAVE_CONTEXT_CHECK_HOSTNAME = False
if hasattr(ssl, "match_hostname"):
from ssl import match_hostname
else:
from backports.ssl_match_hostname import match_hostname
__all__.append("match_hostname")
__all__.append("HAVE_CONTEXT_CHECK_HOSTNAME")
HAVE_SSL = True
except ImportError:
# dummy class of SSLError for ssl none-support environment.
class SSLError(Exception):
pass
class SSLWantReadError(Exception):
pass
class SSLWantWriteError(Exception):
pass
ssl = None
HAVE_SSL = False

View file

@ -0,0 +1,178 @@
"""
"""
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import os
import socket
import struct
from six.moves.urllib.parse import urlparse
__all__ = ["parse_url", "get_proxy_info"]
def parse_url(url):
"""
parse url and the result is tuple of
(hostname, port, resource path and the flag of secure mode)
Parameters
----------
url: str
url string.
"""
if ":" not in url:
raise ValueError("url is invalid")
scheme, url = url.split(":", 1)
parsed = urlparse(url, scheme="http")
if parsed.hostname:
hostname = parsed.hostname
else:
raise ValueError("hostname is invalid")
port = 0
if parsed.port:
port = parsed.port
is_secure = False
if scheme == "ws":
if not port:
port = 80
elif scheme == "wss":
is_secure = True
if not port:
port = 443
else:
raise ValueError("scheme %s is invalid" % scheme)
if parsed.path:
resource = parsed.path
else:
resource = "/"
if parsed.query:
resource += "?" + parsed.query
return hostname, port, resource, is_secure
DEFAULT_NO_PROXY_HOST = ["localhost", "127.0.0.1"]
def _is_ip_address(addr):
try:
socket.inet_aton(addr)
except socket.error:
return False
else:
return True
def _is_subnet_address(hostname):
try:
addr, netmask = hostname.split("/")
return _is_ip_address(addr) and 0 <= int(netmask) < 32
except ValueError:
return False
def _is_address_in_network(ip, net):
ipaddr = struct.unpack('!I', socket.inet_aton(ip))[0]
netaddr, netmask = net.split('/')
netaddr = struct.unpack('!I', socket.inet_aton(netaddr))[0]
netmask = (0xFFFFFFFF << (32 - int(netmask))) & 0xFFFFFFFF
return ipaddr & netmask == netaddr
def _is_no_proxy_host(hostname, no_proxy):
if not no_proxy:
v = os.environ.get("no_proxy", "").replace(" ", "")
if v:
no_proxy = v.split(",")
if not no_proxy:
no_proxy = DEFAULT_NO_PROXY_HOST
if '*' in no_proxy:
return True
if hostname in no_proxy:
return True
if _is_ip_address(hostname):
return any([_is_address_in_network(hostname, subnet) for subnet in no_proxy if _is_subnet_address(subnet)])
for domain in [domain for domain in no_proxy if domain.startswith('.')]:
if hostname.endswith(domain):
return True
return False
def get_proxy_info(
hostname, is_secure, proxy_host=None, proxy_port=0, proxy_auth=None,
no_proxy=None, proxy_type='http'):
"""
Try to retrieve proxy host and port from environment
if not provided in options.
Result is (proxy_host, proxy_port, proxy_auth).
proxy_auth is tuple of username and password
of proxy authentication information.
Parameters
----------
hostname: <type>
websocket server name.
is_secure: <type>
is the connection secure? (wss) looks for "https_proxy" in env
before falling back to "http_proxy"
options: <type>
- http_proxy_host: <type>
http proxy host name.
- http_proxy_port: <type>
http proxy port.
- http_no_proxy: <type>
host names, which doesn't use proxy.
- http_proxy_auth: <type>
http proxy auth information. tuple of username and password. default is None
- proxy_type: <type>
if set to "socks5" PySocks wrapper will be used in place of a http proxy. default is "http"
"""
if _is_no_proxy_host(hostname, no_proxy):
return None, 0, None
if proxy_host:
port = proxy_port
auth = proxy_auth
return proxy_host, port, auth
env_keys = ["http_proxy"]
if is_secure:
env_keys.insert(0, "https_proxy")
for key in env_keys:
value = os.environ.get(key, None)
if value:
proxy = urlparse(value)
auth = (proxy.username, proxy.password) if proxy.username else None
return proxy.hostname, proxy.port, auth
return None, 0, None

View file

@ -0,0 +1,110 @@
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import six
__all__ = ["NoLock", "validate_utf8", "extract_err_message", "extract_error_code"]
class NoLock(object):
def __enter__(self):
pass
def __exit__(self, exc_type, exc_value, traceback):
pass
try:
# If wsaccel is available we use compiled routines to validate UTF-8
# strings.
from wsaccel.utf8validator import Utf8Validator
def _validate_utf8(utfbytes):
return Utf8Validator().validate(utfbytes)[0]
except ImportError:
# UTF-8 validator
# python implementation of http://bjoern.hoehrmann.de/utf-8/decoder/dfa/
_UTF8_ACCEPT = 0
_UTF8_REJECT = 12
_UTF8D = [
# The first part of the table maps bytes to character classes that
# to reduce the size of the transition table and create bitmasks.
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,
7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8,
# The second part is a transition table that maps a combination
# of a state of the automaton and a character class to a state.
0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12,
12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12,
12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12,
12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12,
12,36,12,12,12,12,12,12,12,12,12,12, ]
def _decode(state, codep, ch):
tp = _UTF8D[ch]
codep = (ch & 0x3f) | (codep << 6) if (
state != _UTF8_ACCEPT) else (0xff >> tp) & ch
state = _UTF8D[256 + state + tp]
return state, codep
def _validate_utf8(utfbytes):
state = _UTF8_ACCEPT
codep = 0
for i in utfbytes:
if six.PY2:
i = ord(i)
state, codep = _decode(state, codep, i)
if state == _UTF8_REJECT:
return False
return True
def validate_utf8(utfbytes):
"""
validate utf8 byte string.
utfbytes: utf byte string to check.
return value: if valid utf8 string, return true. Otherwise, return false.
"""
return _validate_utf8(utfbytes)
def extract_err_message(exception):
if exception.args:
return exception.args[0]
else:
return None
def extract_error_code(exception):
if exception.args and len(exception.args) > 1:
return exception.args[0] if isinstance(exception.args[0], int) else None

View file

@ -0,0 +1,6 @@
HTTP/1.1 101 WebSocket Protocol Handshake
Connection: Upgrade
Upgrade: WebSocket
Sec-WebSocket-Accept: Kxep+hNu9n51529fGidYu7a3wO0=
some_header: something

View file

@ -0,0 +1,6 @@
HTTP/1.1 101 WebSocket Protocol Handshake
Connection: Upgrade
Upgrade WebSocket
Sec-WebSocket-Accept: Kxep+hNu9n51529fGidYu7a3wO0=
some_header: something

View file

@ -0,0 +1,6 @@
HTTP/1.1 101 WebSocket Protocol Handshake
Connection: Upgrade, Keep-Alive
Upgrade: WebSocket
Sec-WebSocket-Accept: Kxep+hNu9n51529fGidYu7a3wO0=
some_header: something

View file

@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
#
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import os
import websocket as ws
from websocket._abnf import *
import sys
sys.path[0:0] = [""]
if sys.version_info[0] == 2 and sys.version_info[1] < 7:
import unittest2 as unittest
else:
import unittest
class ABNFTest(unittest.TestCase):
def testInit(self):
a = ABNF(0,0,0,0, opcode=ABNF.OPCODE_PING)
self.assertEqual(a.fin, 0)
self.assertEqual(a.rsv1, 0)
self.assertEqual(a.rsv2, 0)
self.assertEqual(a.rsv3, 0)
self.assertEqual(a.opcode, 9)
self.assertEqual(a.data, '')
a_bad = ABNF(0,1,0,0, opcode=77)
self.assertEqual(a_bad.rsv1, 1)
self.assertEqual(a_bad.opcode, 77)
def testValidate(self):
a = ABNF(0,0,0,0, opcode=ABNF.OPCODE_PING)
self.assertRaises(ws.WebSocketProtocolException, a.validate)
a_bad = ABNF(0,1,0,0, opcode=77)
self.assertRaises(ws.WebSocketProtocolException, a_bad.validate)
a_close = ABNF(0,1,0,0, opcode=ABNF.OPCODE_CLOSE, data="abcdefgh1234567890abcdefgh1234567890abcdefgh1234567890abcdefgh1234567890")
self.assertRaises(ws.WebSocketProtocolException, a_close.validate)
# This caused an error in the Python 2.7 Github Actions build
# Uncomment test case when Python 2 support no longer wanted
# def testMask(self):
# ab = ABNF(0,0,0,0, opcode=ABNF.OPCODE_PING)
# bytes_val = bytes("aaaa", 'utf-8')
# self.assertEqual(ab._get_masked(bytes_val), bytes_val)
def testFrameBuffer(self):
fb = frame_buffer(0, True)
self.assertEqual(fb.recv, 0)
self.assertEqual(fb.skip_utf8_validation, True)
fb.clear
self.assertEqual(fb.header, None)
self.assertEqual(fb.length, None)
self.assertEqual(fb.mask, None)
self.assertEqual(fb.has_mask(), False)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
#
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import os
import os.path
import websocket as ws
import sys
sys.path[0:0] = [""]
try:
import ssl
except ImportError:
HAVE_SSL = False
if sys.version_info[0] == 2 and sys.version_info[1] < 7:
import unittest2 as unittest
else:
import unittest
# Skip test to access the internet.
TEST_WITH_INTERNET = os.environ.get('TEST_WITH_INTERNET', '0') == '1'
TRACEABLE = True
class WebSocketAppTest(unittest.TestCase):
class NotSetYet(object):
""" A marker class for signalling that a value hasn't been set yet.
"""
def setUp(self):
ws.enableTrace(TRACEABLE)
WebSocketAppTest.keep_running_open = WebSocketAppTest.NotSetYet()
WebSocketAppTest.keep_running_close = WebSocketAppTest.NotSetYet()
WebSocketAppTest.get_mask_key_id = WebSocketAppTest.NotSetYet()
def tearDown(self):
WebSocketAppTest.keep_running_open = WebSocketAppTest.NotSetYet()
WebSocketAppTest.keep_running_close = WebSocketAppTest.NotSetYet()
WebSocketAppTest.get_mask_key_id = WebSocketAppTest.NotSetYet()
@unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled")
def testKeepRunning(self):
""" A WebSocketApp should keep running as long as its self.keep_running
is not False (in the boolean context).
"""
def on_open(self, *args, **kwargs):
""" Set the keep_running flag for later inspection and immediately
close the connection.
"""
WebSocketAppTest.keep_running_open = self.keep_running
self.close()
def on_close(self, *args, **kwargs):
""" Set the keep_running flag for the test to use.
"""
WebSocketAppTest.keep_running_close = self.keep_running
app = ws.WebSocketApp('ws://echo.websocket.org/', on_open=on_open, on_close=on_close)
app.run_forever()
# if numpy is installed, this assertion fail
# self.assertFalse(isinstance(WebSocketAppTest.keep_running_open,
# WebSocketAppTest.NotSetYet))
# self.assertFalse(isinstance(WebSocketAppTest.keep_running_close,
# WebSocketAppTest.NotSetYet))
# self.assertEqual(True, WebSocketAppTest.keep_running_open)
# self.assertEqual(False, WebSocketAppTest.keep_running_close)
@unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled")
def testSockMaskKey(self):
""" A WebSocketApp should forward the received mask_key function down
to the actual socket.
"""
def my_mask_key_func():
pass
def on_open(self, *args, **kwargs):
""" Set the value so the test can use it later on and immediately
close the connection.
"""
WebSocketAppTest.get_mask_key_id = id(self.get_mask_key)
self.close()
app = ws.WebSocketApp('ws://echo.websocket.org/', on_open=on_open, get_mask_key=my_mask_key_func)
app.run_forever()
# if numpy is installed, this assertion fail
# Note: We can't use 'is' for comparing the functions directly, need to use 'id'.
# self.assertEqual(WebSocketAppTest.get_mask_key_id, id(my_mask_key_func))
@unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled")
def testPingInterval(self):
""" A WebSocketApp should ping regularly
"""
def on_ping(app, msg):
print("Got a ping!")
app.close()
def on_pong(app, msg):
print("Got a pong! No need to respond")
app.close()
app = ws.WebSocketApp('wss://api-pub.bitfinex.com/ws/1', on_ping=on_ping, on_pong=on_pong)
app.run_forever(ping_interval=2, ping_timeout=1) # , sslopt={"cert_reqs": ssl.CERT_NONE}
self.assertRaises(ws.WebSocketException, app.run_forever, ping_interval=2, ping_timeout=3, sslopt={"cert_reqs": ssl.CERT_NONE})
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,117 @@
"""
"""
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import unittest
from websocket._cookiejar import SimpleCookieJar
class CookieJarTest(unittest.TestCase):
def testAdd(self):
cookie_jar = SimpleCookieJar()
cookie_jar.add("")
self.assertFalse(cookie_jar.jar, "Cookie with no domain should not be added to the jar")
cookie_jar = SimpleCookieJar()
cookie_jar.add("a=b")
self.assertFalse(cookie_jar.jar, "Cookie with no domain should not be added to the jar")
cookie_jar = SimpleCookieJar()
cookie_jar.add("a=b; domain=.abc")
self.assertTrue(".abc" in cookie_jar.jar)
cookie_jar = SimpleCookieJar()
cookie_jar.add("a=b; domain=abc")
self.assertTrue(".abc" in cookie_jar.jar)
self.assertTrue("abc" not in cookie_jar.jar)
cookie_jar = SimpleCookieJar()
cookie_jar.add("a=b; c=d; domain=abc")
self.assertEqual(cookie_jar.get("abc"), "a=b; c=d")
cookie_jar = SimpleCookieJar()
cookie_jar.add("a=b; c=d; domain=abc")
cookie_jar.add("e=f; domain=abc")
self.assertEqual(cookie_jar.get("abc"), "a=b; c=d; e=f")
cookie_jar = SimpleCookieJar()
cookie_jar.add("a=b; c=d; domain=abc")
cookie_jar.add("e=f; domain=.abc")
self.assertEqual(cookie_jar.get("abc"), "a=b; c=d; e=f")
cookie_jar = SimpleCookieJar()
cookie_jar.add("a=b; c=d; domain=abc")
cookie_jar.add("e=f; domain=xyz")
self.assertEqual(cookie_jar.get("abc"), "a=b; c=d")
self.assertEqual(cookie_jar.get("xyz"), "e=f")
self.assertEqual(cookie_jar.get("something"), "")
def testSet(self):
cookie_jar = SimpleCookieJar()
cookie_jar.set("a=b")
self.assertFalse(cookie_jar.jar, "Cookie with no domain should not be added to the jar")
cookie_jar = SimpleCookieJar()
cookie_jar.set("a=b; domain=.abc")
self.assertTrue(".abc" in cookie_jar.jar)
cookie_jar = SimpleCookieJar()
cookie_jar.set("a=b; domain=abc")
self.assertTrue(".abc" in cookie_jar.jar)
self.assertTrue("abc" not in cookie_jar.jar)
cookie_jar = SimpleCookieJar()
cookie_jar.set("a=b; c=d; domain=abc")
self.assertEqual(cookie_jar.get("abc"), "a=b; c=d")
cookie_jar = SimpleCookieJar()
cookie_jar.set("a=b; c=d; domain=abc")
cookie_jar.set("e=f; domain=abc")
self.assertEqual(cookie_jar.get("abc"), "e=f")
cookie_jar = SimpleCookieJar()
cookie_jar.set("a=b; c=d; domain=abc")
cookie_jar.set("e=f; domain=.abc")
self.assertEqual(cookie_jar.get("abc"), "e=f")
cookie_jar = SimpleCookieJar()
cookie_jar.set("a=b; c=d; domain=abc")
cookie_jar.set("e=f; domain=xyz")
self.assertEqual(cookie_jar.get("abc"), "a=b; c=d")
self.assertEqual(cookie_jar.get("xyz"), "e=f")
self.assertEqual(cookie_jar.get("something"), "")
def testGet(self):
cookie_jar = SimpleCookieJar()
cookie_jar.set("a=b; c=d; domain=abc.com")
self.assertEqual(cookie_jar.get("abc.com"), "a=b; c=d")
self.assertEqual(cookie_jar.get("x.abc.com"), "a=b; c=d")
self.assertEqual(cookie_jar.get("abc.com.es"), "")
self.assertEqual(cookie_jar.get("xabc.com"), "")
cookie_jar.set("a=b; c=d; domain=.abc.com")
self.assertEqual(cookie_jar.get("abc.com"), "a=b; c=d")
self.assertEqual(cookie_jar.get("x.abc.com"), "a=b; c=d")
self.assertEqual(cookie_jar.get("abc.com.es"), "")
self.assertEqual(cookie_jar.get("xabc.com"), "")

View file

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
#
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import os
import os.path
import websocket as ws
from websocket._http import proxy_info, read_headers, _open_proxied_socket, _tunnel
import sys
sys.path[0:0] = [""]
if sys.version_info[0] == 2 and sys.version_info[1] < 7:
import unittest2 as unittest
else:
import unittest
class SockMock(object):
def __init__(self):
self.data = []
self.sent = []
def add_packet(self, data):
self.data.append(data)
def gettimeout(self):
return None
def recv(self, bufsize):
if self.data:
e = self.data.pop(0)
if isinstance(e, Exception):
raise e
if len(e) > bufsize:
self.data.insert(0, e[bufsize:])
return e[:bufsize]
def send(self, data):
self.sent.append(data)
return len(data)
def close(self):
pass
class HeaderSockMock(SockMock):
def __init__(self, fname):
SockMock.__init__(self)
path = os.path.join(os.path.dirname(__file__), fname)
with open(path, "rb") as f:
self.add_packet(f.read())
class OptsList():
def __init__(self):
self.timeout = 0
self.sockopt = []
class HttpTest(unittest.TestCase):
def testReadHeader(self):
status, header, status_message = read_headers(HeaderSockMock("data/header01.txt"))
self.assertEqual(status, 101)
self.assertEqual(header["connection"], "Upgrade")
# header02.txt is intentionally malformed
self.assertRaises(ws.WebSocketException, read_headers, HeaderSockMock("data/header02.txt"))
def testTunnel(self):
self.assertRaises(ws.WebSocketProxyException, _tunnel, HeaderSockMock("data/header01.txt"), "example.com", 80, ("username", "password"))
self.assertRaises(ws.WebSocketProxyException, _tunnel, HeaderSockMock("data/header02.txt"), "example.com", 80, ("username", "password"))
def testConnect(self):
# Not currently testing an actual proxy connection, so just check whether TypeError is raised
self.assertRaises(TypeError, _open_proxied_socket, "wss://example.com", OptsList(), proxy_info(http_proxy_host="example.com", http_proxy_port="8080", proxy_type="http"))
self.assertRaises(TypeError, _open_proxied_socket, "wss://example.com", OptsList(), proxy_info(http_proxy_host="example.com", http_proxy_port="8080", proxy_type="socks4"))
self.assertRaises(TypeError, _open_proxied_socket, "wss://example.com", OptsList(), proxy_info(http_proxy_host="example.com", http_proxy_port="8080", proxy_type="socks5h"))
def testProxyInfo(self):
self.assertEqual(proxy_info(http_proxy_host="127.0.0.1", http_proxy_port="8080", proxy_type="http").type, "http")
self.assertRaises(ValueError, proxy_info, http_proxy_host="127.0.0.1", http_proxy_port="8080", proxy_type="badval")
self.assertEqual(proxy_info(http_proxy_host="example.com", http_proxy_port="8080", proxy_type="http").host, "example.com")
self.assertEqual(proxy_info(http_proxy_host="127.0.0.1", http_proxy_port="8080", proxy_type="http").port, "8080")
self.assertEqual(proxy_info(http_proxy_host="127.0.0.1", http_proxy_port="8080", proxy_type="http").auth, None)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,309 @@
# -*- coding: utf-8 -*-
#
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import sys
import os
from websocket._url import get_proxy_info, parse_url, _is_address_in_network, _is_no_proxy_host
if sys.version_info[0] == 2 and sys.version_info[1] < 7:
import unittest2 as unittest
else:
import unittest
sys.path[0:0] = [""]
class UrlTest(unittest.TestCase):
def test_address_in_network(self):
self.assertTrue(_is_address_in_network('127.0.0.1', '127.0.0.0/8'))
self.assertTrue(_is_address_in_network('127.1.0.1', '127.0.0.0/8'))
self.assertFalse(_is_address_in_network('127.1.0.1', '127.0.0.0/24'))
def testParseUrl(self):
p = parse_url("ws://www.example.com/r")
self.assertEqual(p[0], "www.example.com")
self.assertEqual(p[1], 80)
self.assertEqual(p[2], "/r")
self.assertEqual(p[3], False)
p = parse_url("ws://www.example.com/r/")
self.assertEqual(p[0], "www.example.com")
self.assertEqual(p[1], 80)
self.assertEqual(p[2], "/r/")
self.assertEqual(p[3], False)
p = parse_url("ws://www.example.com/")
self.assertEqual(p[0], "www.example.com")
self.assertEqual(p[1], 80)
self.assertEqual(p[2], "/")
self.assertEqual(p[3], False)
p = parse_url("ws://www.example.com")
self.assertEqual(p[0], "www.example.com")
self.assertEqual(p[1], 80)
self.assertEqual(p[2], "/")
self.assertEqual(p[3], False)
p = parse_url("ws://www.example.com:8080/r")
self.assertEqual(p[0], "www.example.com")
self.assertEqual(p[1], 8080)
self.assertEqual(p[2], "/r")
self.assertEqual(p[3], False)
p = parse_url("ws://www.example.com:8080/")
self.assertEqual(p[0], "www.example.com")
self.assertEqual(p[1], 8080)
self.assertEqual(p[2], "/")
self.assertEqual(p[3], False)
p = parse_url("ws://www.example.com:8080")
self.assertEqual(p[0], "www.example.com")
self.assertEqual(p[1], 8080)
self.assertEqual(p[2], "/")
self.assertEqual(p[3], False)
p = parse_url("wss://www.example.com:8080/r")
self.assertEqual(p[0], "www.example.com")
self.assertEqual(p[1], 8080)
self.assertEqual(p[2], "/r")
self.assertEqual(p[3], True)
p = parse_url("wss://www.example.com:8080/r?key=value")
self.assertEqual(p[0], "www.example.com")
self.assertEqual(p[1], 8080)
self.assertEqual(p[2], "/r?key=value")
self.assertEqual(p[3], True)
self.assertRaises(ValueError, parse_url, "http://www.example.com/r")
if sys.version_info[0] == 2 and sys.version_info[1] < 7:
return
p = parse_url("ws://[2a03:4000:123:83::3]/r")
self.assertEqual(p[0], "2a03:4000:123:83::3")
self.assertEqual(p[1], 80)
self.assertEqual(p[2], "/r")
self.assertEqual(p[3], False)
p = parse_url("ws://[2a03:4000:123:83::3]:8080/r")
self.assertEqual(p[0], "2a03:4000:123:83::3")
self.assertEqual(p[1], 8080)
self.assertEqual(p[2], "/r")
self.assertEqual(p[3], False)
p = parse_url("wss://[2a03:4000:123:83::3]/r")
self.assertEqual(p[0], "2a03:4000:123:83::3")
self.assertEqual(p[1], 443)
self.assertEqual(p[2], "/r")
self.assertEqual(p[3], True)
p = parse_url("wss://[2a03:4000:123:83::3]:8080/r")
self.assertEqual(p[0], "2a03:4000:123:83::3")
self.assertEqual(p[1], 8080)
self.assertEqual(p[2], "/r")
self.assertEqual(p[3], True)
class IsNoProxyHostTest(unittest.TestCase):
def setUp(self):
self.no_proxy = os.environ.get("no_proxy", None)
if "no_proxy" in os.environ:
del os.environ["no_proxy"]
def tearDown(self):
if self.no_proxy:
os.environ["no_proxy"] = self.no_proxy
elif "no_proxy" in os.environ:
del os.environ["no_proxy"]
def testMatchAll(self):
self.assertTrue(_is_no_proxy_host("any.websocket.org", ['*']))
self.assertTrue(_is_no_proxy_host("192.168.0.1", ['*']))
self.assertTrue(_is_no_proxy_host("any.websocket.org", ['other.websocket.org', '*']))
os.environ['no_proxy'] = '*'
self.assertTrue(_is_no_proxy_host("any.websocket.org", None))
self.assertTrue(_is_no_proxy_host("192.168.0.1", None))
os.environ['no_proxy'] = 'other.websocket.org, *'
self.assertTrue(_is_no_proxy_host("any.websocket.org", None))
def testIpAddress(self):
self.assertTrue(_is_no_proxy_host("127.0.0.1", ['127.0.0.1']))
self.assertFalse(_is_no_proxy_host("127.0.0.2", ['127.0.0.1']))
self.assertTrue(_is_no_proxy_host("127.0.0.1", ['other.websocket.org', '127.0.0.1']))
self.assertFalse(_is_no_proxy_host("127.0.0.2", ['other.websocket.org', '127.0.0.1']))
os.environ['no_proxy'] = '127.0.0.1'
self.assertTrue(_is_no_proxy_host("127.0.0.1", None))
self.assertFalse(_is_no_proxy_host("127.0.0.2", None))
os.environ['no_proxy'] = 'other.websocket.org, 127.0.0.1'
self.assertTrue(_is_no_proxy_host("127.0.0.1", None))
self.assertFalse(_is_no_proxy_host("127.0.0.2", None))
def testIpAddressInRange(self):
self.assertTrue(_is_no_proxy_host("127.0.0.1", ['127.0.0.0/8']))
self.assertTrue(_is_no_proxy_host("127.0.0.2", ['127.0.0.0/8']))
self.assertFalse(_is_no_proxy_host("127.1.0.1", ['127.0.0.0/24']))
os.environ['no_proxy'] = '127.0.0.0/8'
self.assertTrue(_is_no_proxy_host("127.0.0.1", None))
self.assertTrue(_is_no_proxy_host("127.0.0.2", None))
os.environ['no_proxy'] = '127.0.0.0/24'
self.assertFalse(_is_no_proxy_host("127.1.0.1", None))
def testHostnameMatch(self):
self.assertTrue(_is_no_proxy_host("my.websocket.org", ['my.websocket.org']))
self.assertTrue(_is_no_proxy_host("my.websocket.org", ['other.websocket.org', 'my.websocket.org']))
self.assertFalse(_is_no_proxy_host("my.websocket.org", ['other.websocket.org']))
os.environ['no_proxy'] = 'my.websocket.org'
self.assertTrue(_is_no_proxy_host("my.websocket.org", None))
self.assertFalse(_is_no_proxy_host("other.websocket.org", None))
os.environ['no_proxy'] = 'other.websocket.org, my.websocket.org'
self.assertTrue(_is_no_proxy_host("my.websocket.org", None))
def testHostnameMatchDomain(self):
self.assertTrue(_is_no_proxy_host("any.websocket.org", ['.websocket.org']))
self.assertTrue(_is_no_proxy_host("my.other.websocket.org", ['.websocket.org']))
self.assertTrue(_is_no_proxy_host("any.websocket.org", ['my.websocket.org', '.websocket.org']))
self.assertFalse(_is_no_proxy_host("any.websocket.com", ['.websocket.org']))
os.environ['no_proxy'] = '.websocket.org'
self.assertTrue(_is_no_proxy_host("any.websocket.org", None))
self.assertTrue(_is_no_proxy_host("my.other.websocket.org", None))
self.assertFalse(_is_no_proxy_host("any.websocket.com", None))
os.environ['no_proxy'] = 'my.websocket.org, .websocket.org'
self.assertTrue(_is_no_proxy_host("any.websocket.org", None))
class ProxyInfoTest(unittest.TestCase):
def setUp(self):
self.http_proxy = os.environ.get("http_proxy", None)
self.https_proxy = os.environ.get("https_proxy", None)
self.no_proxy = os.environ.get("no_proxy", None)
if "http_proxy" in os.environ:
del os.environ["http_proxy"]
if "https_proxy" in os.environ:
del os.environ["https_proxy"]
if "no_proxy" in os.environ:
del os.environ["no_proxy"]
def tearDown(self):
if self.http_proxy:
os.environ["http_proxy"] = self.http_proxy
elif "http_proxy" in os.environ:
del os.environ["http_proxy"]
if self.https_proxy:
os.environ["https_proxy"] = self.https_proxy
elif "https_proxy" in os.environ:
del os.environ["https_proxy"]
if self.no_proxy:
os.environ["no_proxy"] = self.no_proxy
elif "no_proxy" in os.environ:
del os.environ["no_proxy"]
def testProxyFromArgs(self):
self.assertEqual(get_proxy_info("echo.websocket.org", False, proxy_host="localhost"), ("localhost", 0, None))
self.assertEqual(get_proxy_info("echo.websocket.org", False, proxy_host="localhost", proxy_port=3128),
("localhost", 3128, None))
self.assertEqual(get_proxy_info("echo.websocket.org", True, proxy_host="localhost"), ("localhost", 0, None))
self.assertEqual(get_proxy_info("echo.websocket.org", True, proxy_host="localhost", proxy_port=3128),
("localhost", 3128, None))
self.assertEqual(get_proxy_info("echo.websocket.org", False, proxy_host="localhost", proxy_auth=("a", "b")),
("localhost", 0, ("a", "b")))
self.assertEqual(
get_proxy_info("echo.websocket.org", False, proxy_host="localhost", proxy_port=3128, proxy_auth=("a", "b")),
("localhost", 3128, ("a", "b")))
self.assertEqual(get_proxy_info("echo.websocket.org", True, proxy_host="localhost", proxy_auth=("a", "b")),
("localhost", 0, ("a", "b")))
self.assertEqual(
get_proxy_info("echo.websocket.org", True, proxy_host="localhost", proxy_port=3128, proxy_auth=("a", "b")),
("localhost", 3128, ("a", "b")))
self.assertEqual(get_proxy_info("echo.websocket.org", True, proxy_host="localhost", proxy_port=3128,
no_proxy=["example.com"], proxy_auth=("a", "b")),
("localhost", 3128, ("a", "b")))
self.assertEqual(get_proxy_info("echo.websocket.org", True, proxy_host="localhost", proxy_port=3128,
no_proxy=["echo.websocket.org"], proxy_auth=("a", "b")),
(None, 0, None))
def testProxyFromEnv(self):
os.environ["http_proxy"] = "http://localhost/"
self.assertEqual(get_proxy_info("echo.websocket.org", False), ("localhost", None, None))
os.environ["http_proxy"] = "http://localhost:3128/"
self.assertEqual(get_proxy_info("echo.websocket.org", False), ("localhost", 3128, None))
os.environ["http_proxy"] = "http://localhost/"
os.environ["https_proxy"] = "http://localhost2/"
self.assertEqual(get_proxy_info("echo.websocket.org", False), ("localhost", None, None))
os.environ["http_proxy"] = "http://localhost:3128/"
os.environ["https_proxy"] = "http://localhost2:3128/"
self.assertEqual(get_proxy_info("echo.websocket.org", False), ("localhost", 3128, None))
os.environ["http_proxy"] = "http://localhost/"
os.environ["https_proxy"] = "http://localhost2/"
self.assertEqual(get_proxy_info("echo.websocket.org", True), ("localhost2", None, None))
os.environ["http_proxy"] = "http://localhost:3128/"
os.environ["https_proxy"] = "http://localhost2:3128/"
self.assertEqual(get_proxy_info("echo.websocket.org", True), ("localhost2", 3128, None))
os.environ["http_proxy"] = "http://a:b@localhost/"
self.assertEqual(get_proxy_info("echo.websocket.org", False), ("localhost", None, ("a", "b")))
os.environ["http_proxy"] = "http://a:b@localhost:3128/"
self.assertEqual(get_proxy_info("echo.websocket.org", False), ("localhost", 3128, ("a", "b")))
os.environ["http_proxy"] = "http://a:b@localhost/"
os.environ["https_proxy"] = "http://a:b@localhost2/"
self.assertEqual(get_proxy_info("echo.websocket.org", False), ("localhost", None, ("a", "b")))
os.environ["http_proxy"] = "http://a:b@localhost:3128/"
os.environ["https_proxy"] = "http://a:b@localhost2:3128/"
self.assertEqual(get_proxy_info("echo.websocket.org", False), ("localhost", 3128, ("a", "b")))
os.environ["http_proxy"] = "http://a:b@localhost/"
os.environ["https_proxy"] = "http://a:b@localhost2/"
self.assertEqual(get_proxy_info("echo.websocket.org", True), ("localhost2", None, ("a", "b")))
os.environ["http_proxy"] = "http://a:b@localhost:3128/"
os.environ["https_proxy"] = "http://a:b@localhost2:3128/"
self.assertEqual(get_proxy_info("echo.websocket.org", True), ("localhost2", 3128, ("a", "b")))
os.environ["http_proxy"] = "http://a:b@localhost/"
os.environ["https_proxy"] = "http://a:b@localhost2/"
os.environ["no_proxy"] = "example1.com,example2.com"
self.assertEqual(get_proxy_info("example.1.com", True), ("localhost2", None, ("a", "b")))
os.environ["http_proxy"] = "http://a:b@localhost:3128/"
os.environ["https_proxy"] = "http://a:b@localhost2:3128/"
os.environ["no_proxy"] = "example1.com,example2.com, echo.websocket.org"
self.assertEqual(get_proxy_info("echo.websocket.org", True), (None, 0, None))
os.environ["http_proxy"] = "http://a:b@localhost:3128/"
os.environ["https_proxy"] = "http://a:b@localhost2:3128/"
os.environ["no_proxy"] = "example1.com,example2.com, .websocket.org"
self.assertEqual(get_proxy_info("echo.websocket.org", True), (None, 0, None))
os.environ["http_proxy"] = "http://a:b@localhost:3128/"
os.environ["https_proxy"] = "http://a:b@localhost2:3128/"
os.environ["no_proxy"] = "127.0.0.0/8, 192.168.0.0/16"
self.assertEqual(get_proxy_info("127.0.0.1", False), (None, 0, None))
self.assertEqual(get_proxy_info("192.168.1.1", False), (None, 0, None))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,433 @@
# -*- coding: utf-8 -*-
#
"""
"""
"""
websocket - WebSocket client library for Python
Copyright (C) 2010 Hiroki Ohtani(liris)
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""
import sys
sys.path[0:0] = [""]
import os
import os.path
import socket
import six
# websocket-client
import websocket as ws
from websocket._handshake import _create_sec_websocket_key, \
_validate as _validate_header
from websocket._http import read_headers
from websocket._utils import validate_utf8
if six.PY3:
from base64 import decodebytes as base64decode
else:
from base64 import decodestring as base64decode
if sys.version_info[0] == 2 and sys.version_info[1] < 7:
import unittest2 as unittest
else:
import unittest
try:
from ssl import SSLError
except ImportError:
# dummy class of SSLError for ssl none-support environment.
class SSLError(Exception):
pass
# Skip test to access the internet.
TEST_WITH_INTERNET = os.environ.get('TEST_WITH_INTERNET', '0') == '1'
TRACEABLE = True
def create_mask_key(_):
return "abcd"
class SockMock(object):
def __init__(self):
self.data = []
self.sent = []
def add_packet(self, data):
self.data.append(data)
def gettimeout(self):
return None
def recv(self, bufsize):
if self.data:
e = self.data.pop(0)
if isinstance(e, Exception):
raise e
if len(e) > bufsize:
self.data.insert(0, e[bufsize:])
return e[:bufsize]
def send(self, data):
self.sent.append(data)
return len(data)
def close(self):
pass
class HeaderSockMock(SockMock):
def __init__(self, fname):
SockMock.__init__(self)
path = os.path.join(os.path.dirname(__file__), fname)
with open(path, "rb") as f:
self.add_packet(f.read())
class WebSocketTest(unittest.TestCase):
def setUp(self):
ws.enableTrace(TRACEABLE)
def tearDown(self):
pass
def testDefaultTimeout(self):
self.assertEqual(ws.getdefaulttimeout(), None)
ws.setdefaulttimeout(10)
self.assertEqual(ws.getdefaulttimeout(), 10)
ws.setdefaulttimeout(None)
def testWSKey(self):
key = _create_sec_websocket_key()
self.assertTrue(key != 24)
self.assertTrue(six.u("¥n") not in key)
def testNonce(self):
""" WebSocket key should be a random 16-byte nonce.
"""
key = _create_sec_websocket_key()
nonce = base64decode(key.encode("utf-8"))
self.assertEqual(16, len(nonce))
def testWsUtils(self):
key = "c6b8hTg4EeGb2gQMztV1/g=="
required_header = {
"upgrade": "websocket",
"connection": "upgrade",
"sec-websocket-accept": "Kxep+hNu9n51529fGidYu7a3wO0="}
self.assertEqual(_validate_header(required_header, key, None), (True, None))
header = required_header.copy()
header["upgrade"] = "http"
self.assertEqual(_validate_header(header, key, None), (False, None))
del header["upgrade"]
self.assertEqual(_validate_header(header, key, None), (False, None))
header = required_header.copy()
header["connection"] = "something"
self.assertEqual(_validate_header(header, key, None), (False, None))
del header["connection"]
self.assertEqual(_validate_header(header, key, None), (False, None))
header = required_header.copy()
header["sec-websocket-accept"] = "something"
self.assertEqual(_validate_header(header, key, None), (False, None))
del header["sec-websocket-accept"]
self.assertEqual(_validate_header(header, key, None), (False, None))
header = required_header.copy()
header["sec-websocket-protocol"] = "sub1"
self.assertEqual(_validate_header(header, key, ["sub1", "sub2"]), (True, "sub1"))
self.assertEqual(_validate_header(header, key, ["sub2", "sub3"]), (False, None))
header = required_header.copy()
header["sec-websocket-protocol"] = "sUb1"
self.assertEqual(_validate_header(header, key, ["Sub1", "suB2"]), (True, "sub1"))
header = required_header.copy()
self.assertEqual(_validate_header(header, key, ["Sub1", "suB2"]), (False, None))
def testReadHeader(self):
status, header, status_message = read_headers(HeaderSockMock("data/header01.txt"))
self.assertEqual(status, 101)
self.assertEqual(header["connection"], "Upgrade")
status, header, status_message = read_headers(HeaderSockMock("data/header03.txt"))
self.assertEqual(status, 101)
self.assertEqual(header["connection"], "Upgrade, Keep-Alive")
HeaderSockMock("data/header02.txt")
self.assertRaises(ws.WebSocketException, read_headers, HeaderSockMock("data/header02.txt"))
def testSend(self):
# TODO: add longer frame data
sock = ws.WebSocket()
sock.set_mask_key(create_mask_key)
s = sock.sock = HeaderSockMock("data/header01.txt")
sock.send("Hello")
self.assertEqual(s.sent[0], six.b("\x81\x85abcd)\x07\x0f\x08\x0e"))
sock.send("こんにちは")
self.assertEqual(s.sent[1], six.b("\x81\x8fabcd\x82\xe3\xf0\x87\xe3\xf1\x80\xe5\xca\x81\xe2\xc5\x82\xe3\xcc"))
sock.send(u"こんにちは")
self.assertEqual(s.sent[1], six.b("\x81\x8fabcd\x82\xe3\xf0\x87\xe3\xf1\x80\xe5\xca\x81\xe2\xc5\x82\xe3\xcc"))
# sock.send("x" * 5000)
# self.assertEqual(s.sent[1], six.b("\x81\x8fabcd\x82\xe3\xf0\x87\xe3\xf1\x80\xe5\xca\x81\xe2\xc5\x82\xe3\xcc"))
self.assertEqual(sock.send_binary(b'1111111111101'), 19)
def testRecv(self):
# TODO: add longer frame data
sock = ws.WebSocket()
s = sock.sock = SockMock()
something = six.b("\x81\x8fabcd\x82\xe3\xf0\x87\xe3\xf1\x80\xe5\xca\x81\xe2\xc5\x82\xe3\xcc")
s.add_packet(something)
data = sock.recv()
self.assertEqual(data, "こんにちは")
s.add_packet(six.b("\x81\x85abcd)\x07\x0f\x08\x0e"))
data = sock.recv()
self.assertEqual(data, "Hello")
@unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled")
def testIter(self):
count = 2
for _ in ws.create_connection('wss://stream.meetup.com/2/rsvps'):
count -= 1
if count == 0:
break
@unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled")
def testNext(self):
sock = ws.create_connection('wss://stream.meetup.com/2/rsvps')
self.assertEqual(str, type(next(sock)))
def testInternalRecvStrict(self):
sock = ws.WebSocket()
s = sock.sock = SockMock()
s.add_packet(six.b("foo"))
s.add_packet(socket.timeout())
s.add_packet(six.b("bar"))
# s.add_packet(SSLError("The read operation timed out"))
s.add_packet(six.b("baz"))
with self.assertRaises(ws.WebSocketTimeoutException):
sock.frame_buffer.recv_strict(9)
# if six.PY2:
# with self.assertRaises(ws.WebSocketTimeoutException):
# data = sock._recv_strict(9)
# else:
# with self.assertRaises(SSLError):
# data = sock._recv_strict(9)
data = sock.frame_buffer.recv_strict(9)
self.assertEqual(data, six.b("foobarbaz"))
with self.assertRaises(ws.WebSocketConnectionClosedException):
sock.frame_buffer.recv_strict(1)
def testRecvTimeout(self):
sock = ws.WebSocket()
s = sock.sock = SockMock()
s.add_packet(six.b("\x81"))
s.add_packet(socket.timeout())
s.add_packet(six.b("\x8dabcd\x29\x07\x0f\x08\x0e"))
s.add_packet(socket.timeout())
s.add_packet(six.b("\x4e\x43\x33\x0e\x10\x0f\x00\x40"))
with self.assertRaises(ws.WebSocketTimeoutException):
sock.recv()
with self.assertRaises(ws.WebSocketTimeoutException):
sock.recv()
data = sock.recv()
self.assertEqual(data, "Hello, World!")
with self.assertRaises(ws.WebSocketConnectionClosedException):
sock.recv()
def testRecvWithSimpleFragmentation(self):
sock = ws.WebSocket()
s = sock.sock = SockMock()
# OPCODE=TEXT, FIN=0, MSG="Brevity is "
s.add_packet(six.b("\x01\x8babcd#\x10\x06\x12\x08\x16\x1aD\x08\x11C"))
# OPCODE=CONT, FIN=1, MSG="the soul of wit"
s.add_packet(six.b("\x80\x8fabcd\x15\n\x06D\x12\r\x16\x08A\r\x05D\x16\x0b\x17"))
data = sock.recv()
self.assertEqual(data, "Brevity is the soul of wit")
with self.assertRaises(ws.WebSocketConnectionClosedException):
sock.recv()
def testRecvWithFireEventOfFragmentation(self):
sock = ws.WebSocket(fire_cont_frame=True)
s = sock.sock = SockMock()
# OPCODE=TEXT, FIN=0, MSG="Brevity is "
s.add_packet(six.b("\x01\x8babcd#\x10\x06\x12\x08\x16\x1aD\x08\x11C"))
# OPCODE=CONT, FIN=0, MSG="Brevity is "
s.add_packet(six.b("\x00\x8babcd#\x10\x06\x12\x08\x16\x1aD\x08\x11C"))
# OPCODE=CONT, FIN=1, MSG="the soul of wit"
s.add_packet(six.b("\x80\x8fabcd\x15\n\x06D\x12\r\x16\x08A\r\x05D\x16\x0b\x17"))
_, data = sock.recv_data()
self.assertEqual(data, six.b("Brevity is "))
_, data = sock.recv_data()
self.assertEqual(data, six.b("Brevity is "))
_, data = sock.recv_data()
self.assertEqual(data, six.b("the soul of wit"))
# OPCODE=CONT, FIN=0, MSG="Brevity is "
s.add_packet(six.b("\x80\x8babcd#\x10\x06\x12\x08\x16\x1aD\x08\x11C"))
with self.assertRaises(ws.WebSocketException):
sock.recv_data()
with self.assertRaises(ws.WebSocketConnectionClosedException):
sock.recv()
def testClose(self):
sock = ws.WebSocket()
sock.sock = SockMock()
sock.connected = True
sock.close()
self.assertEqual(sock.connected, False)
sock = ws.WebSocket()
s = sock.sock = SockMock()
sock.connected = True
s.add_packet(six.b('\x88\x80\x17\x98p\x84'))
sock.recv()
self.assertEqual(sock.connected, False)
def testRecvContFragmentation(self):
sock = ws.WebSocket()
s = sock.sock = SockMock()
# OPCODE=CONT, FIN=1, MSG="the soul of wit"
s.add_packet(six.b("\x80\x8fabcd\x15\n\x06D\x12\r\x16\x08A\r\x05D\x16\x0b\x17"))
self.assertRaises(ws.WebSocketException, sock.recv)
def testRecvWithProlongedFragmentation(self):
sock = ws.WebSocket()
s = sock.sock = SockMock()
# OPCODE=TEXT, FIN=0, MSG="Once more unto the breach, "
s.add_packet(six.b("\x01\x9babcd.\x0c\x00\x01A\x0f\x0c\x16\x04B\x16\n\x15"
"\rC\x10\t\x07C\x06\x13\x07\x02\x07\tNC"))
# OPCODE=CONT, FIN=0, MSG="dear friends, "
s.add_packet(six.b("\x00\x8eabcd\x05\x07\x02\x16A\x04\x11\r\x04\x0c\x07"
"\x17MB"))
# OPCODE=CONT, FIN=1, MSG="once more"
s.add_packet(six.b("\x80\x89abcd\x0e\x0c\x00\x01A\x0f\x0c\x16\x04"))
data = sock.recv()
self.assertEqual(
data,
"Once more unto the breach, dear friends, once more")
with self.assertRaises(ws.WebSocketConnectionClosedException):
sock.recv()
def testRecvWithFragmentationAndControlFrame(self):
sock = ws.WebSocket()
sock.set_mask_key(create_mask_key)
s = sock.sock = SockMock()
# OPCODE=TEXT, FIN=0, MSG="Too much "
s.add_packet(six.b("\x01\x89abcd5\r\x0cD\x0c\x17\x00\x0cA"))
# OPCODE=PING, FIN=1, MSG="Please PONG this"
s.add_packet(six.b("\x89\x90abcd1\x0e\x06\x05\x12\x07C4.,$D\x15\n\n\x17"))
# OPCODE=CONT, FIN=1, MSG="of a good thing"
s.add_packet(six.b("\x80\x8fabcd\x0e\x04C\x05A\x05\x0c\x0b\x05B\x17\x0c"
"\x08\x0c\x04"))
data = sock.recv()
self.assertEqual(data, "Too much of a good thing")
with self.assertRaises(ws.WebSocketConnectionClosedException):
sock.recv()
self.assertEqual(
s.sent[0],
six.b("\x8a\x90abcd1\x0e\x06\x05\x12\x07C4.,$D\x15\n\n\x17"))
@unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled")
def testWebSocket(self):
s = ws.create_connection("ws://echo.websocket.org/")
self.assertNotEqual(s, None)
s.send("Hello, World")
result = s.recv()
self.assertEqual(result, "Hello, World")
s.send(u"こにゃにゃちは、世界")
result = s.recv()
self.assertEqual(result, "こにゃにゃちは、世界")
self.assertRaises(ValueError, s.send_close, -1, "")
s.close()
@unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled")
def testPingPong(self):
s = ws.create_connection("ws://echo.websocket.org/")
self.assertNotEqual(s, None)
s.ping("Hello")
s.pong("Hi")
s.close()
@unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled")
def testSecureWebSocket(self):
import ssl
s = ws.create_connection("wss://api.bitfinex.com/ws/2")
self.assertNotEqual(s, None)
self.assertTrue(isinstance(s.sock, ssl.SSLSocket))
self.assertEqual(s.getstatus(), 101)
self.assertNotEqual(s.getheaders(), None)
s.close()
@unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled")
def testWebSocketWithCustomHeader(self):
s = ws.create_connection("ws://echo.websocket.org/",
headers={"User-Agent": "PythonWebsocketClient"})
self.assertNotEqual(s, None)
s.send("Hello, World")
result = s.recv()
self.assertEqual(result, "Hello, World")
self.assertRaises(ValueError, s.close, -1, "")
s.close()
@unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled")
def testAfterClose(self):
s = ws.create_connection("ws://echo.websocket.org/")
self.assertNotEqual(s, None)
s.close()
self.assertRaises(ws.WebSocketConnectionClosedException, s.send, "Hello")
self.assertRaises(ws.WebSocketConnectionClosedException, s.recv)
class SockOptTest(unittest.TestCase):
@unittest.skipUnless(TEST_WITH_INTERNET, "Internet-requiring tests are disabled")
def testSockOpt(self):
sockopt = ((socket.IPPROTO_TCP, socket.TCP_NODELAY, 1),)
s = ws.create_connection("ws://echo.websocket.org", sockopt=sockopt)
self.assertNotEqual(s.sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY), 0)
s.close()
class UtilsTest(unittest.TestCase):
def testUtf8Validator(self):
state = validate_utf8(six.b('\xf0\x90\x80\x80'))
self.assertEqual(state, True)
state = validate_utf8(six.b('\xce\xba\xe1\xbd\xb9\xcf\x83\xce\xbc\xce\xb5\xed\xa0\x80edited'))
self.assertEqual(state, False)
state = validate_utf8(six.b(''))
self.assertEqual(state, True)
if __name__ == "__main__":
unittest.main()

View file

@ -1,57 +1,160 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
from logging import getLogger from logging import getLogger
from json import loads import json
from ssl import CERT_NONE
from . import backgroundthread, websocket, utils, companion, app, variables as v from . import websocket
from . import backgroundthread, app, variables as v, utils, companion
############################################################################### log = getLogger('PLEX.websocket')
LOG = getLogger('PLEX.websocket_client') PMS_PATH = '/:/websockets/notifications'
############################################################################### PMS_INTERESTING_MESSAGE_TYPES = ('playing', 'timeline', 'activity')
SETTINGS_STRING = '_status'
class WebSocket(backgroundthread.KillableThread): def get_pms_uri():
opcode_data = (websocket.ABNF.OPCODE_TEXT, websocket.ABNF.OPCODE_BINARY) uri = app.CONN.server
if not uri:
return
# Get the appropriate prefix for the websocket
if uri.startswith('https'):
uri = "wss%s" % uri[5:]
else:
uri = "ws%s" % uri[4:]
uri += PMS_PATH
log.debug('uri to connect pms websocket: %s', uri)
if app.ACCOUNT.pms_token:
uri += '?X-Plex-Token=' + app.ACCOUNT.pms_token
return uri
def __init__(self):
self.ws = None
self.redirect_uri = None
self.sleeptime = 0.0
super(WebSocket, self).__init__()
def close_websocket(self): def get_alexa_uri():
if self.ws is not None: if not app.ACCOUNT.plex_token:
self.ws.close() return
self.ws = None return ('wss://pubsub.plex.tv/sub/websockets/%s/%s?X-Plex-Token=%s'
% (app.ACCOUNT.plex_user_id,
v.PKC_MACHINE_IDENTIFIER,
app.ACCOUNT.plex_token))
def process(self, opcode, message):
raise NotImplementedError
def receive(self, ws): def pms_on_message(ws, message):
# Not connected yet """
if ws is None: Called when we receive a message from the PMS, e.g. when a new library
raise websocket.WebSocketConnectionClosedException item has been added.
"""
try:
message = json.loads(message)
except ValueError as err:
log.error('Error decoding PMS websocket message: %s', err)
log.error('message: %s', message)
return
try:
message = message['NotificationContainer']
typus = message['type']
except KeyError:
log.error('Could not parse PMS message: %s', message)
return
# Triage
if typus not in PMS_INTERESTING_MESSAGE_TYPES:
# Drop everything we're not interested in
return
else:
# Put PMS message on queue and let libsync take care of it
app.APP.websocket_queue.put(message)
frame = ws.recv_frame()
if not frame: def alexa_on_message(ws, message):
raise websocket.WebSocketException("Not a valid frame %s" % frame) """
elif frame.opcode in self.opcode_data: Called when we receive a message from Alexa
return frame.opcode, frame.data """
elif frame.opcode == websocket.ABNF.OPCODE_CLOSE: log.debug('alexa message received: %s', message)
ws.send_close() try:
return frame.opcode, None message = utils.etree.fromstring(message)
elif frame.opcode == websocket.ABNF.OPCODE_PING: except Exception as err:
ws.pong("Hi!") log.error('Error decoding message from Alexa: %s %s', type(err), err)
return None, None log.error('message from Alexa: %s', message)
return
try:
if message.attrib['command'] == 'processRemoteControlCommand':
message = message[0]
else:
log.error('Unknown Alexa message received: %s', message)
return
companion.process_command(message.attrib['path'][1:], message.attrib)
except Exception as err:
log.exception('Could not parse Alexa message, error: %s %s',
type(err), err)
log.error('message: %s', message)
def getUri(self):
raise NotImplementedError
def _sleep_cycle(self): def on_error(ws, error):
status = ws.name + SETTINGS_STRING
if isinstance(error, IOError):
# We are probably offline
log.debug('%s: IOError connecting', ws.name)
# Status = IOError - not connected
utils.settings(status, value=utils.lang(39092))
ws.sleep_cycle()
elif isinstance(error, websocket.WebSocketTimeoutException):
log.debug('%s: WebSocketTimeoutException', ws.name)
# Status = 'Timeout - not connected'
utils.settings(status, value=utils.lang(39091))
ws.sleep_cycle()
elif isinstance(error, websocket.WebSocketConnectionClosedException):
log.debug('%s: WebSocketConnectionClosedException', ws.name)
# Status = Not connected
utils.settings(ws.name + SETTINGS_STRING, value=utils.lang(15208))
elif isinstance(error, websocket.WebSocketBadStatusException):
# Most likely Alexa not connecting, throwing a 403
log.debug('%s: got a bad HTTP status: %s', ws.name, error)
# Status = <value of exception>
utils.settings(status, value=str(error))
ws.sleep_cycle()
elif isinstance(error, websocket.WebSocketException):
log.error('%s: got another websocket exception %s: %s',
ws.name, type(error), error)
# Status = Error
utils.settings(status, value=utils.lang(257))
ws.sleep_cycle()
elif isinstance(error, SystemExit):
log.debug('%s: SystemExit detected', ws.name)
# Status = Not connected
utils.settings(ws.name + SETTINGS_STRING, value=utils.lang(15208))
else:
log.exception('%s: got an unexpected exception of type %s: %s',
ws.name, type(error), error)
# Status = Error
utils.settings(status, value=utils.lang(257))
raise RuntimeError
def on_close(ws):
"""
This does not seem to get called by our websocket client :-(
"""
log.debug('%s: connection closed', ws.name)
# Status = Not connected
utils.settings(ws.name + SETTINGS_STRING, value=utils.lang(15208))
def on_open(ws):
log.debug('%s: connected', ws.name)
# Status = Connected
utils.settings(ws.name + SETTINGS_STRING, value=utils.lang(13296))
ws.sleeptime = 0
class PlexWebSocketApp(websocket.WebSocketApp,
backgroundthread.KillableThread):
def __init__(self, **kwargs):
self.sleeptime = 0
backgroundthread.KillableThread.__init__(self)
websocket.WebSocketApp.__init__(self, self.get_uri(), **kwargs)
def sleep_cycle(self):
""" """
Sleeps for 2^self.sleeptime where sleeping period will be doubled with Sleeps for 2^self.sleeptime where sleeping period will be doubled with
each unsuccessful connection attempt. each unsuccessful connection attempt.
@ -59,203 +162,141 @@ class WebSocket(backgroundthread.KillableThread):
""" """
self.sleep(2 ** self.sleeptime) self.sleep(2 ** self.sleeptime)
if self.sleeptime < 6: if self.sleeptime < 6:
self.sleeptime += 1.0 self.sleeptime += 1
def close(self, **kwargs):
"""websocket.WebSocketApp is not yet thread-safe. close() might
encounter websockets that have already been closed"""
try:
websocket.WebSocketApp.close(self, **kwargs)
except AttributeError:
pass
def suspend(self, block=False, timeout=None):
"""
Call this method from another thread to suspend this websocket thread
"""
self.close()
backgroundthread.KillableThread.suspend(self, block, timeout)
def cancel(self):
"""
Call this method from another thread to cancel this websocket thread
"""
self.close()
backgroundthread.KillableThread.cancel(self)
def run(self): def run(self):
LOG.info("----===## Starting %s ##===----", self.__class__.__name__) """
Ensure that sockets will be closed no matter what
"""
log.info("----===## Starting %s ##===----", self.name)
app.APP.register_thread(self) app.APP.register_thread(self)
try: try:
self._run() self._run()
except RuntimeError:
pass
except Exception as err:
log.exception('Exception of type %s occured: %s', type(err), err)
finally: finally:
self.close_websocket() self.close()
# Status = Not connected
utils.settings(self.name + SETTINGS_STRING,
value=utils.lang(15208))
app.APP.deregister_thread(self) app.APP.deregister_thread(self)
LOG.info("##===---- %s Stopped ----===##", self.__class__.__name__) log.info("----===## %s stopped ##===----", self.name)
def _run(self): def _run(self):
while not self.should_cancel(): while not self.should_cancel():
# In the event the server goes offline # In the event the server goes offline
if self.should_suspend(): while self.should_suspend():
# Set in service.py # We will be caught in this loop if either another thread
self.close_websocket() # called the suspend() method, thus setting _suspended = True
# OR if there any other conditions to not open a websocket
# connection - see methods should_suspend() below
# Status = Suspended - not connected
self.set_suspension_settings_status()
if self.wait_while_suspended(): if self.wait_while_suspended():
# Abort was requested while waiting. We should exit # Abort was requested while waiting. We should exit
return return
try: if not self._suspended:
self.process(*self.receive(self.ws)) # because wait_while_suspended will return instantly if
except websocket.WebSocketTimeoutException: # this thread did not get suspended from another thread
# No worries if read timed out self.sleep_cycle()
pass self.url = self.get_uri()
except websocket.WebSocketConnectionClosedException: if not self.url:
LOG.debug("%s: connection closed, (re)connecting", self.sleep_cycle()
self.__class__.__name__) continue
uri, sslopt = self.getUri() self.run_forever()
try:
# Low timeout - let's us shut this thread down!
self.ws = websocket.create_connection(
uri,
timeout=1,
sslopt=sslopt,
enable_multithread=True)
except IOError:
# Server is probably offline
LOG.debug("%s: IOError connecting", self.__class__.__name__)
self.ws = None
self._sleep_cycle()
except websocket.WebSocketTimeoutException:
LOG.debug("%s: WebSocketTimeoutException", self.__class__.__name__)
self.ws = None
self._sleep_cycle()
except websocket.WebsocketRedirect as e:
LOG.debug('301 redirect detected: %s', e)
self.redirect_uri = e.headers.get('location',
e.headers.get('Location'))
if self.redirect_uri:
self.redirect_uri = self.redirect_uri.decode('utf-8')
self.ws = None
self._sleep_cycle()
except websocket.WebSocketException as e:
LOG.debug('%s: WebSocketException: %s', self.__class__.__name__, e)
self.ws = None
self._sleep_cycle()
except Exception as e:
LOG.error('%s: Unknown exception encountered when '
'connecting: %s', self.__class__.__name__, e)
import traceback
LOG.error("%s: Traceback:\n%s",
self.__class__.__name__, traceback.format_exc())
self.ws = None
self._sleep_cycle()
else:
self.sleeptime = 0.0
except Exception as e:
LOG.error("%s: Unknown exception encountered: %s",
self.__class__.__name__, e)
import traceback
LOG.error("%s: Traceback:\n%s",
self.__class__.__name__, traceback.format_exc())
self.close_websocket()
class PMS_Websocket(WebSocket): class PMSWebsocketApp(PlexWebSocketApp):
""" name = 'pms_websocket'
Websocket connection with the PMS for Plex Companion
""" def get_uri(self):
return get_pms_uri()
def should_suspend(self): def should_suspend(self):
""" """
Returns True if the thread is suspended. Returns True if the thread needs to suspend.
""" """
suspend = self._suspended or app.SYNC.background_sync_disabled return (self._suspended or
if suspend: utils.settings('enableBackgroundSync') != 'true')
# This thread needs to clear the Event() _is_not_suspended itself!
self.suspend()
return suspend
def getUri(self): def set_suspension_settings_status(self):
if self.redirect_uri: if utils.settings('enableBackgroundSync') != 'true':
uri = self.redirect_uri # Status = Disabled
self.redirect_uri = None utils.settings(self.name + SETTINGS_STRING,
value=utils.lang(24023))
else: else:
server = app.CONN.server # Status = 'Suspended - not connected'
# Get the appropriate prefix for the websocket utils.settings(self.name + SETTINGS_STRING,
if server.startswith('https'): value=utils.lang(39093))
server = "wss%s" % server[5:]
else:
server = "ws%s" % server[4:]
uri = "%s/:/websockets/notifications" % server
if app.ACCOUNT.pms_token:
uri += '?X-Plex-Token=%s' % app.ACCOUNT.pms_token
sslopt = {}
if v.KODIVERSION == 17 and utils.settings('sslverify') == "false":
sslopt["cert_reqs"] = CERT_NONE
LOG.debug("%s: Uri: %s, sslopt: %s",
self.__class__.__name__, uri, sslopt)
return uri, sslopt
def process(self, opcode, message):
if opcode not in self.opcode_data:
return
try:
message = loads(message)
except ValueError:
LOG.error('%s: Error decoding message from websocket',
self.__class__.__name__)
LOG.error(message)
return
try:
message = message['NotificationContainer']
except KeyError:
LOG.error('%s: Could not parse PMS message: %s',
self.__class__.__name__, message)
return
# Triage
typus = message.get('type')
if typus is None:
LOG.error('%s: No message type, dropping message: %s',
self.__class__.__name__, message)
return
LOG.debug('%s: Received message from PMS server: %s',
self.__class__.__name__, message)
# Drop everything we're not interested in
if typus not in ('playing', 'timeline', 'activity'):
return
else:
# Put PMS message on queue and let libsync take care of it
app.APP.websocket_queue.put(message)
class Alexa_Websocket(WebSocket): class AlexaWebsocketApp(PlexWebSocketApp):
""" name = 'alexa_websocket'
Websocket connection to talk to Amazon Alexa.
""" def get_uri(self):
return get_alexa_uri()
def should_suspend(self): def should_suspend(self):
""" """
Overwrite method since we need to check for plex token Returns True if the thread needs to suspend.
""" """
suspend = self._suspended or \ return self._suspended or \
not app.SYNC.enable_alexa or \ utils.settings('enable_alexa') != 'true' or \
app.ACCOUNT.restricted_user or \ app.ACCOUNT.restricted_user or \
not app.ACCOUNT.plex_token not app.ACCOUNT.plex_token
if suspend:
# This thread needs to clear the Event() _is_not_suspended itself!
self.suspend()
return suspend
def getUri(self): def set_suspension_settings_status(self):
if self.redirect_uri: if utils.settings('enable_alexa') != 'true':
uri = self.redirect_uri # Status = Disabled
self.redirect_uri = None utils.settings(self.name + SETTINGS_STRING,
value=utils.lang(24023))
elif app.ACCOUNT.restricted_user:
# Status = Managed Plex User - not connected
utils.settings(self.name + SETTINGS_STRING,
value=utils.lang(39094))
elif not app.ACCOUNT.plex_token:
# Status = Not logged in to plex.tv
utils.settings(self.name + SETTINGS_STRING,
value=utils.lang(39226))
else: else:
uri = ('wss://pubsub.plex.tv/sub/websockets/%s/%s?X-Plex-Token=%s' # Status = 'Suspended - not connected'
% (app.ACCOUNT.plex_user_id, utils.settings(self.name + SETTINGS_STRING,
v.PKC_MACHINE_IDENTIFIER, value=utils.lang(39093))
app.ACCOUNT.plex_token))
sslopt = {}
LOG.debug("%s: Uri: %s, sslopt: %s",
self.__class__.__name__, uri, sslopt)
return uri, sslopt
def process(self, opcode, message):
if opcode not in self.opcode_data: def get_pms_websocketapp():
return return PMSWebsocketApp(on_open=on_open,
LOG.debug('%s: Received the following message from Alexa:', on_message=pms_on_message,
self.__class__.__name__) on_error=on_error,
LOG.debug('%s: %s', self.__class__.__name__, message) on_close=on_close)
try:
message = utils.defused_etree.fromstring(message)
except Exception as ex: def get_alexa_websocketapp():
LOG.error('%s: Error decoding message from Alexa: %s', return AlexaWebsocketApp(on_open=on_open,
self.__class__.__name__, ex) on_message=alexa_on_message,
return on_error=on_error,
try: on_close=on_close)
if message.attrib['command'] == 'processRemoteControlCommand':
message = message[0]
else:
LOG.error('%s: Unknown Alexa message received',
self.__class__.__name__)
return
except Exception:
LOG.error('%s: Could not parse Alexa message',
self.__class__.__name__)
return
companion.process_command(message.attrib['path'][1:], message.attrib)

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