Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e1910a28b | |||
| b5e5341436 | |||
| 223ef16583 | |||
| 114312e1e5 | |||
| 1a49159b64 | |||
| d0ee9badb2 | |||
| b9116c30ed | |||
| d7e6436d8d | |||
| c039172880 | |||
| bd5da47370 | |||
| e9aabe0a5e | |||
| f3f09dbb9d | |||
| 3cc8a98f67 | |||
| 31e923c080 | |||
| 39b3b4a0c2 | |||
| 8470daa20f | |||
| e852137baf | |||
| 753c46d9fd | |||
| e06ca730a2 | |||
| f84e84b17b | |||
| 4f927b272b | |||
| 662e1a93a9 | |||
| e25a043457 | |||
| b32f923513 | |||
| ad8898266e | |||
| 51e87bdda5 | |||
| f88677b0f6 | |||
| fc71ec0250 | |||
| ca6089c220 | |||
| 7cc051fd90 | |||
| 5b01fda526 | |||
| 585f6b8a4d | |||
| 81aeba0874 | |||
| d9133e2793 | |||
| 9ef740ae1f | |||
| e54fe71e93 | |||
| 9df878b8e3 | |||
| 1a59c267c1 | |||
| f8a07d983b | |||
| 1f1847f246 | |||
| a32dfd6b37 | |||
| b1cce92e04 | |||
| fdf32439c9 | |||
| fc2208f9e5 | |||
| 1a4eb366bb | |||
| b89c64a2c2 | |||
| 68e8f6e753 | |||
| f15cc4cb3c | |||
| 903273e3ef | |||
| 1c9b744d31 | |||
| 7c0fb29886 | |||
| 2505a7510c | |||
| 0a66db40a2 | |||
| 6c68893979 | |||
| c512eab0b6 | |||
| 3cedd4bd0f | |||
| 0759c5e4c6 | |||
| ad6cf4be79 | |||
| 23c3899fb2 | |||
| 1a6515a660 | |||
| 58815a7650 | |||
| c15ec9fefc |
@@ -24,7 +24,6 @@ import support
|
||||
import interface
|
||||
sys.modules["interface"] = interface
|
||||
|
||||
from subliminal.cli import MutexLock
|
||||
from subzero.constants import OS_PLEX_USERAGENT, PERSONAL_MEDIA_IDENTIFIER
|
||||
from interface.menu import *
|
||||
from support.plex_media import media_to_videos, get_media_item_ids, scan_videos
|
||||
@@ -185,6 +184,9 @@ class SubZeroAgent(object):
|
||||
config.init_subliminal_patches()
|
||||
videos = media_to_videos(media, kind=self.agent_type)
|
||||
|
||||
# find local media
|
||||
update_local_media(metadata, media, media_type=self.agent_type)
|
||||
|
||||
# media ignored?
|
||||
use_any_parts = False
|
||||
for video in videos:
|
||||
@@ -205,9 +207,6 @@ class SubZeroAgent(object):
|
||||
|
||||
set_refresh_menu_state(media, media_type=self.agent_type)
|
||||
|
||||
# find local media
|
||||
update_local_media(metadata, media, media_type=self.agent_type)
|
||||
|
||||
# scanned_video_part_map = {subliminal.Video: plex_part, ...}
|
||||
scanned_video_part_map = scan_videos(videos, kind=self.agent_type)
|
||||
|
||||
|
||||
@@ -18,3 +18,6 @@ sys.modules["interface.refresh_item"] = refresh_item
|
||||
|
||||
import item_details
|
||||
sys.modules["interface.item_details"] = item_details
|
||||
|
||||
import sub_mod
|
||||
sys.modules["interface.modification"] = sub_mod
|
||||
|
||||
@@ -7,6 +7,8 @@ import urlparse
|
||||
|
||||
from zipfile import ZipFile, ZIP_DEFLATED
|
||||
|
||||
from babelfish import Language
|
||||
|
||||
from subzero.lib.io import FileIO
|
||||
from subzero.constants import PREFIX, PLUGIN_IDENTIFIER
|
||||
from menu_helpers import SubFolderObjectContainer, debounce, set_refresh_menu_state, ZipObject
|
||||
@@ -14,8 +16,9 @@ from main import fatality
|
||||
from support.helpers import timestamp, pad_title
|
||||
from support.config import config
|
||||
from support.lib import Plex
|
||||
from support.storage import reset_storage, log_storage
|
||||
from support.storage import reset_storage, log_storage, get_subtitle_storage
|
||||
from support.scheduler import scheduler
|
||||
from support.items import set_mods_for_part, get_item_kind_from_rating_key
|
||||
|
||||
|
||||
@route(PREFIX + '/advanced')
|
||||
@@ -49,6 +52,14 @@ def AdvancedMenu(randomize=None, header=None, message=None):
|
||||
key=Callback(TriggerStorageMaintenance, randomize=timestamp()),
|
||||
title=pad_title("Trigger subtitle storage maintenance"),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ApplyDefaultMods, randomize=timestamp()),
|
||||
title=pad_title("Apply configured default subtitle mods to all (active) stored subtitles"),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ReApplyMods, randomize=timestamp()),
|
||||
title=pad_title("Re-Apply mods of all stored subtitles"),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(LogStorage, key="tasks", randomize=timestamp()),
|
||||
title=pad_title("Log the plugin's scheduled tasks state storage"),
|
||||
@@ -92,6 +103,7 @@ def Restart():
|
||||
|
||||
|
||||
@route(PREFIX + '/storage/reset', sure=bool)
|
||||
@debounce
|
||||
def ResetStorage(key, randomize=None, sure=False):
|
||||
if not sure:
|
||||
oc = SubFolderObjectContainer(no_history=True, title1="Reset subtitle storage", title2="Are you sure?")
|
||||
@@ -127,6 +139,7 @@ def LogStorage(key, randomize=None):
|
||||
|
||||
|
||||
@route(PREFIX + '/triggerbetter')
|
||||
@debounce
|
||||
def TriggerBetterSubtitles(randomize=None):
|
||||
scheduler.dispatch_task("FindBetterSubtitles")
|
||||
return AdvancedMenu(
|
||||
@@ -137,6 +150,7 @@ def TriggerBetterSubtitles(randomize=None):
|
||||
|
||||
|
||||
@route(PREFIX + '/triggermaintenance')
|
||||
@debounce
|
||||
def TriggerStorageMaintenance(randomize=None):
|
||||
scheduler.dispatch_task("SubtitleStorageMaintenance")
|
||||
return AdvancedMenu(
|
||||
@@ -146,26 +160,94 @@ def TriggerStorageMaintenance(randomize=None):
|
||||
)
|
||||
|
||||
|
||||
def apply_default_mods(reapply_current=False):
|
||||
storage = get_subtitle_storage()
|
||||
subs_applied = 0
|
||||
for fn in storage.get_all_files():
|
||||
data = storage.load(None, filename=fn)
|
||||
if data:
|
||||
video_id = data.video_id
|
||||
item_type = get_item_kind_from_rating_key(video_id)
|
||||
if not item_type:
|
||||
continue
|
||||
|
||||
for part_id, part in data.parts.iteritems():
|
||||
for lang, subs in part.iteritems():
|
||||
current_sub = subs.get("current")
|
||||
if not current_sub:
|
||||
continue
|
||||
sub = subs[current_sub]
|
||||
|
||||
if not sub.content:
|
||||
continue
|
||||
|
||||
current_mods = sub.mods or []
|
||||
if not reapply_current:
|
||||
add_mods = list(set(config.default_mods).difference(set(current_mods)))
|
||||
if not add_mods:
|
||||
continue
|
||||
else:
|
||||
if not current_mods:
|
||||
continue
|
||||
add_mods = []
|
||||
|
||||
set_mods_for_part(video_id, part_id, Language.fromietf(lang), item_type, add_mods, mode="add")
|
||||
subs_applied += 1
|
||||
Log.Debug("Applied mods to %i items" % subs_applied)
|
||||
|
||||
|
||||
@route(PREFIX + '/applydefaultmods')
|
||||
@debounce
|
||||
def ApplyDefaultMods(randomize=None):
|
||||
Thread.CreateTimer(1.0, apply_default_mods)
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header='Success',
|
||||
message='This may take some time ...'
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/reapplyallmods')
|
||||
@debounce
|
||||
def ReApplyMods(randomize=None):
|
||||
Thread.CreateTimer(1.0, apply_default_mods, reapply_current=True)
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header='Success',
|
||||
message='This may take some time ...'
|
||||
)
|
||||
|
||||
|
||||
@route(PREFIX + '/get_logs_link')
|
||||
def GetLogsLink():
|
||||
if not config.plex_token:
|
||||
oc = ObjectContainer(title2="Download Logs", no_cache=True, no_history=True,
|
||||
header="Sorry, feature unavailable",
|
||||
message="Universal Plex token not available")
|
||||
return oc
|
||||
|
||||
# try getting the link base via the request in context, first, otherwise use the public ip
|
||||
req_headers = Core.sandbox.context.request.headers
|
||||
get_external_ip = True
|
||||
link_base = ""
|
||||
|
||||
if "Origin" in req_headers:
|
||||
link_base = req_headers["Origin"]
|
||||
Log.Debug("Using origin-based link_base")
|
||||
get_external_ip = False
|
||||
|
||||
elif "Referer" in req_headers:
|
||||
parsed = urlparse.urlparse(req_headers["Referer"])
|
||||
link_base = "%s://%s:%s" % (parsed.scheme, parsed.hostname, parsed.port)
|
||||
Log.Debug("Using referer-based link_base")
|
||||
get_external_ip = False
|
||||
|
||||
else:
|
||||
if get_external_ip or "plex.tv" in link_base:
|
||||
ip = Core.networking.http_request("http://www.plexapp.com/ip.php", cacheTime=7200).content.strip()
|
||||
link_base = "https://%s:32400" % ip
|
||||
Log.Debug("Using ip-based fallback link_base")
|
||||
|
||||
logs_link = "%s%s?X-Plex-Token=%s" % (link_base, PREFIX + '/logs', config.universal_plex_token)
|
||||
logs_link = "%s%s?X-Plex-Token=%s" % (link_base, PREFIX + '/logs', config.plex_token)
|
||||
oc = ObjectContainer(title2="Download Logs", no_cache=True, no_history=True,
|
||||
header="Copy this link and open this in your browser, please",
|
||||
message=logs_link)
|
||||
@@ -189,6 +271,7 @@ def DownloadLogs():
|
||||
|
||||
|
||||
@route(PREFIX + '/invalidatecache')
|
||||
@debounce
|
||||
def InvalidateCache(randomize=None):
|
||||
from subliminal.cache import region
|
||||
region.invalidate()
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
# coding=utf-8
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from babelfish import Language
|
||||
from sub_mod import SubtitleModificationsMenu
|
||||
from menu_helpers import debounce, SubFolderObjectContainer, default_thumb, add_ignore_options, get_item_task_data, \
|
||||
set_refresh_menu_state
|
||||
from subzero.modification import registry as mod_registry
|
||||
|
||||
from refresh_item import RefreshItem
|
||||
from subliminal_patch import PatchedSubtitle as Subtitle
|
||||
from subzero.constants import PREFIX
|
||||
from support.config import config
|
||||
from support.helpers import timestamp, cast_bool, df, get_language
|
||||
@@ -15,7 +13,7 @@ from support.items import get_item_kind_from_rating_key, get_item, get_current_s
|
||||
from support.lib import Plex
|
||||
from support.plex_media import get_plex_metadata, scan_videos
|
||||
from support.scheduler import scheduler
|
||||
from support.storage import get_subtitle_storage, save_subtitles
|
||||
from support.storage import get_subtitle_storage
|
||||
|
||||
|
||||
@route(PREFIX + '/item/{rating_key}/actions')
|
||||
@@ -39,6 +37,22 @@ def ItemDetailsMenu(rating_key, title=None, base_title=None, item_title=None, ra
|
||||
timeout = 30
|
||||
|
||||
oc = SubFolderObjectContainer(title2=title, replace_parent=True)
|
||||
|
||||
# add back to season for episode
|
||||
if current_kind == "episode":
|
||||
from interface.menu import MetadataMenu
|
||||
show = get_item(item.show.rating_key)
|
||||
season = get_item(item.season.rating_key)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(MetadataMenu, rating_key=season.rating_key, title=season.title, base_title=show.title,
|
||||
previous_item_type="show", previous_rating_key=show.rating_key,
|
||||
display_items=True, randomize=timestamp()),
|
||||
title=u"< Back to %s" % season.title,
|
||||
summary="Back to %s > %s" % (show.title, season.title),
|
||||
thumb=season.thumb or default_thumb
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RefreshItem, rating_key=rating_key, item_title=item_title, randomize=timestamp(),
|
||||
timeout=timeout * 1000),
|
||||
@@ -121,7 +135,7 @@ def SubtitleOptionsMenu(**kwargs):
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ItemDetailsMenu, rating_key=kwargs["rating_key"], item_title=kwargs["item_title"],
|
||||
title=kwargs["title"], randomize=timestamp()),
|
||||
title=u"Back to: %s" % kwargs["title"],
|
||||
title=u"< Back to %s" % kwargs["title"],
|
||||
summary=kwargs["current_data"],
|
||||
thumb=default_thumb
|
||||
))
|
||||
@@ -139,69 +153,6 @@ def SubtitleOptionsMenu(**kwargs):
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_mods/{rating_key}/{part_id}', force=bool)
|
||||
@debounce
|
||||
def SubtitleModificationsMenu(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
language = kwargs["language"]
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
for identifier, mod in mod_registry.mods.iteritems():
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleApplyMod, mod_identifier=identifier, randomize=timestamp(), **kwargs),
|
||||
title=mod.description
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleApplyMod, mod_identifier=None, randomize=timestamp(), **kwargs),
|
||||
title="Restore original version",
|
||||
summary=u"Currently applied mods: %s" % (", ".join(current_sub.mods) if current_sub.mods else "none")
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_add_mod/{rating_key}/{part_id}/{mod_identifier}', force=bool)
|
||||
@debounce
|
||||
def SubtitleApplyMod(mod_identifier=None, **kwargs):
|
||||
if mod_identifier is not None and mod_identifier not in mod_registry.mods:
|
||||
raise NotImplementedError
|
||||
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
lang_a2 = kwargs["language"]
|
||||
item_type = kwargs["item_type"]
|
||||
|
||||
language = Language.fromietf(lang_a2)
|
||||
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
current_sub.add_mod(mod_identifier)
|
||||
|
||||
storage.save(stored_subs)
|
||||
metadata = get_plex_metadata(rating_key, part_id, item_type)
|
||||
scanned_parts = scan_videos([metadata], kind="series" if item_type == "episode" else "movie", ignore_all=True)
|
||||
video, plex_part = scanned_parts.items()[0]
|
||||
|
||||
subtitle = Subtitle(language, mods=current_sub.mods)
|
||||
subtitle.content = current_sub.content
|
||||
subtitle.plex_media_fps = plex_part.fps
|
||||
subtitle.page_link = "modify subtitles with: %s" % (", ".join(current_sub.mods) if current_sub.mods else "none")
|
||||
subtitle.language = language
|
||||
|
||||
try:
|
||||
save_subtitles(scanned_parts, {video: [subtitle]}, mode="m", bare_save=True)
|
||||
Log.Debug("Modified %s subtitle for: %s:%s with: %s", language.name, rating_key, part_id,
|
||||
", ".join(current_sub.mods) if current_sub.mods else "none")
|
||||
except:
|
||||
Log.Error("Something went wrong when modifying subtitle: %s", traceback.format_exc())
|
||||
|
||||
kwargs.pop("randomize")
|
||||
return SubtitleModificationsMenu(randomize=timestamp(), **kwargs)
|
||||
|
||||
|
||||
@route(PREFIX + '/item/search/{rating_key}/{part_id}', force=bool)
|
||||
@debounce
|
||||
def ListAvailableSubsForItemMenu(rating_key=None, part_id=None, title=None, item_title=None, filename=None,
|
||||
@@ -221,7 +172,7 @@ def ListAvailableSubsForItemMenu(rating_key=None, part_id=None, title=None, item
|
||||
oc = SubFolderObjectContainer(title2=unicode(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,
|
||||
title=u"< Back to %s" % title,
|
||||
summary=current_data,
|
||||
thumb=default_thumb
|
||||
))
|
||||
@@ -267,11 +218,15 @@ def ListAvailableSubsForItemMenu(rating_key=None, part_id=None, title=None, item
|
||||
return oc
|
||||
|
||||
for subtitle in search_results:
|
||||
wrong_fps_addon = ""
|
||||
if subtitle.wrong_fps:
|
||||
wrong_fps_addon = " (wrong FPS, sub: %s, media: %s)" % (subtitle.fps, plex_part.fps)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(TriggerDownloadSubtitle, rating_key=rating_key, randomize=timestamp(), item_title=item_title,
|
||||
subtitle_id=str(subtitle.id), language=language),
|
||||
title=u"%s: %s, score: %s" % ("Available" if current_id != subtitle.id else "Current",
|
||||
subtitle.provider_name, subtitle.score),
|
||||
title=u"%s: %s, score: %s%s" % ("Available" if current_id != subtitle.id else "Current",
|
||||
subtitle.provider_name, subtitle.score, wrong_fps_addon),
|
||||
summary=u"Release: %s, Matches: %s" % (subtitle.release_info, ", ".join(subtitle.matches)),
|
||||
thumb=default_thumb
|
||||
))
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
from subzero.constants import PREFIX, TITLE, ART
|
||||
from support.config import config
|
||||
from support.helpers import pad_title, timestamp, df
|
||||
from support.helpers import pad_title, timestamp, df, get_plex_item_display_title
|
||||
from support.scheduler import scheduler
|
||||
from support.ignore import ignore_list
|
||||
from support.items import get_item_thumb, get_on_deck_items, get_all_items, get_items_info
|
||||
from support.items import get_item_thumb, get_on_deck_items, get_all_items, get_items_info, get_item, \
|
||||
get_item_kind_from_item
|
||||
from menu_helpers import main_icon, debounce, SubFolderObjectContainer, default_thumb, dig_tree, add_ignore_options
|
||||
from item_details import ItemDetailsMenu
|
||||
|
||||
@@ -69,16 +70,24 @@ def fatality(randomize=None, force_title=None, header=None, message=None, only_r
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(OnDeckMenu),
|
||||
title="On Deck items",
|
||||
title="On-deck items",
|
||||
summary="Shows the current on deck items and allows you to individually (force-) refresh their metadata/"
|
||||
"subtitles.",
|
||||
thumb=R("icon-ondeck.jpg")
|
||||
))
|
||||
if "last_played_items" in Dict and Dict["last_played_items"]:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RecentlyPlayedMenu),
|
||||
title=pad_title("Recently played items"),
|
||||
summary="Shows the %i recently played items and allows you to individually (force-) refresh their "
|
||||
"metadata/subtitles." % config.store_recently_played_amount,
|
||||
thumb=R("icon-played.jpg")
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RecentlyAddedMenu),
|
||||
title="Recently Added items",
|
||||
title="Recently-added items",
|
||||
summary="Shows the recently added items per section.",
|
||||
thumb=R("icon-recent.jpg")
|
||||
thumb=R("icon-added.jpg")
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RecentMissingSubtitlesMenu, randomize=timestamp()),
|
||||
@@ -168,6 +177,31 @@ def OnDeckMenu(message=None):
|
||||
return mergedItemsMenu(title="Items On Deck", base_title="Items On Deck", itemGetter=get_on_deck_items)
|
||||
|
||||
|
||||
@route(PREFIX + '/recently_played')
|
||||
def RecentlyPlayedMenu():
|
||||
base_title = "Recently Played"
|
||||
oc = SubFolderObjectContainer(title2=base_title, replace_parent=True)
|
||||
|
||||
for item in [get_item(rating_key) for rating_key in Dict["last_played_items"]]:
|
||||
kind = get_item_kind_from_item(item)
|
||||
if kind not in ("episode", "movie"):
|
||||
continue
|
||||
|
||||
if kind == "episode":
|
||||
item_title = get_plex_item_display_title(item, "show", parent=item.season, section_title=None,
|
||||
parent_title=item.show.title)
|
||||
else:
|
||||
item_title = get_plex_item_display_title(item, kind, section_title=None)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
title=item_title,
|
||||
key=Callback(ItemDetailsMenu, title=base_title + " > " + item.title, item_title=item.title,
|
||||
rating_key=item.rating_key)
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/recently_added')
|
||||
def RecentlyAddedMenu(message=None):
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# coding=utf-8
|
||||
import logging
|
||||
import os
|
||||
|
||||
import logger
|
||||
|
||||
from item_details import ItemDetailsMenu
|
||||
@@ -14,7 +16,7 @@ from support.config import config
|
||||
from support.helpers import timestamp, df
|
||||
from support.ignore import ignore_list
|
||||
from support.items import get_all_items, get_items_info, \
|
||||
get_item_kind_from_rating_key
|
||||
get_item_kind_from_rating_key, get_item
|
||||
|
||||
# init GUI
|
||||
ObjectContainer.art = R(ART)
|
||||
@@ -53,7 +55,7 @@ def FirstLetterMetadataMenu(rating_key, key, title=None, base_title=None, displa
|
||||
|
||||
@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):
|
||||
previous_rating_key=None, randomize=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:
|
||||
@@ -72,6 +74,22 @@ def MetadataMenu(rating_key, title=None, base_title=None, display_items=False, p
|
||||
current_kind = get_item_kind_from_rating_key(rating_key)
|
||||
|
||||
if display_items:
|
||||
timeout = 30
|
||||
|
||||
# add back to series for season
|
||||
if current_kind == "season":
|
||||
timeout = 360
|
||||
|
||||
show = get_item(previous_rating_key)
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(MetadataMenu, rating_key=show.rating_key, title=show.title, base_title=show.section.title,
|
||||
previous_item_type="section", display_items=True, randomize=timestamp()),
|
||||
title=u"< Back to %s" % show.title,
|
||||
thumb=show.thumb or default_thumb
|
||||
))
|
||||
elif current_kind == "series":
|
||||
timeout = 1800
|
||||
|
||||
items = get_all_items(key="children", value=rating_key, base="library/metadata")
|
||||
kind, deeper = get_items_info(items)
|
||||
dig_tree(oc, items, MetadataMenu,
|
||||
@@ -81,12 +99,6 @@ def MetadataMenu(rating_key, title=None, base_title=None, display_items=False, p
|
||||
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 = 360
|
||||
elif current_kind == "series":
|
||||
timeout = 1800
|
||||
|
||||
# add refresh
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RefreshItem, rating_key=rating_key, item_title=title, refresh_kind=current_kind,
|
||||
@@ -147,16 +159,6 @@ def RefreshMissing(randomize=None):
|
||||
@route(PREFIX + '/ValidatePrefs', enforce_route=True)
|
||||
def ValidatePrefs():
|
||||
Core.log.setLevel(logging.DEBUG)
|
||||
Log.Debug("Validate Prefs called.")
|
||||
|
||||
# SZ config debug
|
||||
Log.Debug("--- SZ Config-Debug ---")
|
||||
for attr in [
|
||||
"app_support_path", "data_path", "data_items_path", "plugin_log_path", "server_log_path", "enable_agent",
|
||||
"enable_channel", "permissions_ok", "missing_permissions", "fs_encoding"]:
|
||||
Log.Debug("config.%s: %s", attr, getattr(config, attr))
|
||||
|
||||
Log.Debug("-----------------------")
|
||||
|
||||
# cache the channel state
|
||||
update_dict = False
|
||||
@@ -191,6 +193,42 @@ def ValidatePrefs():
|
||||
Core.log.removeHandler(logger.console_handler)
|
||||
Log.Debug("Stop logging to console")
|
||||
|
||||
Log.Debug("Validate Prefs called.")
|
||||
|
||||
# SZ config debug
|
||||
Log.Debug("--- SZ Config-Debug ---")
|
||||
for attr in [
|
||||
"app_support_path", "data_path", "data_items_path", "enable_agent",
|
||||
"enable_channel", "permissions_ok", "missing_permissions", "fs_encoding"]:
|
||||
Log.Debug("config.%s: %s", attr, getattr(config, attr))
|
||||
|
||||
for attr in ["plugin_log_path", "server_log_path"]:
|
||||
value = getattr(config, attr)
|
||||
access = os.access(value, os.R_OK)
|
||||
if Core.runtime.os == "Windows":
|
||||
try:
|
||||
f = open(value, "r")
|
||||
f.read(1)
|
||||
f.close()
|
||||
except:
|
||||
access = False
|
||||
|
||||
Log.Debug("config.%s: %s (accessible: %s)", attr, value, access)
|
||||
|
||||
# fixme: check existance of and os access of logs
|
||||
Log.Debug("Platform: %s", Core.runtime.platform)
|
||||
Log.Debug("OS: %s", Core.runtime.os)
|
||||
Log.Debug("----- Environment -----")
|
||||
for key, value in os.environ.iteritems():
|
||||
if key.startswith("PLEX"):
|
||||
if "TOKEN" in key:
|
||||
outval = "xxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
else:
|
||||
outval = value
|
||||
Log.Debug("%s: %s", key, outval)
|
||||
Log.Debug("-----------------------")
|
||||
|
||||
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"]))
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# coding=utf-8
|
||||
|
||||
import traceback
|
||||
import types
|
||||
|
||||
from babelfish import Language
|
||||
|
||||
from menu_helpers import debounce, SubFolderObjectContainer, default_thumb
|
||||
from subzero.modification import registry as mod_registry, SubtitleModifications
|
||||
from subzero.constants import PREFIX
|
||||
from support.plex_media import get_plex_metadata, scan_videos
|
||||
from support.helpers import timestamp, pad_title
|
||||
from support.items import get_current_sub, set_mods_for_part
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_mods/{rating_key}/{part_id}', force=bool)
|
||||
@debounce
|
||||
def SubtitleModificationsMenu(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
language = kwargs["language"]
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
kwargs.pop("randomize")
|
||||
|
||||
current_mods = current_sub.mods or []
|
||||
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
|
||||
from interface.item_details import SubtitleOptionsMenu
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleOptionsMenu, randomize=timestamp(), **kwargs),
|
||||
title=u"< Back to subtitle options for: %s" % kwargs["title"],
|
||||
summary=kwargs["current_data"],
|
||||
thumb=default_thumb
|
||||
))
|
||||
|
||||
for identifier, mod in mod_registry.mods.iteritems():
|
||||
if mod.advanced:
|
||||
continue
|
||||
|
||||
if mod.exclusive and identifier in current_mods:
|
||||
continue
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=identifier, mode="add", randomize=timestamp(), **kwargs),
|
||||
title=pad_title(mod.description), summary=mod.long_description or ""
|
||||
))
|
||||
|
||||
fps_mod = SubtitleModifications.get_mod_class("change_FPS")
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleFPSModMenu, randomize=timestamp(), **kwargs),
|
||||
title=pad_title(fps_mod.description), summary=fps_mod.long_description or ""
|
||||
))
|
||||
|
||||
shift_mod = SubtitleModifications.get_mod_class("shift_offset")
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleShiftModUnitMenu, randomize=timestamp(), **kwargs),
|
||||
title=pad_title(shift_mod.description), summary=shift_mod.long_description or ""
|
||||
))
|
||||
|
||||
if current_mods:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=None, mode="remove_last", randomize=timestamp(), **kwargs),
|
||||
title=pad_title("Remove last applied mod (%s)" % current_mods[-1]),
|
||||
summary=u"Currently applied mods: %s" % (", ".join(current_mods) if current_mods else "none")
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleListMods, randomize=timestamp(), **kwargs),
|
||||
title=pad_title("Manage applied mods"),
|
||||
summary=u"Currently applied mods: %s" % (", ".join(current_mods))
|
||||
))
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=None, mode="clear", randomize=timestamp(), **kwargs),
|
||||
title=pad_title("Restore original version"),
|
||||
summary=u"Currently applied mods: %s" % (", ".join(current_mods) if current_mods else "none")
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_mod_fps/{rating_key}/{part_id}', force=bool)
|
||||
def SubtitleFPSModMenu(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
item_type = kwargs["item_type"]
|
||||
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleModificationsMenu, randomize=timestamp(), **kwargs),
|
||||
title="< Back to subtitle modification menu"
|
||||
))
|
||||
|
||||
metadata = get_plex_metadata(rating_key, part_id, item_type)
|
||||
scanned_parts = scan_videos([metadata], kind="series" if item_type == "episode" else "movie", ignore_all=True)
|
||||
video, plex_part = scanned_parts.items()[0]
|
||||
|
||||
target_fps = plex_part.fps
|
||||
|
||||
for fps in ["23.976", "24.000", "25.000", "29.970", "30.000", "50.000", "59.940", "60.000"]:
|
||||
if float(fps) == float(target_fps):
|
||||
continue
|
||||
|
||||
if float(fps) > float(target_fps):
|
||||
indicator = "subs constantly getting faster"
|
||||
else:
|
||||
indicator = "subs constantly getting slower"
|
||||
|
||||
mod_ident = SubtitleModifications.get_mod_signature("change_FPS", **{"from": fps, "to": target_fps})
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=mod_ident, mode="add", randomize=timestamp(), **kwargs),
|
||||
title="%s fps -> %s fps (%s)" % (fps, target_fps, indicator)
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
POSSIBLE_UNITS = (("ms", "milliseconds"), ("s", "seconds"), ("m", "minutes"), ("h", "hours"))
|
||||
POSSIBLE_UNITS_D = dict(POSSIBLE_UNITS)
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_mod_shift_unit/{rating_key}/{part_id}', force=bool)
|
||||
def SubtitleShiftModUnitMenu(**kwargs):
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleModificationsMenu, randomize=timestamp(), **kwargs),
|
||||
title="< Back to subtitle modifications"
|
||||
))
|
||||
|
||||
for unit, title in POSSIBLE_UNITS:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleShiftModMenu, unit=unit, randomize=timestamp(), **kwargs),
|
||||
title="Adjust by %s" % title
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_mod_shift/{rating_key}/{part_id}/{unit}', force=bool)
|
||||
def SubtitleShiftModMenu(unit=None, **kwargs):
|
||||
if unit not in POSSIBLE_UNITS_D:
|
||||
raise NotImplementedError
|
||||
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleShiftModUnitMenu, randomize=timestamp(), **kwargs),
|
||||
title="< Back to unit selection"
|
||||
))
|
||||
|
||||
rng = []
|
||||
if unit == "h":
|
||||
rng = range(-10, 11)
|
||||
elif unit in ("m", "s"):
|
||||
rng = range(-15, 15)
|
||||
elif unit == "ms":
|
||||
rng = range(-900, 1000, 100)
|
||||
|
||||
for i in rng:
|
||||
if i == 0:
|
||||
continue
|
||||
|
||||
mod_ident = SubtitleModifications.get_mod_signature("shift_offset", **{unit: i})
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=mod_ident, mode="add", randomize=timestamp(), **kwargs),
|
||||
title="%s %s" % (("%s" if i < 0 else "+%s") % i, unit)
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_set_mods/{rating_key}/{part_id}/{mods}/{mode}', force=bool)
|
||||
@debounce
|
||||
def SubtitleSetMods(mods=None, mode=None, **kwargs):
|
||||
if not isinstance(mods, types.ListType) and mods:
|
||||
mods = [mods]
|
||||
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
lang_a2 = kwargs["language"]
|
||||
item_type = kwargs["item_type"]
|
||||
|
||||
language = Language.fromietf(lang_a2)
|
||||
|
||||
set_mods_for_part(rating_key, part_id, language, item_type, mods, mode=mode)
|
||||
|
||||
kwargs.pop("randomize")
|
||||
return SubtitleModificationsMenu(randomize=timestamp(), **kwargs)
|
||||
|
||||
|
||||
@route(PREFIX + '/item/sub_list_mods/{rating_key}/{part_id}', force=bool)
|
||||
@debounce
|
||||
def SubtitleListMods(**kwargs):
|
||||
rating_key = kwargs["rating_key"]
|
||||
part_id = kwargs["part_id"]
|
||||
language = kwargs["language"]
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
|
||||
kwargs.pop("randomize")
|
||||
|
||||
oc = SubFolderObjectContainer(title2=kwargs["title"], replace_parent=True)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleModificationsMenu, randomize=timestamp(), **kwargs),
|
||||
title="< Back to subtitle modifications"
|
||||
))
|
||||
|
||||
for identifier in current_sub.mods:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(SubtitleSetMods, mods=identifier, mode="remove", randomize=timestamp(), **kwargs),
|
||||
title="Remove: %s" % identifier
|
||||
))
|
||||
|
||||
return oc
|
||||
@@ -11,9 +11,9 @@ class PlexActivityManager(object):
|
||||
def start(self):
|
||||
activity_sources_enabled = None
|
||||
|
||||
if config.universal_plex_token:
|
||||
if config.plex_token:
|
||||
from plex import Plex
|
||||
Plex.configuration.defaults.authentication(config.universal_plex_token)
|
||||
Plex.configuration.defaults.authentication(config.plex_token)
|
||||
activity_sources_enabled = ["websocket"]
|
||||
Activity.on('websocket.playing', self.on_playing)
|
||||
|
||||
@@ -27,9 +27,6 @@ class PlexActivityManager(object):
|
||||
|
||||
@throttle(5, instance_method=True)
|
||||
def on_playing(self, info):
|
||||
if not config.use_activities:
|
||||
return
|
||||
|
||||
# ignore non-playing states and anything too far in
|
||||
if info["state"] != "playing" or info["viewOffset"] > 60000:
|
||||
return
|
||||
@@ -41,13 +38,22 @@ class PlexActivityManager(object):
|
||||
return
|
||||
|
||||
rating_key = info["ratingKey"]
|
||||
if rating_key not in Dict["last_played_items"]:
|
||||
# new playing; store last 10 recently played items
|
||||
if rating_key in Dict["last_played_items"] and rating_key != Dict["last_played_items"][0]:
|
||||
# shift last played
|
||||
Dict["last_played_items"].insert(0,
|
||||
Dict["last_played_items"].pop(Dict["last_played_items"].index(rating_key)))
|
||||
Dict.Save()
|
||||
|
||||
elif rating_key not in Dict["last_played_items"]:
|
||||
# new playing; store last X recently played items
|
||||
Dict["last_played_items"].insert(0, rating_key)
|
||||
Dict["last_played_items"] = Dict["last_played_items"][:10]
|
||||
Dict["last_played_items"] = Dict["last_played_items"][:config.store_recently_played_amount]
|
||||
|
||||
Dict.Save()
|
||||
|
||||
if not config.react_to_activities:
|
||||
return
|
||||
|
||||
debug_msg = "Started playing %s. Refreshing it." % rating_key
|
||||
|
||||
key_to_refresh = None
|
||||
@@ -108,4 +114,5 @@ class PlexActivityManager(object):
|
||||
if ep.index == 1:
|
||||
return ep
|
||||
|
||||
|
||||
activity = PlexActivityManager()
|
||||
|
||||
@@ -9,6 +9,7 @@ import datetime
|
||||
import subliminal
|
||||
import subliminal_patch
|
||||
from babelfish import Language
|
||||
from subliminal.cli import MutexLock
|
||||
from subzero.lib.io import FileIO, get_viable_encoding
|
||||
from subzero.constants import PLUGIN_NAME, PLUGIN_IDENTIFIER, MOVIE, SHOW
|
||||
from lib import Plex
|
||||
@@ -45,6 +46,7 @@ class Config(object):
|
||||
data_path = None
|
||||
data_items_path = None
|
||||
universal_plex_token = None
|
||||
plex_token = None
|
||||
is_development = False
|
||||
|
||||
enable_channel = True
|
||||
@@ -69,6 +71,7 @@ class Config(object):
|
||||
enabled_sections = None
|
||||
remove_hi = False
|
||||
fix_ocr = False
|
||||
fix_common = False
|
||||
enforce_encoding = False
|
||||
chmod = None
|
||||
forced_only = False
|
||||
@@ -76,9 +79,12 @@ class Config(object):
|
||||
treat_und_as_first = False
|
||||
ext_match_strictness = False
|
||||
default_mods = None
|
||||
use_activities = False
|
||||
debug_mods = False
|
||||
react_to_activities = False
|
||||
activity_mode = None
|
||||
|
||||
store_recently_played_amount = 20
|
||||
|
||||
initialized = False
|
||||
|
||||
def initialize(self):
|
||||
@@ -92,6 +98,7 @@ class Config(object):
|
||||
self.data_path = getattr(Data, "_core").storage.data_path
|
||||
self.data_items_path = os.path.join(self.data_path, "DataItems")
|
||||
self.universal_plex_token = self.get_universal_plex_token()
|
||||
self.plex_token = os.environ.get("PLEXTOKEN", self.universal_plex_token)
|
||||
|
||||
self.set_plugin_mode()
|
||||
self.set_plugin_lock()
|
||||
@@ -99,6 +106,7 @@ class Config(object):
|
||||
|
||||
self.lang_list = self.get_lang_list()
|
||||
self.subtitle_destination_folder = self.get_subtitle_destination_folder()
|
||||
self.forced_only = cast_bool(Prefs["subtitles.only_foreign"])
|
||||
self.providers = self.get_providers()
|
||||
self.provider_settings = self.get_provider_settings()
|
||||
self.max_recent_items_per_library = int_or_default(Prefs["scheduler.max_recent_items_per_library"], 2000)
|
||||
@@ -111,13 +119,14 @@ class Config(object):
|
||||
self.notify_executable = self.check_notify_executable()
|
||||
self.remove_hi = cast_bool(Prefs['subtitles.remove_hi'])
|
||||
self.fix_ocr = cast_bool(Prefs['subtitles.fix_ocr'])
|
||||
self.fix_common = cast_bool(Prefs['subtitles.fix_common'])
|
||||
self.enforce_encoding = cast_bool(Prefs['subtitles.enforce_encoding'])
|
||||
self.chmod = self.check_chmod()
|
||||
self.forced_only = cast_bool(Prefs["subtitles.only_foreign"])
|
||||
self.exotic_ext = cast_bool(Prefs["subtitles.scan.exotic_ext"])
|
||||
self.treat_und_as_first = cast_bool(Prefs["subtitles.language.treat_und_as_first"])
|
||||
self.ext_match_strictness = self.determine_ext_sub_strictness()
|
||||
self.default_mods = self.get_default_mods()
|
||||
self.debug_mods = cast_bool(Prefs['log_debug_mods'])
|
||||
self.initialized = True
|
||||
|
||||
def init_cache(self):
|
||||
@@ -161,6 +170,8 @@ class Config(object):
|
||||
else:
|
||||
Log("Did NOT find Preferences file - please check logfile and hierarchy. Aborting!")
|
||||
|
||||
# fixme: windows
|
||||
|
||||
def set_plugin_mode(self):
|
||||
if Prefs["plugin_mode"] == "only agent":
|
||||
self.enable_channel = False
|
||||
@@ -364,10 +375,13 @@ class Config(object):
|
||||
}
|
||||
|
||||
# ditch non-forced-subtitles-reporting providers
|
||||
if cast_bool(Prefs['subtitles.only_foreign']):
|
||||
if self.forced_only:
|
||||
providers["addic7ed"] = False
|
||||
providers["tvsubtitles"] = False
|
||||
providers["legendastv"] = False
|
||||
providers["napiprojekt"] = False
|
||||
providers["shooter"] = False
|
||||
providers["subscenter"] = False
|
||||
|
||||
return filter(lambda prov: providers[prov], providers)
|
||||
|
||||
@@ -431,16 +445,18 @@ class Config(object):
|
||||
mods.append("remove_HI")
|
||||
if self.fix_ocr:
|
||||
mods.append("OCR_fixes")
|
||||
if self.fix_common:
|
||||
mods.append("common")
|
||||
|
||||
return mods
|
||||
|
||||
def set_activity_modes(self):
|
||||
val = Prefs["activity.on_playback"]
|
||||
if val == "never":
|
||||
self.use_activities = False
|
||||
self.react_to_activities = False
|
||||
return
|
||||
|
||||
self.use_activities = True
|
||||
self.react_to_activities = True
|
||||
if val == "current media item":
|
||||
self.activity_mode = "refresh"
|
||||
elif val == "hybrid: current item or next episode":
|
||||
|
||||
@@ -110,9 +110,9 @@ def str_pad(s, length, align='left', pad_char=' ', trim=False):
|
||||
raise ValueError("Unknown align type, expected either 'left' or 'right'")
|
||||
|
||||
|
||||
def pad_title(value):
|
||||
def pad_title(value, width=49):
|
||||
"""Pad a title to 30 characters to force the 'details' view."""
|
||||
return str_pad(value, 49, pad_char=' ')
|
||||
return str_pad(value, width, pad_char=' ')
|
||||
|
||||
|
||||
def get_plex_item_display_title(item, kind, parent=None, parent_title=None, section_title=None,
|
||||
@@ -303,3 +303,7 @@ def dispatch_track_usage(*args, **kwargs):
|
||||
|
||||
def get_language(lang_short):
|
||||
return Language.fromietf(lang_short)
|
||||
|
||||
|
||||
class PartUnknownException(Exception):
|
||||
pass
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
import logging
|
||||
import re
|
||||
import traceback
|
||||
import types
|
||||
import os
|
||||
from ignore import ignore_list
|
||||
from helpers import is_recent, get_plex_item_display_title, query_plex
|
||||
from helpers import is_recent, get_plex_item_display_title, query_plex, PartUnknownException
|
||||
from lib import Plex, get_intent
|
||||
from config import config, IGNORE_FN
|
||||
from subliminal_patch.subtitle import ModifiedSubtitle
|
||||
from subzero.modification import registry as mod_registry, SubtitleModifications
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,11 +43,11 @@ PLEX_API_TYPE_MAP = {
|
||||
|
||||
def get_item_kind_from_rating_key(key):
|
||||
item = get_item(key)
|
||||
return PLEX_API_TYPE_MAP[get_item_kind(item)]
|
||||
return PLEX_API_TYPE_MAP.get(get_item_kind(item))
|
||||
|
||||
|
||||
def get_item_kind_from_item(item):
|
||||
return PLEX_API_TYPE_MAP[get_item_kind(item)]
|
||||
return PLEX_API_TYPE_MAP.get(get_item_kind(item))
|
||||
|
||||
|
||||
def get_item_thumb(item):
|
||||
@@ -292,4 +295,52 @@ def get_current_sub(rating_key, part_id, language):
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
stored_subs = subtitle_storage.load_or_new(item)
|
||||
current_sub = stored_subs.get_any(part_id, language)
|
||||
return current_sub, stored_subs, subtitle_storage
|
||||
return current_sub, stored_subs, subtitle_storage
|
||||
|
||||
|
||||
def set_mods_for_part(rating_key, part_id, language, item_type, mods, mode="add"):
|
||||
from support.plex_media import get_plex_metadata, scan_videos
|
||||
from support.storage import save_subtitles
|
||||
|
||||
current_sub, stored_subs, storage = get_current_sub(rating_key, part_id, language)
|
||||
if mode == "add":
|
||||
for mod in mods:
|
||||
identifier, args = SubtitleModifications.parse_identifier(mod)
|
||||
if identifier not in mod_registry.mods_available:
|
||||
raise NotImplementedError("Mod unknown or not registered")
|
||||
|
||||
current_sub.add_mod(mod)
|
||||
elif mode == "clear":
|
||||
current_sub.add_mod(None)
|
||||
elif mode == "remove":
|
||||
for mod in mods:
|
||||
current_sub.mods.remove(mod)
|
||||
|
||||
elif mode == "remove_last":
|
||||
if current_sub.mods:
|
||||
current_sub.mods.pop()
|
||||
else:
|
||||
raise NotImplementedError("Wrong mode given")
|
||||
storage.save(stored_subs)
|
||||
|
||||
try:
|
||||
metadata = get_plex_metadata(rating_key, part_id, item_type)
|
||||
except PartUnknownException:
|
||||
return
|
||||
|
||||
scanned_parts = scan_videos([metadata], kind="series" if item_type == "episode" else "movie", ignore_all=True)
|
||||
video, plex_part = scanned_parts.items()[0]
|
||||
|
||||
subtitle = ModifiedSubtitle(language, mods=current_sub.mods)
|
||||
subtitle.content = current_sub.content
|
||||
subtitle.plex_media_fps = plex_part.fps
|
||||
subtitle.page_link = "modify subtitles with: %s" % (", ".join(current_sub.mods) if current_sub.mods else "none")
|
||||
subtitle.language = language
|
||||
subtitle.id = current_sub.id
|
||||
|
||||
try:
|
||||
save_subtitles(scanned_parts, {video: [subtitle]}, mode="m", bare_save=True)
|
||||
Log.Debug("Modified %s subtitle for: %s:%s with: %s", language.name, rating_key, part_id,
|
||||
", ".join(current_sub.mods) if current_sub.mods else "none")
|
||||
except:
|
||||
Log.Error("Something went wrong when modifying subtitle: %s", traceback.format_exc())
|
||||
|
||||
@@ -108,7 +108,8 @@ def find_subtitles(part):
|
||||
if ext.lower()[1:] in config.SUBTITLE_EXTS:
|
||||
# get fn without forced/default/normal tag
|
||||
split_tag = root.rsplit(".", 1)
|
||||
if len(split_tag) > 1 and split_tag[1].lower() in ['forced', 'normal', 'default']:
|
||||
if len(split_tag) > 1 and split_tag[1].lower() in ['forced', 'normal', 'default', 'embedded',
|
||||
'custom']:
|
||||
root = split_tag[0]
|
||||
|
||||
# get associated media file name without language
|
||||
@@ -160,9 +161,8 @@ def find_subtitles(part):
|
||||
# determine whether to pick up the subtitle based on our match strictness
|
||||
elif not filename_matches_part:
|
||||
if sz_config.ext_match_strictness == "strict" or (
|
||||
sz_config.ext_match_strictness == "loose" and not filename_contains_part):
|
||||
|
||||
#Log.Debug("%s doesn't match %s, skipping" % (helpers.unicodize(local_filename),
|
||||
sz_config.ext_match_strictness == "loose" and not filename_contains_part):
|
||||
# Log.Debug("%s doesn't match %s, skipping" % (helpers.unicodize(local_filename),
|
||||
# helpers.unicodize(part_basename)))
|
||||
continue
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# coding=utf-8
|
||||
import traceback
|
||||
import time
|
||||
|
||||
from support.config import config
|
||||
from support.helpers import get_plex_item_display_title, cast_bool
|
||||
@@ -47,7 +48,7 @@ def item_discover_missing_subs(rating_key, kind="show", added_at=None, section_t
|
||||
return added_at, item_id, item_title, item, missing
|
||||
|
||||
|
||||
def items_get_all_missing_subs(items):
|
||||
def items_get_all_missing_subs(items, sleep_after_request=False):
|
||||
missing = []
|
||||
for added_at, kind, section_title, key in items:
|
||||
try:
|
||||
@@ -65,6 +66,8 @@ def items_get_all_missing_subs(items):
|
||||
missing.append(state)
|
||||
except:
|
||||
Log.Error("Something went wrong when getting the state of item %s: %s", key, traceback.format_exc())
|
||||
if sleep_after_request:
|
||||
time.sleep(sleep_after_request)
|
||||
return missing
|
||||
|
||||
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
import os
|
||||
|
||||
import helpers
|
||||
|
||||
from config import config
|
||||
from items import get_item
|
||||
from lib import get_intent, Plex
|
||||
from config import config
|
||||
from subzero.video import parse_video
|
||||
|
||||
|
||||
def get_metadata_dict(item, part, add):
|
||||
data = {
|
||||
"item": item,
|
||||
@@ -179,10 +177,6 @@ def scan_videos(videos, kind="series", ignore_all=False):
|
||||
return ret
|
||||
|
||||
|
||||
class PartUnknownException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_plex_metadata(rating_key, part_id, item_type):
|
||||
"""
|
||||
uses the Plex 3rd party API accessor to get metadata information
|
||||
@@ -202,7 +196,7 @@ def get_plex_metadata(rating_key, part_id, item_type):
|
||||
current_part = part
|
||||
|
||||
if not current_part:
|
||||
raise PartUnknownException("Part unknown")
|
||||
raise helpers.PartUnknownException("Part unknown")
|
||||
|
||||
# get normalized metadata
|
||||
if item_type == "episode":
|
||||
|
||||
@@ -168,6 +168,7 @@ class DefaultScheduler(object):
|
||||
for args, kwargs in queue:
|
||||
Log.Debug("Dispatching single task: %s, %s", args, kwargs)
|
||||
Thread.Create(self.run_task, True, *args, **kwargs)
|
||||
Thread.Sleep(5.0)
|
||||
|
||||
# scheduled tasks
|
||||
for name, info in self.tasks.iteritems():
|
||||
@@ -185,9 +186,11 @@ class DefaultScheduler(object):
|
||||
continue
|
||||
|
||||
if not task.last_run or (task.last_run + datetime.timedelta(**{frequency_key: frequency_num}) <= now):
|
||||
self.run_task(name)
|
||||
# fixme: scheduled tasks run synchronously. is this the best idea?
|
||||
Thread.Create(self.run_task, True, name)
|
||||
Thread.Sleep(5.0)
|
||||
|
||||
Thread.Sleep(5.0)
|
||||
Thread.Sleep(1)
|
||||
|
||||
|
||||
scheduler = DefaultScheduler()
|
||||
|
||||
@@ -137,7 +137,8 @@ def save_subtitles_to_file(subtitles):
|
||||
os.makedirs(fld)
|
||||
subliminal.save_subtitles(video, video_subtitles, directory=fld, single=cast_bool(Prefs['subtitles.only_one']),
|
||||
encode_with=force_utf8 if config.enforce_encoding else None,
|
||||
chmod=config.chmod, forced_tag=config.forced_only, path_decoder=force_unicode)
|
||||
chmod=config.chmod, forced_tag=config.forced_only, path_decoder=force_unicode,
|
||||
debug_mods=config.debug_mods)
|
||||
return True
|
||||
|
||||
|
||||
@@ -145,7 +146,8 @@ def save_subtitles_to_metadata(videos, subtitles):
|
||||
for video, video_subtitles in subtitles.items():
|
||||
mediaPart = videos[video]
|
||||
for subtitle in video_subtitles:
|
||||
content = force_utf8(subtitle.text) if config.enforce_encoding else subtitle.content
|
||||
content = force_utf8(subtitle.get_modified_text(debug=config.debug_mods)) if config.enforce_encoding else \
|
||||
subtitle.get_modified_content(debug=config.debug_mods)
|
||||
|
||||
if not isinstance(mediaPart, Framework.api.agentkit.MediaPart):
|
||||
# we're being handed a Plex.py model instance here, not an internal PMS MediaPart object.
|
||||
@@ -207,3 +209,5 @@ def save_subtitles(scanned_video_part_map, downloaded_subtitles, mode="a", bare_
|
||||
if not bare_save:
|
||||
store_subtitle_info(scanned_video_part_map, downloaded_subtitles, storage, mode=mode)
|
||||
|
||||
return save_successful
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ from storage import save_subtitles, whack_missing_parts, get_subtitle_storage
|
||||
from support.config import config
|
||||
from support.items import get_recent_items, is_ignored, get_item
|
||||
from support.lib import Plex
|
||||
from support.helpers import track_usage, get_title_for_video_metadata, cast_bool
|
||||
from support.plex_media import scan_videos, get_plex_metadata, PartUnknownException
|
||||
from support.helpers import track_usage, get_title_for_video_metadata, cast_bool, PartUnknownException
|
||||
from support.plex_media import scan_videos, get_plex_metadata
|
||||
|
||||
|
||||
class Task(object):
|
||||
@@ -86,7 +86,7 @@ class Task(object):
|
||||
def post_run(self, data_holder):
|
||||
self.running = False
|
||||
self.last_run = datetime.datetime.now()
|
||||
if self.time_start:
|
||||
if self.time_start and self.last_run:
|
||||
self.last_run_time = self.last_run - self.time_start
|
||||
self.time_start = None
|
||||
Log.Info(u"Task: ran: %s", self.name)
|
||||
@@ -124,7 +124,7 @@ class SearchAllRecentlyAddedMissing(Task):
|
||||
def prepare(self, *args, **kwargs):
|
||||
self.items_done = []
|
||||
recent_items = get_recent_items()
|
||||
missing = items_get_all_missing_subs(recent_items)
|
||||
missing = items_get_all_missing_subs(recent_items, sleep_after_request=0.2)
|
||||
ids = set([id for added_at, id, title, item, missing_languages in missing if not is_ignored(id, item=item)])
|
||||
self.items_searching = missing
|
||||
self.items_searching_ids = ids
|
||||
@@ -181,7 +181,7 @@ class SearchAllRecentlyAddedMissing(Task):
|
||||
|
||||
|
||||
class SubtitleListingMixin(object):
|
||||
def list_subtitles(self, rating_key, item_type, part_id, language):
|
||||
def list_subtitles(self, rating_key, item_type, part_id, language, skip_wrong_fps=True):
|
||||
metadata = get_plex_metadata(rating_key, part_id, item_type)
|
||||
|
||||
if item_type == "episode":
|
||||
@@ -197,9 +197,14 @@ class SubtitleListingMixin(object):
|
||||
video, plex_part = scanned_parts.items()[0]
|
||||
config.init_subliminal_patches()
|
||||
|
||||
provider_settings = config.provider_settings.copy()
|
||||
if not skip_wrong_fps:
|
||||
provider_settings = config.provider_settings.copy()
|
||||
provider_settings["opensubtitles"]["skip_wrong_fps"] = False
|
||||
|
||||
available_subs = list_all_subtitles(scanned_parts, {Language.fromietf(language)},
|
||||
providers=config.providers,
|
||||
provider_configs=config.provider_settings,
|
||||
provider_configs=provider_settings,
|
||||
pool_class=config.provider_pool)
|
||||
|
||||
use_hearing_impaired = Prefs['subtitles.search.hearingImpaired'] in ("prefer", "force HI")
|
||||
@@ -293,7 +298,8 @@ class AvailableSubsForItem(SubtitleListingMixin, Task):
|
||||
super(AvailableSubsForItem, self).run()
|
||||
self.running = True
|
||||
track_usage("Subtitle", "manual", "list", 1)
|
||||
self.data = self.list_subtitles(self.rating_key, self.item_type, self.part_id, self.language)
|
||||
self.data = self.list_subtitles(self.rating_key, self.item_type, self.part_id, self.language,
|
||||
skip_wrong_fps=False)
|
||||
|
||||
def post_run(self, task_data):
|
||||
super(AvailableSubsForItem, self).post_run(task_data)
|
||||
@@ -366,6 +372,10 @@ class FindBetterSubtitles(DownloadSubtitleMixin, SubtitleListingMixin, Task):
|
||||
now = datetime.datetime.now()
|
||||
min_score_series = int(Prefs["subtitles.search.minimumTVScore2"].strip())
|
||||
min_score_movies = int(Prefs["subtitles.search.minimumMovieScore2"].strip())
|
||||
overwrite_manually_modified = cast_bool(
|
||||
Prefs["scheduler.tasks.FindBetterSubtitles.overwrite_manually_modified"])
|
||||
overwrite_manually_selected = cast_bool(
|
||||
Prefs["scheduler.tasks.FindBetterSubtitles.overwrite_manually_selected"])
|
||||
|
||||
subtitle_storage = get_subtitle_storage()
|
||||
recent_subs = subtitle_storage.load_recent_files(age_days=max_search_days)
|
||||
@@ -374,6 +384,7 @@ class FindBetterSubtitles(DownloadSubtitleMixin, SubtitleListingMixin, Task):
|
||||
video_id = stored_subs.video_id
|
||||
|
||||
if stored_subs.item_type == "episode":
|
||||
cutoff = self.series_cutoff
|
||||
min_score = min_score_series
|
||||
else:
|
||||
cutoff = self.movies_cutoff
|
||||
@@ -412,11 +423,15 @@ class FindBetterSubtitles(DownloadSubtitleMixin, SubtitleListingMixin, Task):
|
||||
continue
|
||||
|
||||
# got manual subtitle but don't want to touch those?
|
||||
if current_mode == "m" and \
|
||||
not cast_bool(Prefs["scheduler.tasks.FindBetterSubtitles.overwrite_manually_selected"]):
|
||||
if current_mode == "m" and not overwrite_manually_selected:
|
||||
Log.Debug(u"Skipping finding better subs, had manual: %s", stored_subs.title)
|
||||
continue
|
||||
|
||||
# subtitle modifications different from default
|
||||
if not overwrite_manually_modified and set(current.mods).difference(set(config.default_mods)):
|
||||
Log.Debug(u"Skipping finding better subs, it has manual modifications: %s", stored_subs.title)
|
||||
continue
|
||||
|
||||
try:
|
||||
subs = self.list_subtitles(video_id, stored_subs.item_type, part_id, language)
|
||||
except PartUnknownException:
|
||||
@@ -453,8 +468,12 @@ class FindBetterSubtitles(DownloadSubtitleMixin, SubtitleListingMixin, Task):
|
||||
pass
|
||||
subtitle_storage.save(stored_subs)
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
if better_found:
|
||||
Log.Debug("Task: %s, done. Better subtitles found for %s items", self.name, better_found)
|
||||
else:
|
||||
Log.Debug("Task: %s, done. No better subtitles found for %s items", self.name, len(recent_subs))
|
||||
|
||||
|
||||
class SubtitleStorageMaintenance(Task):
|
||||
|
||||
@@ -258,13 +258,14 @@
|
||||
"35",
|
||||
"30",
|
||||
"25",
|
||||
"21",
|
||||
"20",
|
||||
"15",
|
||||
"10",
|
||||
"5",
|
||||
"0"
|
||||
],
|
||||
"default": "25"
|
||||
"default": "21"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.use_random_agents",
|
||||
@@ -332,7 +333,7 @@
|
||||
},
|
||||
{
|
||||
"id": "providers.multithreading",
|
||||
"label": "Search enabled providers simuntaneously (multithreading)",
|
||||
"label": "Search enabled providers simultaneously (multithreading)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
@@ -381,7 +382,7 @@
|
||||
"id": "subtitles.search.minimumMovieScore2",
|
||||
"label": "Minimum score for movies (min: 60, def/sane: 69, min-ideal: 82; see http://v.ht/szscores)",
|
||||
"type": "text",
|
||||
"default": "69"
|
||||
"default": "60"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.hearingImpaired",
|
||||
@@ -399,6 +400,12 @@
|
||||
"id": "subtitles.remove_hi",
|
||||
"label": "Remove Hearing Impaired tags from downloaded subtitles",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.fix_common",
|
||||
"label": "Fix common whitespace/punctuation issues in subtitles",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
@@ -530,6 +537,12 @@
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.tasks.FindBetterSubtitles.overwrite_manually_modified",
|
||||
"label": "Scheduler: Overwrite subtitles with non-default subtitle modifications when better found",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "history_size",
|
||||
"label": "History: amount of items to store historical data for",
|
||||
@@ -628,6 +641,12 @@
|
||||
],
|
||||
"default": "WARNING"
|
||||
},
|
||||
{
|
||||
"id": "log_debug_mods",
|
||||
"label": "Log subtitle modification (debug)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "log_console",
|
||||
"label": "Log to console (for development/debugging)",
|
||||
|
||||
+3
-3
@@ -9,11 +9,11 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.0</string>
|
||||
<string>2.0.15</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2.0.0.12</string>
|
||||
<string>2.0.15.1209</string>
|
||||
<key>PlexFrameworkVersion</key>
|
||||
<string>2</string>
|
||||
<key>PlexPluginClass</key>
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<h1>Sub-Zero for Plex</h1><i>Subtitles done right</i>
|
||||
|
||||
Version 2.0.0.12 DEV
|
||||
Version 2.0.15.1209 RC2
|
||||
|
||||
Originally based on @bramwalet's awesome <a href="https://github.com/bramwalet/Subliminal.bundle">Subliminal.bundle</a>
|
||||
|
||||
|
||||
@@ -369,7 +369,8 @@ class Chapter(object):
|
||||
if chapterdisplays:
|
||||
string = chapterdisplays[0].get('ChapString')
|
||||
language = chapterdisplays[0].get('ChapLanguage')
|
||||
return cls(start, hidden, enabled, end, string, language)
|
||||
return cls(start, hidden, enabled, end, string, language)
|
||||
return cls(start, hidden, enabled, end)
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s [%s, enabled=%s]>' % (self.__class__.__name__, self.start, self.enabled)
|
||||
|
||||
@@ -460,7 +460,7 @@ def get_subtitle_path(video_path, language=None, extension='.srt', forced_tag=Fa
|
||||
|
||||
|
||||
def save_subtitles(video, subtitles, single=False, directory=None, encoding=None, encode_with=None, chmod=None,
|
||||
forced_tag=False, path_decoder=None):
|
||||
forced_tag=False, path_decoder=None, debug_mods=False):
|
||||
"""Save subtitles on filesystem.
|
||||
|
||||
Subtitles are saved in the order of the list. If a subtitle with a language has already been saved, other subtitles
|
||||
@@ -515,7 +515,8 @@ def save_subtitles(video, subtitles, single=False, directory=None, encoding=None
|
||||
|
||||
# save normalized subtitle if encoder or no encoding is given
|
||||
if has_encoder or encoding is None:
|
||||
content = encode_with(subtitle.get_modified_text()) if has_encoder else subtitle.get_modified_content()
|
||||
content = encode_with(subtitle.get_modified_text(debug=debug_mods)) if has_encoder else \
|
||||
subtitle.get_modified_content(debug=debug_mods)
|
||||
with io.open(subtitle_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
@@ -5,3 +5,5 @@ from subliminal.providers import Provider as _Provider
|
||||
|
||||
class Provider(_Provider):
|
||||
hash_verifiable = False
|
||||
skip_wrong_fps = True
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class OpenSubtitlesSubtitle(_OpenSubtitlesSubtitle):
|
||||
|
||||
def __init__(self, language, hearing_impaired, page_link, subtitle_id, matched_by, movie_kind, hash, movie_name,
|
||||
movie_release_name, movie_year, movie_imdb_id, series_season, series_episode, query_parameters,
|
||||
filename, encoding, fps):
|
||||
filename, encoding, fps, skip_wrong_fps=True):
|
||||
super(OpenSubtitlesSubtitle, self).__init__(language, hearing_impaired, page_link, subtitle_id,
|
||||
matched_by, movie_kind, hash,
|
||||
movie_name, movie_release_name, movie_year, movie_imdb_id,
|
||||
@@ -27,6 +27,8 @@ class OpenSubtitlesSubtitle(_OpenSubtitlesSubtitle):
|
||||
self.query_parameters = query_parameters or {}
|
||||
self.fps = fps
|
||||
self.release_info = movie_release_name
|
||||
self.wrong_fps = False
|
||||
self.skip_wrong_fps = skip_wrong_fps
|
||||
|
||||
def get_matches(self, video, hearing_impaired=False):
|
||||
matches = super(OpenSubtitlesSubtitle, self).get_matches(video)
|
||||
@@ -39,9 +41,14 @@ class OpenSubtitlesSubtitle(_OpenSubtitlesSubtitle):
|
||||
|
||||
# video has fps info, sub also, and sub's fps is greater than 0
|
||||
if video.fps and sub_fps and (video.fps != self.fps):
|
||||
logger.debug("Wrong FPS (expected: %s, got: %s, lowering score massively)", video.fps, self.fps)
|
||||
# fixme: may be too harsh
|
||||
return set()
|
||||
self.wrong_fps = True
|
||||
|
||||
if self.skip_wrong_fps:
|
||||
logger.debug("Wrong FPS (expected: %s, got: %s, lowering score massively)", video.fps, self.fps)
|
||||
# fixme: may be too harsh
|
||||
return set()
|
||||
else:
|
||||
logger.debug("Wrong FPS (expected: %s, got: %s, continuing)", video.fps, self.fps)
|
||||
|
||||
# matched by tag?
|
||||
if self.matched_by == "tag":
|
||||
@@ -57,8 +64,9 @@ class OpenSubtitlesProvider(ProviderRetryMixin, _OpenSubtitlesProvider):
|
||||
only_foreign = True
|
||||
subtitle_class = OpenSubtitlesSubtitle
|
||||
hash_verifiable = True
|
||||
skip_wrong_fps = True
|
||||
|
||||
def __init__(self, username=None, password=None, use_tag_search=False, only_foreign=False):
|
||||
def __init__(self, username=None, password=None, use_tag_search=False, only_foreign=False, skip_wrong_fps=True):
|
||||
if username is not None and password is None or username is None and password is not None:
|
||||
raise ConfigurationError('Username and password must be specified')
|
||||
|
||||
@@ -66,6 +74,7 @@ class OpenSubtitlesProvider(ProviderRetryMixin, _OpenSubtitlesProvider):
|
||||
self.password = password or ''
|
||||
self.use_tag_search = use_tag_search
|
||||
self.only_foreign = only_foreign
|
||||
self.skip_wrong_fps = skip_wrong_fps
|
||||
|
||||
if use_tag_search:
|
||||
logger.info("Using tag/exact filename search")
|
||||
@@ -176,7 +185,7 @@ class OpenSubtitlesProvider(ProviderRetryMixin, _OpenSubtitlesProvider):
|
||||
movie_kind,
|
||||
hash, movie_name, movie_release_name, movie_year, movie_imdb_id,
|
||||
series_season, series_episode, query_parameters, filename, encoding,
|
||||
movie_fps)
|
||||
movie_fps, skip_wrong_fps=self.skip_wrong_fps)
|
||||
logger.debug('Found subtitle %r by %s', subtitle, matched_by)
|
||||
subtitles.append(subtitle)
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ class PatchedSubtitle(Subtitle):
|
||||
hash_verifiable = False
|
||||
mods = None
|
||||
plex_media_fps = None
|
||||
skip_wrong_fps = False
|
||||
wrong_fps = False
|
||||
|
||||
def __init__(self, language, hearing_impaired=False, page_link=None, encoding=None, mods=None):
|
||||
super(PatchedSubtitle, self).__init__(language, hearing_impaired=hearing_impaired, page_link=page_link,
|
||||
@@ -136,7 +138,7 @@ class PatchedSubtitle(Subtitle):
|
||||
|
||||
return True
|
||||
|
||||
def get_modified_content(self):
|
||||
def get_modified_content(self, debug=False):
|
||||
"""
|
||||
:return: string
|
||||
"""
|
||||
@@ -144,16 +146,20 @@ class PatchedSubtitle(Subtitle):
|
||||
return self.content
|
||||
|
||||
encoding = self.guess_encoding()
|
||||
|
||||
submods = SubtitleModifications()
|
||||
submods.load(content=self.text, fps=self.plex_media_fps, language=self.language)
|
||||
submods = SubtitleModifications(debug=debug)
|
||||
submods.load(content=self.text, language=self.language)
|
||||
submods.modify(*self.mods)
|
||||
|
||||
return submods.to_string("srt", encoding=encoding).encode(encoding=encoding)
|
||||
|
||||
def get_modified_text(self):
|
||||
def get_modified_text(self, debug=False):
|
||||
"""
|
||||
:return: unicode
|
||||
"""
|
||||
content = self.get_modified_content()
|
||||
content = self.get_modified_content(debug=debug)
|
||||
encoding = self.guess_encoding()
|
||||
return content.decode(encoding=encoding)
|
||||
|
||||
|
||||
class ModifiedSubtitle(PatchedSubtitle):
|
||||
id = None
|
||||
|
||||
@@ -16,8 +16,10 @@ if debug:
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
submod = SubMod(debug=debug)
|
||||
submod.load(fn, language=Language.fromietf("en"))
|
||||
submod.modify("remove_HI", "OCR_fixes")
|
||||
submod.load(fn, language=Language.fromietf("eng"))
|
||||
submod.modify("remove_HI", "OCR_fixes", "common")
|
||||
#submod.modify("OCR_fixes")
|
||||
#submod.modify("change_FPS(from=24,to=25)")
|
||||
#submod.modify("common")
|
||||
|
||||
#print submod.f.to_string("srt")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# coding=utf-8
|
||||
|
||||
from registry import registry
|
||||
from mods import hearing_impaired, ocr_fixes
|
||||
from mods import hearing_impaired, ocr_fixes, fps, offset, common
|
||||
from main import SubtitleModifications, SubMod
|
||||
|
||||
@@ -18,22 +18,21 @@ class SubtitleModifications(object):
|
||||
self.debug = debug
|
||||
self.initialized_mods = {}
|
||||
|
||||
def load(self, fn=None, content=None, fps=None, language=None):
|
||||
def load(self, fn=None, content=None, language=None):
|
||||
"""
|
||||
|
||||
:param language: babelfish.Language language of the subtitle
|
||||
:param fn: filename
|
||||
:param content: unicode
|
||||
:param fps:
|
||||
:return:
|
||||
"""
|
||||
self.language = language
|
||||
self.initialized_mods = {}
|
||||
try:
|
||||
if fn:
|
||||
self.f = pysubs2.load(fn, fps=fps)
|
||||
self.f = pysubs2.load(fn)
|
||||
elif content:
|
||||
self.f = pysubs2.SSAFile.from_string(content, fps=fps)
|
||||
self.f = pysubs2.SSAFile.from_string(content)
|
||||
except (IOError,
|
||||
UnicodeDecodeError,
|
||||
pysubs2.exceptions.UnknownFPSError,
|
||||
@@ -44,19 +43,58 @@ class SubtitleModifications(object):
|
||||
elif content:
|
||||
logger.exception("Couldn't load subtitle: %s", traceback.format_exc())
|
||||
|
||||
@classmethod
|
||||
def parse_identifier(cls, identifier):
|
||||
# simple identifier
|
||||
if identifier in registry.mods:
|
||||
return identifier, {}
|
||||
|
||||
# identifier with params; identifier(param=value)
|
||||
split_args = identifier[identifier.find("(")+1:-1].split(",")
|
||||
args = dict((key, value) for key, value in [sub.split("=") for sub in split_args])
|
||||
return identifier[:identifier.find("(")], args
|
||||
|
||||
@classmethod
|
||||
def get_mod_class(cls, identifier):
|
||||
identifier, args = cls.parse_identifier(identifier)
|
||||
return registry.mods[identifier]
|
||||
|
||||
@classmethod
|
||||
def get_mod_signature(cls, identifier, **kwargs):
|
||||
return cls.get_mod_class(identifier).get_signature(**kwargs)
|
||||
|
||||
def modify(self, *mods):
|
||||
new_f = []
|
||||
|
||||
for identifier in mods:
|
||||
parsed_mods = [SubtitleModifications.parse_identifier(mod) for mod in mods]
|
||||
line_mods = []
|
||||
non_line_mods = []
|
||||
|
||||
for identifier, args in parsed_mods:
|
||||
if identifier not in registry.mods:
|
||||
raise NotImplementedError("Mod %s not loaded" % identifier)
|
||||
|
||||
for line in self.f:
|
||||
applied_mods = []
|
||||
for identifier in mods:
|
||||
if identifier in registry.mods:
|
||||
if identifier not in self.initialized_mods:
|
||||
self.initialized_mods[identifier] = registry.mods[identifier](self)
|
||||
mod_cls = registry.mods[identifier]
|
||||
if mod_cls.modifies_whole_file:
|
||||
non_line_mods.append((identifier, args))
|
||||
else:
|
||||
line_mods.append((identifier, args))
|
||||
|
||||
if identifier not in self.initialized_mods:
|
||||
self.initialized_mods[identifier] = mod_cls(self)
|
||||
|
||||
# apply file mods
|
||||
if non_line_mods:
|
||||
for identifier, args in non_line_mods:
|
||||
mod = self.initialized_mods[identifier]
|
||||
mod.modify(None, debug=self.debug, parent=self, **args)
|
||||
|
||||
# apply line mods
|
||||
if line_mods:
|
||||
for line in self.f:
|
||||
applied_mods = []
|
||||
skip_line = False
|
||||
for identifier, args in line_mods:
|
||||
mod = self.initialized_mods[identifier]
|
||||
|
||||
# don't bother reapplying exclusive mods multiple times
|
||||
@@ -66,15 +104,17 @@ class SubtitleModifications(object):
|
||||
if not mod.processors:
|
||||
continue
|
||||
|
||||
new_content = mod.modify(line.text, debug=self.debug, parent=self)
|
||||
new_content = mod.modify(line.text, debug=self.debug, parent=self, **args)
|
||||
if not new_content:
|
||||
if self.debug:
|
||||
logger.debug("%s: deleting %s", identifier, line)
|
||||
continue
|
||||
skip_line = True
|
||||
break
|
||||
|
||||
line.text = new_content
|
||||
applied_mods.append(identifier)
|
||||
new_f.append(line)
|
||||
if not skip_line:
|
||||
new_f.append(line)
|
||||
|
||||
self.f.events = new_f
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ logger = logging.getLogger(__name__)
|
||||
class SubtitleModification(object):
|
||||
identifier = None
|
||||
description = None
|
||||
long_description = None
|
||||
exclusive = False
|
||||
advanced = False # has parameters
|
||||
modifies_whole_file = False # operates on the whole file, not individual entries
|
||||
pre_processors = []
|
||||
processors = []
|
||||
post_processors = []
|
||||
@@ -18,7 +21,7 @@ class SubtitleModification(object):
|
||||
def __init__(self, parent):
|
||||
return
|
||||
|
||||
def _process(self, content, processors, debug=False, parent=None):
|
||||
def _process(self, content, processors, debug=False, parent=None, **kwargs):
|
||||
if not content:
|
||||
return
|
||||
|
||||
@@ -43,22 +46,27 @@ class SubtitleModification(object):
|
||||
logger.debug("%s: %s -> %s", processor, old_content, new_content)
|
||||
return new_content
|
||||
|
||||
def pre_process(self, content, debug=False, parent=None):
|
||||
return self._process(content, self.pre_processors, debug=debug, parent=parent)
|
||||
def pre_process(self, content, debug=False, parent=None, **kwargs):
|
||||
return self._process(content, self.pre_processors, debug=debug, parent=parent, **kwargs)
|
||||
|
||||
def process(self, content, debug=False, parent=None):
|
||||
return self._process(content, self.processors, debug=debug, parent=parent)
|
||||
def process(self, content, debug=False, parent=None, **kwargs):
|
||||
return self._process(content, self.processors, debug=debug, parent=parent, **kwargs)
|
||||
|
||||
def post_process(self, content, debug=False, parent=None):
|
||||
return self._process(content, self.post_processors, debug=debug, parent=parent)
|
||||
def post_process(self, content, debug=False, parent=None, **kwargs):
|
||||
return self._process(content, self.post_processors, debug=debug, parent=parent, **kwargs)
|
||||
|
||||
def modify(self, content, debug=False, parent=None):
|
||||
def modify(self, content, debug=False, parent=None, **kwargs):
|
||||
new_content = content
|
||||
for method in ("pre_process", "process", "post_process"):
|
||||
new_content = getattr(self, method)(new_content, debug=debug, parent=parent)
|
||||
new_content = getattr(self, method)(new_content, debug=debug, parent=parent, **kwargs)
|
||||
|
||||
return new_content
|
||||
|
||||
@classmethod
|
||||
def get_signature(cls, **kwargs):
|
||||
string_args = ",".join(["%s=%s" % (key, value) for key, value in kwargs.iteritems()])
|
||||
return "%s(%s)" % (cls.identifier, string_args)
|
||||
|
||||
|
||||
class SubtitleTextModification(SubtitleModification):
|
||||
post_processors = [
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# coding=utf-8
|
||||
|
||||
import re
|
||||
|
||||
from subzero.modification.mods import SubtitleTextModification
|
||||
from subzero.modification.processors.string_processor import StringProcessor
|
||||
from subzero.modification.processors.re_processor import NReProcessor
|
||||
from subzero.modification import registry
|
||||
|
||||
|
||||
class CommonFixes(SubtitleTextModification):
|
||||
identifier = "common"
|
||||
description = "Basic common fixes"
|
||||
exclusive = True
|
||||
|
||||
long_description = """\
|
||||
Fix common whitespace/punctuation issues in subtitles
|
||||
"""
|
||||
|
||||
processors = [
|
||||
# no space after ellipsis
|
||||
NReProcessor(re.compile(r'(?u)\.\.\.(?![\s.,!?\'"])(?!$)'), "... ", name="CM_ellipsis_no_space"),
|
||||
|
||||
# multiple spaces
|
||||
NReProcessor(re.compile(r'(?u)[\s]{2,}'), " ", name="CM_multiple_spaces"),
|
||||
|
||||
# no space after starting dash
|
||||
NReProcessor(re.compile(r'(?u)^-(?![\s-])'), "- ", name="CM_dash_space"),
|
||||
|
||||
# '' = "
|
||||
StringProcessor("''", '"', name="CM_double_apostrophe"),
|
||||
|
||||
# remove starting spaced dots (not matching ellipses
|
||||
NReProcessor(re.compile(r'(?u)^(?!\s?(\.\s\.\s\.)|(\s?\.{3}))[\s.]*'), "", name="CM_starting_spacedots"),
|
||||
|
||||
# space missing before doublequote
|
||||
#ReProcessor(re.compile(r'(?u)(?<!^)(?<![\s(\["])("[^"]+")'), r' \1', name="CM_space_before_dblquote"),
|
||||
|
||||
# space missing after doublequote
|
||||
#ReProcessor(re.compile(r'(?u)("[^"\s][^"]+")([^\s.,!?)\]]+)'), r"\1 \2", name="CM_space_after_dblquote"),
|
||||
|
||||
# space before ending doublequote?
|
||||
|
||||
# -- = ...
|
||||
StringProcessor("-- ", '... ', name="CM_doubledash"),
|
||||
|
||||
# remove >>
|
||||
NReProcessor(re.compile(r'(?u)^>>[\s]*'), "", name="CM_leading_crocodiles"),
|
||||
|
||||
# remove leading ...
|
||||
NReProcessor(re.compile(r'(?u)^\.\.\.[\s]*'), "", name="CM_leading_ellipsis"),
|
||||
|
||||
# replace uppercase I with lowercase L in words
|
||||
NReProcessor(re.compile(r'(?u)([A-z]+)I([a-z]+)'), r"\1l\2", name="CM_uppercase_i_in_word"),
|
||||
|
||||
# fix spaces in numbers
|
||||
NReProcessor(re.compile(r'(?u)([0-9]+)[\s]+([0-9:.]*[0-9]+)'), r"\1\2", name="CM_spaces_in_numbers"),
|
||||
]
|
||||
|
||||
|
||||
registry.register(CommonFixes)
|
||||
@@ -0,0 +1,27 @@
|
||||
# coding=utf-8
|
||||
|
||||
import logging
|
||||
|
||||
from subzero.modification.mods import SubtitleModification
|
||||
from subzero.modification import registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChangeFPS(SubtitleModification):
|
||||
identifier = "change_FPS"
|
||||
description = "Change the FPS of the subtitle"
|
||||
exclusive = True
|
||||
advanced = True
|
||||
modifies_whole_file = True
|
||||
|
||||
long_description = """\
|
||||
Re-syncs the subtitle to the framerate of the current media file.
|
||||
"""
|
||||
|
||||
def modify(self, content, debug=False, parent=None, **kwargs):
|
||||
fps_from = kwargs.get("from")
|
||||
fps_to = kwargs.get("to")
|
||||
parent.f.transform_framerate(float(fps_from), float(fps_to))
|
||||
|
||||
registry.register(ChangeFPS)
|
||||
@@ -11,18 +11,22 @@ class HearingImpaired(SubtitleTextModification):
|
||||
description = "Remove Hearing Impaired tags"
|
||||
exclusive = True
|
||||
|
||||
long_description = """\
|
||||
Removes tags, text and characters from subtitles that are meant for hearing impaired people
|
||||
"""
|
||||
|
||||
processors = [
|
||||
# brackets
|
||||
NReProcessor(re.compile(r'(?sux)[([].+[)\]]'), "", name="HI_brackets"),
|
||||
# brackets (only remove if at least 3 consecutive uppercase chars in brackets
|
||||
NReProcessor(re.compile(r'(?sux)[([].+(?=[A-Z]{3,}).+[)\]]'), "", name="HI_brackets"),
|
||||
|
||||
# text before colon (and possible dash in front), max 11 chars after the first whitespace (if any)
|
||||
NReProcessor(re.compile(r'(?u)(^[A-z\-\'"_]+[\w\s]{0,11}:[^0-9{2}][\s]*)'), "", name="HI_before_colon"),
|
||||
|
||||
# all caps line (at least 3 chars)
|
||||
NReProcessor(re.compile(r'(?u)(^[A-Z]{3,}$)'), "", name="HI_all_caps"),
|
||||
# all caps line (at least 4 chars)
|
||||
NReProcessor(re.compile(r'(?u)(^(?=.*[A-Z]{4,})[A-Z\s]+$)'), "", name="HI_all_caps"),
|
||||
|
||||
# dash in front
|
||||
NReProcessor(re.compile(r'(?u)^\s*-\s*'), "", name="HI_starting_dash"),
|
||||
# NReProcessor(re.compile(r'(?u)^\s*-\s*'), "", name="HI_starting_dash"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ class FixOCR(SubtitleTextModification):
|
||||
exclusive = True
|
||||
data_dict = None
|
||||
|
||||
long_description = """\
|
||||
Fix issues that happen when a subtitle gets converted from bitmap to text through OCR
|
||||
"""
|
||||
|
||||
def __init__(self, parent):
|
||||
super(FixOCR, self).__init__(parent)
|
||||
data_dict = OCR_fix_data.get(parent.language.alpha3t)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# coding=utf-8
|
||||
|
||||
import logging
|
||||
|
||||
from subzero.modification.mods import SubtitleModification
|
||||
from subzero.modification import registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ShiftOffset(SubtitleModification):
|
||||
identifier = "shift_offset"
|
||||
description = "Change the timing of the subtitle"
|
||||
exclusive = True
|
||||
advanced = True
|
||||
modifies_whole_file = True
|
||||
|
||||
long_description = """\
|
||||
Adds or substracts a certain amount of time from the whole subtitle to match your media
|
||||
"""
|
||||
|
||||
def modify(self, content, debug=False, parent=None, **kwargs):
|
||||
parent.f.shift(h=int(kwargs.get("h", 0)), m=int(kwargs.get("m", 0)), s=int(kwargs.get("s", 0)),
|
||||
ms=int(kwargs.get("ms", 0)))
|
||||
|
||||
|
||||
registry.register(ShiftOffset)
|
||||
@@ -31,7 +31,8 @@ class NReProcessor(ReProcessor):
|
||||
def process(self, content, debug=False):
|
||||
lines = []
|
||||
for line in content.split(r"\N"):
|
||||
a = super(NReProcessor, self).process(line, debug=debug)
|
||||
a = line.strip()
|
||||
a = super(NReProcessor, self).process(a, debug=debug)
|
||||
if not a:
|
||||
continue
|
||||
lines.append(a)
|
||||
|
||||
@@ -178,9 +178,11 @@ class StoredSubtitlesManager(object):
|
||||
try:
|
||||
subs_for_video = self.storage.LoadObject(fn)
|
||||
except:
|
||||
logger.error("Failed to load item %s: %s" % (fn, traceback.format_exc()))
|
||||
logger.error("Failed to load item \"%s\": %s" % (fn, traceback.format_exc()))
|
||||
|
||||
if not subs_for_video:
|
||||
if not subs_for_video or not hasattr(subs_for_video, "version"):
|
||||
logger.error("Invalid subtitle storage for \"%s\", removing" % fn)
|
||||
self.delete(fn)
|
||||
return
|
||||
|
||||
# apply possible migrations
|
||||
|
||||
@@ -4,25 +4,25 @@
|
||||
|
||||
2
|
||||
00:00:10,759 --> 00:00:12,678
|
||||
ROSE: So what is it?
|
||||
What's wrong?
|
||||
ROSE: (Help us. Please. . .help us.)
|
||||
What's "wrong"?
|
||||
|
||||
3
|
||||
00:00:12,679 --> 00:00:16,097
|
||||
I don't know. Some kind
|
||||
I don't know. Some kind of wrong "1 00" number
|
||||
of signal, drawing the Tardis off course.
|
||||
|
||||
4
|
||||
00:00:16,099 --> 00:00:17,224
|
||||
this is a subtitle test with a text before colons: Where are we?
|
||||
this is a"subtitle" test "with a"text before colons and "peter"following: Where are we?."
|
||||
|
||||
5
|
||||
00:00:17,225 --> 00:00:19,684
|
||||
less text before colons: Earth. Utah, North America.
|
||||
"less text before colons: Earth. Utah, North America."
|
||||
|
||||
6
|
||||
00:00:19,686 --> 00:00:21,103
|
||||
Ithinkyou're About half a mile underground.
|
||||
Ithinkyou're About half a miIe underground.
|
||||
|
||||
7
|
||||
00:00:21,103 --> 00:00:23,603
|
||||
@@ -30,11 +30,11 @@ lrn gonna And when are we?
|
||||
|
||||
8
|
||||
00:00:24,274 --> 00:00:26,649
|
||||
2012.
|
||||
...2012. it's 1 2:00 o'clock
|
||||
|
||||
9
|
||||
00:00:26,650 --> 00:00:29,370
|
||||
God, that's so close. I should be 26!
|
||||
(BIG BROTHER THEME MUSIC)
|
||||
|
||||
10
|
||||
00:00:30,612 --> 00:00:33,112
|
||||
@@ -43,27 +43,28 @@ God, that's so close. I should be 26!
|
||||
11
|
||||
00:00:33,658 --> 00:00:34,783
|
||||
(WHOO
|
||||
SHING) geil
|
||||
SHING) >>geil
|
||||
|
||||
12
|
||||
00:00:34,783 --> 00:00:36,826
|
||||
Blimey.
|
||||
-- Blimey.
|
||||
|
||||
13
|
||||
00:00:36,828 --> 00:00:39,328
|
||||
ROSE: Like a great big museum.
|
||||
ROSE: Like a "great...big museum".
|
||||
|
||||
14
|
||||
00:00:40,414 --> 00:00:42,914
|
||||
DOCTOR's MOM: An alien museum.
|
||||
DOCTOR's MOM: ''An alien museum".
|
||||
|
||||
15
|
||||
00:00:43,542 --> 00:00:46,042
|
||||
Someone's got a hobby.
|
||||
Someone's got a hobby.
|
||||
|
||||
16
|
||||
00:00:46,378 --> 00:00:49,048
|
||||
They must've spent a fortune on this.
|
||||
FULL UPPERCASE LINE HERE
|
||||
and some text
|
||||
|
||||
17
|
||||
00:00:49,631 --> 00:00:51,924
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB |
@@ -10,6 +10,34 @@ Checkout **[the Sub-Zero Wiki](https://github.com/pannal/Sub-Zero.bundle/wiki)**
|
||||
|
||||
## Changelog
|
||||
|
||||
2.0.15.1209 RC2
|
||||
- core: fixes
|
||||
- core: submod-common: fix multiple dots at start of line
|
||||
- core/menu: add subtitle modification debug setting
|
||||
- core/menu: when manually listing available subtitles in menu, display those with wrong FPS also (opensubtitles), because you can fix them later
|
||||
- core/menu: advanced-menu: add apply-all-default-mods menu item; add re-apply all mods menu item
|
||||
- core: always look for currently (not-) existing subtitles when called; hopefully fixes #276
|
||||
- scheduler/menu: be faster; also launch scheduled tasks in threads, not just manually launched ones
|
||||
- core: don't delete subtitles with .custom or .embedded in their filenames when running auto cleanup
|
||||
- menu: add back-to-previous menu items
|
||||
|
||||
|
||||
2.0.12.1180 RC1
|
||||
- core: update subliminal to version 2
|
||||
- core: update all dependencies
|
||||
- core: add new providers: legendastv (pt-BR), napiprojekt (pl), shooter (cn), subscenter (heb)
|
||||
- core: rewritten all subliminal patches for version 2
|
||||
- menu: add icons for menu items; update main channel icon
|
||||
- core: use SSL again for opensubtitles
|
||||
- core: improved matching due to subliminal 2 (and SZ custom) tvdb/omdb refiners
|
||||
- menu: add "Get my logs" function to the advanced menu, which zips up all necessary logs suitable for posting in the forums
|
||||
- core: on non-windows systems, utilize a file-based cache database for provider media lists and subliminal refiner results
|
||||
- core: add manual and automatic subtitle modification framework (fix common OCR issues, remove hearing impaired etc.)
|
||||
- menu: add subtitle modifications (subtitle content fixes, offset-based shifting, framerate conversion)
|
||||
- menu: add recently played menu
|
||||
- improve almost everything Sub-Zero did in 1.4 :)
|
||||
|
||||
|
||||
1.4.27.973
|
||||
- core: ignore "obfuscated" and "scrambled" tags in filenames when searching for subtitles
|
||||
- core: exotic embedded subtitles are now also considered when searching (and when the option is enabled); fixes #264
|
||||
|
||||
Reference in New Issue
Block a user