# coding=utf-8 import logging import operator import logger import os import traceback from menu_helpers import add_ignore_options, dig_tree, set_refresh_menu_state, \ should_display_ignore, enable_channel_wrapper, default_thumb, debounce, SZObjectContainer from subzero.constants import TITLE, ART, ICON, PREFIX, PLUGIN_IDENTIFIER, DEPENDENCY_MODULE_NAMES from support.background import scheduler from support.config import config from support.helpers import pad_title, timestamp from support.ignore import ignore_list from support.items import get_item, get_on_deck_items, refresh_item, get_all_items, get_recent_items, get_items_info, \ get_item_thumb, get_item_kind_from_rating_key from support.lib import Plex from support.missing_subtitles import items_get_all_missing_subs from support.storage import reset_storage, log_storage, get_subtitle_info from subliminal.api import download_subtitles # init GUI ObjectContainer.art = R(ART) ObjectContainer.no_cache = True # default thumb for DirectoryObjects DirectoryObject.thumb = default_thumb # noinspection PyUnboundLocalVariable route = enable_channel_wrapper(route) # noinspection PyUnboundLocalVariable handler = enable_channel_wrapper(handler) @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 = config.full_version#force_title if force_title is not None else config.full_version oc = ObjectContainer(title1=title, title2=title, header=unicode(header) if header else title, message=message, no_history=no_history, replace_parent=replace_parent, no_cache=True) # always re-check permissions config.refresh_permissions_status() # always re-check enabled sections config.refresh_enabled_sections() if not config.permissions_ok and config.missing_permissions: for title, path in config.missing_permissions: oc.add(DirectoryObject( key=Callback(fatality, randomize=timestamp()), title=pad_title("Insufficient permissions"), summary="Insufficient permissions on library %s, folder: %s" % (title, path), )) return oc if not config.enabled_sections: oc.add(DirectoryObject( key=Callback(fatality, randomize=timestamp()), title=pad_title("I'm not enabled!"), summary="Please enable me for some of your libraries in your server settings; currently I do nothing", )) return oc if not only_refresh: if Dict["current_refresh_state"]: oc.add(DirectoryObject( key=Callback(fatality, force_title=" ", randomize=timestamp()), title=pad_title("Working ... refresh here"), 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" ) )) oc.add(DirectoryObject( key=Callback(OnDeckMenu), 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, randomize=timestamp()), 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, randomize=timestamp()), 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): """ displays the items on deck :param message: :return: """ return mergedItemsMenu(title="Items On Deck", base_title="Items On Deck", itemGetter=get_on_deck_items) @route(PREFIX + '/recent') @debounce def RecentlyAddedMenu(message=None, randomize=None): """ displays the recently added items with missing subtitles :param message: :return: """ return recentItemsMenu(title="Missing Subtitles", base_title="Missing Subtitles") def recentItemsMenu(title, base_title=None): oc = SZObjectContainer(title2=title, no_cache=True, no_history=True) recent_items = get_recent_items() if recent_items: missing_items = items_get_all_missing_subs(recent_items) if missing_items: for added_at, item_id, title, item in missing_items: oc.add(DirectoryObject( key=Callback(ItemDetailsMenu, title=base_title + " > " + title, item_title=title, rating_key=item_id), title=title, thumb=get_item_thumb(item) or default_thumb )) return oc def mergedItemsMenu(title, itemGetter, itemGetterKwArgs=None, base_title=None, *args, **kwargs): """ displays an item list of dynamic kinds of items :param title: :param itemGetter: :param itemGetterKwArgs: :param base_title: :param args: :param kwargs: :return: """ oc = SZObjectContainer(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(ItemDetailsMenu, title=base_title + " > " + title, item_title=title, rating_key=item_id), thumb=get_item_thumb(item) or default_thumb )) return oc def determine_section_display(kind, item): """ returns the menu function for a section based on the size of it (amount of items) :param kind: :param item: :return: """ if item.size > 80: 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"): """ displays the ignore options for a menu :param kind: :param rating_key: :param title: :param sure: :param todo: :return: """ is_ignored = rating_key in ignore_list[kind] if not sure: oc = SZObjectContainer(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(): """ displays the menu for all sections :return: """ items = get_all_items("sections") return dig_tree(SZObjectContainer(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): """ displays the contents of a section :param rating_key: :param title: :param base_title: :param section_title: :param ignore_options: :return: """ items = get_all_items(key="all", value=rating_key, base="library/sections") kind, deeper = get_items_info(items) title = unicode(title) section_title = title title = base_title + " > " + title oc = SZObjectContainer(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): """ displays the contents of a section indexed by its first char (A-Z, 0-9...) :param rating_key: :param title: :param base_title: :param section_title: :return: """ items = get_all_items(key="first_character", value=rating_key, base="library/sections") kind, deeper = get_items_info(items) title = unicode(title) oc = SZObjectContainer(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): """ displays the contents of a section filtered by the first letter :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 = SZObjectContainer(title2=title, no_cache=True, no_history=True) items = get_all_items(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): """ displays the contents of a section based on whether it has a deeper tree or not (movies->movie (item) list; series->series list) :param rating_key: :param title: :param base_title: :param display_items: :param previous_item_type: :param previous_rating_key: :return: """ title = unicode(title) item_title = title title = base_title + " > " + title oc = SZObjectContainer(title2=title, no_cache=True, no_history=True) current_kind = get_item_kind_from_rating_key(rating_key) if display_items: items = get_all_items(key="children", value=rating_key, base="library/metadata") 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}) # 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) timeout = 30 if current_kind == "season": timeout = 90 elif current_kind == "series": timeout = 360 # add refresh oc.add(DirectoryObject( key=Callback(RefreshItem, rating_key=rating_key, item_title=title, refresh_kind=current_kind, previous_rating_key=previous_rating_key, timeout=timeout*1000, randomize=timestamp()), title=u"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=title, force=True, refresh_kind=current_kind, previous_rating_key=previous_rating_key, timeout=timeout*1000), title=u"Force-Refresh: %s" % item_title, summary="Issues a forced refresh, ignoring known subtitles and searching for new ones" )) else: return ItemDetailsMenu(rating_key=rating_key, title=title, item_title=item_title) return oc @route(PREFIX + '/ignore_list') def IgnoreListMenu(): oc = SZObjectContainer(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') @debounce def ItemDetailsMenu(rating_key, title=None, base_title=None, item_title=None, randomize=None): """ displays the item details menu of an item that doesn't contain any deeper tree, such as a movie or an episode :param rating_key: :param title: :param base_title: :param item_title: :param randomize: :return: """ title = unicode(base_title) + " > " + unicode(title) if base_title else unicode(title) item = get_item(rating_key) timeout = 30 oc = SZObjectContainer(title2=title, replace_parent=True) oc.add(DirectoryObject( key=Callback(RefreshItem, rating_key=rating_key, item_title=item_title, randomize=timestamp(), timeout=timeout*1000), title=u"Refresh: %s" % item_title, summary="Refreshes the item, possibly picking up new subtitles on disk", thumb=item.thumb or default_thumb )) oc.add(DirectoryObject( key=Callback(RefreshItem, rating_key=rating_key, item_title=item_title, force=True, randomize=timestamp(), timeout=timeout*1000), title=u"Force-Refresh: %s" % item_title, summary="Issues a forced refresh, ignoring known subtitles and searching for new ones", thumb=item.thumb or default_thumb )) # get stored subtitle info for item id current_subtitle_info = get_subtitle_info(rating_key) # get the plex item plex_item = list(Plex["library"].metadata(rating_key))[0] # get current media info for that item media = plex_item.media # look for subtitles for all available media parts and all of their languages for part in media.parts: filename = os.path.basename(part.file) # get corresponding stored subtitle data for that media part (physical media item) sub_part_data = current_subtitle_info.get(str(part.id), {}) if current_subtitle_info else {} # iterate through all configured languages for lang_short in config.lang_list: sub_data_for_lang = sub_part_data.get(lang_short, {}) # try getting current subtitle information for that language current_subtitle_key = sub_data_for_lang.get("current", (None, None)) current_sub_provider_name, current_sub_id = current_subtitle_key current_sub_link = None legacy_storage = False # old storage version; take newest subtitle as current if available if not current_sub_provider_name: subtitle_keys = sorted([(sub["date_added"], key) for key, sub in sub_data_for_lang.iteritems()], None, None, True) current_subtitle_key = None, None if subtitle_keys: current_subtitle_key = subtitle_keys[0][1] current_sub_provider_name, current_sub_id = current_subtitle_key legacy_storage = True summary = u"No current subtitle in storage" if current_sub_provider_name: current_subtitle = sub_part_data[lang_short][current_subtitle_key] current_sub_link = current_subtitle["link"] summary = u"Current subtitle%s: %s (added: %s), Language: %s, Score: %i, Storage: %s, From: %s" % \ (u" (legacy/inaccurate)" if legacy_storage else "", current_sub_provider_name, current_subtitle["date_added"].strftime("%Y-%m-%d %H:%M:%S"), lang_short, current_subtitle["score"], current_subtitle["storage"], current_subtitle["link"]) oc.add(DirectoryObject( key=Callback(ListAvailableSubsForItemMenu, rating_key=rating_key, part_id=part.id, title=title, item_title=item_title, language=lang_short, current_link=current_sub_link, item_type=plex_item.type, filename=filename, current_data=summary, randomize=timestamp()), title=u"Available subtitles for: %s, %s" % (lang_short, filename), summary=summary )) add_ignore_options(oc, "videos", title=item_title, rating_key=rating_key, callback_menu=IgnoreMenu) return oc MANUAL_SUB_SEARCH = {} @route(PREFIX + '/item/search/{rating_key}/{part_id}') @debounce def ListAvailableSubsForItemMenu(rating_key=None, part_id=None, title=None, item_title=None, filename=None, item_type="episode", language=None, force=False, current_link=None, current_data=None, randomize=None): assert rating_key, part_id #config.init_subliminal_patches() running = scheduler.is_task_running("AvailableSubsForItem") task_data = scheduler.get_task_data("AvailableSubsForItem") search_results = task_data.get(rating_key, None) if task_data else None if (not search_results or force) and not running: scheduler.dispatch_task("AvailableSubsForItem", rating_key, item_type, part_id, language) running = True oc = SZObjectContainer(title2=title, replace_parent=True) oc.add(DirectoryObject( key=Callback(ItemDetailsMenu, rating_key=rating_key, item_title=item_title, title=title, randomize=timestamp()), title=u"Back to %s" % title, summary=current_data, thumb=default_thumb )) if not running: oc.add(DirectoryObject( key=Callback(ListAvailableSubsForItemMenu, rating_key=rating_key, item_title=item_title, language=language, filename=filename, part_id=part_id, title=title, current_link=current_link, force=True, current_data=current_data, randomize=timestamp()), title=u"Available subtitles (click for new search)", summary=u"Filename: %s" % filename, thumb=default_thumb )) else: oc.add(DirectoryObject( key=Callback(ListAvailableSubsForItemMenu, rating_key=rating_key, item_title=item_title, language=language, filename=filename, current_data=current_data, part_id=part_id, title=title, current_link=current_link, randomize=timestamp()), title=u"Searching, refresh here ...", summary="", thumb=default_thumb )) if not search_results: return oc for subtitle in search_results: oc.add(DirectoryObject( key=Callback(RefreshItem, rating_key=rating_key, item_title=item_title, randomize=timestamp()), title=u"%s: %s, score: %s" % ("Available" if current_link != subtitle.page_link else "Current", subtitle.provider_name, subtitle.score), summary=u"Release: %s, Matches: %s" % (subtitle.release_info, ", ".join(subtitle.matches)), thumb=default_thumb )) return oc @route(PREFIX + '/item/{rating_key}') @debounce def RefreshItem(rating_key=None, came_from="/recent", item_title=None, force=False, refresh_kind=None, previous_rating_key=None, timeout=8000, randomize=None, trigger=True): assert rating_key header = " " if trigger: set_refresh_menu_state(u"Triggering %sRefresh for %s" % ("Force-" if force else "", item_title)) Thread.Create(refresh_item, rating_key=rating_key, force=force, refresh_kind=refresh_kind, parent_rating_key=previous_rating_key, timeout=int(timeout)) header = u"%s of item %s triggered" % ("Refresh" if not force else "Forced-refresh", rating_key) return fatality(randomize=timestamp(), header=header, replace_parent=True) @route(PREFIX + '/missing/refresh') @debounce def RefreshMissing(randomize=None): Thread.CreateTimer(1.0, lambda: scheduler.run_task("searchAllRecentlyAddedMissing")) header = "Refresh of recently added items with missing subtitles triggered" return fatality(header=header, replace_parent=True) @route(PREFIX + '/advanced') def AdvancedMenu(randomize=None, header=None, message=None): oc = SZObjectContainer(header=header or "Internal stuff, pay attention!", message=message, no_cache=True, no_history=True, replace_parent=False, title2="Advanced") oc.add(DirectoryObject( key=Callback(TriggerRestart, randomize=timestamp()), title=pad_title("Restart the plugin"), )) 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', enforce_route=True) def ValidatePrefs(): Core.log.setLevel(logging.DEBUG) Log.Debug("Validate Prefs called.") # cache the channel state update_dict = False restart = False if "channel_enabled" not in Dict: update_dict = True elif Dict["channel_enabled"] != Prefs["enable_channel"]: Log.Debug("Channel features %s, restarting plugin", "enabled" if Prefs["enable_channel"] else "disabled") update_dict = True restart = True if update_dict: Dict["channel_enabled"] = Prefs["enable_channel"] Dict.Save() if restart: DispatchRestart() 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.register_logging_handler(DEPENDENCY_MODULE_NAMES, level=Prefs["log_level"]) Core.log.setLevel(logging.getLevelName(Prefs["log_level"])) return def DispatchRestart(): Thread.CreateTimer(1.0, Restart) @route(PREFIX + '/advanced/restart/trigger') @debounce def TriggerRestart(randomize=None): set_refresh_menu_state("Restarting the plugin") DispatchRestart() return fatality(header="Restart triggered, please wait about 5 seconds", force_title=" ", only_refresh=True, replace_parent=True, no_history=True, randomize=timestamp()) @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 = SZObjectContainer(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 reset_storage(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): log_storage(key) return AdvancedMenu( randomize=timestamp(), header='Success', message='Information Storage (%s) logged' % key )