Compare commits

...

221 Commits

Author SHA1 Message Date
panni 2cc270708a 1.3.20.403 2015-11-27 02:01:49 +01:00
panni ddf7d4fc96 add debug logging for found metadata subtitles 2015-11-27 01:58:25 +01:00
panni d55a809493 don't use unneeded subtitle metadata proxy info 2015-11-26 22:30:02 +01:00
panni af7434e35d opensubtitles, movies: use query even if hash, size or imdb_id are known 2015-11-26 19:50:47 +01:00
panni ad7239c5d8 set default score to 85 again 2015-11-26 10:34:03 +01:00
panni f90efceac3 catch logging error on unexpected metadata storage 2015-11-26 10:31:46 +01:00
panni c6f70dccca punctuation fixes for & and dash 2015-11-23 15:36:22 +01:00
panni 4e6ce7e8bb 1.3.20.396 2015-11-22 03:22:13 +01:00
panni a2049200b1 lower minimum tv series score to 67; series=44 + season=11 + episode=11 + hearing_impaired=1 2015-11-22 02:38:16 +01:00
panni b10306aca0 rename dry to dont_use_actual_file in scan_video 2015-11-22 02:12:46 +01:00
panni aaf430cae8 let guessit see only the parent directory and the filename; add dry parameter to scan_video for testing 2015-11-22 01:51:27 +01:00
panni e7ee9e3304 more debug testing on scanVideo 2015-11-22 00:54:38 +01:00
panni a4f65adda9 register subzero's libraries' logging handlers exclusively 2015-11-22 00:48:29 +01:00
panni d38b90d1f3 move debug log call 2015-11-22 00:26:54 +01:00
panni a07a4a167c try to catch enzyme scanning errors generally 2015-11-22 00:13:10 +01:00
panni a77c29af48 blank 2015-11-21 17:05:30 +01:00
panni 4044f3e787 don't fail on unscanned video file; fixes #84 2015-11-21 17:03:28 +01:00
panni 70de96a9e8 don't fail on wrong proxy info 2015-11-21 17:00:38 +01:00
panni 8fdc50b2aa regression: actually refresh the menu again 2015-11-19 22:48:50 +01:00
panni 88874fb9b6 bad merge 2015-11-19 22:22:27 +01:00
panni 11ad4cdeac Merge remote-tracking branch 'origin/master'
Conflicts:
	Contents/Code/support/missing_subtitles.py
	Contents/DefaultPrefs.json
2015-11-19 22:19:53 +01:00
panni c5f1b39fba 1.3.19.379 2015-11-19 22:13:41 +01:00
panni 6eb8af8fd5 make max_recent_items_per_library configurable 2015-11-19 21:45:18 +01:00
panni 2ec3b393fc make logging to console configurable, default off 2015-11-19 19:42:23 +01:00
panni 7a2977d4c8 remove thesubdb support 2015-11-19 19:19:50 +01:00
panni b987142b3f add fixme; set correct default value for flat 2015-11-15 17:48:32 +01:00
panni 22656d62d4 remove debug print 2015-11-15 17:44:49 +01:00
panni 7d6693e206 add logging configuration handlers; implement dynamic ignore list 2015-11-15 17:33:29 +01:00
panni c3f2bb4d21 add log_level setting; remove blacklist prefs 2015-11-15 16:44:16 +01:00
panni e154019d07 move builtins restoring to shared library to make it more readable :) 2015-11-14 06:41:51 +01:00
panni 1b891eba73 add ignore list management to menu; add key_order ordering to ignore list; slightly break out of the sandbox 2015-11-14 06:37:02 +01:00
panni 38e5f8e4e9 add IgnoreListMenu dummy; make IgnoreMenu smarter so it can be used programatically (don't toggle) 2015-11-14 04:31:39 +01:00
panni 428ab4c6d7 added proof of concept to restore globals (sandbox) 2015-11-14 04:03:22 +01:00
panni 27ce34bce6 change some obsolete no_history replace_parent attributes which do nothing 2015-11-14 02:15:59 +01:00
panni 6fb5760a6a store and display last state in addition to current state in menu 2015-11-13 17:23:26 +01:00
panni 2e2fd1580d only match hash if format also is right 2015-11-13 14:56:40 +01:00
panni 8ab826d27d move DictProxy to subzero.lib to avoid sandbox 2015-11-13 14:55:45 +01:00
panni d1f33baa30 explicitly save ignore list 2015-11-13 07:02:31 +01:00
panni 7239941168 save ignore list on setitem 2015-11-13 06:57:19 +01:00
panni ca00e8680d rename interface.helpers; add ignorelist log and reset functions; add title storage to ignore list for later use 2015-11-13 06:53:02 +01:00
panni 57d9e0c600 correctly move ignore stuff 2015-11-13 06:21:39 +01:00
panni f2811422f0 move menu and ignore stuff 2015-11-13 06:10:10 +01:00
panni 0f71d2e0e2 add support/ignore; add ignore option to sections, series, items 2015-11-13 05:53:31 +01:00
panni 388c4baa15 add iter to Libraries/Shared/subzero, because somehow we can't have it in the sandbox 2015-11-13 05:51:54 +01:00
panni 13a8c2facd fix typo; simplify hash validity detection 2015-11-13 00:46:12 +01:00
panni def5a26d98 reduce info logger to debug 2015-11-13 00:40:35 +01:00
panni d1ad72b0f2 correct title doesn't automatically mean episode and season are correct 2015-11-13 00:39:08 +01:00
panni da62656f7e correct hash matching, but only if important other stuff matches 2015-11-13 00:30:04 +01:00
pannal da3e2399f7 subtitles.scan.embedded now default false 2015-11-12 13:31:59 +01:00
panni c70af212d1 remove redundant menu description 2015-11-11 23:42:59 +01:00
panni 8becc8bd72 use pprint.pformat for storage logging 2015-11-11 23:40:34 +01:00
panni 5bc0307242 show the refresh trigger action in the menu state, also; add doc 2015-11-11 23:38:35 +01:00
panni 034b2975d6 clean up menu items; show current plugin state (restart, force/refreshing) on the refresh button in the menu; add intent resolving 2015-11-11 23:33:19 +01:00
panni 3ffde8c52b add comment 2015-11-11 22:47:22 +01:00
panni b125a747c8 handle all possible media types in section/first_character interface 2015-11-11 22:46:20 +01:00
panni 00e656dbce better # support; add Track parsing to section/first_character interface 2015-11-11 22:43:44 +01:00
panni a7f6224237 use the item title in firstlettermetadatamenu instead of key, to support # 2015-11-11 22:31:11 +01:00
panni 81f469531b add "All" to firstCharacter view 2015-11-11 22:25:38 +01:00
panni a4794d1619 remove section from item name; show current breadcrumbs in title2 2015-11-11 19:04:15 +01:00
panni d6b7bd1194 add version to title; better section/letter title 2015-11-11 18:19:25 +01:00
panni c0169afbc2 implement dynamic section menu; use a section/X/firstCharacter based menu if too many items are in one section to display in one go 2015-11-11 18:03:58 +01:00
panni 19fcc6a175 add function to get size of a section; special Directory handling to support sections/X/firstCharacter 2015-11-11 18:03:07 +01:00
panni cada8483fe add simple plex api query for retrieving basic information, without using any big parsing library 2015-11-11 18:02:07 +01:00
panni 2464894fd5 Plex.py: add size property to Directory object; Plex.py implement firstCharacter section filtering interface 2015-11-11 18:01:22 +01:00
panni d700df9a60 don't use hash for an episode if season and episode index don't match; fixes #80 2015-11-11 16:03:52 +01:00
panni 273a376a4a add size and total_size to plex.py's MediaContainer parser for later usage for pagination 2015-11-09 23:30:21 +01:00
panni 41b78d80e4 fix #81 2015-11-09 22:56:24 +01:00
panni d904462417 better fix than the previous quick one 2015-11-09 22:52:12 +01:00
panni 6bf9836f57 quick fix for empty season or episode index 2015-11-09 22:41:32 +01:00
pannal 92c4a2af59 do the ignore list bailout a bit earlier 2015-11-09 22:38:42 +01:00
panni bbeced7e7e re-add the upper limit of 200 per section 2015-11-08 21:49:01 +01:00
panni c94295b472 remove item count limitation on recently added 2015-11-08 18:49:22 +01:00
panni 4905429bb0 don't use /recentlyAdded per section anymore, but do a real item search 2015-11-08 16:03:25 +01:00
panni c0d60222aa finalize library-digger interface 2015-11-08 15:50:00 +01:00
panni 312c6c9729 menu update 2015-11-08 06:48:59 +01:00
panni 137cb6bb45 Merge branch 'recently-added' into menu-more
Conflicts:
	Contents/Code/interface/menu.py
	Contents/Code/support/items.py
2015-11-08 05:27:07 +01:00
panni bc3408c25d correct description 2015-11-08 04:19:13 +01:00
panni 5cb8e5e49c cleanup 2015-11-08 04:07:00 +01:00
panni 36b924443d use new recent items in recentlyAddedItems task 2015-11-08 03:58:10 +01:00
panni 5122935e10 finalize real recently added items with missing subtitles 2015-11-08 03:54:57 +01:00
panni b5176600f4 temporarily support both recently_added implementations 2015-11-07 06:16:52 +01:00
panni e073a3c289 blank current recently_added implementation 2015-11-07 06:06:22 +01:00
panni 18c2f782c2 test new recently_added implementation 2015-11-07 05:58:36 +01:00
panni 6449513cb8 remove mutable parameters 2015-11-07 04:30:57 +01:00
panni f56e39e3c2 use native String.UUID instead of uuid.uuid1 2015-11-07 02:54:17 +01:00
panni 90e423b62c 1.3.6.316 2015-11-06 22:21:47 +01:00
panni 8e455b48c3 add doc 2015-11-06 22:14:08 +01:00
panni c0d54dc6dd add doc 2015-11-06 22:07:46 +01:00
panni 3d7f4ba844 Merge branch '1.3-fixes' 2015-11-06 22:06:32 +01:00
panni ae4a0f8caa remove speedup, readd delay to 1 second 2015-11-06 19:54:45 +01:00
panni 61e02f0666 task speedup 2015-11-06 19:22:13 +01:00
panni ee9460d43e Merge branch '1.3-fixes' 2015-11-06 18:32:44 +01:00
panni 264c640036 Merge branch 'hint-guessit' 2015-11-06 18:32:15 +01:00
panni 8ae0c9bee1 report failed items to the logs after finishing the task 2015-11-06 17:14:59 +01:00
panni 670b2d18b4 try a stalled item for 4 times, then skip it 2015-11-06 17:10:31 +01:00
panni 4a37f1e6f0 add stalled items handling 2015-11-06 17:06:13 +01:00
panni 897bdff957 1.3.6.304 2015-11-06 15:35:52 +01:00
panni f1893517e0 handle rare cases of getfilesystemencoding==ANSI_X3.4-1968 2015-11-06 15:21:45 +01:00
panni 4b510f1ff6 handle filesystemencoding==ascii 2015-11-06 15:08:15 +01:00
panni 961944b0b2 patch subliminal.api.save_subtitles to work with the correct filesystem encoding 2015-11-06 14:24:03 +01:00
panni 93d0959766 fix simplejson warning 2015-11-06 14:13:44 +01:00
panni 00a5678784 correct is_recent; when searching for missing subtitles, don't refresh all at once 2015-11-06 13:59:31 +01:00
panni c34373cc00 test deep menu; make getMergedItems be more like getItems 2015-11-06 04:09:44 +01:00
panni d2992adddb correctly hint type 2015-11-06 00:20:39 +01:00
panni 0d826be66e hint guessit to the correct title and series if applicable 2015-11-06 00:10:40 +01:00
panni 67d4250c71 regression, ids needed after all 2015-11-05 23:26:25 +01:00
panni 9c2b7aead1 1.3.6.297 2015-11-05 22:36:47 +01:00
panni 67ad6cd551 reformat 2015-11-05 22:11:21 +01:00
panni a4d1ee4be0 reformatted subliminal_patch 2015-11-05 21:52:27 +01:00
panni 72b725c933 remove leftover scannedVideo.id storage 2015-11-05 20:15:26 +01:00
panni 7a308e5aed reformat menu.py; add scheduler.init_storage and call it on storage reset aswell 2015-11-05 20:09:03 +01:00
panni 7dd4bdbf74 reset self.items_searching_ids and move self.running = False 2015-11-05 20:00:16 +01:00
panni 5560afcd8f reformat DefaultPrefs; move plex credentials to the top 2015-11-05 19:52:43 +01:00
panni e2c90548ed split task run logic into prepare(), run() and post_run(); remove running as a stored parameter; get correct item ids while task is running 2015-11-05 19:51:54 +01:00
panni dd050ba770 re-add path encoding 2015-11-05 18:31:49 +01:00
panni d2e67af495 Merge remote-tracking branch 'origin/master'
Conflicts:
	Contents/Code/support/localmedia.py
2015-11-05 18:29:11 +01:00
panni b870175031 pep8; add .idea to gitignore; reformat project 2015-11-05 18:23:50 +01:00
pannal f8fc50b37b actually use the file system encoding and utf-8 as a fallback 2015-11-04 23:36:27 +01:00
pannal 730a46e32f utf-8ify file path in localmedia 2015-11-04 23:29:28 +01:00
pannal a06343b1f1 clarify on initial refresh 2015-11-04 23:11:48 +01:00
panni 675fcf8dbc remove ascii-enforcing on menu items, let plex decide 2015-11-04 22:58:11 +01:00
panni 7ef23c8434 menu: add log option for internal storages; let tasks handle their running state 2015-11-04 22:40:10 +01:00
panni 8ae7d5b755 1.3.5.281 2015-11-02 22:13:14 +01:00
pannal 46ce038238 fix no previous task storage existing raises error on signal 2015-11-02 21:59:21 +01:00
pannal d4b3e7680a Merge pull request #67 from pannal/1.3.0
1.3.5.273
2015-11-02 20:00:58 +01:00
pannal c64cdc6525 Update README.md 2015-11-02 20:00:09 +01:00
pannal 5c4bd03c94 Update README.md 2015-11-02 19:58:40 +01:00
pannal 06fe8f3144 Update README.md 2015-11-02 19:56:49 +01:00
pannal 9044090afd Update README.md 2015-11-02 19:56:27 +01:00
panni c282ff2dfb 1.3.5.273 2015-11-02 19:55:23 +01:00
panni 1e45429795 1.3.0.273 2015-11-01 16:52:40 +01:00
panni ba73109b5c Merge remote-tracking branch 'origin/1.3.0' into 1.3.0 2015-11-01 05:00:49 +01:00
panni aee03abc63 time.sleep instead of Thread.Sleep 2015-11-01 04:52:42 +01:00
panni d56bc38aeb enforce ascii on item titles 2015-11-01 04:45:05 +01:00
panni 995b917ae6 handle single refreshes while missing subtitles task is running 2015-11-01 04:32:03 +01:00
panni 821e35ebab better menu; actually skip task if already running 2015-11-01 04:19:13 +01:00
panni ecf942d267 add refresh menu item to channel 2015-11-01 03:20:33 +01:00
panni 8061dd2ed4 remove debug print 2015-11-01 02:16:13 +01:00
panni 4962fb8b66 force wide items in plex api error mode menu, in plex web 2015-11-01 02:12:13 +01:00
pannal 6e949b9cbe reduce to try:finally: 2015-11-01 00:07:06 +01:00
panni 9e1d32a8e6 make the update function more robust and make sure to always send a state info to the scheduler 2015-10-31 20:13:14 +01:00
panni 44edd4a92a correct route in PMS API ERROR menu mode 2015-10-31 18:02:38 +01:00
panni 7b6cea3b1f 1.3.0.261 2015-10-31 17:27:14 +01:00
panni dab490e21c remove localization again 2015-10-31 17:25:57 +01:00
panni bcd32924dc 1.3.0.259 2015-10-31 15:33:59 +01:00
panni df463ae2e7 add locale-data to repo 2015-10-31 15:32:21 +01:00
pannal 77cb9e328a add restart note 2015-10-31 15:22:05 +01:00
panni c1df4a06a6 1.3.0.256 2015-10-31 15:05:28 +01:00
panni 1b5a61f69d re-add babel 2015-10-31 15:03:32 +01:00
panni c546035f32 force refresh now actually force refreshes 2015-10-31 15:00:31 +01:00
panni e4eddcb9a6 1.3.0.253 2015-10-31 14:42:48 +01:00
panni bc83076daf test PMS API and fail miserably if failed; fixes #58 2015-10-31 14:38:39 +01:00
panni 7f0d1436a2 add internal provider test script; fix addic7ed show id parsing for shows with years 2015-10-31 14:19:03 +01:00
panni 056d73801b hide plex token from logs; fixes #64 2015-10-31 13:44:00 +01:00
panni 536371a580 Merge remote-tracking branch 'origin/1.3.0' into 1.3.0 2015-10-31 13:38:56 +01:00
panni cede650552 add localization stuff; localize date/time in channel menu 2015-10-31 13:38:32 +01:00
panni 96360498f8 rewrite task scheduling; keep track of missing subtitles search task 2015-10-31 04:07:33 +01:00
pannal 1c489e361d Update README.md 2015-10-30 05:00:05 +01:00
panni abc26bbba2 1.3.0.245 2015-10-30 04:56:22 +01:00
panni 3e0adb422a add date_added to subtitle storage, fixes #59 2015-10-30 04:41:46 +01:00
panni 7d2fa36d2c add donate button to info 2015-10-30 03:59:20 +01:00
panni ea6cab53ad more robust scheduler; update menu; better last_run and next_run handling 2015-10-30 03:23:12 +01:00
panni 92610fd46a move config.Plex to lib.Plex 2015-10-30 02:53:51 +01:00
pannal bcc8a1fd81 a task never ran actually is none, not now() 2015-10-29 02:33:30 +01:00
pannal edd137c7f4 fix syntax error 2015-10-29 01:48:23 +01:00
pannal 6ed0889ce9 clarify menu items 2015-10-29 01:46:50 +01:00
pannal 25fdfa5ba3 use correct way of setting Plex.configuration defaults 2015-10-29 01:38:51 +01:00
pannal 28c811163f force-save the task state even if it has never run before 2015-10-29 01:26:01 +01:00
pannal b6cf3d588a more robust task running; ensure task state even if errors occurred 2015-10-29 01:15:23 +01:00
pannal 2cce587a72 add donation button 2015-10-28 11:10:27 +01:00
pannal 5d54c24c7b Update README.md 2015-10-28 02:01:38 +01:00
panni cd152eec7f 1.3.0.232 2015-10-28 01:57:19 +01:00
panni ef8e0a4b13 add client specific uuid to plex auth 2015-10-28 01:56:26 +01:00
panni b15347ea8e 1.3.0.230 2015-10-28 01:44:29 +01:00
panni be1ad61f8b add more info to the menu 2015-10-28 01:42:31 +01:00
panni a0b44dd833 some menu cleanup 2015-10-28 01:02:35 +01:00
panni c15b316aba hopefully support plex.tv authentication now 2015-10-28 00:30:06 +01:00
panni 6349d8acfd add plexpy/Plex.tv 2015-10-27 22:16:02 +01:00
pannal 9625b63577 update intent handling; should fix issues with multiple intent sets at a time 2015-10-27 19:57:19 +01:00
pannal 3a574c7b1f fix version display in the agent names 2015-10-27 19:48:48 +01:00
pannal f2be845b10 1.3.0.222 2015-10-25 20:15:30 +01:00
pannal 8fd0d3f79b 1.3.0.222 2015-10-25 20:15:03 +01:00
pannal bfe0cd04f2 actually honor the "never" setting 2015-10-25 20:04:08 +01:00
pannal 60a01e8e85 forgot brackets 2015-10-25 20:00:11 +01:00
pannal 01e2e49f20 Update README.md 2015-10-25 16:14:02 +01:00
pannal 6c5876364b Update README.md 2015-10-25 16:13:26 +01:00
pannal 8f3c62e2a8 Update CHANGELOG.md 2015-10-25 16:10:48 +01:00
panni 04882952e1 update version 2015-10-25 16:09:55 +01:00
panni 36ac372b15 add recently added missing subtitles search task; finalize scheduler 2015-10-25 16:08:36 +01:00
panni 757f9628b6 add scheduler prefs; add refresh missing to menu; bulk commit 2015-10-25 15:38:49 +01:00
panni 3d861bf5d3 correct routing 2015-10-25 07:23:58 +01:00
panni 74a3dce903 simplify video title 2015-10-25 07:12:48 +01:00
panni 123550fa9a add locmem key-value intent object; add refresh item menu stuff 2015-10-25 07:10:17 +01:00
panni 4be85c8515 make KV-store less caring 2015-10-25 05:19:38 +01:00
panni f6059a98a2 add temporary key-value-store 2015-10-25 05:16:34 +01:00
panni 016e067596 Merge remote-tracking branch 'origin/1.3.0' into 1.3.0 2015-10-25 04:28:47 +01:00
panni a7e2141528 add advanced menu; move advanced stuff there; add plex.py handler for onDeck; add on_deck to menu 2015-10-25 04:24:20 +01:00
panni 2be59901c9 add on_deck to plex.py 2015-10-25 02:39:18 +01:00
pannal 861c2c3d80 reflect license change in readme 2015-10-24 22:13:42 +02:00
panni 9f092c539b mute prints in recent_items 2015-10-24 17:38:10 +02:00
panni e38279719b add confirmation step to storage reset 2015-10-24 17:36:21 +02:00
panni f87845f839 remove reset settings; add basic GUI; add artwork, defaults; 2015-10-24 16:07:35 +02:00
panni 734c32a63f change LICENSE from MIT to The Unlicense; update licenses in README 2015-10-24 14:59:08 +02:00
panni f367f24dc9 move subzero lib to support; add basic agent handler; add restart endpoint 2015-10-24 04:20:22 +02:00
panni 90bb518922 move ./subzero to ./support; add basic routes 2015-10-24 04:00:32 +02:00
panni 31cd106b7d updated gitignore; added subzero/lib and plex/lib 2015-10-23 15:24:59 +02:00
panni b7c15471b0 keep score of subtitle in subtitle instance for later storage 2015-10-23 15:14:54 +02:00
panni 30881d68a5 store subtitle information; update plex_test 2015-10-23 15:14:14 +02:00
panni 10cc126e99 generalize agents; add version information to logs and agents 2015-10-23 13:47:17 +02:00
panni fff9b72dd0 Merge remote-tracking branch 'origin/1.2.11-fixes' into 1.3.0 2015-10-23 12:17:35 +02:00
panni 727d0db354 improved show id search on addic7ed 2015-10-23 12:15:43 +02:00
panni 21285c2f54 declutter __init__.py; move custom configuration stuff into subzero/config.py#Config() 2015-10-22 18:33:00 +02:00
panni 9e8f60cde1 Merge remote-tracking branch 'origin/master' into 1.3.0 2015-10-22 16:06:41 +02:00
panni 6595ff525a Merge remote-tracking branch 'origin/1.3.0' into 1.3.0 2015-10-21 17:18:47 +02:00
panni ed4752bdc9 incorporate previous test functions for missing subtitles; add scheduler 2015-10-21 17:17:30 +02:00
panni 86a59ed08d contribute to themoviedb 2015-10-21 15:13:06 +02:00
pannal 807a38d117 move all languages downloaded condition up 2015-10-21 14:43:37 +02:00
panni 7b0b7c623c add basic tester for automatic refresh of items with missing subtitles 2015-10-20 17:47:44 +02:00
panni e2f7845b94 plex.py: add refresh endpoint to library/metadata 2015-10-20 17:18:47 +02:00
panni cc7c9d4597 add missing Stream properties to plex.py 2015-10-20 16:05:56 +02:00
panni 3b8e72c0de add plex.py 0.7.0 2015-10-20 14:22:42 +02:00
126 changed files with 7876 additions and 1804 deletions
+2 -1
View File
@@ -13,7 +13,6 @@ build/
develop-eggs/
dist/
eggs/
lib/
lib64/
parts/
sdist/
@@ -53,3 +52,5 @@ coverage.xml
# Sphinx documentation
docs/_build/
# pycharm
.idea
Regular → Executable
+125
View File
@@ -1,3 +1,128 @@
1.3.20.396
- core: fix logging handlers (when saving log_level settings loggers got duplicated)
- core: better movie matching by only hinting the filename and the last subdirectory to guessit (instead of the full path)
- core: don't fail on wrong detection/scanning of media file
- lower minimum tv series score from 85 to 67 (removed title; composed of: series=44 + season=11 + episode=11 + hearing_impaired=1)
1.3.19.379
- core: new recent items implementation (used in "Items with missing subtitles"), now really picking up everything instead of using Plex's recently_added API endpoint
- core: be more strict about title matching - a matched title doesn't automatically mean season and episode are correct, too
- core: rewrote the hash matching algorithm to not blindly trust hash matches anymore, but instead episodes have to match the series name, season number, episode number and format (BluRay, HDTV...); movie have to at least match the title, format and codec for the hash to be considered
- core: remove TheSubDB support for now, as it only supports hash-based matching
- scheduler: more robust item-fail-handling (fixes #81)
- config: "Scan: include embedded subtitles" now by default is off, as embedded subs have proven to be pretty unreliable
- config: add configuration option for how many items per library are to be considered recent (default: 200)
- config: make logging verbosity configurable, default: WARNING - log files should be considerably smaller now
- config: make console logging optional, default: off - good for development/debugging
- config: removed the ignore lists
- menu: added "Browse all items", where you can browse all your libraries and manage your ignore list (add/remove sections/series/items)
- menu: added "Display ignore list", where you can manage your ignored sections, series and items
- menu: the submenu titles are now dynamically composed of a breadcrumb-style tree so you see where you are
- menu: show the current and past state of the important menu actions such as (force)-refresh an item or refreshing the menu, on the Refresh-button's description
- plugin now isn't in the dev mode by default and has logging to the console off (in certain configurations this resulted in huge syslogs)
1.3.6.316
- scheduler: missing subtitles task now able to handle huge libraries (thanks @chopeta, @comrade)
- scheduler: detect item-stalling, add wait and retry logic to make missing subtitles task more robust
- scheduler: report failed items to logs after task run completion
- hint series name and episode title, or movie title to guessit to make detection way better (e.g. for Mr. Robot)
1.3.6.304
- scheduler: correct the recent-determination of the search for missing subtitles in recently_added task
- scheduler: rewrote search for missing subtitles task; it now requests refreshes one by one and not in bulk anymore (hopefully fixes stalling)
- handle rare cases of weird file system encodings (ANSI_X3.4-1968 for example)
- fix simplejson warning on startup
1.3.6.297
- rename Sub-Zero to Sub-Zero.bundle (requirement for adding Sub-Zero to the Plex channel directory)
- channel: add logging actions for the internal storage to the advanced menu
- channel: handle item titles with foreign characters in them correctly
- (hopefully) fix handling file names with foreign characters in them when scanning for local media
- reformat the whole project, mostly honoring pep8
- scheduler: fixed some serious bugs; broken tasks (stalled) and some errors many of you have seen should be gone now
- scheduler: partly rewritten to be more robust, again
- settings: move Plex.tv credentials to the top
1.3.5.281
- fix tasks broken for 1.2 -> 1.3.5 upgraders
1.3.5.273 (same build as Beta Release 1.3.0.273) - changes from previous stable 1.2.11.180
- add a channel menu, making this plugin a hybrid (Agent+Channel)
- add a generic background task scheduler
- add a task to search for subtitles for items with missing subtitles (manually triggered and automatic)
- add artwork
- add Plex.tv credentials/token-generation support (needed for Plex Home users for the API to work)
- addic7ed: improve show name matching again
- channel: able to browse current on-deck and recently-added items, and refresh or force-refresh (search for new subtitles) single items
- add library/series/video blacklist for items which should be skipped in "Search for missing subtitles"-task
- add donation links
- change the license to The Unlicense (while keeping the original MIT license from subliminal.bundle intact)
- store subtitle information in internal plugin storage (for later usage)
- many internal code improvements
- update documentation
1.3.0.273
- more robust update functionality
- menu: add refresh button to menu (to see the task state updating)
- scheduler: actually skip a task if it's already running
- scheduler: better behaviour when a task is running and a single item is refreshed at the same time
- menu: enforce ascii on item titles
1.3.0.261
- removed localization again
1.3.0.259
- forgot locale-data
1.3.0.256
- fix force-refresh single items to actually force-refresh
- re-add babel library
1.3.0.253
- rewrote background tasks subsystem
- keep track of the status of a task and its runtime
- add task state in channel menu to "Search for missing subtitles"
- add date/time localization to channel menu
- hide plex token from logs, when requesting
- fix addic7ed show id parsing for shows with year set
- test PMS API connectivity and fail miserably if needed (channel disabled, scheduler disabled)
- feature-freeze for 1.3.0 final
1.3.0.245
- add the option to buy me a beer
- clarify menu items
- more robust scheduler handling (should fix the issues of scheduler runs in the past)
- internal cleanups
- add date_added to stored subtitle info (all of the 1.3.0 testers: please delete your internal subtitle storage using the channel->advanced menu)
1.3.0.232
- integrate plex.tv authentication for plex home users (test phase)
- menu cleanup
- more info in the menu (scheduler last and next run for example)
- hopefully fixed intent handling (should throw less errors now)
- fix version display in agent names
1.3.0.222
- bugfix for search missing subtitles
- schedduler: honor "never"
1.3.0.216
- add channel menu
- add generic task scheduler
- add functionality to search for missing subtitles (via recently added items)
- add artwork
- change license to The Unlicense
- ...
1.2.11.180
- fix #49 (metadata storage didn't work)
- add better detection for existing subtitles stored in metadata
1.2.11.177
- updated naming scheme to reflect rewrite.major.minor.build (this release is the same as 1.1.0.5)
1.1.0.5
- addic7ed: fixed error in show id search
- addic7ed: even better show matching
Regular → Executable
+170 -145
View File
@@ -1,171 +1,175 @@
# coding=utf-8
import string
import os
import urllib
import zipfile
import re
import copy
import sys
# just some slight modifications to support sum and iter again
from subzero.sandbox import restore_builtins
module = sys.modules['__main__']
restore_builtins(module, {})
globals = getattr(module, "__builtins__")["globals"]
for key, value in getattr(module, "__builtins__").iteritems():
if key != "globals":
globals()[key] = value
import logger
sys.modules["logger"] = logger
import subliminal
import subliminal_patch
import subzero
import logger
import support
from babelfish import Language
from datetime import timedelta
import interface
sys.modules["interface"] = interface
from subzero.subtitlehelpers import getSubtitlesFromMetadata
from subzero.constants import OS_PLEX_USERAGENT, PERSONAL_MEDIA_IDENTIFIER
from subzero import intent
from interface.menu import *
from support.subtitlehelpers import getSubtitlesFromMetadata
from support.storage import storeSubtitleInfo
from support.config import config
OS_PLEX_USERAGENT = 'plexapp.com v9.0'
DEPENDENCY_MODULE_NAMES = ['subliminal', 'subliminal_patch', 'enzyme', 'guessit', 'requests']
PERSONAL_MEDIA_IDENTIFIER = "com.plexapp.agents.none"
def Start():
HTTP.CacheTime = 0
HTTP.Headers['User-agent'] = OS_PLEX_USERAGENT
Log.Debug("START CALLED")
logger.registerLoggingHander(DEPENDENCY_MODULE_NAMES)
# configured cache to be in memory as per https://github.com/Diaoul/subliminal/issues/303
subliminal.region.configure('dogpile.cache.memory')
def ValidatePrefs():
Log.Debug("Validate Prefs called.")
return
# init defaults; perhaps not the best idea to use ValidatePrefs here, but we'll see
ValidatePrefs()
Log.Debug(config.full_version)
# Prepare a list of languages we want subs for
def getLangList():
langList = {Language.fromietf(Prefs["langPref1"])}
langCustom = Prefs["langPrefCustom"].strip()
if not config.plex_api_working:
Log.Error(lib_unaccessible_error)
return
if Prefs["langPref2"] != "None":
langList.update({Language.fromietf(Prefs["langPref2"])})
scheduler.run()
if Prefs["langPref3"] != "None":
langList.update({Language.fromietf(Prefs["langPref3"])})
if len(langCustom) and langCustom != "None":
for lang in langCustom.split(u","):
lang = lang.strip()
try:
real_lang = Language.fromietf(lang)
except:
try:
real_lang = Language.fromname(lang)
except:
continue
langList.update({real_lang})
return langList
def getSubtitleDestinationFolder():
if not Prefs["subtitles.save.filesystem"]:
return
fld_custom = Prefs["subtitles.save.subFolder.Custom"].strip() if bool(Prefs["subtitles.save.subFolder.Custom"]) else None
return fld_custom or (Prefs["subtitles.save.subFolder"] if Prefs["subtitles.save.subFolder"] != "current folder" else None)
def initSubliminalPatches():
# configure custom subtitle destination folders for scanning pre-existing subs
dest_folder = getSubtitleDestinationFolder()
dest_folder = config.subtitleDestinationFolder
subliminal_patch.patch_video.CUSTOM_PATHS = [dest_folder] if dest_folder else []
subliminal_patch.patch_provider_pool.DOWNLOAD_TRIES = int(Prefs['subtitles.try_downloads'])
subliminal_patch.patch_providers.addic7ed.USE_BOOST = bool(Prefs['provider.addic7ed.boost'])
def getProviders():
providers = {'opensubtitles' : Prefs['provider.opensubtitles.enabled'],
'thesubdb' : Prefs['provider.thesubdb.enabled'],
'podnapisi' : Prefs['provider.podnapisi.enabled'],
'addic7ed' : Prefs['provider.addic7ed.enabled'],
'tvsubtitles' : Prefs['provider.tvsubtitles.enabled']
}
return filter(lambda prov: providers[prov], providers)
def getProviderSettings():
provider_settings = {'addic7ed': {'username': Prefs['provider.addic7ed.username'],
'password': Prefs['provider.addic7ed.password'],
'use_random_agents': Prefs['provider.addic7ed.use_random_agents'],
},
'opensubtitles': {'username': Prefs['provider.opensubtitles.username'],
'password': Prefs['provider.opensubtitles.password'],
},
}
return provider_settings
def scanTvMedia(media):
videos = {}
for season in media.seasons:
for episode in media.seasons[season].episodes:
ep = media.seasons[season].episodes[episode]
force_refresh = intent.get("force", ep.id)
for item in media.seasons[season].episodes[episode].items:
for part in item.parts:
scannedVideo = scanVideo(part, "episode")
videos[scannedVideo] = part
scanned_video = scanVideo(part, ignore_all=force_refresh,
hints={"type": "episode", "expected_series": [media.title], "expected_title": [ep.title]})
if not scanned_video:
continue
scanned_video.id = media.seasons[season].episodes[episode].id
videos[scanned_video] = part
return videos
def scanMovieMedia(media):
videos = {}
force_refresh = intent.get("force", media.id)
for item in media.items:
for part in item.parts:
scannedVideo = scanVideo(part, "movie")
videos[scannedVideo] = part
scanned_video = scanVideo(part, ignore_all=force_refresh, hints={"type": "movie", "expected_title": [media.title]})
if not scanned_video:
continue
scanned_video.id = media.id
videos[scanned_video] = part
return videos
def scanVideo(part, video_type):
embedded_subtitles = Prefs['subtitles.scan.embedded']
external_subtitles = Prefs['subtitles.scan.external']
def getItemIDs(media, kind="series"):
ids = []
if kind == "movies":
ids.append(media.id)
else:
for season in media.seasons:
for episode in media.seasons[season].episodes:
ids.append(media.seasons[season].episodes[episode].id)
return ids
def scanVideo(part, ignore_all=False, hints=None):
embedded_subtitles = not ignore_all and Prefs['subtitles.scan.embedded']
external_subtitles = not ignore_all and Prefs['subtitles.scan.external']
if ignore_all:
Log.Debug("Force refresh intended.")
Log.Debug("Scanning video: %s, subtitles=%s, embedded_subtitles=%s" % (part.file, external_subtitles, embedded_subtitles))
try:
return subliminal.video.scan_video(part.file, subtitles=external_subtitles, embedded_subtitles=embedded_subtitles, video_type=video_type)
return subliminal.video.scan_video(part.file, subtitles=external_subtitles, embedded_subtitles=embedded_subtitles, hints=hints or {})
except ValueError:
Log.Warn("File could not be guessed by subliminal")
def downloadBestSubtitles(video_part_map, min_score=0):
hearing_impaired = Prefs['subtitles.search.hearingImpaired']
languages = getLangList()
if not languages:
return
languages = config.langList
if not languages:
return
missing_languages = False
for video, part in video_part_map.iteritems():
if not Prefs['subtitles.save.filesystem']:
# scan for existing metadata subtitles
meta_subs = getSubtitlesFromMetadata(part)
for language, subList in meta_subs.iteritems():
if subList:
video.subtitle_languages.add(language)
Log.Debug("Found metadata subtitle %s for %s", language, video)
if not Prefs['subtitles.save.filesystem']:
# scan for existing metadata subtitles
meta_subs = getSubtitlesFromMetadata(part)
for language, subList in meta_subs.iteritems():
if subList:
video.subtitle_languages.add(language)
Log.Debug("Found metadata subtitle %s for %s", language, video)
if not (languages - video.subtitle_languages):
Log.Debug('All languages %r exist for %s', languages, video)
continue
missing_languages = True
break
if not (languages - video.subtitle_languages):
Log.Debug('All languages %r exist for %s', languages, video)
continue
missing_languages = True
break
if missing_languages:
Log.Debug("Download best subtitles using settings: min_score: %s, hearing_impaired: %s" %(min_score, hearing_impaired))
return subliminal.api.download_best_subtitles(video_part_map.keys(), languages, min_score, hearing_impaired, providers=getProviders(), provider_configs=getProviderSettings())
Log.Debug("Download best subtitles using settings: min_score: %s, hearing_impaired: %s" % (min_score, hearing_impaired))
return subliminal.api.download_best_subtitles(video_part_map.keys(), languages, min_score, hearing_impaired, providers=config.providers,
provider_configs=config.providerSettings)
Log.Debug("All languages for all requested videos exist. Doing nothing.")
def saveSubtitles(videos, subtitles):
if Prefs['subtitles.save.filesystem']:
Log.Debug("Using filesystem as subtitle storage")
saveSubtitlesToFile(subtitles)
storage = "filesystem"
else:
Log.Debug("Using metadata as subtitle storage")
saveSubtitlesToMetadata(videos, subtitles)
storage = "metadata"
storeSubtitleInfo(videos, subtitles, storage)
def saveSubtitlesToFile(subtitles):
fld_custom = Prefs["subtitles.save.subFolder.Custom"].strip() if bool(Prefs["subtitles.save.subFolder.Custom"]) else None
for video, video_subtitles in subtitles.items():
if not video_subtitles:
continue
fld = None
if fld_custom or Prefs["subtitles.save.subFolder"] != "current folder":
# specific subFolder requested, create it if it doesn't exist
for video, video_subtitles in subtitles.items():
if not video_subtitles:
continue
fld = None
if fld_custom or Prefs["subtitles.save.subFolder"] != "current folder":
# specific subFolder requested, create it if it doesn't exist
fld_base = os.path.split(video.name)[0]
if fld_custom:
if fld_custom.startswith("/"):
@@ -179,71 +183,92 @@ def saveSubtitlesToFile(subtitles):
os.makedirs(fld)
subliminal.api.save_subtitles(video, video_subtitles, directory=fld)
def saveSubtitlesToMetadata(videos, subtitles):
for video, video_subtitles in subtitles.items():
mediaPart = videos[video]
for subtitle in video_subtitles:
for subtitle in video_subtitles:
mediaPart.subtitles[Locale.Language.Match(subtitle.language.alpha2)][subtitle.page_link] = Proxy.Media(subtitle.content, ext="srt")
def updateLocalMedia(media, media_type="movies"):
def updateLocalMedia(metadata, media, media_type="movies"):
# Look for subtitles
if media_type == "movies":
for item in media.items:
for part in item.parts:
subzero.localmedia.findSubtitles(part)
return
for item in media.items:
for part in item.parts:
support.localmedia.findSubtitles(part)
return
# Look for subtitles for each episode.
for s in media.seasons:
# If we've got a date based season, ignore it for now, otherwise it'll collide with S/E folders/XML and PMS
# prefers date-based (why?)
if int(s) < 1900 or metadata.guid.startswith(PERSONAL_MEDIA_IDENTIFIER):
for e in media.seasons[s].episodes:
for i in media.seasons[s].episodes[e].items:
# If we've got a date based season, ignore it for now, otherwise it'll collide with S/E folders/XML and PMS
# prefers date-based (why?)
if int(s) < 1900 or metadata.guid.startswith(PERSONAL_MEDIA_IDENTIFIER):
for e in media.seasons[s].episodes:
for i in media.seasons[s].episodes[e].items:
# Look for subtitles.
for part in i.parts:
subzero.localmedia.findSubtitles(part)
else:
pass
# Look for subtitles.
for part in i.parts:
support.localmedia.findSubtitles(part)
else:
pass
class SubZeroSubtitlesAgentMovies(Agent.Movies):
name = 'Sub-Zero Subtitles (Movies)'
class SubZeroAgent(object):
agent_type = None
languages = [Locale.Language.English]
primary_provider = False
contributes_to = ['com.plexapp.agents.imdb', 'com.plexapp.agents.xbmcnfo']
def __init__(self, *args, **kwargs):
super(SubZeroAgent, self).__init__(*args, **kwargs)
self.agent_type = "movies" if isinstance(self, Agent.Movies) else "series"
self.name = "Sub-Zero Subtitles (%s, %s)" % ("Movies" if self.agent_type == "movies" else "TV", config.getVersion())
def search(self, results, media, lang):
Log.Debug("MOVIE SEARCH CALLED")
Log.Debug("Sub-Zero %s, %s search" % (config.version, self.agent_type))
results.Append(MetadataSearchResult(id='null', score=100))
def update(self, metadata, media, lang):
Log.Debug("MOVIE UPDATE CALLED")
initSubliminalPatches()
Log.Debug("Sub-Zero %s, %s update called" % (config.version, self.agent_type))
set_refresh_menu_state(media, media_type=self.agent_type)
item_ids = []
try:
initSubliminalPatches()
videos, subtitles = getattr(self, "update_%s" % self.agent_type)(metadata, media, lang)
item_ids = getItemIDs(media, kind=self.agent_type)
if subtitles:
saveSubtitles(videos, subtitles)
updateLocalMedia(metadata, media, media_type=self.agent_type)
finally:
# update the menu state
set_refresh_menu_state(None)
# notify any running tasks about our finished update
for item_id in item_ids:
scheduler.signal("updated_metadata", item_id)
# resolve existing intent for that id
intent.resolve("force", item_id)
def update_movies(self, metadata, media, lang):
videos = scanMovieMedia(media)
subtitles = downloadBestSubtitles(videos, min_score=int(Prefs["subtitles.search.minimumMovieScore"]))
if subtitles:
saveSubtitles(videos, subtitles)
updateLocalMedia(media, media_type="movies")
return videos, subtitles
class SubZeroSubtitlesAgentTvShows(Agent.TV_Shows):
name = 'Sub-Zero Subtitles (TV)'
languages = [Locale.Language.English]
primary_provider = False
contributes_to = ['com.plexapp.agents.thetvdb', 'com.plexapp.agents.thetvdbdvdorder', 'com.plexapp.agents.xbmcnfotv']
def search(self, results, media, lang):
Log.Debug("TV SEARCH CALLED")
results.Append(MetadataSearchResult(id='null', score=100))
def update(self, metadata, media, lang):
Log.Debug("TvUpdate. Lang %s" % lang)
initSubliminalPatches()
def update_series(self, metadata, media, lang):
videos = scanTvMedia(media)
subtitles = downloadBestSubtitles(videos, min_score=int(Prefs["subtitles.search.minimumTVScore"]))
if subtitles:
saveSubtitles(videos, subtitles)
return videos, subtitles
updateLocalMedia(media, media_type="series")
class SubZeroSubtitlesAgentMovies(SubZeroAgent, Agent.Movies):
contributes_to = ['com.plexapp.agents.imdb', 'com.plexapp.agents.xbmcnfo', 'com.plexapp.agents.themoviedb']
class SubZeroSubtitlesAgentTvShows(SubZeroAgent, Agent.TV_Shows):
contributes_to = ['com.plexapp.agents.thetvdb', 'com.plexapp.agents.thetvdbdvdorder', 'com.plexapp.agents.xbmcnfotv']
+7
View File
@@ -0,0 +1,7 @@
import sys
import menu
sys.modules["interface.menu"] = menu
import menu_helpers
sys.modules["interface.menu_helpers"] = menu_helpers
+433
View File
@@ -0,0 +1,433 @@
# coding=utf-8
import logging
import logger
from subzero.constants import TITLE, ART, ICON, PREFIX, PLUGIN_IDENTIFIER, DEPENDENCY_MODULE_NAMES
from support.config import config
from support.helpers import pad_title, timestamp
from support.auth import refresh_plex_token
from support.ignore import ignore_list
from support.missing_subtitles import getAllMissing
from support.storage import resetStorage, logStorage
from support.items import getRecentItems, MI_DEEPER, MI_KIND, get_items_info
from support.items import getOnDeckItems, refreshItem, getAllItems
from support.background import scheduler
from support.lib import Plex, lib_unaccessible_error
from menu_helpers import add_ignore_options, dig_tree, set_refresh_menu_state, should_display_ignore
# init GUI
ObjectContainer.art = R(ART)
ObjectContainer.no_cache = True
@handler(PREFIX, TITLE, art=ART, thumb=ICON)
@route(PREFIX)
def fatality(randomize=None, force_title=None, header=None, message=None, only_refresh=False, no_history=False, replace_parent=False):
"""
subzero main menu
"""
title = force_title if force_title is not None else config.full_version
oc = ObjectContainer(title1=title, title2=None, header=unicode(header) if header else header, message=message, no_history=no_history,
replace_parent=replace_parent)
if not config.plex_api_working:
oc.add(DirectoryObject(
key=Callback(fatality, randomize=timestamp()),
title=pad_title("PMS API ERROR"),
summary=lib_unaccessible_error
))
return oc
if not only_refresh:
oc.add(DirectoryObject(
key=Callback(OnDeckMenu),
title=pad_title("On Deck items"),
summary="Shows the current on deck items and allows you to individually (force-) refresh their metadata/subtitles."
))
oc.add(DirectoryObject(
key=Callback(RecentlyAddedMenu),
title="Items with missing subtitles",
summary="Shows the items honoring the configured 'Item age to be considered recent'-setting (%s)"
" and allowing you to individually (force-) refresh their metadata/subtitles. " % Prefs["scheduler.item_is_recent_age"]
))
oc.add(DirectoryObject(
key=Callback(SectionsMenu),
title="Browse all items",
summary="Go through your whole library and manage your ignore list. You can also "
"(force-) refresh the metadata/subtitles of individual items."
))
task_name = "searchAllRecentlyAddedMissing"
task = scheduler.task(task_name)
if task.ready_for_display:
task_state = "Running: %s/%s (%s%%)" % (len(task.items_done), len(task.items_searching), task.percentage)
else:
task_state = "Last scheduler run: %s; Next scheduled run: %s; Last runtime: %s" % (scheduler.last_run(task_name) or "never",
scheduler.next_run(task_name) or "never",
str(task.last_run_time).split(".")[0])
oc.add(DirectoryObject(
key=Callback(RefreshMissing),
title="Search for missing subtitles (in recently-added items, max-age: %s)" % Prefs["scheduler.item_is_recent_age"],
summary="Automatically run periodically by the scheduler, if configured. %s" % task_state
))
oc.add(DirectoryObject(
key=Callback(IgnoreListMenu),
title="Display ignore list (%d)" % len(ignore_list),
summary="Show the current ignore list (mainly used for the automatic tasks)"
))
oc.add(DirectoryObject(
key=Callback(fatality, force_title=" ", randomize=timestamp()),
title=pad_title("Refresh"),
summary="Current state: %s; Last state: %s" % (
(Dict["current_refresh_state"] or "Idle") if "current_refresh_state" in Dict else "Idle",
(Dict["last_refresh_state"] or "None") if "last_refresh_state" in Dict else "None"
)
))
if not only_refresh:
oc.add(DirectoryObject(
key=Callback(AdvancedMenu),
title=pad_title("Advanced functions"),
summary="Use at your own risk"
))
return oc
@route(PREFIX + '/on_deck')
def OnDeckMenu(message=None):
return mergedItemsMenu(title="Items On Deck", base_title="Items On Deck", itemGetter=getOnDeckItems)
@route(PREFIX + '/recent')
def RecentlyAddedMenu(message=None):
return recentItemsMenu(title="Missing Subtitles", base_title="Missing Subtitles")
def recentItemsMenu(title, base_title=None):
oc = ObjectContainer(title2=title, no_cache=True, no_history=True)
recent_items = getRecentItems()
if recent_items:
missing_items = getAllMissing(recent_items)
if missing_items:
for added_at, item_id, title in missing_items:
oc.add(DirectoryObject(
key=Callback(RefreshItemMenu, title=base_title + " > " + title, item_title=title, rating_key=item_id), title=title
))
return oc
def mergedItemsMenu(title, itemGetter, itemGetterKwArgs=None, base_title=None, *args, **kwargs):
oc = ObjectContainer(title2=title, no_cache=True, no_history=True)
items = itemGetter(*args, **kwargs)
for kind, title, item_id, deeper, item in items:
oc.add(DirectoryObject(
title=title,
key=Callback(RefreshItemMenu, title=base_title + " > " + title, item_title=title, rating_key=item_id)
))
return oc
def determine_section_display(kind, item):
if item.size > 200:
return SectionFirstLetterMenu
return SectionMenu
@route(PREFIX + '/ignore/set/{kind}/{rating_key}/{todo}/sure={sure}', kind=str, rating_key=str, todo=str, sure=bool)
def IgnoreMenu(kind, rating_key, title=None, sure=False, todo="not_set"):
is_ignored = rating_key in ignore_list[kind]
if not sure:
oc = ObjectContainer(no_history=True, replace_parent=True, title1="%s %s %s %s the ignore list" % (
"Add" if not is_ignored else "Remove", ignore_list.verbose(kind), title, "to" if not is_ignored else "from"), title2="Are you sure?")
oc.add(DirectoryObject(
key=Callback(IgnoreMenu, kind=kind, rating_key=rating_key, title=title, sure=True, todo="add" if not is_ignored else "remove"),
title=pad_title("Are you sure?")
))
return oc
rel = ignore_list[kind]
dont_change = False
if todo == "remove":
if not is_ignored:
dont_change = True
else:
rel.remove(rating_key)
Log.Info("Removed %s (%s) from the ignore list", title, rating_key)
ignore_list.remove_title(kind, rating_key)
ignore_list.save()
state = "removed from"
elif todo == "add":
if is_ignored:
dont_change = True
else:
rel.append(rating_key)
Log.Info("Added %s (%s) to the ignore list", title, rating_key)
ignore_list.add_title(kind, rating_key, title)
ignore_list.save()
state = "added to"
else:
dont_change = True
if dont_change:
return fatality(force_title=" ", header="Didn't change the ignore list", no_history=True)
return fatality(force_title=" ", header="%s %s the ignore list" % (title, state), no_history=True)
@route(PREFIX + '/sections')
def SectionsMenu():
items = getAllItems("sections")
return dig_tree(ObjectContainer(title2="Sections", no_cache=True, no_history=True), items, None,
menu_determination_callback=determine_section_display, pass_kwargs={"base_title": "Sections"},
fill_args={"title": "section_title"})
@route(PREFIX + '/section', ignore_options=bool)
def SectionMenu(rating_key, title=None, base_title=None, section_title=None, ignore_options=True):
items = getAllItems(key="all", value=rating_key, base="library/sections")
kind, deeper = get_items_info(items)
section_title = title
title = base_title + " > " + title
oc = ObjectContainer(title2=title, no_cache=True, no_history=True)
if ignore_options:
add_ignore_options(oc, "sections", title=section_title, rating_key=rating_key, callback_menu=IgnoreMenu)
return dig_tree(oc, items, MetadataMenu,
pass_kwargs={"base_title": title, "display_items": deeper, "previous_item_type": "section",
"previous_rating_key": rating_key})
@route(PREFIX + '/section/firstLetter', deeper=bool)
def SectionFirstLetterMenu(rating_key, title=None, base_title=None, section_title=None):
items = getAllItems(key="first_character", value=rating_key, base="library/sections")
kind, deeper = get_items_info(items)
title = unicode(title)
oc = ObjectContainer(title2=section_title, no_cache=True, no_history=True)
title = base_title + " > " + title
add_ignore_options(oc, "sections", title=section_title, rating_key=rating_key, callback_menu=IgnoreMenu)
oc.add(DirectoryObject(
key=Callback(SectionMenu, title="All", base_title=title, rating_key=rating_key, ignore_options=False),
title="All"
)
)
return dig_tree(oc, items, FirstLetterMetadataMenu, force_rating_key=rating_key, fill_args={"key": "key"},
pass_kwargs={"base_title": title, "display_items": deeper, "previous_rating_key": rating_key})
@route(PREFIX + '/section/firstLetter/key', deeper=bool)
def FirstLetterMetadataMenu(rating_key, key, title=None, base_title=None, display_items=False, previous_item_type=None,
previous_rating_key=None):
"""
:param rating_key: actually is the section's key
:param key: the firstLetter wanted
:param title: the first letter, or #
:param deeper:
:return:
"""
title = base_title + " > " + unicode(title)
oc = ObjectContainer(title2=title, no_cache=True, no_history=True)
items = getAllItems(key="first_character", value=[rating_key, key], base="library/sections", flat=False)
kind, deeper = get_items_info(items)
dig_tree(oc, items, MetadataMenu,
pass_kwargs={"base_title": title, "display_items": deeper, "previous_item_type": kind, "previous_rating_key": rating_key})
return oc
@route(PREFIX + '/section/contents', display_items=bool)
def MetadataMenu(rating_key, title=None, base_title=None, display_items=False, previous_item_type=None, previous_rating_key=None):
title = unicode(title)
item_title = title
title = base_title + " > " + title
oc = ObjectContainer(title2=title, no_cache=True, no_history=True)
if display_items:
items = getAllItems(key="children", value=rating_key, base="library/metadata")
kind, deeper = get_items_info(items)
# we don't know exactly where we are here, only add ignore option to series
if should_display_ignore(items, previous=previous_item_type):
add_ignore_options(oc, "series", title=item_title, rating_key=rating_key, callback_menu=IgnoreMenu)
dig_tree(oc, items, MetadataMenu,
pass_kwargs={"base_title": title, "display_items": deeper, "previous_item_type": kind, "previous_rating_key": rating_key})
else:
return RefreshItemMenu(rating_key=rating_key, title=title, item_title=item_title)
return oc
@route(PREFIX + '/ignore_list')
def IgnoreListMenu():
oc = ObjectContainer(title2="Ignore list", replace_parent=True)
for key in ignore_list.key_order:
values = ignore_list[key]
for value in values:
add_ignore_options(oc, key, title=ignore_list.get_title(key, value), rating_key=value, callback_menu=IgnoreMenu)
return oc
@route(PREFIX + '/item/{rating_key}/actions')
def RefreshItemMenu(rating_key, title=None, base_title=None, item_title=None, came_from="/recent"):
title = unicode(base_title) + " > " + unicode(title) if base_title else unicode(title)
oc = ObjectContainer(title2=title, replace_parent=True)
add_ignore_options(oc, "videos", title=item_title, rating_key=rating_key, callback_menu=IgnoreMenu)
oc.add(DirectoryObject(
key=Callback(RefreshItem, rating_key=rating_key, item_title=item_title),
title="Refresh: %s" % item_title,
summary="Refreshes the item, possibly picking up new subtitles on disk"
))
oc.add(DirectoryObject(
key=Callback(RefreshItem, rating_key=rating_key, item_title=item_title, force=True),
title="Force-Refresh: %s" % item_title,
summary="Issues a forced refresh, ignoring known subtitles and searching for new ones"
))
return oc
@route(PREFIX + '/item/{rating_key}')
def RefreshItem(rating_key=None, came_from="/recent", item_title=None, force=False):
assert rating_key
set_refresh_menu_state("Triggering %sRefresh for %s" % ("Force-" if force else "", item_title))
Thread.Create(refreshItem, rating_key=rating_key, force=force)
return fatality(randomize=timestamp(), header="%s of item %s triggered" % ("Refresh" if not force else "Forced-refresh", rating_key),
replace_parent=True)
@route(PREFIX + '/missing/refresh')
def RefreshMissing(randomize=None):
Thread.CreateTimer(1.0, lambda: scheduler.run_task("searchAllRecentlyAddedMissing"))
return fatality(header="Refresh of recently added items with missing subtitles triggered", replace_parent=True)
@route(PREFIX + '/advanced')
def AdvancedMenu(randomize=None, header=None, message=None):
oc = ObjectContainer(header=header or "Internal stuff, pay attention!", message=message, no_cache=True, no_history=True,
replace_parent=True, title2="Advanced")
oc.add(DirectoryObject(
key=Callback(TriggerRestart),
title=pad_title("Restart the plugin")
))
oc.add(DirectoryObject(
key=Callback(RefreshToken, randomize=timestamp()),
title=pad_title("Re-request the API token from plex.tv")
))
oc.add(DirectoryObject(
key=Callback(LogStorage, key="tasks", randomize=timestamp()),
title=pad_title("Log the plugin's scheduled tasks state storage")
))
oc.add(DirectoryObject(
key=Callback(LogStorage, key="subs", randomize=timestamp()),
title=pad_title("Log the plugin's internal subtitle information storage")
))
oc.add(DirectoryObject(
key=Callback(LogStorage, key="ignore", randomize=timestamp()),
title=pad_title("Log the plugin's internal ignorelist storage")
))
oc.add(DirectoryObject(
key=Callback(ResetStorage, key="tasks", randomize=timestamp()),
title=pad_title("Reset the plugin's scheduled tasks state storage")
))
oc.add(DirectoryObject(
key=Callback(ResetStorage, key="subs", randomize=timestamp()),
title=pad_title("Reset the plugin's internal subtitle information storage")
))
oc.add(DirectoryObject(
key=Callback(ResetStorage, key="ignore", randomize=timestamp()),
title=pad_title("Reset the plugin's internal ignorelist storage")
))
return oc
@route(PREFIX + '/ValidatePrefs')
def ValidatePrefs():
Core.log.setLevel(logging.DEBUG)
Log.Debug("Validate Prefs called.")
config.initialize()
scheduler.setup_tasks()
set_refresh_menu_state(None)
if Prefs["log_console"]:
Core.log.addHandler(logger.console_handler)
Log.Debug("Logging to console from now on")
else:
Core.log.removeHandler(logger.console_handler)
Log.Debug("Stop logging to console")
Log.Debug("Setting log-level to %s", Prefs["log_level"])
logger.registerLoggingHander(DEPENDENCY_MODULE_NAMES, level=Prefs["log_level"])
Core.log.setLevel(logging.getLevelName(Prefs["log_level"]))
return
@route(PREFIX + '/advanced/restart/trigger')
def TriggerRestart(randomize=None):
set_refresh_menu_state("Restarting the plugin")
Thread.CreateTimer(1.0, Restart)
return fatality(header="Restart triggered, please wait about 5 seconds", force_title=" ", only_refresh=True, replace_parent=True,
no_history=True)
@route(PREFIX + '/advanced/restart/execute')
def Restart():
Plex[":/plugins"].restart(PLUGIN_IDENTIFIER)
@route(PREFIX + '/storage/reset', sure=bool)
def ResetStorage(key, randomize=None, sure=False):
if not sure:
oc = ObjectContainer(no_history=True, title1="Reset subtitle storage", title2="Are you sure?")
oc.add(DirectoryObject(
key=Callback(ResetStorage, key=key, sure=True, randomize=timestamp()),
title=pad_title("Are you really sure?")
))
return oc
resetStorage(key)
if key == "tasks":
# reinitialize the scheduler
scheduler.init_storage()
scheduler.setup_tasks()
return AdvancedMenu(
randomize=timestamp(),
header='Success',
message='Information Storage (%s) reset' % key
)
@route(PREFIX + '/storage/log')
def LogStorage(key, randomize=None):
logStorage(key)
return AdvancedMenu(
randomize=timestamp(),
header='Success',
message='Information Storage (%s) logged' % key
)
@route(PREFIX + '/refresh_token')
def RefreshToken(randomize=None):
result = refresh_plex_token()
if result:
msg = "Token successfully refreshed."
else:
msg = "Couldn't refresh the token, please check your credentials"
return AdvancedMenu(header=msg)
+91
View File
@@ -0,0 +1,91 @@
# coding=utf-8
import types
from support.items import get_kind
from subzero import intent
from support.helpers import format_video
from support.ignore import ignore_list
def should_display_ignore(items, previous=None):
kind = get_kind(items)
return items and (
(kind in ("show", "season")) or
(kind == "episode" and previous != "season")
)
def add_ignore_options(oc, kind, callback_menu=None, title=None, rating_key=None, add_kind=True):
"""
:param oc: oc to add our options to
:param kind: movie, show, episode ... - gets translated to the ignore key (sections, series, items)
:param callback_menu: menu to inject
:param title:
:param rating_key:
:return:
"""
# try to translate kind to the ignore key
use_kind = kind
if kind not in ignore_list:
use_kind = ignore_list.translate_key(kind)
if not use_kind or use_kind not in ignore_list:
return
in_list = rating_key in ignore_list[use_kind]
oc.add(DirectoryObject(
key=Callback(callback_menu, kind=use_kind, rating_key=rating_key, title=title),
title="%s %s \"%s\" %s the ignore list" % (
"Remove" if in_list else "Add", ignore_list.verbose(kind) if add_kind else "", unicode(title), "from" if in_list else "to")
)
)
def dig_tree(oc, items, menu_callback, menu_determination_callback=None, force_rating_key=None, fill_args=None, pass_kwargs=None):
for kind, title, key, dig_deeper, item in items:
add_kwargs = {}
if fill_args:
add_kwargs = dict((name, getattr(item, k)) for k, name in fill_args.iteritems() if item and hasattr(item, k))
if pass_kwargs:
add_kwargs.update(pass_kwargs)
oc.add(DirectoryObject(
key=Callback(menu_callback or menu_determination_callback(kind, item), title=title, rating_key=force_rating_key or key,
**add_kwargs),
title=title
))
return oc
def set_refresh_menu_state(state_or_media, media_type="movies"):
"""
:param state_or_media: string, None, or Media argument from Agent.update()
:param media_type: movies or series
:return:
"""
if not state_or_media:
# store it in last state and remove the current
Dict["last_refresh_state"] = Dict["current_refresh_state"]
Dict["current_refresh_state"] = None
return
if isinstance(state_or_media, types.StringTypes):
Dict["current_refresh_state"] = state_or_media
return
media = state_or_media
media_id = media.id
title = None
if media_type == "series":
for season in media.seasons:
for episode in media.seasons[season].episodes:
ep = media.seasons[season].episodes[episode]
media_id = ep.id
title = format_video("show", ep.title, parent_title=media.title, season=int(season), episode=int(episode))
else:
title = format_video("movie", media.title)
force_refresh = intent.get("force", media_id)
Dict["current_refresh_state"] = "%sRefreshing %s" % ("Force-" if force_refresh else "", title)
+18 -6
View File
@@ -1,15 +1,22 @@
import logging
def registerLoggingHander(dependencies):
def registerLoggingHander(dependencies, level="ERROR"):
plexHandler = PlexLoggerHandler()
for dependency in dependencies:
Log.Debug("Registering LoggerHandler for dependency: %s" % dependency)
for dependency in dependencies:
Log.Debug("Registering LoggerHandler for dependency: %s" % dependency)
log = logging.getLogger(dependency)
log.setLevel('DEBUG')
# remove previous plex logging handlers
# fixme: this is not the most elegant solution...
for handler in log.handlers:
if isinstance(handler, PlexLoggerHandler):
log.removeHandler(handler)
log.setLevel(level)
log.addHandler(plexHandler)
class PlexLoggerHandler(logging.StreamHandler):
def __init__(self, level=0):
super(PlexLoggerHandler, self).__init__(level)
@@ -30,4 +37,9 @@ class PlexLoggerHandler(logging.StreamHandler):
elif record.levelno == logging.FATAL:
Log.Exception(self.getFormattedString(record))
else:
Log.Error("UNKNOWN LEVEL: %s", record.getMessage())
Log.Error("UNKNOWN LEVEL: %s", record.getMessage())
console_handler = logging.StreamHandler()
console_formatter = Framework.core.LogFormatter('%(asctime)-15s - %(name)-32s (%(thread)x) : %(levelname)s (%(module)s:%(lineno)d) - %(message)s')
console_handler.setFormatter(console_formatter)
-16
View File
@@ -1,16 +0,0 @@
import sys
# thanks, https://github.com/trakt/Plex-Trakt-Scrobbler/blob/master/Trakttv.bundle/Contents/Code/core/__init__.py
import config
sys.modules["subzero.config"] = config
import helpers
sys.modules["subzero.helpers"] = helpers
import localmedia
sys.modules["subzero.localmedia"] = localmedia
import subtitlehelpers
sys.modules["subzero.subtitlehelpers"] = subtitlehelpers
-4
View File
@@ -1,4 +0,0 @@
SUBTITLE_EXTS = ['utf','utf8','utf-8','srt','smi','rt','ssa','aqt','jss','ass','idx','sub','txt', 'psb']
VIDEO_EXTS = ['3g2', '3gp', 'asf', 'asx', 'avc', 'avi', 'avs', 'bivx', 'bup', 'divx', 'dv', 'dvr-ms', 'evo', 'fli', 'flv',
'm2t', 'm2ts', 'm2v', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'mts', 'nsv', 'nuv', 'ogm', 'ogv', 'tp',
'pva', 'qt', 'rm', 'rmvb', 'sdp', 'svq3', 'strm', 'ts', 'ty', 'vdr', 'viv', 'vob', 'vp3', 'wmv', 'wpl', 'wtv', 'xsp', 'xvid', 'webm']
-36
View File
@@ -1,36 +0,0 @@
# coding=utf-8
import unicodedata
# Unicode control characters can appear in ID3v2 tags but are not legal in XML.
RE_UNICODE_CONTROL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + \
u'|' + \
u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' % \
(
unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff),
unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff),
unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff)
)
# A platform independent way to split paths which might come in with different separators.
def splitPath(str):
if str.find('\\') != -1:
return str.split('\\')
else:
return str.split('/')
def unicodize(s):
filename = s
try:
filename = unicodedata.normalize('NFC', unicode(s.decode('utf-8')))
except:
Log('Failed to unicodize: ' + filename)
try:
filename = re.sub(RE_UNICODE_CONTROL, '', filename)
except:
Log('Couldn\'t strip control characters: ' + filename)
return filename
def cleanFilename(filename):
#this will remove any whitespace and punctuation chars and replace them with spaces, strip and return as lowercase
return string.translate(filename.encode('utf-8'), string.maketrans(string.punctuation + string.whitespace, ' ' * len (string.punctuation + string.whitespace))).strip().lower()
-116
View File
@@ -1,116 +0,0 @@
# coding=utf-8
import os, unicodedata
import config
import helpers
import subtitlehelpers
def findSubtitles(part):
lang_sub_map = {}
part_filename = helpers.unicodize(part.file)
part_basename = os.path.splitext(os.path.basename(part_filename))[0]
paths = [ os.path.dirname(part_filename) ]
# Check for local subtitles subdirectory
sub_dirs_default = ["sub", "subs", "subtitle", "subtitles"]
sub_dir_base = paths[0]
sub_dir_list = []
if Prefs["subtitles.save.subFolder"] != "current folder":
# got selected subfolder
sub_dir_list.append(os.path.join(sub_dir_base, Prefs["subtitles.save.subFolder"]))
sub_dir_custom = Prefs["subtitles.save.subFolder.Custom"].strip() if bool(Prefs["subtitles.save.subFolder.Custom"]) else None
if sub_dir_custom:
# got custom subfolder
if sub_dir_custom.startswith("/"):
# absolute folder
sub_dir_list.append(sub_dir_custom)
else:
# relative folder
sub_dir_list.append(os.path.join(sub_dir_base, sub_dir_custom))
for sub_dir in sub_dir_list:
if os.path.isdir(sub_dir):
paths.append(sub_dir)
# Check for a global subtitle location
global_subtitle_folder = os.path.join(Core.app_support_path, 'Subtitles')
if os.path.exists(global_subtitle_folder):
paths.append(global_subtitle_folder)
# We start by building a dictionary of files to their absolute paths. We also need to know
# the number of media files that are actually present, in case the found local media asset
# is limited to a single instance per media file.
#
file_paths = {}
total_media_files = 0
for path in paths:
path = helpers.unicodize(path)
for file_path_listing in os.listdir(path):
# When using os.listdir with a unicode path, it will always return a string using the
# NFD form. However, we internally are using the form NFC and therefore need to convert
# it to allow correct regex / comparisons to be performed.
#
file_path_listing = helpers.unicodize(file_path_listing)
if os.path.isfile(os.path.join(path, file_path_listing)):
file_paths[file_path_listing.lower()] = os.path.join(path, file_path_listing)
# If we've found an actual media file, we should record it.
(root, ext) = os.path.splitext(file_path_listing)
if ext.lower()[1:] in config.VIDEO_EXTS:
total_media_files += 1
Log('Looking for subtitle media in %d paths with %d media files.', len(paths), total_media_files)
Log('Paths: %s', ", ".join([ helpers.unicodize(p) for p in paths ]))
for file_path in file_paths.values():
local_basename = helpers.unicodize(os.path.splitext(os.path.basename(file_path))[0])
local_basename2 = local_basename.rsplit('.', 1)[0]
filename_matches_part = local_basename == part_basename or local_basename2 == part_basename
# If the file is located within the global subtitle folder and it's name doesn't match exactly
# then we should simply ignore it.
#
if file_path.count(global_subtitle_folder) and not filename_matches_part:
continue
# If we have more than one media file within the folder and located filename doesn't match
# exactly then we should simply ignore it.
#
if total_media_files > 1 and not filename_matches_part:
continue
subtitle_helper = subtitlehelpers.SubtitleHelpers(file_path)
if subtitle_helper != None:
local_lang_map = subtitle_helper.process_subtitles(part)
for new_language, subtitles in local_lang_map.items():
# Add the possible new language along with the located subtitles so that we can validate them
# at the end...
#
if not lang_sub_map.has_key(new_language):
lang_sub_map[new_language] = []
lang_sub_map[new_language] = lang_sub_map[new_language] + subtitles
# add known metadata subs to our sub list
if not Prefs['subtitles.save.filesystem']:
for language, sub_list in subtitlehelpers.getSubtitlesFromMetadata(part).iteritems():
if sub_list:
if not language in lang_sub_map:
lang_sub_map[language] = []
lang_sub_map[language] = lang_sub_map[language] + sub_list
# Now whack subtitles that don't exist anymore.
for language in lang_sub_map.keys():
part.subtitles[language].validate_keys(lang_sub_map[language])
# Now whack the languages that don't exist anymore.
for language in list(set(part.subtitles.keys()) - set(lang_sub_map.keys())):
part.subtitles[language].validate_keys({})
-143
View File
@@ -1,143 +0,0 @@
# coding=utf-8
import re, unicodedata, os
import config
import helpers
class SubtitleHelper(object):
def __init__(self, filename):
self.filename = filename
def SubtitleHelpers(filename):
filename = helpers.unicodize(filename)
for cls in [ VobSubSubtitleHelper, DefaultSubtitleHelper ]:
if cls.is_helper_for(filename):
return cls(filename)
return None
#####################################################################################################################
class VobSubSubtitleHelper(SubtitleHelper):
@classmethod
def is_helper_for(cls, filename):
(file, file_extension) = os.path.splitext(filename)
# We only support idx (and maybe sub)
if not file_extension.lower() in ['.idx', '.sub']:
return False
# If we've been given a sub, we only support it if there exists a matching idx file
return os.path.exists(file + '.idx')
def process_subtitles(self, part):
lang_sub_map = {}
# We don't directly process the sub file, only the idx. Therefore if we are passed on of these files, we simply
# ignore it.
(file, ext) = os.path.splitext(self.filename)
if ext == '.sub':
return lang_sub_map
# If we have an idx file, we need to confirm there is an identically names sub file before we can proceed.
sub_filename = file + ".sub"
if os.path.exists(sub_filename) == False:
return lang_sub_map
Log('Attempting to parse VobSub file: ' + self.filename)
idx = Core.storage.load(os.path.join(self.filename))
if idx.count('VobSub index file') == 0:
Log('The idx file does not appear to be a VobSub, skipping...')
return lang_sub_map
languages = {}
language_index = 0
basename = os.path.basename(self.filename)
for language in re.findall('\nid: ([A-Za-z]{2})', idx):
if not languages.has_key(language):
languages[language] = []
Log('Found .idx subtitle file: ' + self.filename + ' language: ' + language + ' stream index: ' + str(language_index))
languages[language].append(Proxy.LocalFile(self.filename, index = str(language_index), format = "vobsub"))
language_index += 1
if not lang_sub_map.has_key(language):
lang_sub_map[language] = []
lang_sub_map[language].append(basename)
for language, subs in languages.items():
part.subtitles[language][basename] = subs
return lang_sub_map
#####################################################################################################################
class DefaultSubtitleHelper(SubtitleHelper):
@classmethod
def is_helper_for(cls, filename):
(file, file_extension) = os.path.splitext(filename)
return file_extension.lower()[1:] in config.SUBTITLE_EXTS
def process_subtitles(self, part):
lang_sub_map = {}
basename = os.path.basename(self.filename)
(file, ext) = os.path.splitext(self.filename)
# Remove the initial '.' from the extension
ext = ext[1:]
# Attempt to extract the language from the filename (e.g. Avatar (2009).eng)
language = ""
# IETF support thanks to https://github.com/hpsbranco/LocalMedia.bundle/commit/4fad9aefedece78a1fa96401304351347f644369
language_match = re.match(".+\.([^\.]+)$" if not Prefs["subtitles.language.ietf"] else ".+\.([^-.]+)(?:-[A-Za-z]+)?$", file)
if language_match and len(language_match.groups()) == 1:
language = language_match.groups()[0]
language = Locale.Language.Match(language)
codec = None
format = None
if ext in ['txt', 'sub']:
try:
file_contents = Core.storage.load(self.filename)
lines = [ line.strip() for line in file_contents.splitlines(True) ]
if re.match('^\{[0-9]+\}\{[0-9]*\}', lines[1]):
format = 'microdvd'
elif re.match('^[0-9]{1,2}:[0-9]{2}:[0-9]{2}[:=,]', lines[1]):
format = 'txt'
elif '[SUBTITLE]' in lines[1]:
format = 'subviewer'
else:
Log("The subtitle file does not have a known format, skipping... : " + self.filename)
return lang_sub_map
except:
Log("An error occurred while attempting to parse the subtitle file, skipping... : " + self.filename)
return lang_sub_map
if codec is None and ext in ['ass', 'ssa', 'smi', 'srt', 'psb']:
codec = ext.replace('ass', 'ssa')
if format is None:
format = codec
Log('Found subtitle file: ' + self.filename + ' language: ' + language + ' codec: ' + str(codec) + ' format: ' + str(format))
part.subtitles[language][basename] = Proxy.LocalFile(self.filename, codec = codec, format = format)
lang_sub_map[language] = [ basename ]
return lang_sub_map
def getSubtitlesFromMetadata(part):
subs = {}
for language in part.subtitles:
subs[language] = []
for key, proxy in getattr(part.subtitles[language], "_proxies").iteritems():
p_type, p_value, p_sort, p_index, p_codec, p_format = proxy
if p_type == "Media":
# metadata subtitle
subs[language].append(key)
return subs
+50
View File
@@ -0,0 +1,50 @@
import sys
# thanks, https://github.com/trakt/Plex-Trakt-Scrobbler/blob/master/Trakttv.bundle/Contents/Code/core/__init__.py
import config
sys.modules["support.config"] = config
import helpers
sys.modules["support.helpers"] = helpers
import lib
sys.modules["support.lib"] = lib
import localmedia
sys.modules["subzero.localmedia"] = localmedia
import subtitlehelpers
sys.modules["support.subtitlehelpers"] = subtitlehelpers
import items
sys.modules["support.items"] = items
import missing_subtitles
sys.modules["support.missing_subtitles"] = missing_subtitles
import background
sys.modules["support.background"] = background
import tasks
sys.modules["support.tasks"] = tasks
import storage
sys.modules["support.storage"] = storage
import auth
sys.modules["support.auth"] = auth
import ignore
sys.modules["support.ignore"] = ignore
+42
View File
@@ -0,0 +1,42 @@
# coding=utf-8
def refresh_plex_token():
username = Prefs["plex_username"]
password = Prefs["plex_password"]
if not username or not password:
if "token" in Dict:
del Dict["token"]
Dict.Save()
return
if "uuid" not in Dict:
Dict["uuid"] = String.UUID()
Dict.Save()
current_uuid = Dict["uuid"]
headers = {
'X-Plex-Device-Name': 'Sub-Zero',
'X-Plex-Product': 'Sub-Zero',
'X-Plex-Version': '1.3.0',
'X-Plex-Client-Identifier': "%s" % current_uuid,
}
request = HTTP.Request("https://plex.tv/users/sign_in.json", headers=headers,
values={'user[login]': Prefs["plex_username"], 'user[password]': Prefs["plex_password"]}, immediate=True)
token = None
if request:
try:
data = JSON.ObjectFromString(request.content)
token = data["user"]["authentication_token"]
log_data = data.copy()
log_data["user"]["authentication_token"] = "xxxxxxxxxxxxxxxxxx"
Log.Debug("Data returned from plex.tv: %s", log_data)
except:
pass
if token:
Dict["token"] = token
Dict.Save()
return True
+127
View File
@@ -0,0 +1,127 @@
# coding=utf-8
import datetime
import logging
import traceback
def parse_frequency(s):
if s == "never":
return None, None
kind, num, unit = s.split()
return int(num), unit
class DefaultScheduler(object):
thread = None
running = False
registry = None
def __init__(self):
self.thread = None
self.running = False
self.registry = []
self.tasks = {}
self.init_storage()
def init_storage(self):
if "tasks" not in Dict:
Dict["tasks"] = {}
Dict.Save()
def register(self, task):
self.registry.append(task)
def setup_tasks(self):
# discover tasks;
self.tasks = {}
for cls in self.registry:
task = cls(self)
self.tasks[task.name] = {"task": task, "frequency": parse_frequency(Prefs["scheduler.tasks.%s" % task.name])}
def run(self):
self.running = True
self.thread = Thread.Create(self.worker)
def stop(self):
self.running = False
def task(self, name):
if name not in self.tasks:
return None
return self.tasks[name]["task"]
def last_run(self, task):
if task not in self.tasks:
return None
return self.tasks[task]["task"].last_run
def next_run(self, task):
if task not in self.tasks:
return None
frequency_num, frequency_key = self.tasks[task]["frequency"]
if not frequency_num:
return None
last = self.tasks[task]["task"].last_run
use_date = last
now = datetime.datetime.now()
if not use_date:
use_date = now
return max(use_date + datetime.timedelta(**{frequency_key: frequency_num}), now)
def run_task(self, name):
task = self.tasks[name]["task"]
if task.running:
Log.Debug("Scheduler: Not running %s, as it's currently running.", name)
return
Log.Debug("Scheduler: Running task %s", name)
try:
task.prepare()
task.run()
except Exception, e:
Log.Error("Scheduler: Something went wrong when running %s: %s", name, traceback.format_exc())
finally:
task.post_run()
def signal(self, name, *args, **kwargs):
for task_name, info in self.tasks.iteritems():
task = info["task"]
if task.running:
Log.Debug("Scheduler: Sending signal %s to task %s (%s, %s)", name, task_name, args, kwargs)
status = task.signal(name, *args, **kwargs)
if status:
Log.Debug("Scheduler: Signal accepted by %s", task_name)
else:
Log.Debug("Scheduler: Signal not accepted by %s", task_name)
continue
Log.Debug("Scheduler: Not sending signal %s to task %s, because: not running", name, task_name)
def worker(self):
Thread.Sleep(10.0)
while 1:
if not self.running:
break
for name, info in self.tasks.iteritems():
now = datetime.datetime.now()
task = info["task"]
if name not in Dict["tasks"]:
continue
if task.running:
continue
frequency_num, frequency_key = info["frequency"]
if not frequency_num:
continue
if not task.last_run or task.last_run + datetime.timedelta(**{frequency_key: frequency_num}) <= now:
self.run_task(name)
Thread.Sleep(10.0)
scheduler = DefaultScheduler()
+119
View File
@@ -0,0 +1,119 @@
# coding=utf-8
import os
import re
import inspect
from babelfish import Language
from subzero.lib.io import FileIO
from subzero.constants import PLUGIN_NAME
from auth import refresh_plex_token
from lib import configure_plex, Plex
SUBTITLE_EXTS = ['utf', 'utf8', 'utf-8', 'srt', 'smi', 'rt', 'ssa', 'aqt', 'jss', 'ass', 'idx', 'sub', 'txt', 'psb']
VIDEO_EXTS = ['3g2', '3gp', 'asf', 'asx', 'avc', 'avi', 'avs', 'bivx', 'bup', 'divx', 'dv', 'dvr-ms', 'evo', 'fli', 'flv',
'm2t', 'm2ts', 'm2v', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'mts', 'nsv', 'nuv', 'ogm', 'ogv', 'tp',
'pva', 'qt', 'rm', 'rmvb', 'sdp', 'svq3', 'strm', 'ts', 'ty', 'vdr', 'viv', 'vob', 'vp3', 'wmv', 'wpl', 'wtv', 'xsp', 'xvid',
'webm']
VERSION_RE = re.compile(ur'CFBundleVersion.+?<string>([0-9\.]+)</string>', re.DOTALL)
def int_or_default(s, default):
try:
return int(s)
except ValueError:
return default
class Config(object):
version = None
langList = None
subtitleDestinationFolder = None
providers = None
providerSettings = None
max_recent_items_per_library = 200
plex_api_working = False
initialized = False
def initialize(self):
self.version = self.getVersion()
self.full_version = u"%s %s" % (PLUGIN_NAME, self.version)
self.langList = self.getLangList()
self.subtitleDestinationFolder = self.getSubtitleDestinationFolder()
self.providers = self.getProviders()
self.providerSettings = self.getProviderSettings()
self.max_recent_items_per_library = int_or_default(Prefs["scheduler.max_recent_items_per_library"], 200)
self.initialized = True
configure_plex()
self.plex_api_working = self.checkPlexAPI()
def checkPlexAPI(self):
return bool(Plex["library"].sections())
def getVersion(self):
curDir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
info_file_path = os.path.abspath(os.path.join(curDir, "..", "..", "Info.plist"))
data = FileIO.read(info_file_path)
result = VERSION_RE.search(data)
if result:
return result.group(1)
def getBlacklist(self, key):
return map(lambda id: id.strip(), (Prefs[key] or "").split(","))
# Prepare a list of languages we want subs for
def getLangList(self):
l = {Language.fromietf(Prefs["langPref1"])}
langCustom = Prefs["langPrefCustom"].strip()
if Prefs["langPref2"] != "None":
l.update({Language.fromietf(Prefs["langPref2"])})
if Prefs["langPref3"] != "None":
l.update({Language.fromietf(Prefs["langPref3"])})
if len(langCustom) and langCustom != "None":
for lang in langCustom.split(u","):
lang = lang.strip()
try:
real_lang = Language.fromietf(lang)
except:
try:
real_lang = Language.fromname(lang)
except:
continue
l.update({real_lang})
return l
def getSubtitleDestinationFolder(self):
if not Prefs["subtitles.save.filesystem"]:
return
fld_custom = Prefs["subtitles.save.subFolder.Custom"].strip() if bool(Prefs["subtitles.save.subFolder.Custom"]) else None
return fld_custom or (Prefs["subtitles.save.subFolder"] if Prefs["subtitles.save.subFolder"] != "current folder" else None)
def getProviders(self):
providers = {'opensubtitles': Prefs['provider.opensubtitles.enabled'],
#'thesubdb': Prefs['provider.thesubdb.enabled'],
'podnapisi': Prefs['provider.podnapisi.enabled'],
'addic7ed': Prefs['provider.addic7ed.enabled'],
'tvsubtitles': Prefs['provider.tvsubtitles.enabled']
}
return filter(lambda prov: providers[prov], providers)
def getProviderSettings(self):
provider_settings = {'addic7ed': {'username': Prefs['provider.addic7ed.username'],
'password': Prefs['provider.addic7ed.password'],
'use_random_agents': Prefs['provider.addic7ed.use_random_agents'],
},
'opensubtitles': {'username': Prefs['provider.opensubtitles.username'],
'password': Prefs['provider.opensubtitles.password'],
},
}
return provider_settings
config = Config()
+143
View File
@@ -0,0 +1,143 @@
# coding=utf-8
import unicodedata
import datetime
import urllib
import time
import re
# Unicode control characters can appear in ID3v2 tags but are not legal in XML.
RE_UNICODE_CONTROL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + \
u'|' + \
u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' % \
(
unichr(0xd800), unichr(0xdbff), unichr(0xdc00), unichr(0xdfff),
unichr(0xd800), unichr(0xdbff), unichr(0xdc00), unichr(0xdfff),
unichr(0xd800), unichr(0xdbff), unichr(0xdc00), unichr(0xdfff)
)
# A platform independent way to split paths which might come in with different separators.
def splitPath(str):
if str.find('\\') != -1:
return str.split('\\')
else:
return str.split('/')
def unicodize(s):
filename = s
try:
filename = unicodedata.normalize('NFC', unicode(s.decode('utf-8')))
except:
Log('Failed to unicodize: ' + filename)
try:
filename = re.sub(RE_UNICODE_CONTROL, '', filename)
except:
Log('Couldn\'t strip control characters: ' + filename)
return filename
def cleanFilename(filename):
# this will remove any whitespace and punctuation chars and replace them with spaces, strip and return as lowercase
return string.translate(filename.encode('utf-8'), string.maketrans(string.punctuation + string.whitespace,
' ' * len(string.punctuation + string.whitespace))).strip().lower()
def is_recent(t):
now = datetime.datetime.now()
when = datetime.datetime.fromtimestamp(t)
value, key = Prefs["scheduler.item_is_recent_age"].split()
if now - datetime.timedelta(**{key: int(value)}) < when:
return True
return False
# thanks, Plex-Trakt-Scrobbler
def str_pad(s, length, align='left', pad_char=' ', trim=False):
if not s:
return s
if not isinstance(s, (str, unicode)):
s = str(s)
if len(s) == length:
return s
elif len(s) > length and not trim:
return s
if align == 'left':
if len(s) > length:
return s[:length]
else:
return s + (pad_char * (length - len(s)))
elif align == 'right':
if len(s) > length:
return s[len(s) - length:]
else:
return (pad_char * (length - len(s))) + s
else:
raise ValueError("Unknown align type, expected either 'left' or 'right'")
def pad_title(value):
"""Pad a title to 30 characters to force the 'details' view."""
return str_pad(value, 30, pad_char=' ')
def format_item(item, kind, parent=None, parent_title=None, section_title=None, add_section_title=False):
"""
:param item: plex item
:param kind: show or movie
:param parent: season or None
:param parent_title: parentTitle or None
:return:
"""
return format_video(kind, item.title,
section_title=(section_title or (parent.section.title if parent and getattr(parent, "section") else None)),
parent_title=(parent_title or (parent.show.title if parent else None)),
season=parent.index if parent else None,
episode=item.index if kind == "show" else None,
add_section_title=add_section_title)
def format_video(kind, title, section_title=None, parent_title=None, season=None, episode=None, add_section_title=False):
section_add = ""
if add_section_title:
section_add = ("%s: " % section_title) if section_title else ""
if kind == "show" and parent_title:
if season and episode:
return '%s%s S%02dE%02d, %s' % (section_add, parent_title, season or 0, episode or 0, title)
return '%s%s, %s' % (section_add, parent_title, title)
return "%s%s" % (section_add, title)
def encode_message(base, s):
return "%s?message=%s" % (base, urllib.quote_plus(s))
def decode_message(s):
return urllib.unquote_plus(s)
def timestamp():
return int(time.time())
def query_plex(url, args):
"""
simple http query to the plex API without parsing anything too complicated
:param url:
:param args:
:return:
"""
use_args = args.copy()
if "token" in Dict and Dict["token"]:
use_args["X-Plex-Token"] = Dict["token"]
computed_args = "&".join(["%s=%s" % (key, String.Quote(value)) for key, value in use_args.iteritems()])
return HTTP.Request(url + ("?%s" % computed_args) if computed_args else "", immediate=True)
+62
View File
@@ -0,0 +1,62 @@
# coding=utf-8
from subzero.lib.dict import DictProxy
class IgnoreDict(DictProxy):
store = "ignore"
# single item keys returned by helpers.items.getItems mapped to their parents
translate_keys = {
"section": "sections",
"show": "series",
"movie": "videos",
"episode": "videos"
}
# getItems types mapped to their verbose names
keys_verbose = {
"sections": "Section",
"series": "Series",
"videos": "Item",
}
key_order = ("sections", "series", "videos")
def __len__(self):
try:
return sum(len(self.Dict[self.store][key]) for key in self.key_order)
except KeyError:
# old version
self.Dict[self.store] = self.setup_defaults()
return 0
def translate_key(self, name):
return self.translate_keys.get(name)
def verbose(self, name):
return self.keys_verbose.get(name)
def get_title_key(self, kind, key):
return "%s_%s" % (kind, key)
def add_title(self, kind, key, title):
self["titles"][self.get_title_key(kind, key)] = title
def remove_title(self, kind, key):
title_key = self.get_title_key(kind, key)
if title_key in self.titles:
del self.titles[title_key]
def get_title(self, kind, key):
title_key = self.get_title_key(kind, key)
if title_key in self.titles:
return self.titles[title_key]
def save(self):
Dict.Save()
def setup_defaults(self):
return {"sections": [], "series": [], "videos": [], "titles": {}}
ignore_list = IgnoreDict(Dict)
+171
View File
@@ -0,0 +1,171 @@
# coding=utf-8
import logging
import re
import types
from ignore import ignore_list
from helpers import is_recent, format_item, query_plex
from subzero import intent
from lib import Plex
from config import config
logger = logging.getLogger(__name__)
MI_KIND, MI_TITLE, MI_KEY, MI_DEEPER, MI_ITEM = 0, 1, 2, 3, 4
container_size_re = re.compile(ur'totalSize="(\d+)"')
def get_items_info(items):
return items[0][MI_KIND], items[0][MI_DEEPER]
def get_kind(items):
return items[0][MI_KIND]
def getSectionSize(key):
"""
quick query to determine the section size
:param key:
:return:
"""
size = None
url = "https://127.0.0.1:32400/library/sections/%s/all" % int(key)
use_args = {
"X-Plex-Container-Size": "0",
"X-Plex-Container-Start": "0"
}
response = query_plex(url, use_args)
matches = container_size_re.findall(response.content)
if matches:
size = int(matches[0])
return size
def getItems(key="recently_added", base="library", value=None, flat=False, add_section_title=False):
"""
try to handle all return types plex throws at us and return a generalized item tuple
"""
items = []
apply_value = None
if value:
if isinstance(value, types.ListType):
apply_value = value
else:
apply_value = [value]
result = getattr(Plex[base], key)(*(apply_value or []))
for item in result:
cls = getattr(getattr(item, "__class__"), "__name__")
if hasattr(item, "scanner"):
kind = "section"
elif cls == "Directory":
kind = "directory"
else:
kind = item.type
if kind == "season":
# fixme: i think this case is unused now
if flat:
# return episodes
for child in item.children():
items.append(("episode", format_item(child, "show", parent=item, add_section_title=add_section_title), int(item.rating_key),
False, child))
else:
# return seasons
items.append(("season", item.title, int(item.rating_key), True, item))
elif kind == "directory":
items.append(("directory", item.title, item.key, True, item))
elif kind == "section":
item.size = getSectionSize(item.key)
items.append(("section", item.title, int(item.key), True, item))
elif kind == "episode":
items.append(
(kind, format_item(item, "show", parent=item.season, parent_title=item.show.title, section_title=item.section.title,
add_section_title=add_section_title), int(item.rating_key), False, item))
elif kind in ("movie", "artist", "photo"):
items.append((kind, format_item(item, kind, section_title=item.section.title, add_section_title=add_section_title),
int(item.rating_key), False, item))
elif kind == "show":
items.append((
kind, format_item(item, kind, section_title=item.section.title, add_section_title=add_section_title), int(item.rating_key), True,
item))
return items
def getRecentlyAddedItems():
items = getItems(key="recently_added")
return filter(lambda x: is_recent(x[MI_ITEM].added_at), items)
def getRecentItems():
"""
actually get the recent items, not limited like /library/recentlyAdded
:return:
"""
args = {
"sort": "addedAt:desc",
"X-Plex-Container-Start": "0",
"X-Plex-Container-Size": "%s" % config.max_recent_items_per_library
}
episode_re = re.compile(ur'ratingKey="(?P<key>\d+)"'
ur'.+?grandparentRatingKey="(?P<parent_key>\d+)"'
ur'.+?title="(?P<title>.*?)"'
ur'.+?grandparentTitle="(?P<parent_title>.*?)"'
ur'.+?index="(?P<episode>\d+?)"'
ur'.+?parentIndex="(?P<season>\d+?)".+?addedAt="(?P<added>\d+)"')
movie_re = re.compile(ur'ratingKey="(?P<key>\d+)".+?title="(?P<title>.*?)".+?addedAt="(?P<added>\d+)"')
available_keys = ("key", "title", "parent_key", "parent_title", "season", "episode", "added")
recent = []
for section in Plex["library"].sections():
if section.type not in ("movie", "show") or section.key in ignore_list.sections:
Log.Debug(u"Skipping section: %s" % section.title)
continue
use_args = args.copy()
if section.type == "show":
use_args["type"] = "4"
url = "https://127.0.0.1:32400/library/sections/%s/all" % int(section.key)
response = query_plex(url, use_args)
matcher = episode_re if section.type == "show" else movie_re
matches = [m.groupdict() for m in matcher.finditer(response.content)]
for match in matches:
data = dict((key, match[key] if key in match else None) for key in available_keys)
if section.type == "show" and data["parent_key"] in ignore_list.series:
Log.Debug(u"Skipping series: %s" % data["parent_title"])
continue
if data["key"] in ignore_list.videos:
Log.Debug(u"Skipping item: %s" % data["title"])
continue
if is_recent(int(data["added"])):
recent.append((int(data["added"]), section.type, section.title, data["key"]))
return recent
def getOnDeckItems():
return getItems(key="on_deck", add_section_title=True)
def getAllItems(key, base="library", value=None, flat=False):
return getItems(key, base=base, value=value, flat=flat)
def refreshItem(rating_key, force=False, timeout=8000):
# timeout actually is the time for which the intent will be valid
if force:
intent.set("force", rating_key, timeout=timeout)
Log.Info("%s item %s", "Refreshing" if not force else "Forced-refreshing", rating_key)
Plex["library/metadata"].refresh(rating_key)
+18
View File
@@ -0,0 +1,18 @@
# coding=utf-8
from plex import Plex
from auth import refresh_plex_token
def configure_plex():
# this may be the only viable usage of global :O (correct me if i'm wrong)
global Plex
if not "token" in Dict or not (Prefs["plex_username"] and Prefs["plex_password"]):
refresh_plex_token()
# initialize Plex api
Plex.configuration.defaults.authentication(Dict["token"] if "token" in Dict else None)
lib_unaccessible_error = "\n\n\n!!!!!!!!!!!!!! ATTENTION !!!!!!!!!!!!! \nCan't access your Plex Media Servers' API.\nAre you using Plex Home?"" \
""Please configure your Plex.tv credentials! Advanced features disabled!\n\n\n"
+116
View File
@@ -0,0 +1,116 @@
# coding=utf-8
import os
import config
import helpers
import subtitlehelpers
from subzero.lib.io import getViableEncoding
def findSubtitles(part):
lang_sub_map = {}
part_filename = helpers.unicodize(part.file)
part_basename = os.path.splitext(os.path.basename(part_filename))[0]
paths = [os.path.dirname(part_filename)]
# Check for local subtitles subdirectory
sub_dirs_default = ["sub", "subs", "subtitle", "subtitles"]
sub_dir_base = paths[0]
sub_dir_list = []
if Prefs["subtitles.save.subFolder"] != "current folder":
# got selected subfolder
sub_dir_list.append(os.path.join(sub_dir_base, Prefs["subtitles.save.subFolder"]))
sub_dir_custom = Prefs["subtitles.save.subFolder.Custom"].strip() if bool(Prefs["subtitles.save.subFolder.Custom"]) else None
if sub_dir_custom:
# got custom subfolder
if sub_dir_custom.startswith("/"):
# absolute folder
sub_dir_list.append(sub_dir_custom)
else:
# relative folder
sub_dir_list.append(os.path.join(sub_dir_base, sub_dir_custom))
for sub_dir in sub_dir_list:
if os.path.isdir(sub_dir):
paths.append(sub_dir)
# Check for a global subtitle location
global_subtitle_folder = os.path.join(Core.app_support_path, 'Subtitles')
if os.path.exists(global_subtitle_folder):
paths.append(global_subtitle_folder)
# We start by building a dictionary of files to their absolute paths. We also need to know
# the number of media files that are actually present, in case the found local media asset
# is limited to a single instance per media file.
#
file_paths = {}
total_media_files = 0
for path in paths:
path = helpers.unicodize(path)
for file_path_listing in os.listdir(path):
# When using os.listdir with a unicode path, it will always return a string using the
# NFD form. However, we internally are using the form NFC and therefore need to convert
# it to allow correct regex / comparisons to be performed.
#
file_path_listing = helpers.unicodize(file_path_listing)
if os.path.isfile(os.path.join(path, file_path_listing).encode(getViableEncoding())):
file_paths[file_path_listing.lower()] = os.path.join(path, file_path_listing)
# If we've found an actual media file, we should record it.
(root, ext) = os.path.splitext(file_path_listing)
if ext.lower()[1:] in config.VIDEO_EXTS:
total_media_files += 1
Log('Looking for subtitle media in %d paths with %d media files.', len(paths), total_media_files)
Log('Paths: %s', ", ".join([helpers.unicodize(p) for p in paths]))
for file_path in file_paths.values():
local_basename = helpers.unicodize(os.path.splitext(os.path.basename(file_path))[0])
local_basename2 = local_basename.rsplit('.', 1)[0]
filename_matches_part = local_basename == part_basename or local_basename2 == part_basename
# If the file is located within the global subtitle folder and it's name doesn't match exactly
# then we should simply ignore it.
#
if file_path.count(global_subtitle_folder) and not filename_matches_part:
continue
# If we have more than one media file within the folder and located filename doesn't match
# exactly then we should simply ignore it.
#
if total_media_files > 1 and not filename_matches_part:
continue
subtitle_helper = subtitlehelpers.SubtitleHelpers(file_path)
if subtitle_helper != None:
local_lang_map = subtitle_helper.process_subtitles(part)
for new_language, subtitles in local_lang_map.items():
# Add the possible new language along with the located subtitles so that we can validate them
# at the end...
#
if not lang_sub_map.has_key(new_language):
lang_sub_map[new_language] = []
lang_sub_map[new_language] = lang_sub_map[new_language] + subtitles
# add known metadata subs to our sub list
if not Prefs['subtitles.save.filesystem']:
for language, sub_list in subtitlehelpers.getSubtitlesFromMetadata(part).iteritems():
if sub_list:
if language not in lang_sub_map:
lang_sub_map[language] = []
lang_sub_map[language] = lang_sub_map[language] + sub_list
# Now whack subtitles that don't exist anymore.
for language in lang_sub_map.keys():
part.subtitles[language].validate_keys(lang_sub_map[language])
# Now whack the languages that don't exist anymore.
for language in list(set(part.subtitles.keys()) - set(lang_sub_map.keys())):
part.subtitles[language].validate_keys({})
+77
View File
@@ -0,0 +1,77 @@
# coding=utf-8
import traceback
from support.config import config
from support.helpers import format_item
from lib import Plex
def itemDiscoverMissing(rating_key, kind="show", added_at=None, section_title=None, internal=False, external=True, languages=()):
existing_subs = {"internal": [], "external": [], "count": 0}
item_id = int(rating_key)
item_container = Plex["library"].metadata(item_id)
item = list(item_container)[0]
if kind == "show":
item_title = format_item(item, kind, parent=item.season, section_title=section_title, parent_title=item.show.title)
else:
item_title = format_item(item, kind, section_title=section_title)
video = item.media
for part in video.parts:
for stream in part.streams:
if stream.stream_type == 3:
if stream.index:
key = "internal"
else:
key = "external"
existing_subs[key].append(Locale.Language.Match(stream.language_code or ""))
existing_subs["count"] = existing_subs["count"] + 1
missing = languages
if existing_subs["count"]:
existing_flat = (existing_subs["internal"] if internal else []) + (existing_subs["external"] if external else [])
languages_set = set(languages)
if languages_set.issubset(existing_flat):
# all subs found
Log.Info(u"All subtitles exist for '%s'", item_title)
return
missing = languages_set - set(existing_flat)
Log.Info(u"Subs still missing for '%s': %s", item_title, missing)
if missing:
return added_at, item_id, item_title
def getAllMissing(items):
missing = []
for added_at, kind, section_title, key in items:
try:
state = itemDiscoverMissing(
key,
kind=kind,
added_at=added_at,
section_title=section_title,
languages=config.langList,
internal=bool(Prefs["subtitles.scan.embedded"]),
external=bool(Prefs["subtitles.scan.external"])
)
if state:
# (added_at, item_id, title)
missing.append(state)
except:
Log.Error("Something went wrong when getting the state of item %s: %s", key, traceback.format_exc())
return missing
def refresh_item(item, title):
Plex["library/metadata"].refresh(item)
def refresh_items(items):
for item, title in items:
refresh_item(item, title)
+53
View File
@@ -0,0 +1,53 @@
# coding=utf-8
import datetime
import pprint
def storeSubtitleInfo(videos, subtitles, storage_type):
"""
stores information about downloaded subtitles in plex's Dict()
"""
if not "subs" in Dict:
Dict["subs"] = {}
storage = Dict["subs"]
for video, video_subtitles in subtitles.items():
part = videos[video]
if not video.id in storage:
storage[video.id] = {}
video_dict = storage[video.id]
if not part.id in video_dict:
video_dict[part.id] = {}
part_dict = video_dict[part.id]
for subtitle in video_subtitles:
lang = Locale.Language.Match(subtitle.language.alpha2)
if lang not in part_dict:
part_dict[lang] = {}
lang_dict = part_dict[lang]
sub_key = (subtitle.provider_name, subtitle.id)
lang_dict[sub_key] = dict(score=subtitle.score, link=subtitle.page_link, storage=storage_type, hash=Hash.MD5(subtitle.content),
date_added=datetime.datetime.now())
Dict.Save()
def resetStorage(key):
"""
resets the Dict[key] storage, thanks to https://docs.google.com/document/d/1hhLjV1pI-TA5y91TiJq64BdgKwdLnFt4hWgeOqpz1NA/edit#
We can't use the nice Plex interface for this, as it calls get multiple times before set
#Plex[":/plugins/*/prefs"].set("com.plexapp.agents.subzero", "reset_storage", False)
"""
Log.Debug("resetting storage")
Dict[key] = {}
Dict.Save()
def logStorage(key):
if key in Dict:
Log.Debug(pprint.pformat(Dict[key]))
+153
View File
@@ -0,0 +1,153 @@
# coding=utf-8
import re, unicodedata, os
import config
import helpers
class SubtitleHelper(object):
def __init__(self, filename):
self.filename = filename
def SubtitleHelpers(filename):
filename = helpers.unicodize(filename)
for cls in [VobSubSubtitleHelper, DefaultSubtitleHelper]:
if cls.is_helper_for(filename):
return cls(filename)
return None
#####################################################################################################################
class VobSubSubtitleHelper(SubtitleHelper):
@classmethod
def is_helper_for(cls, filename):
(file, file_extension) = os.path.splitext(filename)
# We only support idx (and maybe sub)
if not file_extension.lower() in ['.idx', '.sub']:
return False
# If we've been given a sub, we only support it if there exists a matching idx file
return os.path.exists(file + '.idx')
def process_subtitles(self, part):
lang_sub_map = {}
# We don't directly process the sub file, only the idx. Therefore if we are passed on of these files, we simply
# ignore it.
(file, ext) = os.path.splitext(self.filename)
if ext == '.sub':
return lang_sub_map
# If we have an idx file, we need to confirm there is an identically names sub file before we can proceed.
sub_filename = file + ".sub"
if not os.path.exists(sub_filename):
return lang_sub_map
Log('Attempting to parse VobSub file: ' + self.filename)
idx = Core.storage.load(os.path.join(self.filename))
if idx.count('VobSub index file') == 0:
Log('The idx file does not appear to be a VobSub, skipping...')
return lang_sub_map
languages = {}
language_index = 0
basename = os.path.basename(self.filename)
for language in re.findall('\nid: ([A-Za-z]{2})', idx):
if not languages.has_key(language):
languages[language] = []
Log('Found .idx subtitle file: ' + self.filename + ' language: ' + language + ' stream index: ' + str(language_index))
languages[language].append(Proxy.LocalFile(self.filename, index=str(language_index), format="vobsub"))
language_index += 1
if not lang_sub_map.has_key(language):
lang_sub_map[language] = []
lang_sub_map[language].append(basename)
for language, subs in languages.items():
part.subtitles[language][basename] = subs
return lang_sub_map
#####################################################################################################################
class DefaultSubtitleHelper(SubtitleHelper):
@classmethod
def is_helper_for(cls, filename):
(file, file_extension) = os.path.splitext(filename)
return file_extension.lower()[1:] in config.SUBTITLE_EXTS
def process_subtitles(self, part):
lang_sub_map = {}
basename = os.path.basename(self.filename)
(file, ext) = os.path.splitext(self.filename)
# Remove the initial '.' from the extension
ext = ext[1:]
# Attempt to extract the language from the filename (e.g. Avatar (2009).eng)
language = ""
# IETF support thanks to https://github.com/hpsbranco/LocalMedia.bundle/commit/4fad9aefedece78a1fa96401304351347f644369
language_match = re.match(".+\.([^\.]+)$" if not Prefs["subtitles.language.ietf"] else ".+\.([^-.]+)(?:-[A-Za-z]+)?$", file)
if language_match and len(language_match.groups()) == 1:
language = language_match.groups()[0]
language = Locale.Language.Match(language)
codec = None
format = None
if ext in ['txt', 'sub']:
try:
file_contents = Core.storage.load(self.filename)
lines = [line.strip() for line in file_contents.splitlines(True)]
if re.match('^\{[0-9]+\}\{[0-9]*\}', lines[1]):
format = 'microdvd'
elif re.match('^[0-9]{1,2}:[0-9]{2}:[0-9]{2}[:=,]', lines[1]):
format = 'txt'
elif '[SUBTITLE]' in lines[1]:
format = 'subviewer'
else:
Log("The subtitle file does not have a known format, skipping... : " + self.filename)
return lang_sub_map
except:
Log("An error occurred while attempting to parse the subtitle file, skipping... : " + self.filename)
return lang_sub_map
if codec is None and ext in ['ass', 'ssa', 'smi', 'srt', 'psb']:
codec = ext.replace('ass', 'ssa')
if format is None:
format = codec
Log('Found subtitle file: ' + self.filename + ' language: ' + language + ' codec: ' + str(codec) + ' format: ' + str(format))
part.subtitles[language][basename] = Proxy.LocalFile(self.filename, codec=codec, format=format)
lang_sub_map[language] = [basename]
return lang_sub_map
def getSubtitlesFromMetadata(part):
subs = {}
for language in part.subtitles:
subs[language] = []
for key, proxy in getattr(part.subtitles[language], "_proxies").iteritems():
if not proxy or not len(proxy) >= 5:
Log.Debug("Can't parse metadata: %s" % repr(proxy))
continue
p_type = proxy[0]
if p_type == "Media":
# metadata subtitle
Log.Debug(u"Found metadata subtitle: %s, %s" % (language, repr(proxy)))
subs[language].append(key)
return subs
+144
View File
@@ -0,0 +1,144 @@
# coding=utf-8
import datetime
import time
from missing_subtitles import getAllMissing, refresh_item
from background import scheduler
from support.items import getRecentItems
class Task(object):
name = None
scheduler = None
running = False
time_start = None
stored_attributes = ("last_run", "last_run_time")
# task ready for being status-displayed?
ready_for_display = False
def __init__(self, scheduler):
self.ready_for_display = False
self.running = False
self.time_start = None
self.scheduler = scheduler
if self.name not in Dict["tasks"]:
Dict["tasks"][self.name] = {"last_run": None, "last_run_time": None}
def __getattribute__(self, name):
if name in object.__getattribute__(self, "stored_attributes"):
return Dict["tasks"].get(self.name, {}).get(name, None)
return object.__getattribute__(self, name)
def __setattr__(self, name, value):
if name in object.__getattribute__(self, "stored_attributes"):
Dict["tasks"][self.name][name] = value
Dict.Save()
return
object.__setattr__(self, name, value)
def signal(self, *args, **kwargs):
raise NotImplementedError
def prepare(self):
raise NotImplementedError
def run(self):
raise NotImplementedError
class SearchAllRecentlyAddedMissing(Task):
name = "searchAllRecentlyAddedMissing"
items_done = None
items_searching = None
items_searching_ids = None
items_failed = None
percentage = 0
stall_time = 30
def __init__(self, scheduler):
super(SearchAllRecentlyAddedMissing, self).__init__(scheduler)
self.items_done = None
self.items_searching = None
self.items_searching_ids = None
self.items_failed = None
self.percentage = 0
def signal(self, signal_name, *args, **kwargs):
handler = getattr(self, "signal_%s" % signal_name)
return handler(*args, **kwargs) if handler else None
def signal_updated_metadata(self, *args, **kwargs):
item_id = int(args[0])
if item_id in self.items_searching_ids:
self.items_done.append(item_id)
return True
def prepare(self):
self.items_done = []
recent_items = getRecentItems()
missing = getAllMissing(recent_items)
ids = set([id for added_at, id, title in missing])
self.items_searching = missing
self.items_searching_ids = ids
self.items_failed = []
self.percentage = 0
self.time_start = datetime.datetime.now()
self.ready_for_display = True
def run(self):
self.running = True
missing_count = len(self.items_searching)
items_done_count = 0
for added_at, item_id, title in self.items_searching:
Log.Debug(u"Task: %s, triggering refresh for %s (%s)", self.name, title, item_id)
refresh_item(item_id, title)
search_started = datetime.datetime.now()
tries = 1
while 1:
if item_id in self.items_done:
items_done_count += 1
Log.Debug(u"Task: %s, item %s done", self.name, item_id)
self.percentage = int(items_done_count * 100 / missing_count)
break
# item considered stalled after self.stall_time seconds passed after last refresh
if (datetime.datetime.now() - search_started).total_seconds() > self.stall_time:
if tries > 3:
self.items_failed.append(item_id)
Log.Debug(u"Task: %s, item stalled for %s times: %s, skipping", self.name, tries, item_id)
break
Log.Debug(u"Task: %s, item stalled for %s seconds: %s, retrying", self.name, self.stall_time, item_id)
tries += 1
refresh_item(item_id, title)
search_started = datetime.datetime.now()
time.sleep(1)
time.sleep(0.1)
# we can't hammer the PMS, otherwise requests will be stalled
time.sleep(1)
Log.Debug("Task: %s, done. Failed items: %s", self.name, self.items_failed)
self.running = False
def post_run(self):
self.ready_for_display = False
self.last_run = datetime.datetime.now()
if self.time_start:
self.last_run_time = self.last_run - self.time_start
self.time_start = None
self.percentage = 0
self.items_done = None
self.items_failed = None
self.items_searching = None
self.items_searching_ids = None
scheduler.register(SearchAllRecentlyAddedMissing)
+435 -161
View File
@@ -1,163 +1,437 @@
[
{ "id": "subtitles.try_downloads",
"label": "How many download tries per subtitle (on timeout or error)",
"type": "enum",
"values": ["1", "2", "3", "4"],
"default": "2"
},
{
"id": "provider.addic7ed.username",
"label": "Addic7ed Username",
"type": "text",
"default": "Username"
},
{
"id": "provider.addic7ed.password",
"label": "Addic7ed Password",
"type": "text",
"option": "hidden",
"default": "",
"secure": "true"
},
{
"id": "provider.opensubtitles.username",
"label": "Opensubtitles Username (VIP)",
"type": "text",
"default": ""
},
{
"id": "provider.opensubtitles.password",
"label": "Opensubtitles Password",
"type": "text",
"option": "hidden",
"default": "",
"secure": "true"
},
{
"id": "provider.addic7ed.use_random_agents",
"label": "Addic7ed: Use random user agents (should not be necessary)",
"type": "bool",
"default": "false"
},
{
"id": "langPref1",
"label": "Subtitle Language (1)",
"type": "enum",
"values": ["sq","ar","be","bs","bg","ca","zh","cs","da","nl","en","et","fi","fr","de","el","he","hi","hu","is","id","it","ja","ko","lv","lt","mk","ms","no","fa","pl","pt","pt-br","ro","ru","sr","sk","sl","es","sv","th","tr","uk","vi","hr"],
"default": "en"
},
{
"id": "langPref2",
"label": "Subtitle Language (2)",
"type": "enum",
"values": ["None", "sq","ar","be","bs","bg","ca","zh","cs","da","nl","en","et","fi","fr","de","el","he","hi","hu","is","id","it","ja","ko","lv","lt","mk","ms","no","fa","pl","pt","pt-br","ro","ru","sr","sk","sl","es","sv","th","tr","uk","vi","hr"],
"default": "None"
},
{
"id": "langPref3",
"label": "Subtitle Language (3)",
"type": "enum",
"values": ["None", "sq","ar","be","bs","bg","ca","zh","cs","da","nl","en","et","fi","fr","de","el","he","hi","hu","is","id","it","ja","ko","lv","lt","mk","ms","no","fa","pl","pt","pt-br","ro","ru","sr","sk","sl","es","sv","th","tr","uk","vi","hr"],
"default": "None"
},
{
"id": "langPrefCustom",
"label": "Additional Subtitle Languages (use ISO-639-1 codes; comma-separated)",
"type": "text",
"default": "None"
},
{
"id": "provider.opensubtitles.enabled",
"label": "Provider: Enable OpenSubtitles",
"type": "bool",
"default": "true"
},
{
"id": "provider.thesubdb.enabled",
"label": "Provider: Enable TheSubDB",
"type": "bool",
"default": "true"
},
{
"id": "provider.podnapisi.enabled",
"label": "Provider: Enable Podnapisi.NET",
"type": "bool",
"default": "true"
},
{
"id": "provider.addic7ed.enabled",
"label": "Provider: Enable Addic7ed",
"type": "bool",
"default": "true"
},
{
"id": "provider.addic7ed.boost",
"label": "Addic7ed: boost over hash score if requirements met (prefer over other providers)",
"type": "bool",
"default": "false"
},
{
"id": "provider.tvsubtitles.enabled",
"label": "Provider: Enable TVsubtitles.net",
"type": "bool",
"default": "true"
},
{
"id": "subtitles.scan.embedded",
"label": "Scan: include embedded subtitles (skip if existing)",
"type": "bool",
"default": "true"
},
{
"id": "subtitles.scan.external",
"label": "Scan: include external subtitles (skip if existing)",
"type": "bool",
"default": "true"
},
{
"id": "subtitles.search.minimumTVScore",
"label": "Minimum score for TV subtitles to download",
"type": "enum",
"values": ["100","95","90","85","80","75","70","65","60","55","50","45","40","35","30","25","20","15","10","5","0"],
"default": "85"
},
{
"id": "subtitles.search.minimumMovieScore",
"label": "Minimum score for movie subtitles to download",
"type": "enum",
"values": ["100","95","90","85","80","75","70","65","60","55","50","45","40","35","30","25","23","20","15","10","5","0"],
"default": "23"
},
{
"id": "subtitles.search.hearingImpaired",
"label": "Download hearing impaired subtitles.",
"type": "enum",
"values": ["prefer", "don't prefer", "force HI", "force non-HI"],
"default": "don't prefer"
},
{
"id": "subtitles.save.filesystem",
"label": "Store subtitles next to media files (instead of metadata)",
"type": "bool",
"default": "true"
},
{
"id": "subtitles.save.subFolder",
"label": "Subtitle Folder (\"current folder\" is the folder the current media file lives in)",
"type": "enum",
"values": ["current folder", "sub", "subs", "subtitle", "subtitles"],
"default": "current folder"
},
{
"id": "subtitles.save.subFolder.Custom",
"label": "Custom Subtitle folder (overrides \"Subtitle Folder\"; computes to real paths)",
"type": "text",
"default": ""
},
{
"id": "subtitles.language.ietf",
"label": "Treat IETF language tags as ISO 639-1 (e.g. pt-BR = pt)",
"type": "bool",
"default": "true"
}
{
"id": "subtitles.try_downloads",
"label": "How many download tries per subtitle (on timeout or error)",
"type": "enum",
"values": [
"1",
"2",
"3",
"4"
],
"default": "2"
},
{
"id": "plex_username",
"label": "Plex.tv Username (needed for Plex Home users)",
"type": "text",
"default": ""
},
{
"id": "plex_password",
"label": "Plex.tv Password",
"type": "text",
"option": "hidden",
"default": "",
"secure": "true"
},
{
"id": "provider.addic7ed.username",
"label": "Addic7ed Username",
"type": "text",
"default": "Username"
},
{
"id": "provider.addic7ed.password",
"label": "Addic7ed Password",
"type": "text",
"option": "hidden",
"default": "",
"secure": "true"
},
{
"id": "provider.opensubtitles.username",
"label": "Opensubtitles Username (VIP)",
"type": "text",
"default": ""
},
{
"id": "provider.opensubtitles.password",
"label": "Opensubtitles Password",
"type": "text",
"option": "hidden",
"default": "",
"secure": "true"
},
{
"id": "provider.addic7ed.use_random_agents",
"label": "Addic7ed: Use random user agents (should not be necessary)",
"type": "bool",
"default": "false"
},
{
"id": "langPref1",
"label": "Subtitle Language (1)",
"type": "enum",
"values": [
"sq",
"ar",
"be",
"bs",
"bg",
"ca",
"zh",
"cs",
"da",
"nl",
"en",
"et",
"fi",
"fr",
"de",
"el",
"he",
"hi",
"hu",
"is",
"id",
"it",
"ja",
"ko",
"lv",
"lt",
"mk",
"ms",
"no",
"fa",
"pl",
"pt",
"pt-br",
"ro",
"ru",
"sr",
"sk",
"sl",
"es",
"sv",
"th",
"tr",
"uk",
"vi",
"hr"
],
"default": "en"
},
{
"id": "langPref2",
"label": "Subtitle Language (2)",
"type": "enum",
"values": [
"None",
"sq",
"ar",
"be",
"bs",
"bg",
"ca",
"zh",
"cs",
"da",
"nl",
"en",
"et",
"fi",
"fr",
"de",
"el",
"he",
"hi",
"hu",
"is",
"id",
"it",
"ja",
"ko",
"lv",
"lt",
"mk",
"ms",
"no",
"fa",
"pl",
"pt",
"pt-br",
"ro",
"ru",
"sr",
"sk",
"sl",
"es",
"sv",
"th",
"tr",
"uk",
"vi",
"hr"
],
"default": "None"
},
{
"id": "langPref3",
"label": "Subtitle Language (3)",
"type": "enum",
"values": [
"None",
"sq",
"ar",
"be",
"bs",
"bg",
"ca",
"zh",
"cs",
"da",
"nl",
"en",
"et",
"fi",
"fr",
"de",
"el",
"he",
"hi",
"hu",
"is",
"id",
"it",
"ja",
"ko",
"lv",
"lt",
"mk",
"ms",
"no",
"fa",
"pl",
"pt",
"pt-br",
"ro",
"ru",
"sr",
"sk",
"sl",
"es",
"sv",
"th",
"tr",
"uk",
"vi",
"hr"
],
"default": "None"
},
{
"id": "langPrefCustom",
"label": "Additional Subtitle Languages (use ISO-639-1 codes; comma-separated)",
"type": "text",
"default": "None"
},
{
"id": "provider.opensubtitles.enabled",
"label": "Provider: Enable OpenSubtitles",
"type": "bool",
"default": "true"
},
{
"id": "provider.thesubdb.enabled",
"label": "Provider: Enable TheSubDB",
"type": "bool",
"default": "true"
},
{
"id": "provider.podnapisi.enabled",
"label": "Provider: Enable Podnapisi.NET",
"type": "bool",
"default": "true"
},
{
"id": "provider.addic7ed.enabled",
"label": "Provider: Enable Addic7ed",
"type": "bool",
"default": "true"
},
{
"id": "provider.addic7ed.boost",
"label": "Addic7ed: boost over hash score if requirements met (prefer over other providers)",
"type": "bool",
"default": "false"
},
{
"id": "provider.tvsubtitles.enabled",
"label": "Provider: Enable TVsubtitles.net",
"type": "bool",
"default": "true"
},
{
"id": "subtitles.scan.embedded",
"label": "Scan: include subtitles embedded in the media file (and don't download seperate ones)",
"type": "bool",
"default": "false"
},
{
"id": "subtitles.scan.external",
"label": "Scan: include external subtitles (and don't download new ones)",
"type": "bool",
"default": "true"
},
{
"id": "subtitles.search.minimumTVScore",
"label": "Minimum score for TV subtitles to download",
"type": "enum",
"values": [
"100",
"95",
"90",
"85",
"80",
"75",
"70",
"67",
"65",
"60",
"55",
"50",
"45",
"40",
"35",
"30",
"25",
"20",
"15",
"10",
"5",
"0"
],
"default": "85"
},
{
"id": "subtitles.search.minimumMovieScore",
"label": "Minimum score for movie subtitles to download",
"type": "enum",
"values": [
"100",
"95",
"90",
"85",
"80",
"75",
"70",
"65",
"60",
"55",
"50",
"45",
"40",
"35",
"30",
"25",
"23",
"20",
"15",
"10",
"5",
"0"
],
"default": "23"
},
{
"id": "subtitles.search.hearingImpaired",
"label": "Download hearing impaired subtitles.",
"type": "enum",
"values": [
"prefer",
"don't prefer",
"force HI",
"force non-HI"
],
"default": "don't prefer"
},
{
"id": "subtitles.save.filesystem",
"label": "Store subtitles next to media files (instead of metadata)",
"type": "bool",
"default": "true"
},
{
"id": "subtitles.save.subFolder",
"label": "Subtitle Folder (\"current folder\" is the folder the current media file lives in)",
"type": "enum",
"values": [
"current folder",
"sub",
"subs",
"subtitle",
"subtitles"
],
"default": "current folder"
},
{
"id": "subtitles.save.subFolder.Custom",
"label": "Custom Subtitle folder (overrides \"Subtitle Folder\"; computes to real paths)",
"type": "text",
"default": ""
},
{
"id": "subtitles.language.ietf",
"label": "Treat IETF language tags as ISO 639-1 (e.g. pt-BR = pt)",
"type": "bool",
"default": "true"
},
{
"id": "scheduler.tasks.searchAllRecentlyAddedMissing",
"label": "Scheduler: Periodically search for recent items with missing subtitles",
"type": "enum",
"values": [
"never",
"every 1 hours",
"every 3 hours",
"every 6 hours",
"every 12 hours",
"every 24 hours"
],
"default": "every 6 hours"
},
{
"id": "scheduler.item_is_recent_age",
"label": "Scheduler: Item age to be considered recent",
"type": "enum",
"values": [
"1 days",
"2 days",
"3 days",
"4 days",
"1 weeks",
"2 weeks",
"3 weeks",
"4 weeks",
"5 weeks",
"6 weeks"
],
"default": "2 weeks"
},
{
"id": "scheduler.max_recent_items_per_library",
"label": "Scheduler: Recent items to consider per library",
"type": "text",
"default": "200"
},
{
"id": "log_level",
"label": "How verbose should the logging be?",
"type": "enum",
"values": [
"CRITICAL",
"ERROR",
"WARNING",
"INFO",
"DEBUG"
],
"default": "WARNING"
},
{
"id": "log_console",
"label": "Log to console (for development/debugging)",
"type": "bool",
"default": "false"
}
]
Regular → Executable
+9 -7
View File
@@ -9,11 +9,11 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>1.2.11</string>
<string>1.3.6</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.2.11.180</string>
<string>1.3.20.403</string>
<key>PlexFrameworkVersion</key>
<string>2</string>
<key>PlexPluginClass</key>
@@ -21,24 +21,26 @@
<key>PlexPluginMode</key>
<string>Daemon</string>
<key>PlexPluginConsoleLogging</key>
<string>1</string>
<string>0</string>
<key>PlexPluginDevMode</key>
<string>1</string>
<string>0</string>
<key>PlexPluginCodePolicy</key>
<!-- this allows channels to access some python methods which are otherwise blocked, as well as import external code libraries, and interact with the PMS HTTP API -->
<string>Elevated</string>
<key>PlexAgentAttributionText</key>
<string>&lt;div style=&quot;white-space: pre;&quot;&gt;&lt;img src=&quot;https://raw.githubusercontent.com/pannal/Sub-Zero/master/Contents/Resources/subzero.gif&quot; /&gt;
<string>&lt;div style=&quot;white-space: pre;&quot;&gt;&lt;img src=&quot;https://raw.githubusercontent.com/pannal/Sub-Zero.bundle/master/Contents/Resources/subzero.gif&quot; /&gt;
&lt;h1&gt;Sub-Zero for Plex&lt;/h1&gt;&lt;i&gt;Subtitles done right&lt;/i&gt;
Version 1.2.11.180
Version 1.3.20.403
Originally based on @bramwalet's awesome &lt;a href=&quot;https://github.com/bramwalet/Subliminal.bundle&quot;&gt;Subliminal.bundle&lt;/a&gt;
If you like this, buy me a beer: &lt;a href=&quot;https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&amp;hosted_button_id=G9VKR2B8PMNKG&quot; target=&quot;_blank&quot; title=&quot;donate&quot;&gt;&lt;img src=&quot;https://www.paypalobjects.com/en_US/i/btn/btn_donate_LG.gif&quot; alt=&quot;donate&quot; title=&quot;donate&quot; /&gt;&lt;/a&gt;
&lt;strong&gt;Need help?&lt;/strong&gt;
Plex thread: &lt;a href=&quot;https://forums.plex.tv/discussion/186575&quot;>https://forums.plex.tv/discussion/186575&lt;/a&gt;
Github: &lt;a href=&quot;https://github.com/pannal/Sub-Zero&quot;&gt;https://github.com/pannal/Sub-Zero&lt;/a&gt;
Github: &lt;a href=&quot;https://github.com/pannal/Sub-Zero.bundle&quot;&gt;https://github.com/pannal/Sub-Zero&lt;/a&gt;
panni, 2015
&lt;/div&gt;
View File
@@ -0,0 +1,11 @@
import logging
log = logging.getLogger(__name__)
__version__ = '0.7.0'
try:
from plex.client import Plex
except Exception as ex:
log.warn('Unable to import submodules - %s', ex, exc_info=True)
+116
View File
@@ -0,0 +1,116 @@
from plex.core.configuration import ConfigurationManager
from plex.core.http import HttpClient
from plex.helpers import has_attribute
from plex.interfaces import construct_map
from plex.interfaces.core.base import InterfaceProxy
from plex.lib.six import add_metaclass
from plex.objects.core.manager import ObjectManager
import logging
import socket
log = logging.getLogger(__name__)
class PlexClient(object):
__interfaces = None
def __init__(self):
# Construct interfaces
self.http = HttpClient(self)
self.configuration = ConfigurationManager()
self.__interfaces = construct_map(self)
# Discover modules
ObjectManager.construct()
@property
def base_url(self):
host = self.configuration.get('server.host', '127.0.0.1')
port = self.configuration.get('server.port', 32400)
return 'http://%s:%s' % (host, port)
def __getitem__(self, path):
parts = path.strip('/').split('/')
cur = self.__interfaces
parameters = []
while parts and type(cur) is dict:
key = parts.pop(0)
if key == '*':
key = None
elif key not in cur:
if None in cur:
parameters.append(key)
cur = cur[None]
continue
return None
cur = cur[key]
while type(cur) is dict:
cur = cur.get(None)
if parts:
parameters.extend(parts)
if parameters:
return InterfaceProxy(cur, parameters)
return cur
def __getattr__(self, name):
interface = self.__interfaces.get(None)
if not interface:
raise Exception("Root interface not found")
return getattr(interface, name)
class PlexMeta(type):
@property
def client(cls):
if cls._client is None:
cls.construct()
return cls._client
def __getattr__(self, name):
if has_attribute(self, name):
return super(PlexMeta, self).__getattribute__(name)
if self.client is None:
self.construct()
return getattr(self.client, name)
def __setattr__(self, name, value):
if has_attribute(self, name):
return super(PlexMeta, self).__setattr__(name, value)
if self.client is None:
self.construct()
setattr(self.client, name, value)
def __getitem__(self, key):
if self.client is None:
self.construct()
return self.client[key]
@add_metaclass(PlexMeta)
class Plex(object):
_client = None
@classmethod
def construct(cls):
cls._client = PlexClient()
@@ -0,0 +1,115 @@
class ConfigurationManager(object):
def __init__(self):
self.stack = [
Configuration(self)
]
@property
def current(self):
return self.stack[-1]
@property
def defaults(self):
return self.stack[0]
def authentication(self, token):
return Configuration(self).authentication(token)
def cache(self, **definitions):
return Configuration(self).cache(**definitions)
def client(self, identifier, product, version):
return Configuration(self).client(identifier, product, version)
def device(self, name, system):
return Configuration(self).device(name, system)
def headers(self, headers):
return Configuration(self).headers(headers)
def platform(self, name, version):
return Configuration(self).platform(name, version)
def server(self, host='127.0.0.1', port=32400):
return Configuration(self).server(host, port)
def get(self, key, default=None):
for x in range(len(self.stack) - 1, -1, -1):
value = self.stack[x].get(key)
if value is not None:
return value
return default
def __getitem__(self, key):
return self.get(key)
def __setitem__(self, key, value):
self.current[key] = value
class Configuration(object):
def __init__(self, manager):
self.manager = manager
self.data = {}
def authentication(self, token):
self.data['authentication.token'] = token
return self
def cache(self, **definitions):
for key, value in definitions.items():
self.data['cache.%s' % key] = value
return self
def client(self, identifier, product, version):
self.data['client.identifier'] = identifier
self.data['client.product'] = product
self.data['client.version'] = version
return self
def device(self, name, system):
self.data['device.name'] = name
self.data['device.system'] = system
return self
def headers(self, headers):
self.data['headers'] = headers
return self
def platform(self, name, version):
self.data['platform.name'] = name
self.data['platform.version'] = version
return self
def server(self, host='127.0.0.1', port=32400):
self.data['server.host'] = host
self.data['server.port'] = port
return self
def get(self, key, default=None):
return self.data.get(key, default)
def __enter__(self):
self.manager.stack.append(self)
def __exit__(self, exc_type, exc_val, exc_tb):
item = self.manager.stack.pop()
assert item == self
def __getitem__(self, key):
return self.data[key]
def __setitem__(self, key, value):
self.data[key] = value
@@ -0,0 +1,26 @@
from threading import Lock
class Context(object):
def __init__(self, **kwargs):
self.kwargs = kwargs
def __getattr__(self, key):
return self.kwargs.get(key)
class ContextStack(object):
def __init__(self):
self._list = []
self._lock = Lock()
def pop(self):
context = self._list.pop()
self._lock.release()
return context
def push(self, **kwargs):
self._lock.acquire()
return self._list.append(Context(**kwargs))
@@ -0,0 +1,105 @@
# ExtensionImporter (```flask.exthook```)
# ----------------------------------
# :copyright: (c) 2014 by Armin Ronacher.
# :license: BSD, see LICENSE for more details.
from plex.lib.six import reraise
import os
import sys
class ExtensionImporter(object):
"""This importer redirects imports from this submodule to other locations.
This makes it possible to transition from the old flaskext.name to the
newer flask_name without people having a hard time.
"""
def __init__(self, module_choices, wrapper_module):
self.module_choices = module_choices
self.wrapper_module = wrapper_module
self.prefix = wrapper_module + '.'
self.prefix_cutoff = wrapper_module.count('.') + 1
def __eq__(self, other):
return self.__class__.__module__ == other.__class__.__module__ and \
self.__class__.__name__ == other.__class__.__name__ and \
self.wrapper_module == other.wrapper_module and \
self.module_choices == other.module_choices
def __ne__(self, other):
return not self.__eq__(other)
def install(self):
sys.meta_path[:] = [x for x in sys.meta_path if self != x] + [self]
def find_module(self, fullname, path=None):
if fullname.startswith(self.prefix):
return self
def load_module(self, fullname):
if fullname in sys.modules:
return sys.modules[fullname]
modname = fullname.split('.', self.prefix_cutoff)[self.prefix_cutoff]
for path in self.module_choices:
realname = path % modname
try:
__import__(realname)
except ImportError:
exc_type, exc_value, tb = sys.exc_info()
# since we only establish the entry in sys.modules at the
# very this seems to be redundant, but if recursive imports
# happen we will call into the move import a second time.
# On the second invocation we still don't have an entry for
# fullname in sys.modules, but we will end up with the same
# fake module name and that import will succeed since this
# one already has a temporary entry in the modules dict.
# Since this one "succeeded" temporarily that second
# invocation now will have created a fullname entry in
# sys.modules which we have to kill.
sys.modules.pop(fullname, None)
# If it's an important traceback we reraise it, otherwise
# we swallow it and try the next choice. The skipped frame
# is the one from __import__ above which we don't care about
if self.is_important_traceback(realname, tb):
reraise(exc_type, exc_value, tb.tb_next)
continue
module = sys.modules[fullname] = sys.modules[realname]
if '.' not in modname:
setattr(sys.modules[self.wrapper_module], modname, module)
return module
raise ImportError('No module named %s' % fullname)
def is_important_traceback(self, important_module, tb):
"""Walks a traceback's frames and checks if any of the frames
originated in the given important module. If that is the case then we
were able to import the module itself but apparently something went
wrong when the module was imported. (Eg: import of an import failed).
"""
while tb is not None:
if self.is_important_frame(important_module, tb):
return True
tb = tb.tb_next
return False
def is_important_frame(self, important_module, tb):
"""Checks a single frame if it's important."""
g = tb.tb_frame.f_globals
if '__name__' not in g:
return False
module_name = g['__name__']
# Python 2.7 Behavior. Modules are cleaned up late so the
# name shows up properly here. Success!
if module_name == important_module:
return True
# Some python versions will will clean up modules so early that the
# module name at that point is no longer set. Try guessing from
# the filename then.
filename = os.path.abspath(tb.tb_frame.f_code.co_filename)
test_string = os.path.sep + important_module.replace('.', os.path.sep)
return test_string + '.py' in filename or \
test_string + os.path.sep + '__init__.py' in filename
@@ -0,0 +1,59 @@
from plex.lib import six
import re
import unicodedata
def flatten(text):
if text is None:
return None
# Normalize `text` to ascii
text = normalize(text)
# Remove special characters
text = re.sub('[^A-Za-z0-9\s]+', '', text)
# Merge duplicate spaces
text = ' '.join(text.split())
# Convert to lower-case
return text.lower()
def normalize(text):
if text is None:
return None
# Normalize unicode characters
if type(text) is six.text_type:
text = unicodedata.normalize('NFKD', text)
# Ensure text is ASCII, ignore unknown characters
text = text.encode('ascii', 'ignore')
# Return decoded `text`
return text.decode('ascii')
def to_iterable(value):
if value is None:
return None
if isinstance(value, (list, tuple)):
return value
return [value]
def synchronized(func):
def wrapper(self, *__args, **__kw):
self._lock.acquire()
try:
return func(self, *__args, **__kw)
finally:
self._lock.release()
wrapper.__name__ = func.__name__
wrapper.__dict__ = func.__dict__
wrapper.__doc__ = func.__doc__
return wrapper
+150
View File
@@ -0,0 +1,150 @@
from plex.core.context import ContextStack
from plex.core.helpers import synchronized
from plex.request import PlexRequest
from threading import Condition
import hashlib
import logging
import requests
import socket
log = logging.getLogger(__name__)
class HttpClient(object):
def __init__(self, client):
self.client = client
self.configuration = ContextStack()
self.session = None
# Private
self._lock = Condition()
# Build requests session
self._build()
@property
def cache(self):
return self.client.configuration.get('cache.http')
def configure(self, path=None):
self.configuration.push(base_path=path)
return self
def request(self, method, path=None, params=None, query=None, data=None, credentials=None, **kwargs):
# retrieve configuration
ctx = self.configuration.pop()
if path is not None and type(path) is not str:
# Convert `path` to string (excluding NoneType)
path = str(path)
if ctx.base_path and path:
# Prepend `base_path` to relative `path`s
if not path.startswith('/'):
path = ctx.base_path + '/' + path
elif ctx.base_path:
path = ctx.base_path
elif not path:
path = ''
request = PlexRequest(
self.client,
method=method,
path=path,
params=params,
query=query,
data=data,
credentials=credentials,
**kwargs
)
prepared = request.prepare()
# Try retrieve cached response
response = self._cache_lookup(prepared)
if response:
return response
# TODO retrying requests on 502, 503 errors?
try:
response = self.session.send(prepared)
except socket.gaierror as e:
code, _ = e
if code != 8:
raise e
log.warn('Encountered socket.gaierror (code: 8)')
response = self._build().send(prepared)
# Store response in cache
self._cache_store(prepared, response)
return response
def get(self, path=None, params=None, query=None, data=None, **kwargs):
return self.request('GET', path, params, query, data, **kwargs)
def put(self, path=None, params=None, query=None, data=None, **kwargs):
return self.request('PUT', path, params, query, data, **kwargs)
def post(self, path=None, params=None, query=None, data=None, **kwargs):
return self.request('POST', path, params, query, data, **kwargs)
def delete(self, path=None, params=None, query=None, data=None, **kwargs):
return self.request('DELETE', path, params, query, data, **kwargs)
def _build(self):
if self.session:
log.info('Rebuilding session and connection pools...')
# Rebuild the connection pool (old pool has stale connections)
self.session = requests.Session()
return self.session
@synchronized
def _cache_lookup(self, request):
if self.cache is None:
return None
if request.method not in ['GET']:
return None
# Retrieve from cache
return self.cache.get(self._cache_key(request))
@synchronized
def _cache_store(self, request, response):
if self.cache is None:
return None
if request.method not in ['GET']:
return None
# Store in cache
self.cache[self._cache_key(request)] = response
@staticmethod
def _cache_key(request):
raw = ','.join([request.method, request.url])
# Generate MD5 hash of key
m = hashlib.md5()
m.update(raw.encode('utf-8'))
return m.hexdigest()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
pass
@@ -0,0 +1,54 @@
from plex.lib.six import string_types
class idict(dict):
def __init__(self, initial=None):
if initial:
self.update(initial)
def get(self, k, d=None):
if isinstance(k, string_types):
k = k.lower()
if super(idict, self).__contains__(k):
return self[k]
return d
def update(self, E=None, **F):
if E:
if hasattr(E, 'keys'):
# Update with `E` dictionary
for k in E:
self[k] = E[k]
else:
# Update with `E` items
for (k, v) in E:
self[k] = v
# Update with `F` dictionary
for k in F:
self[k] = F[k]
def __contains__(self, k):
if isinstance(k, string_types):
k = k.lower()
return super(idict, self).__contains__(k)
def __delitem__(self, k):
if isinstance(k, string_types):
k = k.lower()
super(idict, self).__delitem__(k)
def __getitem__(self, k):
if isinstance(k, string_types):
k = k.lower()
return super(idict, self).__getitem__(k)
def __setitem__(self, k, value):
if isinstance(k, string_types):
k = k.lower()
super(idict, self).__setitem__(k, value)
@@ -0,0 +1,4 @@
from plex.core.extension import ExtensionImporter
importer = ExtensionImporter(['plex_%s'], __name__)
importer.install()
@@ -0,0 +1,6 @@
def has_attribute(obj, name):
try:
object.__getattribute__(obj, name)
return True
except AttributeError:
return False
@@ -0,0 +1,81 @@
from plex.interfaces.channel import ChannelInterface
from plex.interfaces.library import LibraryInterface
from plex.interfaces.library.metadata import LibraryMetadataInterface
from plex.interfaces.plugin import PluginInterface
from plex.interfaces.plugin.preferences import PluginPreferencesInterface
from plex.interfaces.preferences import PreferencesInterface
from plex.interfaces.root import RootInterface
from plex.interfaces.section import SectionInterface
from plex.interfaces.status import StatusInterface
from plex.interfaces.timeline import TimelineInterface
# TODO automatic interface discovery
INTERFACES = [
RootInterface,
# /
ChannelInterface,
StatusInterface,
# /library
LibraryInterface,
LibraryMetadataInterface,
SectionInterface,
# /:
PreferencesInterface,
TimelineInterface,
# /:/plugins
PluginInterface,
PluginPreferencesInterface
]
def get_interfaces():
for interface in INTERFACES:
if interface.path:
path = interface.path.strip('/')
else:
path = ''
if path:
path = path.split('/')
else:
path = []
yield path, interface
def construct_map(client, d=None, interfaces=None):
if d is None:
d = {}
if interfaces is None:
interfaces = get_interfaces()
for path, interface in interfaces:
if len(path) > 0:
key = path.pop(0)
else:
key = None
if key == '*':
key = None
if len(path) == 0:
d[key] = interface(client)
continue
value = d.get(key, {})
if type(value) is not dict:
value = {None: value}
construct_map(client, value, [(path, interface)])
d[key] = value
return d
@@ -0,0 +1,8 @@
from plex.interfaces.core.base import Interface
class ChannelInterface(Interface):
path = 'channels'
def all(self):
raise NotImplementedError()
@@ -0,0 +1,216 @@
from plex.lib.six import string_types, StringIO
from plex.lib.six.moves.urllib_parse import urlparse
from functools import wraps
import logging
# Import available parser
PARSER = None
try:
from lxml import etree
PARSER = 'etree.HTMLParser'
except ImportError:
from xml.etree import ElementTree as etree
PARSER = 'etree.XMLParser'
log = logging.getLogger(__name__)
class Helpers(object):
@staticmethod
def get(node, attr):
if PARSER == 'etree.HTMLParser':
return node.get(attr.lower())
return node.get(attr)
@staticmethod
def find(node, tag):
if PARSER == 'etree.HTMLParser':
return node.find(tag.lower())
return node.find(tag)
@staticmethod
def findall(node, tag):
if PARSER == 'etree.HTMLParser':
return node.findall(tag.lower())
return node.findall(tag)
class Interface(object):
helpers = Helpers
path = None
object_map = {}
def __init__(self, client):
self.client = client
def __getitem__(self, name):
if hasattr(self, name):
return getattr(self, name)
raise ValueError('Unknown action "%s" on %s', name, self)
@property
def http(self):
if not self.client:
return None
return self.client.http.configure(self.path)
def parse(self, response, schema):
if response.status_code < 200 or response.status_code >= 300:
return None
try:
root = self.__parse_xml(response.content)
except SyntaxError as ex:
log.error('Unable to parse XML response: %s', ex, exc_info=True, extra={
'data': {
'snippet': self.__error_snippet(response, ex)
}
})
return None
except Exception as ex:
log.error('Unable to parse XML response: %s', ex, exc_info=True)
return None
url = urlparse(response.url)
path = url.path
return self.__construct(self.client, path, root, schema)
@staticmethod
def __parse_xml(content):
if PARSER == 'etree.HTMLParser':
html = etree.fromstring(content, parser=etree.HTMLParser())
assert html.tag == 'html'
bodies = [e for e in html if e.tag == 'body']
assert len(bodies) == 1
body = bodies[0]
assert len(body) == 1
return body[0]
return etree.fromstring(content)
@staticmethod
def __error_snippet(response, ex):
# Retrieve the error line
position = getattr(ex, 'position', None)
if not position or len(position) != 2:
return None
n_line, n_column = position
snippet = None
# Create StringIO stream
stream = StringIO(response.text)
# Iterate over `content` to find `n_line`
for x, l in enumerate(stream):
if x < n_line - 1:
continue
# Line found
snippet = l
break
# Close the stream
stream.close()
if not snippet:
# Couldn't find the line
return None
# Find an attribute value containing `n_column`
start = snippet.find('"', n_column)
end = snippet.find('"', start + 1)
# Trim `snippet` (if attribute value was found)
if start >= 0 and end >= 0:
return snippet[start:end + 1]
return snippet
@classmethod
def __construct(cls, client, path, node, schema):
if not schema:
return None
# Try retrieve schema for `tag`
item = schema.get(node.tag)
if item is None:
raise ValueError('Unknown node with tag "%s"' % node.tag)
if type(item) is dict:
value = cls.helpers.get(node, item.get('_', 'type'))
if value is None:
return None
item = item.get(value)
if item is None:
raise ValueError('Unknown node type "%s"' % value)
descriptor = None
child_schema = None
if type(item) is tuple and len(item) == 2:
descriptor, child_schema = item
else:
descriptor = item
if isinstance(descriptor, string_types):
if descriptor not in cls.object_map:
raise Exception('Unable to find descriptor by name "%s"' % descriptor)
descriptor = cls.object_map.get(descriptor)
if descriptor is None:
raise Exception('Unable to find descriptor')
keys_used, obj = descriptor.construct(client, node, path=path)
# Lazy-construct children
def iter_children():
for child_node in node:
item = cls.__construct(client, path, child_node, child_schema)
if item:
yield item
obj._children = iter_children()
return obj
class InterfaceProxy(object):
def __init__(self, interface, args):
self.interface = interface
self.args = list(args)
def __getattr__(self, name):
value = getattr(self.interface, name)
if not hasattr(value, '__call__'):
return value
@wraps(value)
def wrap(*args, **kwargs):
args = self.args + list(args)
return value(*args, **kwargs)
return wrap
@@ -0,0 +1,104 @@
from plex.core.idict import idict
from plex.interfaces.core.base import Interface
class LibraryInterface(Interface):
path = 'library'
def metadata(self, rating_key):
response = self.http.get('metadata', rating_key)
return self.parse(response, idict({
'MediaContainer': ('MediaContainer', idict({
'Directory': {
'album': 'Album',
'artist': 'Artist',
'season': 'Season',
'show': 'Show'
},
'Video': {
'episode': 'Episode',
'clip': 'Clip',
'movie': 'Movie'
},
'Track': 'Track'
}))
}))
def on_deck(self):
response = self.http.get('onDeck')
return self.parse(response, idict({
'MediaContainer': ('MediaContainer', idict({
'Video': {
'movie': 'Movie',
'episode': 'Episode'
}
}))
}))
def recently_added(self):
response = self.http.get('recentlyAdded')
return self.parse(response, idict({
'MediaContainer': ('MediaContainer', idict({
'Directory': {
'album': 'Album',
'season': 'Season'
},
'Video': {
'movie': 'Movie'
}
}))
}))
def sections(self):
response = self.http.get('sections')
return self.parse(response, idict({
'MediaContainer': ('SectionContainer', idict({
'Directory': ('Section', idict({
'Location': 'Location'
}))
}))
}))
#
# Item actions
#
def rate(self, key, rating):
response = self.http.get(
'/:/rate',
query={
'identifier': 'com.plexapp.plugins.library',
'key': key,
'rating': int(round(rating, 0))
}
)
return response.status_code == 200
def scrobble(self, key):
response = self.http.get(
'/:/scrobble',
query={
'identifier': 'com.plexapp.plugins.library',
'key': key
}
)
return response.status_code == 200
def unscrobble(self, key):
response = self.http.get(
'/:/unscrobble',
query={
'identifier': 'com.plexapp.plugins.library',
'key': key
}
)
return response.status_code == 200
@@ -0,0 +1,65 @@
from plex.core.idict import idict
from plex.interfaces.core.base import Interface
class LibraryMetadataInterface(Interface):
path = 'library/metadata'
def refresh(self, key):
response = self.http.put(str(key) + "/refresh")
def all_leaves(self, key):
response = self.http.get(key, 'allLeaves')
return self.parse(response, idict({
'MediaContainer': {
'_': 'viewGroup',
'episode': ('ShowLeavesContainer', idict({
'Video': {
'episode': 'Episode'
}
})),
'track': ('ArtistLeavesContainer', idict({
'Track': 'Track'
}))
}
}))
def children(self, key):
response = self.http.get(key, 'children')
return self.parse(response, idict({
'MediaContainer': {
'_': 'viewGroup',
# ---------------------------------------
# Music
# ---------------------------------------
'album': ('ArtistChildrenContainer', idict({
'Directory': {
'album': 'Album'
}
})),
'track': ('AlbumChildrenContainer', idict({
'Track': 'Track'
})),
# ---------------------------------------
# TV
# ---------------------------------------
'season': ('ShowChildrenContainer', idict({
'Directory': {
'season': 'Season'
}
})),
'episode': ('SeasonChildrenContainer', idict({
'Video': {
'episode': 'Episode'
}
}))
}
}))
@@ -0,0 +1,13 @@
from plex.interfaces.core.base import Interface
class PluginInterface(Interface):
path = ':/plugins'
def reload_services(self, plugin_id):
response = self.http.get(plugin_id, 'services/reload')
return response.status_code == 200
def restart(self, plugin_id):
response = self.http.get(plugin_id, 'restart')
return response.status_code == 200
@@ -0,0 +1,40 @@
from plex.core.idict import idict
from plex.interfaces.core.base import Interface
class PluginPreferencesInterface(Interface):
path = ':/plugins/*/prefs'
def get(self, plugin_id, id=None):
response = self.http.get('/:/plugins/%s/prefs' % plugin_id)
container = self.parse(response, idict({
'MediaContainer': ('MediaContainer', idict({
'Setting': 'Setting'
}))
}))
if container is None or id is None:
return container
for setting in container:
if setting.id == id:
return setting
return None
def set(self, plugin_id, id, value):
response = self.http.get('/:/plugins/%s/prefs/set' % plugin_id, query={
id: self.to_setting_value(value, type(value))
})
return response.status_code == 200
def to_setting_value(self, value, value_type=None):
if value is None:
return None
if value_type is bool:
return str(value).lower()
return str(value)
@@ -0,0 +1,40 @@
from plex.core.idict import idict
from plex.interfaces.core.base import Interface
class PreferencesInterface(Interface):
path = ':/prefs'
def get(self, id=None):
response = self.http.get()
container = self.parse(response, idict({
'MediaContainer': ('MediaContainer', idict({
'Setting': 'Setting'
}))
}))
if container is None or id is None:
return container
for setting in container:
if setting.id == id:
return setting
return None
def set(self, id, value):
response = self.http.put(query={
id: self.to_setting_value(value, type(value))
})
return response.status_code == 200
def to_setting_value(self, value, value_type=None):
if value is None:
return None
if value_type is bool:
return str(value).lower()
return str(value)
@@ -0,0 +1,42 @@
from plex.core.idict import idict
from plex.interfaces.core.base import Interface
class RootInterface(Interface):
def detail(self):
response = self.http.get()
return self.parse(response, idict({
'MediaContainer': ('Detail', idict({
'Directory': 'Directory'
}))
}))
def version(self):
detail = self.detail()
if not detail:
return None
return detail.version
def clients(self):
response = self.http.get('clients')
return self.parse(response, idict({
'MediaContainer': ('ClientContainer', idict({
'Server': 'Client'
}))
}))
def players(self):
pass
def servers(self):
response = self.http.get('servers')
return self.parse(response, idict({
'MediaContainer': ('Container', idict({
'Server': 'Server'
}))
}))
@@ -0,0 +1,52 @@
from plex.core.idict import idict
from plex.interfaces.core.base import Interface
class SectionInterface(Interface):
path = 'library/sections'
def all(self, key):
response = self.http.get(key, 'all')
return self.parse(response, idict({
'MediaContainer': ('MediaContainer', idict({
'Directory': {
'artist': 'Artist',
'show': 'Show'
},
'Video': {
'movie': 'Movie'
}
}))
}))
def first_character(self, key, character=None):
if character:
response = self.http.get(key, ['firstCharacter', character])
# somehow plex wrongly returns items of other libraries when character is #
return self.parse(response, idict({
'MediaContainer': ('MediaContainer', idict({
'Directory': {
'album': 'Album',
'artist': 'Artist',
'season': 'Season',
'show': 'Show'
},
'Video': {
'episode': 'Episode',
'clip': 'Clip',
'movie': 'Movie'
},
'Track': 'Track'
}))
}))
response = self.http.get(key, 'firstCharacter')
return self.parse(response, idict({
'MediaContainer': ('MediaContainer', idict({
'Directory': 'Directory'
}))
}))
@@ -0,0 +1,21 @@
from plex.core.idict import idict
from plex.interfaces.core.base import Interface
class StatusInterface(Interface):
path = 'status'
def sessions(self):
response = self.http.get('sessions')
return self.parse(response, idict({
'MediaContainer': ('SessionContainer', idict({
'Track': 'Track',
'Video': {
'episode': 'Episode',
'clip': 'Clip',
'movie': 'Movie'
}
}))
}))
@@ -0,0 +1,36 @@
from plex.interfaces.core.base import Interface
TIMELINE_STATES = [
'buffering',
'paused',
'playing',
'stopped'
]
class TimelineInterface(Interface):
path = ':/timeline'
def update(self, rating_key, state, time, duration, key=None, play_queue_item_id=None):
if not rating_key:
raise ValueError('Invalid "rating_key" parameter')
if time is None or duration is None:
raise ValueError('"time" and "duration" parameters are required')
if state not in TIMELINE_STATES:
raise ValueError('Unknown "state"')
response = self.http.get(query=[
('ratingKey', rating_key),
('state', state),
('time', time),
('duration', duration),
# Optional parameters
('key', key),
('playQueueItemID', play_queue_item_id)
])
return response and response.status_code == 200
+762
View File
@@ -0,0 +1,762 @@
"""Utilities for writing code that runs on Python 2 and 3"""
# Copyright (c) 2010-2014 Benjamin Peterson
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from __future__ import absolute_import
import functools
import operator
import sys
import types
__author__ = "Benjamin Peterson <benjamin@python.org>"
__version__ = "1.8.0"
# Useful for very coarse version differentiation.
PY2 = sys.version_info[0] == 2
PY3 = sys.version_info[0] == 3
if PY3:
string_types = str,
integer_types = int,
class_types = type,
text_type = str
binary_type = bytes
MAXSIZE = sys.maxsize
else:
string_types = basestring,
integer_types = (int, long)
class_types = (type, types.ClassType)
text_type = unicode
binary_type = str
if sys.platform.startswith("java"):
# Jython always uses 32 bits.
MAXSIZE = int((1 << 31) - 1)
else:
# It's possible to have sizeof(long) != sizeof(Py_ssize_t).
class X(object):
def __len__(self):
return 1 << 31
try:
len(X())
except OverflowError:
# 32-bit
MAXSIZE = int((1 << 31) - 1)
else:
# 64-bit
MAXSIZE = int((1 << 63) - 1)
del X
def _add_doc(func, doc):
"""Add documentation to a function."""
func.__doc__ = doc
def _import_module(name):
"""Import module, returning the module after the last dot."""
__import__(name)
return sys.modules[name]
class _LazyDescr(object):
def __init__(self, name):
self.name = name
def __get__(self, obj, tp):
result = self._resolve()
setattr(obj, self.name, result) # Invokes __set__.
# This is a bit ugly, but it avoids running this again.
delattr(obj.__class__, self.name)
return result
class MovedModule(_LazyDescr):
def __init__(self, name, old, new=None):
super(MovedModule, self).__init__(name)
if PY3:
if new is None:
new = name
self.mod = new
else:
self.mod = old
def _resolve(self):
return _import_module(self.mod)
def __getattr__(self, attr):
_module = self._resolve()
value = getattr(_module, attr)
setattr(self, attr, value)
return value
class _LazyModule(types.ModuleType):
def __init__(self, name):
super(_LazyModule, self).__init__(name)
self.__doc__ = self.__class__.__doc__
def __dir__(self):
attrs = ["__doc__", "__name__"]
attrs += [attr.name for attr in self._moved_attributes]
return attrs
# Subclasses should override this
_moved_attributes = []
class MovedAttribute(_LazyDescr):
def __init__(self, name, old_mod, new_mod, old_attr=None, new_attr=None):
super(MovedAttribute, self).__init__(name)
if PY3:
if new_mod is None:
new_mod = name
self.mod = new_mod
if new_attr is None:
if old_attr is None:
new_attr = name
else:
new_attr = old_attr
self.attr = new_attr
else:
self.mod = old_mod
if old_attr is None:
old_attr = name
self.attr = old_attr
def _resolve(self):
module = _import_module(self.mod)
return getattr(module, self.attr)
class _SixMetaPathImporter(object):
"""
A meta path importer to import six.moves and its submodules.
This class implements a PEP302 finder and loader. It should be compatible
with Python 2.5 and all existing versions of Python3
"""
def __init__(self, six_module_name):
self.name = six_module_name
self.known_modules = {}
def _add_module(self, mod, *fullnames):
for fullname in fullnames:
self.known_modules[self.name + "." + fullname] = mod
def _get_module(self, fullname):
return self.known_modules[self.name + "." + fullname]
def find_module(self, fullname, path=None):
if fullname in self.known_modules:
return self
return None
def __get_module(self, fullname):
try:
return self.known_modules[fullname]
except KeyError:
raise ImportError("This loader does not know module " + fullname)
def load_module(self, fullname):
try:
# in case of a reload
return sys.modules[fullname]
except KeyError:
pass
mod = self.__get_module(fullname)
if isinstance(mod, MovedModule):
mod = mod._resolve()
else:
mod.__loader__ = self
sys.modules[fullname] = mod
return mod
def is_package(self, fullname):
"""
Return true, if the named module is a package.
We need this method to get correct spec objects with
Python 3.4 (see PEP451)
"""
return hasattr(self.__get_module(fullname), "__path__")
def get_code(self, fullname):
"""Return None
Required, if is_package is implemented"""
self.__get_module(fullname) # eventually raises ImportError
return None
get_source = get_code # same as get_code
_importer = _SixMetaPathImporter(__name__)
class _MovedItems(_LazyModule):
"""Lazy loading of moved objects"""
__path__ = [] # mark as package
_moved_attributes = [
MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"),
MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"),
MovedAttribute("filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse"),
MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"),
MovedAttribute("intern", "__builtin__", "sys"),
MovedAttribute("map", "itertools", "builtins", "imap", "map"),
MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"),
MovedAttribute("reload_module", "__builtin__", "imp", "reload"),
MovedAttribute("reduce", "__builtin__", "functools"),
MovedAttribute("shlex_quote", "pipes", "shlex", "quote"),
MovedAttribute("StringIO", "StringIO", "io"),
MovedAttribute("UserDict", "UserDict", "collections"),
MovedAttribute("UserList", "UserList", "collections"),
MovedAttribute("UserString", "UserString", "collections"),
MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"),
MovedAttribute("zip", "itertools", "builtins", "izip", "zip"),
MovedAttribute("zip_longest", "itertools", "itertools", "izip_longest", "zip_longest"),
MovedModule("builtins", "__builtin__"),
MovedModule("configparser", "ConfigParser"),
MovedModule("copyreg", "copy_reg"),
MovedModule("dbm_gnu", "gdbm", "dbm.gnu"),
MovedModule("_dummy_thread", "dummy_thread", "_dummy_thread"),
MovedModule("http_cookiejar", "cookielib", "http.cookiejar"),
MovedModule("http_cookies", "Cookie", "http.cookies"),
MovedModule("html_entities", "htmlentitydefs", "html.entities"),
MovedModule("html_parser", "HTMLParser", "html.parser"),
MovedModule("http_client", "httplib", "http.client"),
MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"),
MovedModule("email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart"),
MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"),
MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"),
MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"),
MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"),
MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"),
MovedModule("cPickle", "cPickle", "pickle"),
MovedModule("queue", "Queue"),
MovedModule("reprlib", "repr"),
MovedModule("socketserver", "SocketServer"),
MovedModule("_thread", "thread", "_thread"),
MovedModule("tkinter", "Tkinter"),
MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"),
MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"),
MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"),
MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"),
MovedModule("tkinter_tix", "Tix", "tkinter.tix"),
MovedModule("tkinter_ttk", "ttk", "tkinter.ttk"),
MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"),
MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"),
MovedModule("tkinter_colorchooser", "tkColorChooser",
"tkinter.colorchooser"),
MovedModule("tkinter_commondialog", "tkCommonDialog",
"tkinter.commondialog"),
MovedModule("tkinter_tkfiledialog", "tkFileDialog", "tkinter.filedialog"),
MovedModule("tkinter_font", "tkFont", "tkinter.font"),
MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"),
MovedModule("tkinter_tksimpledialog", "tkSimpleDialog",
"tkinter.simpledialog"),
MovedModule("urllib_parse", __name__ + ".moves.urllib_parse", "urllib.parse"),
MovedModule("urllib_error", __name__ + ".moves.urllib_error", "urllib.error"),
MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"),
MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"),
MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"),
MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"),
MovedModule("winreg", "_winreg"),
]
for attr in _moved_attributes:
setattr(_MovedItems, attr.name, attr)
if isinstance(attr, MovedModule):
_importer._add_module(attr, "moves." + attr.name)
del attr
_MovedItems._moved_attributes = _moved_attributes
moves = _MovedItems(__name__ + ".moves")
_importer._add_module(moves, "moves")
class Module_six_moves_urllib_parse(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_parse"""
_urllib_parse_moved_attributes = [
MovedAttribute("ParseResult", "urlparse", "urllib.parse"),
MovedAttribute("SplitResult", "urlparse", "urllib.parse"),
MovedAttribute("parse_qs", "urlparse", "urllib.parse"),
MovedAttribute("parse_qsl", "urlparse", "urllib.parse"),
MovedAttribute("urldefrag", "urlparse", "urllib.parse"),
MovedAttribute("urljoin", "urlparse", "urllib.parse"),
MovedAttribute("urlparse", "urlparse", "urllib.parse"),
MovedAttribute("urlsplit", "urlparse", "urllib.parse"),
MovedAttribute("urlunparse", "urlparse", "urllib.parse"),
MovedAttribute("urlunsplit", "urlparse", "urllib.parse"),
MovedAttribute("quote", "urllib", "urllib.parse"),
MovedAttribute("quote_plus", "urllib", "urllib.parse"),
MovedAttribute("unquote", "urllib", "urllib.parse"),
MovedAttribute("unquote_plus", "urllib", "urllib.parse"),
MovedAttribute("urlencode", "urllib", "urllib.parse"),
MovedAttribute("splitquery", "urllib", "urllib.parse"),
MovedAttribute("splittag", "urllib", "urllib.parse"),
MovedAttribute("splituser", "urllib", "urllib.parse"),
MovedAttribute("uses_fragment", "urlparse", "urllib.parse"),
MovedAttribute("uses_netloc", "urlparse", "urllib.parse"),
MovedAttribute("uses_params", "urlparse", "urllib.parse"),
MovedAttribute("uses_query", "urlparse", "urllib.parse"),
MovedAttribute("uses_relative", "urlparse", "urllib.parse"),
]
for attr in _urllib_parse_moved_attributes:
setattr(Module_six_moves_urllib_parse, attr.name, attr)
del attr
Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes
_importer._add_module(Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"),
"moves.urllib_parse", "moves.urllib.parse")
class Module_six_moves_urllib_error(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_error"""
_urllib_error_moved_attributes = [
MovedAttribute("URLError", "urllib2", "urllib.error"),
MovedAttribute("HTTPError", "urllib2", "urllib.error"),
MovedAttribute("ContentTooShortError", "urllib", "urllib.error"),
]
for attr in _urllib_error_moved_attributes:
setattr(Module_six_moves_urllib_error, attr.name, attr)
del attr
Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes
_importer._add_module(Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"),
"moves.urllib_error", "moves.urllib.error")
class Module_six_moves_urllib_request(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_request"""
_urllib_request_moved_attributes = [
MovedAttribute("urlopen", "urllib2", "urllib.request"),
MovedAttribute("install_opener", "urllib2", "urllib.request"),
MovedAttribute("build_opener", "urllib2", "urllib.request"),
MovedAttribute("pathname2url", "urllib", "urllib.request"),
MovedAttribute("url2pathname", "urllib", "urllib.request"),
MovedAttribute("getproxies", "urllib", "urllib.request"),
MovedAttribute("Request", "urllib2", "urllib.request"),
MovedAttribute("OpenerDirector", "urllib2", "urllib.request"),
MovedAttribute("HTTPDefaultErrorHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPRedirectHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPCookieProcessor", "urllib2", "urllib.request"),
MovedAttribute("ProxyHandler", "urllib2", "urllib.request"),
MovedAttribute("BaseHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPPasswordMgr", "urllib2", "urllib.request"),
MovedAttribute("HTTPPasswordMgrWithDefaultRealm", "urllib2", "urllib.request"),
MovedAttribute("AbstractBasicAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPBasicAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("ProxyBasicAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("AbstractDigestAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPDigestAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("ProxyDigestAuthHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPSHandler", "urllib2", "urllib.request"),
MovedAttribute("FileHandler", "urllib2", "urllib.request"),
MovedAttribute("FTPHandler", "urllib2", "urllib.request"),
MovedAttribute("CacheFTPHandler", "urllib2", "urllib.request"),
MovedAttribute("UnknownHandler", "urllib2", "urllib.request"),
MovedAttribute("HTTPErrorProcessor", "urllib2", "urllib.request"),
MovedAttribute("urlretrieve", "urllib", "urllib.request"),
MovedAttribute("urlcleanup", "urllib", "urllib.request"),
MovedAttribute("URLopener", "urllib", "urllib.request"),
MovedAttribute("FancyURLopener", "urllib", "urllib.request"),
MovedAttribute("proxy_bypass", "urllib", "urllib.request"),
]
for attr in _urllib_request_moved_attributes:
setattr(Module_six_moves_urllib_request, attr.name, attr)
del attr
Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes
_importer._add_module(Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"),
"moves.urllib_request", "moves.urllib.request")
class Module_six_moves_urllib_response(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_response"""
_urllib_response_moved_attributes = [
MovedAttribute("addbase", "urllib", "urllib.response"),
MovedAttribute("addclosehook", "urllib", "urllib.response"),
MovedAttribute("addinfo", "urllib", "urllib.response"),
MovedAttribute("addinfourl", "urllib", "urllib.response"),
]
for attr in _urllib_response_moved_attributes:
setattr(Module_six_moves_urllib_response, attr.name, attr)
del attr
Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes
_importer._add_module(Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"),
"moves.urllib_response", "moves.urllib.response")
class Module_six_moves_urllib_robotparser(_LazyModule):
"""Lazy loading of moved objects in six.moves.urllib_robotparser"""
_urllib_robotparser_moved_attributes = [
MovedAttribute("RobotFileParser", "robotparser", "urllib.robotparser"),
]
for attr in _urllib_robotparser_moved_attributes:
setattr(Module_six_moves_urllib_robotparser, attr.name, attr)
del attr
Module_six_moves_urllib_robotparser._moved_attributes = _urllib_robotparser_moved_attributes
_importer._add_module(Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"),
"moves.urllib_robotparser", "moves.urllib.robotparser")
class Module_six_moves_urllib(types.ModuleType):
"""Create a six.moves.urllib namespace that resembles the Python 3 namespace"""
__path__ = [] # mark as package
parse = _importer._get_module("moves.urllib_parse")
error = _importer._get_module("moves.urllib_error")
request = _importer._get_module("moves.urllib_request")
response = _importer._get_module("moves.urllib_response")
robotparser = _importer._get_module("moves.urllib_robotparser")
def __dir__(self):
return ['parse', 'error', 'request', 'response', 'robotparser']
_importer._add_module(Module_six_moves_urllib(__name__ + ".moves.urllib"),
"moves.urllib")
def add_move(move):
"""Add an item to six.moves."""
setattr(_MovedItems, move.name, move)
def remove_move(name):
"""Remove item from six.moves."""
try:
delattr(_MovedItems, name)
except AttributeError:
try:
del moves.__dict__[name]
except KeyError:
raise AttributeError("no such move, %r" % (name,))
if PY3:
_meth_func = "__func__"
_meth_self = "__self__"
_func_closure = "__closure__"
_func_code = "__code__"
_func_defaults = "__defaults__"
_func_globals = "__globals__"
else:
_meth_func = "im_func"
_meth_self = "im_self"
_func_closure = "func_closure"
_func_code = "func_code"
_func_defaults = "func_defaults"
_func_globals = "func_globals"
try:
advance_iterator = next
except NameError:
def advance_iterator(it):
return it.next()
next = advance_iterator
try:
callable = callable
except NameError:
def callable(obj):
return any("__call__" in klass.__dict__ for klass in type(obj).__mro__)
if PY3:
def get_unbound_function(unbound):
return unbound
create_bound_method = types.MethodType
Iterator = object
else:
def get_unbound_function(unbound):
return unbound.im_func
def create_bound_method(func, obj):
return types.MethodType(func, obj, obj.__class__)
class Iterator(object):
def next(self):
return type(self).__next__(self)
callable = callable
_add_doc(get_unbound_function,
"""Get the function out of a possibly unbound function""")
get_method_function = operator.attrgetter(_meth_func)
get_method_self = operator.attrgetter(_meth_self)
get_function_closure = operator.attrgetter(_func_closure)
get_function_code = operator.attrgetter(_func_code)
get_function_defaults = operator.attrgetter(_func_defaults)
get_function_globals = operator.attrgetter(_func_globals)
if PY3:
def iterkeys(d, **kw):
return iter(d.keys(**kw))
def itervalues(d, **kw):
return iter(d.values(**kw))
def iteritems(d, **kw):
return iter(d.items(**kw))
def iterlists(d, **kw):
return iter(d.lists(**kw))
else:
def iterkeys(d, **kw):
return iter(d.iterkeys(**kw))
def itervalues(d, **kw):
return iter(d.itervalues(**kw))
def iteritems(d, **kw):
return iter(d.iteritems(**kw))
def iterlists(d, **kw):
return iter(d.iterlists(**kw))
_add_doc(iterkeys, "Return an iterator over the keys of a dictionary.")
_add_doc(itervalues, "Return an iterator over the values of a dictionary.")
_add_doc(iteritems,
"Return an iterator over the (key, value) pairs of a dictionary.")
_add_doc(iterlists,
"Return an iterator over the (key, [values]) pairs of a dictionary.")
if PY3:
def b(s):
return s.encode("latin-1")
def u(s):
return s
unichr = chr
if sys.version_info[1] <= 1:
def int2byte(i):
return bytes((i,))
else:
# This is about 2x faster than the implementation above on 3.2+
int2byte = operator.methodcaller("to_bytes", 1, "big")
byte2int = operator.itemgetter(0)
indexbytes = operator.getitem
iterbytes = iter
import io
StringIO = io.StringIO
BytesIO = io.BytesIO
else:
def b(s):
return s
# Workaround for standalone backslash
def u(s):
return unicode(s.replace(r'\\', r'\\\\'), "unicode_escape")
unichr = unichr
int2byte = chr
def byte2int(bs):
return ord(bs[0])
def indexbytes(buf, i):
return ord(buf[i])
def iterbytes(buf):
return (ord(byte) for byte in buf)
import StringIO
StringIO = BytesIO = StringIO.StringIO
_add_doc(b, """Byte literal""")
_add_doc(u, """Text literal""")
if PY3:
exec_ = getattr(moves.builtins, "exec")
def reraise(tp, value, tb=None):
if value is None:
value = tp()
if value.__traceback__ is not tb:
raise value.with_traceback(tb)
raise value
else:
def exec_(_code_, _globs_=None, _locs_=None):
"""Execute code in a namespace."""
if _globs_ is None:
frame = sys._getframe(1)
_globs_ = frame.f_globals
if _locs_ is None:
_locs_ = frame.f_locals
del frame
elif _locs_ is None:
_locs_ = _globs_
exec("""exec _code_ in _globs_, _locs_""")
exec_("""def reraise(tp, value, tb=None):
raise tp, value, tb
""")
print_ = getattr(moves.builtins, "print", None)
if print_ is None:
def print_(*args, **kwargs):
"""The new-style print function for Python 2.4 and 2.5."""
fp = kwargs.pop("file", sys.stdout)
if fp is None:
return
def write(data):
if not isinstance(data, basestring):
data = str(data)
# If the file has an encoding, encode unicode with it.
if (isinstance(fp, file) and
isinstance(data, unicode) and
fp.encoding is not None):
errors = getattr(fp, "errors", None)
if errors is None:
errors = "strict"
data = data.encode(fp.encoding, errors)
fp.write(data)
want_unicode = False
sep = kwargs.pop("sep", None)
if sep is not None:
if isinstance(sep, unicode):
want_unicode = True
elif not isinstance(sep, str):
raise TypeError("sep must be None or a string")
end = kwargs.pop("end", None)
if end is not None:
if isinstance(end, unicode):
want_unicode = True
elif not isinstance(end, str):
raise TypeError("end must be None or a string")
if kwargs:
raise TypeError("invalid keyword arguments to print()")
if not want_unicode:
for arg in args:
if isinstance(arg, unicode):
want_unicode = True
break
if want_unicode:
newline = unicode("\n")
space = unicode(" ")
else:
newline = "\n"
space = " "
if sep is None:
sep = space
if end is None:
end = newline
for i, arg in enumerate(args):
if i:
write(sep)
write(arg)
write(end)
_add_doc(reraise, """Reraise an exception.""")
if sys.version_info[0:2] < (3, 4):
def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS,
updated=functools.WRAPPER_UPDATES):
def wrapper(f):
f = functools.wraps(wrapped)(f)
f.__wrapped__ = wrapped
return f
return wrapper
else:
wraps = functools.wraps
def with_metaclass(meta, *bases):
"""Create a base class with a metaclass."""
# This requires a bit of explanation: the basic idea is to make a dummy
# metaclass for one level of class instantiation that replaces itself with
# the actual metaclass.
class metaclass(meta):
def __new__(cls, name, this_bases, d):
return meta(name, bases, d)
return type.__new__(metaclass, 'temporary_class', (), {})
def add_metaclass(metaclass):
"""Class decorator for creating a class with a metaclass."""
def wrapper(cls):
orig_vars = cls.__dict__.copy()
slots = orig_vars.get('__slots__')
if slots is not None:
if isinstance(slots, str):
slots = [slots]
for slots_var in slots:
orig_vars.pop(slots_var)
orig_vars.pop('__dict__', None)
orig_vars.pop('__weakref__', None)
return metaclass(cls.__name__, cls.__bases__, orig_vars)
return wrapper
# Complete the moves implementation.
# This code is at the end of this module to speed up module loading.
# Turn this module into a package.
__path__ = [] # required for PEP 302 and PEP 451
__package__ = __name__ # see PEP 366 @ReservedAssignment
if globals().get("__spec__") is not None:
__spec__.submodule_search_locations = [] # PEP 451 @UndefinedVariable
# Remove other six meta path importers, since they cause problems. This can
# happen if six is removed from sys.modules and then reloaded. (Setuptools does
# this for some reason.)
if sys.meta_path:
for i, importer in enumerate(sys.meta_path):
# Here's some real nastiness: Another "instance" of the six module might
# be floating around. Therefore, we can't use isinstance() to check for
# the six meta path importer, since the other six instance will have
# inserted an importer with different class.
if (type(importer).__name__ == "_SixMetaPathImporter" and
importer.name == __name__):
del sys.meta_path[i]
break
del i, importer
# Finally, add the importer to the meta path import hook.
sys.meta_path.append(_importer)
@@ -0,0 +1,32 @@
from plex.core.helpers import to_iterable
from plex.objects.container import Container
from plex.objects.core.base import Property
from plex.objects.server import Server
class Client(Server):
product = Property
device_class = Property('deviceClass')
protocol = Property
protocol_version = Property('protocolVersion', type=int)
protocol_capabilities = Property('protocolCapabilities')
class ClientContainer(Container):
filter_passes = lambda _, allowed, value: allowed is None or value in allowed
def filter(self, identifiers=None):
identifiers = to_iterable(identifiers)
for client in self:
if not self.filter_passes(identifiers, client.machine_identifier):
continue
yield client
def get(self, identifier):
for item in self.filter(identifier):
return item
return None
@@ -0,0 +1,7 @@
from plex.objects.core.base import Descriptor, Property
class Container(Descriptor):
size = Property(type=int)
updated_at = Property('updatedAt', int)
@@ -0,0 +1,168 @@
from plex.lib.six import add_metaclass
from plex.interfaces.core.base import Interface
import logging
import traceback
import types
log = logging.getLogger(__name__)
class Property(object):
helpers = Interface.helpers
def __init__(self, name=None, type=None, resolver=None):
self.name = name
self.type = type
self.resolver = resolver
def value(self, client, key, node, keys_used):
if self.resolver is not None:
return self.value_func(client, node, keys_used)
return self.value_node(key, node, keys_used)
def value_node(self, key, node, keys_used):
value = self.helpers.get(node, key)
keys_used.append(key.lower())
if value is None:
return None
return self.value_convert(value)
def value_convert(self, value):
if not self.type:
return value
types = self.type if type(self.type) is list else [self.type]
result = value
for target_type in types:
try:
result = target_type(result)
except:
return None
return result
def value_func(self, client, node, keys_used):
func = self.resolver()
try:
keys, value = func(client, node)
keys_used.extend([k.lower() for k in keys])
return value
except Exception as ex:
log.warn('Exception in value function (%s): %s - %s', func, ex, traceback.format_exc())
return None
class DescriptorMeta(type):
def __init__(self, name, bases, attrs):
super(DescriptorMeta, self).__init__(name, bases, attrs)
Interface.object_map[self.__name__] = self
@add_metaclass(DescriptorMeta)
class Descriptor(Interface):
attribute_map = None
def __init__(self, client, path):
super(Descriptor, self).__init__(client)
self.path = path
self._children = None
@classmethod
def properties(cls):
keys = [k for k in dir(cls) if not k.startswith('_')]
#log.debug('%s - keys: %s', self, keys)
for key in keys:
if key.startswith('_'):
continue
value = getattr(cls, key)
if value is Property:
yield key, Property(key)
elif isinstance(value, Property):
yield key, value
@classmethod
def construct(cls, client, node, attribute_map=None, path=None, child=False):
if node is None:
return [], None
keys_available = [k.lower() for k in node.keys()]
keys_used = []
if attribute_map is None:
attribute_map = cls.attribute_map or {}
require_map = attribute_map.get('*') != '*'
# Determine path from object "key"
key = cls.helpers.get(node, 'key')
if key is not None:
path = key[:key.rfind('/')]
# Construct object
obj = cls(client, path)
#log.debug('%s - Properties: %s', cls.__name__, list(obj.properties()))
for key, prop in cls.properties():
node_key = prop.name or key
if attribute_map:
if node_key in attribute_map:
node_key = attribute_map.get(node_key)
elif require_map:
setattr(obj, key, None)
continue
#log.debug('%s - Found property "%s"', cls.__name__, key)
setattr(obj, key, prop.value(client, node_key, node, keys_used))
# Post-fill transformation
obj.__transform__()
# Look for omitted keys
omitted = list(set(keys_available) - set(keys_used))
omitted.sort()
if omitted and not child:
log.warn('%s - Omitted attributes: %s', cls.__name__, ', '.join(omitted))
return keys_used, obj
def __transform__(self):
pass
def __iter__(self):
return self._children or []
def __getstate__(self):
data = self.__dict__
def build():
for key, value in data.items():
if isinstance(value, types.GeneratorType):
value = list(value)
if key in ['client']:
continue
yield key, value
return dict(build())
class DescriptorMixin(Descriptor):
pass
@@ -0,0 +1,89 @@
import inspect
import logging
import os
log = logging.getLogger(__name__)
UNC_PREFIX = '\\\\?\\'
class ObjectManager(object):
base_dir = None
objects_dir = None
objects_map = {}
ignore_files = [
'__init__.py'
]
ignore_paths = [
'plex\\objects\\core\\base.py',
'plex\\objects\\core\\manager.py'
]
@classmethod
def discover(cls):
cls.objects_dir = os.path.join(cls.base_dir, 'plex', 'objects')
# Walk plex/objects directory
for current, directories, files in os.walk(cls.objects_dir):
# Iterate files, yield valid paths
for filename in files:
if not filename.endswith('.py'):
continue
# Ensure filename is not in ignore list
if filename in cls.ignore_files:
continue
path = os.path.join(current, filename)
# Ensure path is not in ignore list
if not all([not path.endswith(p) for p in cls.ignore_paths]):
continue
# Remove UNC prefix (if it exists)
if path.startswith(UNC_PREFIX):
path = path[len(UNC_PREFIX):]
path = os.path.relpath(path, cls.base_dir)
name = os.path.splitext(path)[0].replace(os.path.sep, '.')
yield path, name
@classmethod
def load(cls):
for path, name in cls.discover():
try:
mod = __import__(name, fromlist=['*'])
except Exception as ex:
log.warn('Unable to import "%s" - %s', name, ex)
continue
# Get classes in module
classes = [
(key, getattr(mod, key)) for key in dir(mod)
if not key.startswith('_')
]
# Filter to module-specific classes
classes = [
(key, value) for (key, value) in classes
if inspect.isclass(value) and value.__module__ == name
]
yield classes
@classmethod
def construct(cls):
log.debug('Loading descriptors...')
cls.base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../', '..'))
# Load modules, find descriptor classes
for classes in cls.load():
# Update object map
for key, value in classes:
cls.objects_map[key] = value
log.debug('Loaded %s descriptors (%s)', len(cls.objects_map), ', '.join(sorted(cls.objects_map.keys())))
@@ -0,0 +1,62 @@
from plex.objects.core.base import Descriptor, Property
from plex.objects.container import Container
class Detail(Container):
myplex = Property(resolver=lambda: Detail.construct_myplex)
transcoder = Property(resolver=lambda: Detail.construct_transcoder)
friendly_name = Property('friendlyName')
machine_identifier = Property('machineIdentifier')
version = Property
platform = Property
platform_version = Property('platformVersion')
allow_camera_upload = Property('allowCameraUpload', [int, bool])
allow_channel_access = Property('allowChannelAccess', [int, bool])
allow_sync = Property('allowSync', [int, bool])
certificate = Property(type=[int, bool])
multiuser = Property(type=[int, bool])
sync = Property(type=[int, bool])
start_state = Property('startState')
silverlight = Property('silverlightInstalled', [int, bool])
soundflower = Property('soundflowerInstalled', [int, bool])
flash = Property('flashInstalled', [int, bool])
webkit = Property(type=[int, bool])
cookie_parameters = Property('requestParametersInCookie', [int, bool])
@staticmethod
def construct_myplex(client, node):
return MyPlexDetail.construct(client, node, child=True)
@staticmethod
def construct_transcoder(client, node):
return TranscoderDetail.construct(client, node, child=True)
class MyPlexDetail(Descriptor):
enabled = Property('myPlex', type=bool)
username = Property('myPlexUsername')
mapping_state = Property('myPlexMappingState')
signin_state = Property('myPlexSigninState')
subscription = Property('myPlexSubscription', [int, bool])
class TranscoderDetail(Descriptor):
audio = Property('transcoderAudio', [int, bool])
video = Property('transcoderVideo', [int, bool])
video_bitrates = Property('transcoderVideoBitrates')
video_qualities = Property('transcoderVideoQualities')
video_resolutions = Property('transcoderVideoResolutions')
active_video_sessions = Property('transcoderActiveVideoSessions', int)
@@ -0,0 +1,16 @@
from plex.objects.core.base import Descriptor, Property
class Directory(Descriptor):
key = Property
type = Property
title = Property
size = Property
art = Property
thumb = Property
allow_sync = Property('allowSync', bool)
updated_at = Property('updatedAt', int)
@@ -0,0 +1,80 @@
from plex.core.helpers import flatten, to_iterable
from plex.objects.core.base import Property
from plex.objects.container import Container
from plex.objects.library.section import Section
class MediaContainer(Container):
section = Property(resolver=lambda: MediaContainer.construct_section)
title1 = Property
title2 = Property
identifier = Property
art = Property
thumb = Property
view_group = Property('viewGroup')
view_mode = Property('viewMode', int)
media_tag_prefix = Property('mediaTagPrefix')
media_tag_version = Property('mediaTagVersion')
size = Property('size', int)
total_size = Property('totalSize', int)
allow_sync = Property('allowSync', bool)
mixed_parents = Property('mixedParents', bool)
no_cache = Property('nocache', bool)
sort_asc = Property('sortAsc', bool)
@staticmethod
def construct_section(client, node):
attribute_map = {
'key': 'librarySectionID',
'uuid': 'librarySectionUUID',
'title': 'librarySectionTitle'
}
return Section.construct(client, node, attribute_map, child=True)
def __iter__(self):
for item in super(MediaContainer, self).__iter__():
item.section = self.section
yield item
class ChildrenContainer(MediaContainer):
pass
class LeavesContainer(MediaContainer):
pass
class SectionContainer(MediaContainer):
filter_passes = lambda _, allowed, value: allowed is None or value in allowed
def filter(self, types=None, keys=None, titles=None):
types = to_iterable(types)
keys = to_iterable(keys)
titles = to_iterable(titles)
if titles:
# Flatten titles
titles = [flatten(x) for x in titles]
for section in self:
if not self.filter_passes(types, section.type):
continue
if not self.filter_passes(keys, section.key):
continue
if not self.filter_passes(titles, flatten(section.title)):
continue
yield section
@@ -0,0 +1,10 @@
from plex.objects.core.base import Descriptor, Property
class Country(Descriptor):
id = Property(type=int)
tag = Property
@classmethod
def from_node(cls, client, node):
return cls.construct(client, cls.helpers.find(node, 'Country'), child=True)
@@ -0,0 +1,10 @@
from plex.objects.core.base import Descriptor, Property
class Director(Descriptor):
id = Property(type=int)
tag = Property
@classmethod
def from_node(cls, client, node):
return cls.construct(client, cls.helpers.find(node, 'Director'), child=True)
@@ -0,0 +1,17 @@
from plex.objects.core.base import Descriptor, Property
class Genre(Descriptor):
id = Property(type=int)
tag = Property
@classmethod
def from_node(cls, client, node):
items = []
for genre in cls.helpers.findall(node, 'Genre'):
_, obj = Genre.construct(client, genre, child=True)
items.append(obj)
return [], items
@@ -0,0 +1,20 @@
from plex.objects.core.base import Descriptor, Property
class Role(Descriptor):
id = Property(type=int)
tag = Property
role = Property
thumb = Property
@classmethod
def from_node(cls, client, node):
items = []
for genre in cls.helpers.findall(node, 'Role'):
_, obj = Role.construct(client, genre, child=True)
items.append(obj)
return [], items
@@ -0,0 +1,17 @@
from plex.objects.core.base import Descriptor, Property
class Writer(Descriptor):
id = Property(type=int)
tag = Property
@classmethod
def from_node(cls, client, node):
items = []
for genre in cls.helpers.findall(node, 'Writer'):
_, obj = Writer.construct(client, genre, child=True)
items.append(obj)
return [], items
@@ -0,0 +1,6 @@
from plex.objects.core.base import Descriptor, Property
class Location(Descriptor):
id = Property
path = Property
@@ -0,0 +1,28 @@
from plex.objects.core.base import Descriptor, Property
from plex.objects.library.part import Part
class Media(Descriptor):
parts = Property(resolver=lambda: Part.from_node)
id = Property(type=int)
video_codec = Property('videoCodec')
video_frame_rate = Property('videoFrameRate')
video_resolution = Property('videoResolution')
audio_channels = Property('audioChannels', type=int)
audio_codec = Property('audioCodec')
container = Property
width = Property(type=int)
height = Property(type=int)
aspect_ratio = Property('aspectRatio', type=float)
bitrate = Property(type=int)
duration = Property(type=int)
@classmethod
def from_node(cls, client, node):
return cls.construct(client, cls.helpers.find(node, 'Media'), child=True)
@@ -0,0 +1,68 @@
from plex.objects.core.base import Property
from plex.objects.directory import Directory
from plex.objects.library.container import ChildrenContainer
from plex.objects.library.extra.genre import Genre
from plex.objects.library.metadata.base import Metadata
from plex.objects.library.metadata.artist import Artist
from plex.objects.mixins.rate import RateMixin
class Album(Directory, Metadata, RateMixin):
artist = Property(resolver=lambda: Album.construct_artist)
genres = Property(resolver=lambda: Genre.from_node)
index = Property(type=int)
year = Property(type=int)
originally_available_at = Property('originallyAvailableAt')
track_count = Property('leafCount', int)
viewed_track_count = Property('viewedLeafCount', int)
def children(self):
return self.client['library/metadata'].children(self.rating_key)
@staticmethod
def construct_artist(client, node):
attribute_map = {
'key': 'parentKey',
'ratingKey': 'parentRatingKey',
'title': 'parentTitle',
'thumb': 'parentThumb'
}
return Artist.construct(client, node, attribute_map, child=True)
class AlbumChildrenContainer(ChildrenContainer):
artist = Property(resolver=lambda: AlbumChildrenContainer.construct_artist)
album = Property(resolver=lambda: AlbumChildrenContainer.construct_album)
key = Property
@staticmethod
def construct_artist(client, node):
attribute_map = {
'title': 'grandparentTitle'
}
return Artist.construct(client, node, attribute_map, child=True)
@staticmethod
def construct_album(client, node):
attribute_map = {
'index': 'parentIndex',
'title': 'parentTitle',
'year' : 'parentYear'
}
return Album.construct(client, node, attribute_map, child=True)
def __iter__(self):
for item in super(ChildrenContainer, self).__iter__():
item.artist = self.artist
item.album = self.album
yield item
@@ -0,0 +1,58 @@
from plex.objects.core.base import Property
from plex.objects.directory import Directory
from plex.objects.library.container import LeavesContainer, ChildrenContainer
from plex.objects.library.metadata.base import Metadata
from plex.objects.mixins.rate import RateMixin
class Artist(Directory, Metadata, RateMixin):
index = Property(type=int)
def all_leaves(self):
return self.client['library/metadata'].all_leaves(self.rating_key)
def children(self):
return self.client['library/metadata'].children(self.rating_key)
class ArtistChildrenContainer(ChildrenContainer):
artist = Property(resolver=lambda: ArtistChildrenContainer.construct_artist)
key = Property
summary = Property
@staticmethod
def construct_artist(client, node):
attribute_map = {
'index': 'parentIndex',
'title': 'parentTitle'
}
return Artist.construct(client, node, attribute_map, child=True)
def __iter__(self):
for item in super(ChildrenContainer, self).__iter__():
item.artist = self.artist
yield item
class ArtistLeavesContainer(LeavesContainer):
artist = Property(resolver=lambda: ArtistLeavesContainer.construct_artist)
key = Property
@staticmethod
def construct_artist(client, node):
attribute_map = {
'index': 'parentIndex',
'title': 'parentTitle'
}
return Artist.construct(client, node, attribute_map, child=True)
def __iter__(self):
for item in super(LeavesContainer, self).__iter__():
item.artist = self.artist
yield item
@@ -0,0 +1,34 @@
from plex.objects.core.base import Descriptor, Property
from plex.objects.library.section import Section
class Metadata(Descriptor):
section = Property(resolver=lambda: Metadata.construct_section)
key = Property
guid = Property
rating_key = Property('ratingKey')
extra_key = Property('primaryExtraKey')
title = Property
title_sort = Property('titleSort')
title_original = Property('originalTitle')
summary = Property
thumb = Property
source_title = Property('sourceTitle')
added_at = Property('addedAt', int)
last_viewed_at = Property('lastViewedAt', int)
@staticmethod
def construct_section(client, node):
attribute_map = {
'key': 'librarySectionID',
'uuid': 'librarySectionUUID',
'title': 'librarySectionTitle'
}
return Section.construct(client, node, attribute_map, child=True)
@@ -0,0 +1,9 @@
from plex.objects.core.base import Property
from plex.objects.library.metadata.base import Metadata
from plex.objects.library.video import Video
class Clip(Video, Metadata):
extra_type = Property('extraType', type=int)
index = Property(type=int)
@@ -0,0 +1,48 @@
from plex.objects.core.base import Property
from plex.objects.library.metadata.season import Season
from plex.objects.library.metadata.show import Show
from plex.objects.library.metadata.base import Metadata
from plex.objects.library.video import Video
from plex.objects.mixins.rate import RateMixin
from plex.objects.mixins.scrobble import ScrobbleMixin
class Episode(Video, Metadata, RateMixin, ScrobbleMixin):
show = Property(resolver=lambda: Episode.construct_show)
season = Property(resolver=lambda: Episode.construct_season)
index = Property(type=int)
studio = Property
audience_rating = Property('audienceRating', float)
content_rating = Property('contentRating')
year = Property(type=int)
originally_available_at = Property('originallyAvailableAt')
@staticmethod
def construct_show(client, node):
attribute_map = {
'key': 'grandparentKey',
'ratingKey': 'grandparentRatingKey',
'title': 'grandparentTitle',
'art': 'grandparentArt',
'theme': 'grandparentTheme',
'thumb': 'grandparentThumb'
}
return Show.construct(client, node, attribute_map, child=True)
@staticmethod
def construct_season(client, node):
attribute_map = {
'index': 'parentIndex',
'key': 'parentKey',
'ratingKey': 'parentRatingKey',
'thumb': 'parentThumb'
}
return Season.construct(client, node, attribute_map, child=True)
@@ -0,0 +1,22 @@
from plex.objects.core.base import Property
from plex.objects.library.extra.country import Country
from plex.objects.library.extra.genre import Genre
from plex.objects.library.extra.role import Role
from plex.objects.library.metadata.base import Metadata
from plex.objects.library.video import Video
from plex.objects.mixins.rate import RateMixin
from plex.objects.mixins.scrobble import ScrobbleMixin
class Movie(Video, Metadata, RateMixin, ScrobbleMixin):
country = Property(resolver=lambda: Country.from_node)
genres = Property(resolver=lambda: Genre.from_node)
roles = Property(resolver=lambda: Role.from_node)
studio = Property
content_rating = Property('contentRating')
year = Property(type=int)
originally_available_at = Property('originallyAvailableAt')
tagline = Property
@@ -0,0 +1,78 @@
from plex.objects.core.base import Property
from plex.objects.library.container import ChildrenContainer
from plex.objects.library.metadata.show import Show
from plex.objects.library.metadata.base import Metadata
from plex.objects.library.video import Directory
class Season(Directory, Metadata):
show = Property(resolver=lambda: Season.construct_show)
index = Property(type=int)
banner = Property
theme = Property
year = Property(type=int)
episode_count = Property('leafCount', int)
viewed_episode_count = Property('viewedLeafCount', int)
view_count = Property('viewCount', type=int)
def children(self):
return self.client['library/metadata'].children(self.rating_key)
@staticmethod
def construct_show(client, node):
attribute_map = {
'index' : 'parentIndex',
'key' : 'parentKey',
'ratingKey': 'parentRatingKey',
'title' : 'parentTitle',
'summary' : 'parentSummary',
'thumb' : 'parentThumb',
'theme' : 'parentTheme'
}
return Show.construct(client, node, attribute_map, child=True)
class SeasonChildrenContainer(ChildrenContainer):
show = Property(resolver=lambda: SeasonChildrenContainer.construct_show)
season = Property(resolver=lambda: SeasonChildrenContainer.construct_season)
key = Property
banner = Property
theme = Property
@staticmethod
def construct_show(client, node):
attribute_map = {
'title' : 'grandparentTitle',
'contentRating': 'grandparentContentRating',
'studio' : 'grandparentStudio',
'theme' : 'grandparentTheme'
}
return Show.construct(client, node, attribute_map, child=True)
@staticmethod
def construct_season(client, node):
attribute_map = {
'index': 'parentIndex',
'title': 'parentTitle'
}
return Season.construct(client, node, attribute_map, child=True)
def __iter__(self):
for item in super(ChildrenContainer, self).__iter__():
item.show = self.show
item.season = self.season
yield item
@@ -0,0 +1,85 @@
from plex.objects.core.base import Property
from plex.objects.directory import Directory
from plex.objects.library.container import LeavesContainer, ChildrenContainer
from plex.objects.library.metadata.base import Metadata
from plex.objects.mixins.rate import RateMixin
class Show(Directory, Metadata, RateMixin):
index = Property(type=int)
duration = Property(type=int)
studio = Property
content_rating = Property('contentRating')
banner = Property
theme = Property
year = Property(type=int)
originally_available_at = Property('originallyAvailableAt')
season_count = Property('childCount', int)
episode_count = Property('leafCount', int)
viewed_episode_count = Property('viewedLeafCount', int)
view_count = Property('viewCount', int)
def all_leaves(self):
return self.client['library/metadata'].all_leaves(self.rating_key)
def children(self):
return self.client['library/metadata'].children(self.rating_key)
class ShowChildrenContainer(ChildrenContainer):
show = Property(resolver=lambda: ShowLeavesContainer.construct_show)
key = Property
summary = Property
banner = Property
theme = Property
@staticmethod
def construct_show(client, node):
attribute_map = {
'index': 'parentIndex',
'title': 'parentTitle',
'year' : 'parentYear'
}
return Show.construct(client, node, attribute_map, child=True)
def __iter__(self):
for item in super(ChildrenContainer, self).__iter__():
item.show = self.show
yield item
class ShowLeavesContainer(LeavesContainer):
show = Property(resolver=lambda: ShowLeavesContainer.construct_show)
key = Property
banner = Property
theme = Property
@staticmethod
def construct_show(client, node):
attribute_map = {
'index': 'parentIndex',
'title': 'parentTitle',
'year' : 'parentYear'
}
return Show.construct(client, node, attribute_map, child=True)
def __iter__(self):
for item in super(LeavesContainer, self).__iter__():
item.show = self.show
yield item
@@ -0,0 +1,47 @@
from plex.objects.core.base import Property
from plex.objects.directory import Directory
from plex.objects.library.metadata.album import Album
from plex.objects.library.metadata.artist import Artist
from plex.objects.library.metadata.base import Metadata
from plex.objects.mixins.scrobble import ScrobbleMixin
from plex.objects.mixins.session import SessionMixin
class Track(Directory, Metadata, SessionMixin, ScrobbleMixin):
artist = Property(resolver=lambda: Track.construct_artist)
album = Property(resolver=lambda: Track.construct_album)
index = Property(type=int)
view_count = Property('viewCount', type=int)
view_offset = Property('viewOffset', type=int)
duration = Property(type=int)
@staticmethod
def construct_artist(client, node):
attribute_map = {
'key': 'grandparentKey',
'ratingKey': 'grandparentRatingKey',
'title': 'grandparentTitle',
'thumb': 'grandparentThumb'
}
return Artist.construct(client, node, attribute_map, child=True)
@staticmethod
def construct_album(client, node):
attribute_map = {
'index': 'parentIndex',
'key': 'parentKey',
'ratingKey': 'parentRatingKey',
'title': 'parentTitle',
'year': 'parentYear',
'thumb': 'parentThumb'
}
return Album.construct(client, node, attribute_map, child=True)
@@ -0,0 +1,26 @@
from plex.objects.core.base import Descriptor, Property
from plex.objects.library.stream import Stream
class Part(Descriptor):
streams = Property(resolver=lambda: Stream.from_node)
id = Property(type=int)
key = Property
file = Property
container = Property
duration = Property(type=int)
size = Property(type=int)
@classmethod
def from_node(cls, client, node):
items = []
for genre in cls.helpers.findall(node, 'Part'):
_, obj = Part.construct(client, genre, child=True)
items.append(obj)
return [], items
@@ -0,0 +1,36 @@
from plex.core.idict import idict
from plex.objects.core.base import Property
from plex.objects.directory import Directory
class Section(Directory):
uuid = Property
filters = Property(type=bool)
refreshing = Property(type=bool)
agent = Property
scanner = Property
language = Property
composite = Property
created_at = Property('createdAt', int)
def __transform__(self):
self.path = '/library/sections/%s' % self.key
def all(self):
response = self.http.get('all')
return self.parse(response, idict({
'MediaContainer': ('MediaContainer', idict({
'Directory': {
'artist': 'Artist',
'show': 'Show'
},
'Video': {
'movie': 'Movie'
}
}))
}))
@@ -0,0 +1,51 @@
from plex.objects.core.base import Descriptor, Property
class Stream(Descriptor):
id = Property(type=int)
index = Property(type=int)
stream_type = Property('streamType', type=int)
selected = Property(type=bool)
title = Property
duration = Property(type=int)
codec = Property
codec_id = Property('codecID')
bit_depth = Property('bitDepth', type=int)
chroma_subsampling = Property('chromaSubsampling')
color_space = Property('colorSpace')
width = Property(type=int)
height = Property(type=int)
bitrate = Property(type=int)
bitrate_mode = Property('bitrateMode')
channels = Property(type=int)
sampling_rate = Property('samplingRate', type=int)
frame_rate = Property('frameRate')
profile = Property
scan_type = Property('scanType')
language = Property('language')
language_code = Property('languageCode')
bvop = Property(type=int)
gmc = Property(type=int)
level = Property(type=int)
qpel = Property(type=int)
@classmethod
def from_node(cls, client, node):
items = []
for genre in cls.helpers.findall(node, 'Stream'):
_, obj = Stream.construct(client, genre, child=True)
items.append(obj)
return [], items
@@ -0,0 +1,22 @@
from plex.objects.core.base import Property
from plex.objects.directory import Directory
from plex.objects.library.extra.director import Director
from plex.objects.library.extra.writer import Writer
from plex.objects.library.media import Media
from plex.objects.mixins.session import SessionMixin
class Video(Directory, SessionMixin):
director = Property(resolver=lambda: Director.from_node)
media = Property(resolver=lambda: Media.from_node)
writers = Property(resolver=lambda: Writer.from_node)
view_count = Property('viewCount', type=int)
view_offset = Property('viewOffset', type=int)
chapter_source = Property('chapterSource')
duration = Property(type=int)
@property
def seen(self):
return self.view_count and self.view_count >= 1
@@ -0,0 +1,10 @@
from plex import Plex
from plex.objects.core.base import Property, DescriptorMixin
class RateMixin(DescriptorMixin):
rating = Property(type=float)
user_rating = Property('userRating', type=float)
def rate(self, value):
return Plex['library'].rate(self.rating_key, value)
@@ -0,0 +1,7 @@
from plex import Plex
from plex.objects.core.base import DescriptorMixin
class ScrobbleMixin(DescriptorMixin):
def scrobble(self):
return Plex['library'].scrobble(self.rating_key)
@@ -0,0 +1,32 @@
from plex.objects.core.base import Descriptor, Property, DescriptorMixin
from plex.objects.player import Player
from plex.objects.transcode_session import TranscodeSession
from plex.objects.user import User
class SessionMixin(DescriptorMixin):
session = Property(resolver=lambda: SessionMixin.construct_session)
@staticmethod
def construct_session(client, node):
return Session.construct(client, node, child=True)
class Session(Descriptor):
key = Property('sessionKey', int)
user = Property(resolver=lambda: Session.construct_user)
player = Property(resolver=lambda: Session.construct_player)
transcode_session = Property(resolver=lambda: Session.construct_transcode_session)
@classmethod
def construct_user(cls, client, node):
return User.construct(client, cls.helpers.find(node, 'User'), child=True)
@classmethod
def construct_player(cls, client, node):
return Player.construct(client, cls.helpers.find(node, 'Player'), child=True)
@classmethod
def construct_transcode_session(cls, client, node):
return TranscodeSession.construct(client, cls.helpers.find(node, 'TranscodeSession'), child=True)
@@ -0,0 +1,11 @@
from plex.objects.core.base import Descriptor, Property
class Player(Descriptor):
title = Property
machine_identifier = Property('machineIdentifier')
state = Property
platform = Property
product = Property
@@ -0,0 +1,12 @@
from plex.objects.core.base import Descriptor, Property
class Server(Descriptor):
name = Property
host = Property
address = Property
port = Property(type=int)
machine_identifier = Property('machineIdentifier')
version = Property
@@ -0,0 +1,21 @@
from plex.core.helpers import to_iterable
from plex.objects.library.container import MediaContainer
class SessionContainer(MediaContainer):
filter_passes = lambda _, allowed, value: allowed is None or value in allowed
def filter(self, keys=None):
keys = to_iterable(keys)
for item in self:
if not self.filter_passes(keys, item.session.key):
continue
yield item
def get(self, key):
for item in self.filter(key):
return item
return None
@@ -0,0 +1,53 @@
from plex.objects.core.base import Descriptor, Property
class Setting(Descriptor):
id = Property
label = Property
summary = Property
type = Property
group = Property
value = Property(resolver=lambda: Setting.parse_value)
default = Property(resolver=lambda: Setting.parse_default)
options = Property('enumValues', resolver=lambda: Setting.parse_options)
hidden = Property(type=[int, bool])
advanced = Property(type=[int, bool])
@classmethod
def parse_value(cls, client, node):
type = cls.helpers.get(node, 'type')
value = cls.helpers.get(node, 'value')
return ['value'], Setting.convert(type, value)
@classmethod
def parse_default(cls, client, node):
type = cls.helpers.get(node, 'type')
default = cls.helpers.get(node, 'default')
return ['default'], Setting.convert(type, default)
@classmethod
def parse_options(cls, client, node):
value = cls.helpers.get(node, 'enumValues')
if not value:
return [], None
return ['enumValues'], [
tuple(option.split(':', 2)) for option in value.split('|')
]
@staticmethod
def convert(type, value):
if type == 'bool':
value = value.lower()
value = value == 'true'
elif type == 'int':
value = int(value)
return value
@@ -0,0 +1,24 @@
from plex.objects.core.base import Descriptor, Property
class TranscodeSession(Descriptor):
key = Property
progress = Property(type=float)
speed = Property(type=float)
duration = Property(type=int)
protocol = Property
throttled = Property(type=int) # TODO this needs to cast: str -> int -> bool
container = Property('container')
video_codec = Property('videoCodec')
video_decision = Property('videoDecision')
audio_codec = Property('audioCodec')
audio_channels = Property('audioChannels', int)
audio_decision = Property('audioDecision')
width = Property(type=int)
height = Property(type=int)
@@ -0,0 +1,8 @@
from plex.objects.core.base import Descriptor, Property
class User(Descriptor):
id = Property(type=int)
title = Property
thumb = Property
+121
View File
@@ -0,0 +1,121 @@
from plex.lib.six.moves.urllib_parse import urlencode
from requests import Request
import json
class PlexRequest(object):
def __init__(self, client, **kwargs):
self.client = client
self.kwargs = kwargs
self.request = None
# Parsed Attributes
self.path = None
self.params = None
self.data = None
self.headers = None
self.method = None
def prepare(self):
self.request = Request()
self.transform_parameters()
self.request.url = self.construct_url()
self.request.data = self.transform_data()
self.request.headers = self.transform_headers()
self.request.method = self.transform_method()
return self.request.prepare()
def construct_url(self):
"""Construct a full plex request URI, with `params`."""
path = [self.path]
path.extend([str(x) for x in self.params])
url = self.client.base_url + '/'.join(x for x in path if x)
query = self.kwargs.get('query')
if query:
# Dict -> List
if type(query) is dict:
query = query.items()
# Remove items with `None` value
query = [
(k, v) for (k, v) in query
if v is not None
]
# Encode query, append to URL
url += '?' + urlencode(query)
return url
def transform_parameters(self):
# Transform `path`
self.path = self.kwargs.get('path')
if not self.path.startswith('/'):
self.path = '/' + self.path
if self.path.endswith('/'):
self.path = self.path[:-1]
# Transform `params` into list
self.params = self.kwargs.get('params') or []
if type(self.params) is not list:
self.params = [self.params]
def transform_data(self):
self.data = self.kwargs.get('data')
if self.data is None:
return None
return json.dumps(self.data)
def transform_headers(self):
self.headers = self.kwargs.get('headers') or {}
# Authentication
self.headers['X-Plex-Token'] = self.client.configuration['authentication.token']
# Client
self.headers['X-Plex-Client-Identifier'] = self.client.configuration['client.identifier']
self.headers['X-Plex-Product'] = self.client.configuration['client.product']
self.headers['X-Plex-Version'] = self.client.configuration['client.version']
# Device
self.headers['X-Device'] = self.client.configuration['device.system']
self.headers['X-Device-Name'] = self.client.configuration['device.name']
# Platform
self.headers['X-Platform'] = self.client.configuration['platform.name']
self.headers['X-Platform-Version'] = self.client.configuration['platform.version']
# Update with extra headers from configuration
c_headers = self.client.configuration['headers']
if c_headers:
self.headers.update(c_headers)
# Only return headers with valid values
return dict([
(k, v) for (k, v) in self.headers.items()
if v is not None
])
def transform_method(self):
self.method = self.kwargs.get('method')
# Pick `method` (if not provided)
if not self.method:
self.method = 'POST' if self.data else 'GET'
return self.method
@@ -0,0 +1,17 @@
import jsonpickle
class Serializer(object):
@classmethod
def encode(cls, value):
return jsonpickle.encode(value)
@classmethod
def decode(cls, value, client=None):
try:
result = jsonpickle.decode(value)
result.client = client
return result
except:
return None
+101
View File
@@ -0,0 +1,101 @@
# coding=utf-8
import datetime
import logging
import sys
from plex import Plex
from plex.client import PlexClient
logger = logging.getLogger(__name__)
now = datetime.datetime.now()
def is_recent(item):
addedAt = datetime.datetime.fromtimestamp(item.added_at)
if now - datetime.timedelta(weeks=2) > addedAt:
return False
return True
def findMissingSubtitles(list_item, _type="episode", internal=False, external=True, languages=["eng"], section_blacklist=["3"],
series_blacklist=["26059"], dry_run=False):
existing_subs = {"internal": [], "external": [], "count": 0}
# get requested item again to have access to the streams - should not be necessary
item_id = int(list_item.key.split("/")[-1])
item_container = Plex["library"].metadata(item_id)
# don't process blacklisted sections
if item_container.section.key in section_blacklist:
return
item = list(item_container)[0]
if _type == "episode" and item.show.rating_key in series_blacklist:
logger.debug("Skipping show %s in blacklist", item.show.key)
return
elif _type == "movie" and item.rating_key in movie_blacklist:
logger.debug("Skipping movie %s in blacklist", item.key)
return
video = item.media
for part in video.parts:
for stream in part.streams:
if stream.stream_type == 3:
if stream.index:
key = "internal"
else:
key = "external"
existing_subs[key].append(stream.language_code)
existing_subs["count"] += 1
missing = languages
if existing_subs["count"]:
existing_flat = existing_subs["internal"] if internal else [] + existing_subs["external"] if external else []
languages_set = set(languages)
if languages_set.issubset(existing_flat):
# all subs found
logger.debug(u"All subtitles existing for %s", item.title)
return
else:
missing = languages_set - set(existing_flat)
logger.info(u"Subs still missing: %s", missing)
if missing:
logger.info("Triggering refresh for '%s'", item.title)
if not dry_run:
Plex["library/metadata"].refresh(item_id)
def run():
itemCount = 0
dry_run = "--dry-run" in sys.argv
with Plex.configuration.authentication("asdfasdfasdf"):
print Plex.configuration.stack[1].data
# Plex[":/plugins"].restart("com.plexapp.agents.subzero")
# Plex[":/plugins/*/prefs"].set("com.plexapp.agents.subzero", "reset_storage", True)
return
for item in Plex['library'].recently_added():
if item.type == "season":
for child in item.children():
if is_recent(child):
print u"Series: %s, Season: %s, Episode: %s %s" % (item.show.title, item.title, child.index, child.title)
findMissingSubtitles(child, _type="episode", dry_run=dry_run)
itemCount += 1
elif item.type == "movie":
if is_recent(item):
print "Movie: ", item.title
findMissingSubtitles(item, _type="movie", dry_run=dry_run)
itemCount += 1
print "Items: ", itemCount
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
run()

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