Compare commits
323 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 824e2c5106 | |||
| 5ec1f31434 | |||
| 4f4a9a8048 | |||
| a456ae4fa7 | |||
| b3b0ab225b | |||
| f4aa5d2bf1 | |||
| 8cc7ab5775 | |||
| 6d4a07db2e | |||
| a0d924c3b0 | |||
| c201bf3ef3 | |||
| 8d45a46ee2 | |||
| 6a5a9b33c2 | |||
| 6d237b1781 | |||
| 46b40bf2f0 | |||
| 546c258c82 | |||
| f6031e9b9c | |||
| b6480f9e32 | |||
| b830aba31c | |||
| c6b0c95aa4 | |||
| 129f58c059 | |||
| c10242b388 | |||
| 5c0a430d84 | |||
| 382afa52e9 | |||
| 8fd5191685 | |||
| ce67d74980 | |||
| 0e95e67d7e | |||
| 26e7a572d4 | |||
| 0d3d27c343 | |||
| 97764cbac8 | |||
| 883d9b60ee | |||
| 24f6a8e1f2 | |||
| fa366f2789 | |||
| 2bbe7d15eb | |||
| c5e3dda387 | |||
| 0184c41c8e | |||
| 0c8b0c1dd9 | |||
| 71e5c74b77 | |||
| 21ab566cff | |||
| 20e475cfb7 | |||
| febf592db6 | |||
| fe94358f0c | |||
| 0cb560b856 | |||
| faa0bb7550 | |||
| 1d7df79465 | |||
| 72f2a4fc86 | |||
| 8434eb4ff4 | |||
| ba4280ee4e | |||
| 34f34cef4d | |||
| 30f21d71c8 | |||
| 592d264b19 | |||
| 9d55dca0e1 | |||
| da4111904c | |||
| a4b9358f14 | |||
| 122c6527d4 | |||
| 844b76e116 | |||
| f262009349 | |||
| bc1a4ceb42 | |||
| a8ba984064 | |||
| fda6dab572 | |||
| 4cdb777840 | |||
| f94d9595a8 | |||
| 5d38bd26a2 | |||
| 9239261c5a | |||
| e3aed706fb | |||
| 89d87c6356 | |||
| a0cfe0b6fd | |||
| 476c311e01 | |||
| bb10b8fffa | |||
| 4a8fa4a838 | |||
| 624b844454 | |||
| 027f1f4045 | |||
| 28d66dc162 | |||
| 3995e732f6 | |||
| 60f553707a | |||
| c37e2ceaab | |||
| abd7922700 | |||
| c47389426e | |||
| 6b5c7bd14b | |||
| cb072c2aa6 | |||
| 533649c791 | |||
| 3105f2e8ae | |||
| 8160bc98fd | |||
| 8a1b615fe9 | |||
| 3f3bb2d830 | |||
| ae4871f6dd | |||
| f46da7b12f | |||
| b3f5bdd58d | |||
| ca8ecd297b | |||
| d954d25a73 | |||
| bda261b495 | |||
| af3142546e | |||
| 05b9a400fd | |||
| 5a0f6969d9 | |||
| 7ea0f3f73b | |||
| a383682147 | |||
| 7dd414bc8f | |||
| 32fca9dadb | |||
| dd75eacebf | |||
| ca42e7e7f1 | |||
| 3430702d51 | |||
| 653a9087c4 | |||
| 05889e7554 | |||
| 7fca0cd201 | |||
| 7321c9095e | |||
| bbb83d9cad | |||
| 275023c844 | |||
| 35c6aee5dd | |||
| 25686e981f | |||
| ad8022666f | |||
| 68f246cda5 | |||
| cc977fce35 | |||
| d4f7e2712e | |||
| 9a89b01741 | |||
| 009938bc06 | |||
| 487e933c25 | |||
| 539f621c0b | |||
| bfe9860c92 | |||
| 5b5645e042 | |||
| b9053d1dfd | |||
| a7084ecd88 | |||
| b3b301332c | |||
| eb43778718 | |||
| d6ed4e6b0b | |||
| e38d696ac9 | |||
| 07c3a48657 | |||
| ebede7a297 | |||
| 59ab5e16cc | |||
| 889399fc04 | |||
| 69eda1420b | |||
| 063920a2a5 | |||
| 1623ee858f | |||
| 1edd13b229 | |||
| d0b6fbb7b4 | |||
| 4fc21a29e3 | |||
| 3e6d03eea1 | |||
| b950485f6c | |||
| 3007c0d57f | |||
| 5a2b30432c | |||
| cfb66db035 | |||
| 1eec18b76d | |||
| 1d2bfe2195 | |||
| f4a13b2e7a | |||
| b29667b9f6 | |||
| dcd21aab1c | |||
| bfbfcd2d8b | |||
| bb72181359 | |||
| 2d0b9ab9f1 | |||
| 291f462955 | |||
| 74d6de9c78 | |||
| 9f99390145 | |||
| 8cdf12bafd | |||
| 2b5442a2a8 | |||
| ebb9f42771 | |||
| c75e2b778f | |||
| 1f6d198bf5 | |||
| 30bbfc37fc | |||
| 5a693ae673 | |||
| 38325f84ac | |||
| 0eebd164ec | |||
| f60b730411 | |||
| 16db1db748 | |||
| 818cf4bc33 | |||
| 789b7ba9aa | |||
| fdf389f62c | |||
| 8ae8433463 | |||
| 71464cd5bf | |||
| 44ca3b9e34 | |||
| 2dd24f02c6 | |||
| a6e6bc810a | |||
| 9d00a82343 | |||
| 1246c53c77 | |||
| 8fc10c873e | |||
| b48aac638f | |||
| e427565fcf | |||
| 0e028b3ffe | |||
| c81e3a7def | |||
| 669c9b4fb7 | |||
| 5f015c3d69 | |||
| faa46a7e4d | |||
| 70d2a225f3 | |||
| 1521a77281 | |||
| 516551714b | |||
| e794122b7f | |||
| 67282d1ebd | |||
| 2c5975cf26 | |||
| dc142281f5 | |||
| 5a445fc5bd | |||
| 7ff2f97ac3 | |||
| d47492188e | |||
| 263d3e7546 | |||
| ce31bf63e9 | |||
| 3c030dd6c3 | |||
| 147c3dfe9d | |||
| c2e820f851 | |||
| f53f5f1870 | |||
| c20ecaa616 | |||
| 2cc270708a | |||
| ddf7d4fc96 | |||
| 1e73b530ed | |||
| 5c4a1275fb | |||
| d55a809493 | |||
| 50ecf71879 | |||
| af7434e35d | |||
| ad7239c5d8 | |||
| f90efceac3 | |||
| c6f70dccca | |||
| eca358e73a | |||
| 4e6ce7e8bb | |||
| a2049200b1 | |||
| b10306aca0 | |||
| aaf430cae8 | |||
| e7ee9e3304 | |||
| a4f65adda9 | |||
| d38b90d1f3 | |||
| a07a4a167c | |||
| a77c29af48 | |||
| 4044f3e787 | |||
| 70de96a9e8 | |||
| 014f34d813 | |||
| 8fdc50b2aa | |||
| 88874fb9b6 | |||
| 11ad4cdeac | |||
| c5f1b39fba | |||
| 6eb8af8fd5 | |||
| 2ec3b393fc | |||
| 7a2977d4c8 | |||
| b987142b3f | |||
| 22656d62d4 | |||
| 7d6693e206 | |||
| c3f2bb4d21 | |||
| e154019d07 | |||
| 1b891eba73 | |||
| 38e5f8e4e9 | |||
| 428ab4c6d7 | |||
| 27ce34bce6 | |||
| 6fb5760a6a | |||
| 2e2fd1580d | |||
| 8ab826d27d | |||
| d1f33baa30 | |||
| 7239941168 | |||
| ca00e8680d | |||
| 57d9e0c600 | |||
| f2811422f0 | |||
| 0f71d2e0e2 | |||
| 388c4baa15 | |||
| 13a8c2facd | |||
| def5a26d98 | |||
| d1ad72b0f2 | |||
| da62656f7e | |||
| da3e2399f7 | |||
| c70af212d1 | |||
| 8becc8bd72 | |||
| 5bc0307242 | |||
| 034b2975d6 | |||
| 3ffde8c52b | |||
| b125a747c8 | |||
| 00e656dbce | |||
| a7f6224237 | |||
| 81f469531b | |||
| a4794d1619 | |||
| d6b7bd1194 | |||
| c0169afbc2 | |||
| 19fcc6a175 | |||
| cada8483fe | |||
| 2464894fd5 | |||
| d700df9a60 | |||
| 273a376a4a | |||
| 41b78d80e4 | |||
| d904462417 | |||
| 6bf9836f57 | |||
| 92c4a2af59 | |||
| bbeced7e7e | |||
| c94295b472 | |||
| 4905429bb0 | |||
| c0d60222aa | |||
| 312c6c9729 | |||
| 137cb6bb45 | |||
| bc3408c25d | |||
| 5cb8e5e49c | |||
| 36b924443d | |||
| 5122935e10 | |||
| b5176600f4 | |||
| e073a3c289 | |||
| 18c2f782c2 | |||
| 6449513cb8 | |||
| f56e39e3c2 | |||
| 90e423b62c | |||
| 8e455b48c3 | |||
| c0d54dc6dd | |||
| 3d7f4ba844 | |||
| ae4a0f8caa | |||
| 61e02f0666 | |||
| ee9460d43e | |||
| 264c640036 | |||
| 8ae0c9bee1 | |||
| 670b2d18b4 | |||
| 4a37f1e6f0 | |||
| 897bdff957 | |||
| f1893517e0 | |||
| 4b510f1ff6 | |||
| 961944b0b2 | |||
| 93d0959766 | |||
| 00a5678784 | |||
| c34373cc00 | |||
| d2992adddb | |||
| 0d826be66e | |||
| 67d4250c71 | |||
| 9c2b7aead1 | |||
| 67ad6cd551 | |||
| a4d1ee4be0 | |||
| 72b725c933 | |||
| 7a308e5aed | |||
| 7dd4bdbf74 | |||
| 5560afcd8f | |||
| e2c90548ed | |||
| dd050ba770 | |||
| d2e67af495 | |||
| b870175031 | |||
| f8fc50b37b | |||
| 730a46e32f | |||
| a06343b1f1 | |||
| 675fcf8dbc | |||
| 7ef23c8434 |
@@ -52,3 +52,5 @@ coverage.xml
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# pycharm
|
||||
.idea
|
||||
|
||||
Regular → Executable
+118
@@ -1,3 +1,121 @@
|
||||
1.3.31.513
|
||||
|
||||
- core: add option to only download one language again (and skip the addition of .lang to the subtitle filename) (default: off); fixes #126
|
||||
- core: add option to always encode saved subtitles to UTF-8 (default: on); fixes #128
|
||||
- core: add fallback encoding detection using bs4.UnicodeDammit; hopefully fixes #101
|
||||
- core: update libraries: chardet, beautifulsoup, six
|
||||
- menu/core: check Plex libraries for permission problems on plugin start and report them in the channel menu (option, default: on); fixes #143
|
||||
- menu: while a manual refresh takes place, add a refresh button to the top of the SZ menu for convenience
|
||||
- menu: move the "add/remove X to ignore list" menu item to the bottom of the list on item detail
|
||||
|
||||
|
||||
1.3.27.491
|
||||
|
||||
- menu/core: make Sub-Zero channel menu optional (setting: "Enable Sub-Zero channel (disabling doesn't affect the subtitle features)?")
|
||||
- OpenSubtitles: detect and match video/subtitle FPS (framerate) to reduce out of sync subtitle matches
|
||||
- core: internal fixes; add _markerlib library (rare)
|
||||
- core: don't score tvshow episode title matches, should improve episode subtitle matches quite a bit (and reduce out of sync subtitles)
|
||||
- OpenSubtitles: make tag/exact filename matches optional (setting: "I keep the exact (release-) filename of my media files")
|
||||
- menu: unicode video title errors fixed
|
||||
- TVSubtitles: correctly match certain show IDs (such as "Series Name (US)")
|
||||
- core: don't break subtitle evaluation on crashed guessing
|
||||
|
||||
|
||||
1.3.23.459
|
||||
|
||||
- core: slight code cleanup and fixes
|
||||
- core: add physical (filesystem) ignore mode (create files named `subzero.ignore`, `.subzero.ignore`, `.nosz` to ignore specific files/seasons/series/libraries)
|
||||
- core: fix guessit hinting of tv series with rare folder layout (e.g. series_name/a/S01E01.mkv)
|
||||
- core: remove "format" necessity from (opensubtitles) hash-validation
|
||||
- OpenSubtitles: dramatically improve matching: add tag (exact filename) matching and treat it just like hash matches
|
||||
- core: ignore embedded forced subtitles (fixes #106)
|
||||
- docs: update
|
||||
- settings: clarify
|
||||
|
||||
|
||||
1.3.20.422
|
||||
- tvsubtitles: show matching was partially broken
|
||||
- addic7ed: better show matching
|
||||
- core: correctly skip subtitles stored in filesystem if metadata storage was selected (Local Media Assets agent may still pick them up)
|
||||
- core: fix local API access (switch from HTTPS to HTTP)
|
||||
- core: fix handling of library names and media paths with non-ascii chars in it
|
||||
- core: fix bundle version to correctly display current bundle version
|
||||
- core: skip downloading multi-CD subtitle
|
||||
- settings: clarify
|
||||
|
||||
|
||||
1.3.20.403
|
||||
- core: handle & and - ("and" and dash) in names
|
||||
- core: fixed handling of internal metadata subtitles
|
||||
- re-upped the minimum tv score to 85 (may be even higher in the future)
|
||||
- opensubtitles: possibly significantly better movie matching (now also query for movie title, instead of only querying for video hash)
|
||||
|
||||
|
||||
1.3.20.396
|
||||
- core: fix logging handlers (when saving log_level settings loggers got duplicated)
|
||||
- core: better movie matching by only hinting the filename and the last subdirectory to guessit (instead of the full path)
|
||||
- core: don't fail on wrong detection/scanning of media file
|
||||
- lower minimum tv series score from 85 to 67 (removed title; composed of: series=44 + season=11 + episode=11 + hearing_impaired=1)
|
||||
|
||||
|
||||
1.3.19.379
|
||||
- core: new recent items implementation (used in "Items with missing subtitles"), now really picking up everything instead of using Plex's recently_added API endpoint
|
||||
- core: be more strict about title matching - a matched title doesn't automatically mean season and episode are correct, too
|
||||
- core: rewrote the hash matching algorithm to not blindly trust hash matches anymore, but instead episodes have to match the series name, season number, episode number and format (BluRay, HDTV...); movie have to at least match the title, format and codec for the hash to be considered
|
||||
- core: remove TheSubDB support for now, as it only supports hash-based matching
|
||||
- scheduler: more robust item-fail-handling (fixes #81)
|
||||
- config: "Scan: include embedded subtitles" now by default is off, as embedded subs have proven to be pretty unreliable
|
||||
- config: add configuration option for how many items per library are to be considered recent (default: 200)
|
||||
- config: make logging verbosity configurable, default: WARNING - log files should be considerably smaller now
|
||||
- config: make console logging optional, default: off - good for development/debugging
|
||||
- config: removed the ignore lists
|
||||
- menu: added "Browse all items", where you can browse all your libraries and manage your ignore list (add/remove sections/series/items)
|
||||
- menu: added "Display ignore list", where you can manage your ignored sections, series and items
|
||||
- menu: the submenu titles are now dynamically composed of a breadcrumb-style tree so you see where you are
|
||||
- menu: show the current and past state of the important menu actions such as (force)-refresh an item or refreshing the menu, on the Refresh-button's description
|
||||
- plugin now isn't in the dev mode by default and has logging to the console off (in certain configurations this resulted in huge syslogs)
|
||||
|
||||
|
||||
1.3.6.316
|
||||
- scheduler: missing subtitles task now able to handle huge libraries (thanks @chopeta, @comrade)
|
||||
- scheduler: detect item-stalling, add wait and retry logic to make missing subtitles task more robust
|
||||
- scheduler: report failed items to logs after task run completion
|
||||
- hint series name and episode title, or movie title to guessit to make detection way better (e.g. for Mr. Robot)
|
||||
|
||||
1.3.6.304
|
||||
- scheduler: correct the recent-determination of the search for missing subtitles in recently_added task
|
||||
- scheduler: rewrote search for missing subtitles task; it now requests refreshes one by one and not in bulk anymore (hopefully fixes stalling)
|
||||
- handle rare cases of weird file system encodings (ANSI_X3.4-1968 for example)
|
||||
- fix simplejson warning on startup
|
||||
|
||||
1.3.6.297
|
||||
- rename Sub-Zero to Sub-Zero.bundle (requirement for adding Sub-Zero to the Plex channel directory)
|
||||
- channel: add logging actions for the internal storage to the advanced menu
|
||||
- channel: handle item titles with foreign characters in them correctly
|
||||
- (hopefully) fix handling file names with foreign characters in them when scanning for local media
|
||||
- reformat the whole project, mostly honoring pep8
|
||||
- scheduler: fixed some serious bugs; broken tasks (stalled) and some errors many of you have seen should be gone now
|
||||
- scheduler: partly rewritten to be more robust, again
|
||||
- settings: move Plex.tv credentials to the top
|
||||
|
||||
1.3.5.281
|
||||
- fix tasks broken for 1.2 -> 1.3.5 upgraders
|
||||
|
||||
1.3.5.273 (same build as Beta Release 1.3.0.273) - changes from previous stable 1.2.11.180
|
||||
- add a channel menu, making this plugin a hybrid (Agent+Channel)
|
||||
- add a generic background task scheduler
|
||||
- add a task to search for subtitles for items with missing subtitles (manually triggered and automatic)
|
||||
- add artwork
|
||||
- add Plex.tv credentials/token-generation support (needed for Plex Home users for the API to work)
|
||||
- addic7ed: improve show name matching again
|
||||
- channel: able to browse current on-deck and recently-added items, and refresh or force-refresh (search for new subtitles) single items
|
||||
- add library/series/video blacklist for items which should be skipped in "Search for missing subtitles"-task
|
||||
- add donation links
|
||||
- change the license to The Unlicense (while keeping the original MIT license from subliminal.bundle intact)
|
||||
- store subtitle information in internal plugin storage (for later usage)
|
||||
- many internal code improvements
|
||||
- update documentation
|
||||
|
||||
1.3.0.273
|
||||
- more robust update functionality
|
||||
- menu: add refresh button to menu (to see the task state updating)
|
||||
|
||||
Regular → Executable
+179
-136
@@ -1,35 +1,49 @@
|
||||
# coding=utf-8
|
||||
|
||||
import string
|
||||
import os
|
||||
import urllib
|
||||
import zipfile
|
||||
import re
|
||||
import copy
|
||||
import sys
|
||||
|
||||
# just some slight modifications to support sum and iter again
|
||||
from subzero.sandbox import restore_builtins
|
||||
|
||||
module = sys.modules['__main__']
|
||||
restore_builtins(module, {})
|
||||
|
||||
globals = getattr(module, "__builtins__")["globals"]
|
||||
for key, value in getattr(module, "__builtins__").iteritems():
|
||||
if key != "globals":
|
||||
globals()[key] = value
|
||||
|
||||
import logger
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
# temporarily add the console handler and set it to DEBUG to catch errors upon imports
|
||||
Core.log.addHandler(logger.console_handler)
|
||||
Core.log.setLevel(logging.DEBUG)
|
||||
|
||||
sys.modules["logger"] = logger
|
||||
|
||||
import subliminal
|
||||
import subliminal_patch
|
||||
import support
|
||||
|
||||
import interface
|
||||
sys.modules["interface"] = interface
|
||||
|
||||
from subzero.constants import OS_PLEX_USERAGENT, DEPENDENCY_MODULE_NAMES, PERSONAL_MEDIA_IDENTIFIER, PLUGIN_IDENTIFIER_SHORT,\
|
||||
PLUGIN_IDENTIFIER, PLUGIN_NAME, PREFIX
|
||||
from subzero.constants import OS_PLEX_USERAGENT, PERSONAL_MEDIA_IDENTIFIER
|
||||
from subzero import intent
|
||||
from support.lib import lib_unaccessible_error
|
||||
from support.background import scheduler
|
||||
|
||||
from interface.menu import fatality as MainMenu, ValidatePrefs
|
||||
from support.subtitlehelpers import getSubtitlesFromMetadata
|
||||
from support.storage import storeSubtitleInfo
|
||||
from interface.menu import *
|
||||
from support.plex_media import convert_media_to_parts, get_media_item_ids, scan_parts
|
||||
from support.subtitlehelpers import get_subtitles_from_metadata, force_utf8
|
||||
from support.helpers import notify_executable
|
||||
from support.storage import store_subtitle_info, whack_missing_parts
|
||||
from support.items import is_ignored
|
||||
from support.config import config
|
||||
|
||||
|
||||
def Start():
|
||||
HTTP.CacheTime = 0
|
||||
HTTP.Headers['User-agent'] = OS_PLEX_USERAGENT
|
||||
logger.registerLoggingHander(DEPENDENCY_MODULE_NAMES)
|
||||
|
||||
# configured cache to be in memory as per https://github.com/Diaoul/subliminal/issues/303
|
||||
subliminal.region.configure('dogpile.cache.memory')
|
||||
|
||||
@@ -37,107 +51,101 @@ def Start():
|
||||
ValidatePrefs()
|
||||
Log.Debug(config.full_version)
|
||||
|
||||
if not config.plex_api_working:
|
||||
Log.Error(lib_unaccessible_error)
|
||||
return
|
||||
if not config.permissions_ok:
|
||||
Log.Error("Insufficient permissions on library folders:")
|
||||
for title, path in config.missing_permissions:
|
||||
Log.Error("Insufficient permissions on library %s, folder: %s" % (title, path))
|
||||
return
|
||||
|
||||
scheduler.run()
|
||||
|
||||
|
||||
def initSubliminalPatches():
|
||||
def init_subliminal_patches():
|
||||
# configure custom subtitle destination folders for scanning pre-existing subs
|
||||
dest_folder = config.subtitleDestinationFolder
|
||||
dest_folder = config.subtitle_destination_folder
|
||||
subliminal_patch.patch_video.CUSTOM_PATHS = [dest_folder] if dest_folder else []
|
||||
subliminal_patch.patch_provider_pool.DOWNLOAD_TRIES = int(Prefs['subtitles.try_downloads'])
|
||||
subliminal_patch.patch_providers.addic7ed.USE_BOOST = bool(Prefs['provider.addic7ed.boost'])
|
||||
|
||||
def scanTvMedia(media):
|
||||
videos = {}
|
||||
for season in media.seasons:
|
||||
for episode in media.seasons[season].episodes:
|
||||
ep = media.seasons[season].episodes[episode]
|
||||
forceRefresh = intent.get("force", ep.id)
|
||||
for item in media.seasons[season].episodes[episode].items:
|
||||
for part in item.parts:
|
||||
scannedVideo = scanVideo(part, "episode", ignore_all=forceRefresh)
|
||||
scannedVideo.id = media.seasons[season].episodes[episode].id
|
||||
videos[scannedVideo] = part
|
||||
return videos
|
||||
|
||||
def scanMovieMedia(media):
|
||||
videos = {}
|
||||
forceRefresh = intent.get("force", media.id)
|
||||
for item in media.items:
|
||||
for part in item.parts:
|
||||
scannedVideo = scanVideo(part, "movie", ignore_all=forceRefresh)
|
||||
scannedVideo.id = media.id
|
||||
videos[scannedVideo] = part
|
||||
return videos
|
||||
|
||||
def scanVideo(part, video_type, ignore_all=False):
|
||||
|
||||
embedded_subtitles = not ignore_all and Prefs['subtitles.scan.embedded']
|
||||
external_subtitles = not ignore_all and Prefs['subtitles.scan.external']
|
||||
|
||||
if ignore_all:
|
||||
Log.Debug("Force refresh intended.")
|
||||
|
||||
Log.Debug("Scanning video: %s, subtitles=%s, embedded_subtitles=%s" % (part.file, external_subtitles, embedded_subtitles))
|
||||
try:
|
||||
return subliminal.video.scan_video(part.file, subtitles=external_subtitles, embedded_subtitles=embedded_subtitles, video_type=video_type)
|
||||
except ValueError:
|
||||
Log.Warn("File could not be guessed by subliminal")
|
||||
|
||||
def downloadBestSubtitles(video_part_map, min_score=0):
|
||||
def download_best_subtitles(video_part_map, min_score=0):
|
||||
hearing_impaired = Prefs['subtitles.search.hearingImpaired']
|
||||
languages = config.langList
|
||||
if not languages:
|
||||
return
|
||||
languages = config.lang_list
|
||||
if not languages:
|
||||
return
|
||||
|
||||
missing_languages = False
|
||||
for video, part in video_part_map.iteritems():
|
||||
if not Prefs['subtitles.save.filesystem']:
|
||||
# scan for existing metadata subtitles
|
||||
meta_subs = getSubtitlesFromMetadata(part)
|
||||
for language, subList in meta_subs.iteritems():
|
||||
if subList:
|
||||
video.subtitle_languages.add(language)
|
||||
Log.Debug("Found metadata subtitle %s for %s", language, video)
|
||||
if not Prefs['subtitles.save.filesystem']:
|
||||
# scan for existing metadata subtitles
|
||||
meta_subs = get_subtitles_from_metadata(part)
|
||||
for language, subList in meta_subs.iteritems():
|
||||
if subList:
|
||||
video.subtitle_languages.add(language)
|
||||
Log.Debug("Found metadata subtitle %s for %s", language, video)
|
||||
|
||||
if not (languages - video.subtitle_languages):
|
||||
Log.Debug('All languages %r exist for %s', languages, video)
|
||||
continue
|
||||
missing_languages = True
|
||||
break
|
||||
missing_subs = (languages - video.subtitle_languages)
|
||||
|
||||
# all languages are found if we either really have subs for all languages or we only want to have exactly one language
|
||||
# and we've only found one (the case for a selected language, Prefs['subtitles.only_one'] (one found sub matches any language))
|
||||
found_one_which_is_enough = len(video.subtitle_languages) >= 1 and Prefs['subtitles.only_one']
|
||||
if not missing_subs or found_one_which_is_enough:
|
||||
if found_one_which_is_enough:
|
||||
Log.Debug('Only one language was requested, and we\'ve got a subtitle for %s', video)
|
||||
else:
|
||||
Log.Debug('All languages %r exist for %s', languages, video)
|
||||
continue
|
||||
missing_languages = True
|
||||
break
|
||||
|
||||
if missing_languages:
|
||||
Log.Debug("Download best subtitles using settings: min_score: %s, hearing_impaired: %s" %(min_score, hearing_impaired))
|
||||
|
||||
return subliminal.api.download_best_subtitles(video_part_map.keys(), languages, min_score, hearing_impaired, providers=config.providers, provider_configs=config.providerSettings)
|
||||
Log.Debug("Download best subtitles using settings: min_score: %s, hearing_impaired: %s" % (min_score, hearing_impaired))
|
||||
|
||||
return subliminal.api.download_best_subtitles(video_part_map.keys(), languages, min_score, hearing_impaired, providers=config.providers,
|
||||
provider_configs=config.provider_settings)
|
||||
Log.Debug("All languages for all requested videos exist. Doing nothing.")
|
||||
|
||||
def saveSubtitles(videos, subtitles):
|
||||
|
||||
def save_subtitles(videos, subtitles):
|
||||
meta_fallback = False
|
||||
save_successful = False
|
||||
storage = "metadata"
|
||||
if Prefs['subtitles.save.filesystem']:
|
||||
Log.Debug("Using filesystem as subtitle storage")
|
||||
saveSubtitlesToFile(subtitles)
|
||||
storage = "filesystem"
|
||||
else:
|
||||
Log.Debug("Using metadata as subtitle storage")
|
||||
saveSubtitlesToMetadata(videos, subtitles)
|
||||
storage = "metadata"
|
||||
storage = "filesystem"
|
||||
try:
|
||||
Log.Debug("Using filesystem as subtitle storage")
|
||||
save_subtitles_to_file(subtitles)
|
||||
except OSError:
|
||||
if Prefs["subtitles.save.metadata_fallback"]:
|
||||
meta_fallback = True
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
save_successful = True
|
||||
|
||||
storeSubtitleInfo(videos, subtitles, storage)
|
||||
if not Prefs['subtitles.save.filesystem'] or meta_fallback:
|
||||
if meta_fallback:
|
||||
Log.Debug("Using metadata as subtitle storage, because filesystem storage failed")
|
||||
else:
|
||||
Log.Debug("Using metadata as subtitle storage")
|
||||
save_successful = save_subtitles_to_metadata(videos, subtitles)
|
||||
|
||||
def saveSubtitlesToFile(subtitles):
|
||||
if save_successful and config.notify_executable:
|
||||
notify_executable(config.notify_executable, videos, subtitles, storage)
|
||||
|
||||
store_subtitle_info(videos, subtitles, storage)
|
||||
|
||||
|
||||
def save_subtitles_to_file(subtitles):
|
||||
fld_custom = Prefs["subtitles.save.subFolder.Custom"].strip() if bool(Prefs["subtitles.save.subFolder.Custom"]) else None
|
||||
|
||||
for video, video_subtitles in subtitles.items():
|
||||
if not video_subtitles:
|
||||
continue
|
||||
|
||||
fld = None
|
||||
if fld_custom or Prefs["subtitles.save.subFolder"] != "current folder":
|
||||
# specific subFolder requested, create it if it doesn't exist
|
||||
for video, video_subtitles in subtitles.items():
|
||||
if not video_subtitles:
|
||||
continue
|
||||
|
||||
fld = None
|
||||
if fld_custom or Prefs["subtitles.save.subFolder"] != "current folder":
|
||||
# specific subFolder requested, create it if it doesn't exist
|
||||
fld_base = os.path.split(video.name)[0]
|
||||
if fld_custom:
|
||||
if fld_custom.startswith("/"):
|
||||
@@ -149,83 +157,118 @@ def saveSubtitlesToFile(subtitles):
|
||||
fld = os.path.join(fld_base, Prefs["subtitles.save.subFolder"])
|
||||
if not os.path.exists(fld):
|
||||
os.makedirs(fld)
|
||||
subliminal.api.save_subtitles(video, video_subtitles, directory=fld)
|
||||
subliminal.api.save_subtitles(video, video_subtitles, directory=fld, single=Prefs['subtitles.only_one'],
|
||||
encode_with=force_utf8 if Prefs['subtitles.enforce_encoding'] else None)
|
||||
return True
|
||||
|
||||
def saveSubtitlesToMetadata(videos, subtitles):
|
||||
|
||||
def save_subtitles_to_metadata(videos, subtitles):
|
||||
for video, video_subtitles in subtitles.items():
|
||||
mediaPart = videos[video]
|
||||
for subtitle in video_subtitles:
|
||||
mediaPart.subtitles[Locale.Language.Match(subtitle.language.alpha2)][subtitle.page_link] = Proxy.Media(subtitle.content, ext="srt")
|
||||
content = force_utf8(subtitle.text) if Prefs['subtitles.enforce_encoding'] else subtitle.content
|
||||
mediaPart.subtitles[Locale.Language.Match(subtitle.language.alpha2)][subtitle.page_link] = Proxy.Media(content, ext="srt")
|
||||
return True
|
||||
|
||||
def updateLocalMedia(media, media_type="movies"):
|
||||
|
||||
def update_local_media(metadata, media, media_type="movies"):
|
||||
# Look for subtitles
|
||||
if media_type == "movies":
|
||||
for item in media.items:
|
||||
for part in item.parts:
|
||||
support.localmedia.findSubtitles(part)
|
||||
return
|
||||
for item in media.items:
|
||||
for part in item.parts:
|
||||
support.localmedia.find_subtitles(part)
|
||||
return
|
||||
|
||||
# Look for subtitles for each episode.
|
||||
for s in media.seasons:
|
||||
# If we've got a date based season, ignore it for now, otherwise it'll collide with S/E folders/XML and PMS
|
||||
# prefers date-based (why?)
|
||||
if int(s) < 1900 or metadata.guid.startswith(PERSONAL_MEDIA_IDENTIFIER):
|
||||
for e in media.seasons[s].episodes:
|
||||
for i in media.seasons[s].episodes[e].items:
|
||||
# If we've got a date based season, ignore it for now, otherwise it'll collide with S/E folders/XML and PMS
|
||||
# prefers date-based (why?)
|
||||
if int(s) < 1900 or metadata.guid.startswith(PERSONAL_MEDIA_IDENTIFIER):
|
||||
for e in media.seasons[s].episodes:
|
||||
for i in media.seasons[s].episodes[e].items:
|
||||
|
||||
# Look for subtitles.
|
||||
for part in i.parts:
|
||||
support.localmedia.findSubtitles(part)
|
||||
else:
|
||||
pass
|
||||
# Look for subtitles.
|
||||
for part in i.parts:
|
||||
support.localmedia.find_subtitles(part)
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
class SubZeroAgent(object):
|
||||
agent_type = None
|
||||
agent_type_verbose = None
|
||||
languages = [Locale.Language.English]
|
||||
primary_provider = False
|
||||
score_prefs_key = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(SubZeroAgent, self).__init__(*args, **kwargs)
|
||||
self.agent_type = "movies" if isinstance(self, Agent.Movies) else "series"
|
||||
self.name = "Sub-Zero Subtitles (%s, %s)" % ("Movies" if self.agent_type == "movies" else "TV", config.getVersion())
|
||||
super(SubZeroAgent, self).__init__(*args, **kwargs)
|
||||
self.agent_type = "movies" if isinstance(self, Agent.Movies) else "series"
|
||||
self.name = "Sub-Zero Subtitles (%s, %s)" % (self.agent_type_verbose, config.get_version())
|
||||
|
||||
def search(self, results, media, lang):
|
||||
Log.Debug("Sub-Zero %s, %s search" % (config.version, self.agent_type))
|
||||
results.Append(MetadataSearchResult(id='null', score=100))
|
||||
|
||||
def update(self, metadata, media, lang):
|
||||
Log.Debug("Sub-Zero %s, %s update called" % (config.version, self.agent_type))
|
||||
Log.Debug("Sub-Zero %s, %s update called" % (config.version, self.agent_type))
|
||||
|
||||
try:
|
||||
initSubliminalPatches()
|
||||
videos, subtitles = getattr(self, "update_%s" % self.agent_type)(metadata, media, lang)
|
||||
if not media:
|
||||
Log.Error("Called with empty media, something is really wrong with your setup!")
|
||||
return
|
||||
|
||||
if subtitles:
|
||||
saveSubtitles(videos, subtitles)
|
||||
set_refresh_menu_state(media, media_type=self.agent_type)
|
||||
|
||||
updateLocalMedia(media, media_type=self.agent_type)
|
||||
item_ids = []
|
||||
try:
|
||||
init_subliminal_patches()
|
||||
parts = convert_media_to_parts(media, kind=self.agent_type)
|
||||
|
||||
finally:
|
||||
# notify any running tasks about our finished update
|
||||
for video in videos.keys():
|
||||
scheduler.signal("updated_metadata", video.id)
|
||||
# media ignored?
|
||||
use_any_parts = False
|
||||
for part in parts:
|
||||
if is_ignored(part["id"]):
|
||||
Log.Debug(u"Ignoring %s" % part)
|
||||
continue
|
||||
use_any_parts = True
|
||||
|
||||
def update_movies(self, metadata, media, lang):
|
||||
videos = scanMovieMedia(media)
|
||||
subtitles = downloadBestSubtitles(videos, min_score=int(Prefs["subtitles.search.minimumMovieScore"]))
|
||||
return videos, subtitles
|
||||
if not use_any_parts:
|
||||
Log.Debug(u"Nothing to do.")
|
||||
return
|
||||
|
||||
def update_series(self, metadata, media, lang):
|
||||
videos = scanTvMedia(media)
|
||||
subtitles = downloadBestSubtitles(videos, min_score=int(Prefs["subtitles.search.minimumTVScore"]))
|
||||
return videos, subtitles
|
||||
use_score = Prefs[self.score_prefs_key]
|
||||
scanned_parts = scan_parts(parts, kind=self.agent_type)
|
||||
subtitles = download_best_subtitles(scanned_parts, min_score=int(use_score))
|
||||
item_ids = get_media_item_ids(media, kind=self.agent_type)
|
||||
|
||||
whack_missing_parts(scanned_parts)
|
||||
|
||||
if subtitles:
|
||||
save_subtitles(scanned_parts, subtitles)
|
||||
|
||||
update_local_media(metadata, media, media_type=self.agent_type)
|
||||
|
||||
finally:
|
||||
# update the menu state
|
||||
set_refresh_menu_state(None)
|
||||
|
||||
# notify any running tasks about our finished update
|
||||
for item_id in item_ids:
|
||||
scheduler.signal("updated_metadata", item_id)
|
||||
|
||||
# resolve existing intent for that id
|
||||
intent.resolve("force", item_id)
|
||||
Dict.Save()
|
||||
|
||||
|
||||
class SubZeroSubtitlesAgentMovies(SubZeroAgent, Agent.Movies):
|
||||
contributes_to = ['com.plexapp.agents.imdb', 'com.plexapp.agents.xbmcnfo', 'com.plexapp.agents.themoviedb']
|
||||
contributes_to = ['com.plexapp.agents.imdb', 'com.plexapp.agents.xbmcnfo', 'com.plexapp.agents.themoviedb', 'com.plexapp.agents.hama']
|
||||
score_prefs_key = "subtitles.search.minimumMovieScore"
|
||||
agent_type_verbose = "Movies"
|
||||
|
||||
|
||||
class SubZeroSubtitlesAgentTvShows(SubZeroAgent, Agent.TV_Shows):
|
||||
contributes_to = ['com.plexapp.agents.thetvdb', 'com.plexapp.agents.thetvdbdvdorder', 'com.plexapp.agents.xbmcnfotv']
|
||||
|
||||
contributes_to = ['com.plexapp.agents.thetvdb', 'com.plexapp.agents.themoviedb',
|
||||
'com.plexapp.agents.thetvdbdvdorder', 'com.plexapp.agents.xbmcnfotv', 'com.plexapp.agents.hama']
|
||||
score_prefs_key = "subtitles.search.minimumTVScore"
|
||||
agent_type_verbose = "TV"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import sys
|
||||
|
||||
import menu
|
||||
sys.modules["interface.menu"] = menu
|
||||
sys.modules["interface.menu"] = menu
|
||||
|
||||
import menu_helpers
|
||||
sys.modules["interface.menu_helpers"] = menu_helpers
|
||||
+483
-103
@@ -1,190 +1,570 @@
|
||||
# coding=utf-8
|
||||
import logging
|
||||
import logger
|
||||
|
||||
from subzero import intent
|
||||
from subzero.constants import TITLE, ART, ICON, PREFIX, PLUGIN_IDENTIFIER
|
||||
from support.config import config
|
||||
from support.helpers import pad_title, encode_message, decode_message, timestamp
|
||||
from support.auth import refresh_plex_token
|
||||
from support.storage import resetStorage
|
||||
from support.items import getRecentlyAddedItems, getOnDeckItems, refreshItem
|
||||
from support.missing_subtitles import getAllRecentlyAddedMissing, searchMissing
|
||||
from menu_helpers import add_ignore_options, dig_tree, set_refresh_menu_state, \
|
||||
should_display_ignore, enable_channel_wrapper, default_thumb, debounce
|
||||
from subzero.constants import TITLE, ART, ICON, PREFIX, PLUGIN_IDENTIFIER, DEPENDENCY_MODULE_NAMES
|
||||
from support.background import scheduler
|
||||
from support.lib import Plex, lib_unaccessible_error
|
||||
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
|
||||
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 support.plex_media import scan_parts
|
||||
|
||||
# init GUI
|
||||
ObjectContainer.title1 = TITLE
|
||||
ObjectContainer.art = R(ART)
|
||||
ObjectContainer.no_history = True
|
||||
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, header=None, message=None, only_refresh=False):
|
||||
def fatality(randomize=None, force_title=None, header=None, message=None, only_refresh=False, no_history=False, replace_parent=False):
|
||||
"""
|
||||
subzero main menu
|
||||
"""
|
||||
oc = ObjectContainer(header=header, message=message, no_cache=True, no_history=True)
|
||||
title = force_title if force_title is not None else config.full_version
|
||||
oc = ObjectContainer(title1=title, title2=None, header=unicode(header) if header else header, message=message, no_history=no_history,
|
||||
replace_parent=replace_parent, no_cache=True)
|
||||
|
||||
if not config.plex_api_working:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(fatality, randomize=timestamp()),
|
||||
title=pad_title("PMS API ERROR"),
|
||||
summary=lib_unaccessible_error
|
||||
))
|
||||
return oc
|
||||
if not 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 only_refresh:
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(OnDeckMenu),
|
||||
title=pad_title("Subtitles for 'On Deck' items"),
|
||||
summary="Shows the current on deck items and allows you to individually (force-) refresh their metadata/subtitles."
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RecentlyAddedMenu),
|
||||
title="Subtitles for 'Recently Added' items (max-age: %s)" % Prefs["scheduler.item_is_recent_age"],
|
||||
summary="Shows the recently added 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"]
|
||||
))
|
||||
|
||||
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])
|
||||
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(RefreshMissing, randomize=timestamp()),
|
||||
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),
|
||||
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
|
||||
))
|
||||
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, randomize=timestamp()),
|
||||
key=Callback(fatality, force_title=" ", randomize=timestamp()),
|
||||
title=pad_title("Refresh"),
|
||||
summary="Refreshes the current view"
|
||||
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, randomize=timestamp()),
|
||||
title=pad_title("Advanced functions"),
|
||||
summary="Use at your own risk"
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(AdvancedMenu),
|
||||
title=pad_title("Advanced functions"),
|
||||
summary="Use at your own risk"
|
||||
))
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/on_deck')
|
||||
def OnDeckMenu(message=None):
|
||||
return mergedItemsMenu(title="Items On Deck", itemGetter=getOnDeckItems)
|
||||
"""
|
||||
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')
|
||||
def RecentlyAddedMenu(message=None):
|
||||
return mergedItemsMenu(title="Recently Added Items", itemGetter=getRecentlyAddedItems)
|
||||
"""
|
||||
displays the recently added items with missing subtitles
|
||||
:param message:
|
||||
:return:
|
||||
"""
|
||||
return recentItemsMenu(title="Missing Subtitles", base_title="Missing Subtitles")
|
||||
|
||||
def mergedItemsMenu(title, itemGetter):
|
||||
|
||||
def recentItemsMenu(title, base_title=None):
|
||||
oc = ObjectContainer(title2=title, no_cache=True, no_history=True)
|
||||
items = itemGetter()
|
||||
|
||||
for kind, title, item in items:
|
||||
menu_title = title
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RefreshItemMenu, title=menu_title, rating_key=item.rating_key),
|
||||
title=menu_title
|
||||
))
|
||||
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
|
||||
|
||||
@route(PREFIX + '/item/{rating_key}/actions')
|
||||
def RefreshItemMenu(rating_key, title=None, came_from="/recent"):
|
||||
|
||||
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 = ObjectContainer(title2=title, no_cache=True, no_history=True)
|
||||
items = itemGetter(*args, **kwargs)
|
||||
|
||||
for kind, title, item_id, deeper, item in items:
|
||||
oc.add(DirectoryObject(
|
||||
title=title,
|
||||
key=Callback(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 > 200:
|
||||
return SectionFirstLetterMenu
|
||||
return SectionMenu
|
||||
|
||||
|
||||
@route(PREFIX + '/ignore/set/{kind}/{rating_key}/{todo}/sure={sure}', kind=str, rating_key=str, todo=str, sure=bool)
|
||||
def IgnoreMenu(kind, rating_key, title=None, sure=False, todo="not_set"):
|
||||
"""
|
||||
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 = ObjectContainer(no_history=True, replace_parent=True, title1="%s %s %s %s the ignore list" % (
|
||||
"Add" if not is_ignored else "Remove", ignore_list.verbose(kind), title, "to" if not is_ignored else "from"), title2="Are you sure?")
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(IgnoreMenu, kind=kind, rating_key=rating_key, title=title, sure=True, todo="add" if not is_ignored else "remove"),
|
||||
title=pad_title("Are you sure?"),
|
||||
))
|
||||
return oc
|
||||
|
||||
rel = ignore_list[kind]
|
||||
dont_change = False
|
||||
if todo == "remove":
|
||||
if not is_ignored:
|
||||
dont_change = True
|
||||
else:
|
||||
rel.remove(rating_key)
|
||||
Log.Info("Removed %s (%s) from the ignore list", title, rating_key)
|
||||
ignore_list.remove_title(kind, rating_key)
|
||||
ignore_list.save()
|
||||
state = "removed from"
|
||||
elif todo == "add":
|
||||
if is_ignored:
|
||||
dont_change = True
|
||||
else:
|
||||
rel.append(rating_key)
|
||||
Log.Info("Added %s (%s) to the ignore list", title, rating_key)
|
||||
ignore_list.add_title(kind, rating_key, title)
|
||||
ignore_list.save()
|
||||
state = "added to"
|
||||
else:
|
||||
dont_change = True
|
||||
|
||||
if dont_change:
|
||||
return fatality(force_title=" ", header="Didn't change the ignore list", no_history=True)
|
||||
|
||||
return fatality(force_title=" ", header="%s %s the ignore list" % (title, state), no_history=True)
|
||||
|
||||
|
||||
@route(PREFIX + '/sections')
|
||||
def SectionsMenu():
|
||||
"""
|
||||
displays the menu for all sections
|
||||
:return:
|
||||
"""
|
||||
items = get_all_items("sections")
|
||||
|
||||
return dig_tree(ObjectContainer(title2="Sections", no_cache=True, no_history=True), items, None,
|
||||
menu_determination_callback=determine_section_display, pass_kwargs={"base_title": "Sections"},
|
||||
fill_args={"title": "section_title"})
|
||||
|
||||
|
||||
@route(PREFIX + '/section', ignore_options=bool)
|
||||
def SectionMenu(rating_key, title=None, base_title=None, section_title=None, ignore_options=True):
|
||||
"""
|
||||
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 = ObjectContainer(title2=title, no_cache=True, no_history=True)
|
||||
if ignore_options:
|
||||
add_ignore_options(oc, "sections", title=section_title, rating_key=rating_key, callback_menu=IgnoreMenu)
|
||||
|
||||
return dig_tree(oc, items, MetadataMenu,
|
||||
pass_kwargs={"base_title": title, "display_items": deeper, "previous_item_type": "section",
|
||||
"previous_rating_key": rating_key})
|
||||
|
||||
|
||||
@route(PREFIX + '/section/firstLetter', deeper=bool)
|
||||
def SectionFirstLetterMenu(rating_key, title=None, base_title=None, section_title=None):
|
||||
"""
|
||||
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 = ObjectContainer(title2=section_title, no_cache=True, no_history=True)
|
||||
title = base_title + " > " + title
|
||||
add_ignore_options(oc, "sections", title=section_title, rating_key=rating_key, callback_menu=IgnoreMenu)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RefreshItem, rating_key=rating_key),
|
||||
title=u"Refresh: %s" % title,
|
||||
summary="Refreshes the item, possibly picking up new subtitles on disk"
|
||||
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 = ObjectContainer(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 = ObjectContainer(title2=title, no_cache=True, no_history=True)
|
||||
|
||||
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)
|
||||
|
||||
# add refresh
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RefreshItem, rating_key=rating_key, item_title=item_title, refresh_kind=kind, previous_rating_key=previous_rating_key,
|
||||
timeout=16000, 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=item_title, force=True, refresh_kind=kind,
|
||||
previous_rating_key=previous_rating_key, timeout=16000),
|
||||
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 = ObjectContainer(title2="Ignore list", replace_parent=True)
|
||||
for key in ignore_list.key_order:
|
||||
values = ignore_list[key]
|
||||
for value in values:
|
||||
add_ignore_options(oc, key, title=ignore_list.get_title(key, value), rating_key=value, callback_menu=IgnoreMenu)
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/{rating_key}/actions')
|
||||
def 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)
|
||||
|
||||
oc = ObjectContainer(title2=title, replace_parent=True)
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RefreshItem, rating_key=rating_key, item_title=item_title, randomize=timestamp()),
|
||||
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, force=True),
|
||||
title=u"Force-Refresh: %s" % title,
|
||||
summary="Issues a forced refresh, ignoring known subtitles and searching for new ones"
|
||||
key=Callback(RefreshItem, rating_key=rating_key, item_title=item_title, force=True, randomize=timestamp()),
|
||||
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
|
||||
))
|
||||
add_ignore_options(oc, "videos", title=item_title, rating_key=rating_key, callback_menu=IgnoreMenu)
|
||||
|
||||
return oc
|
||||
|
||||
|
||||
@route(PREFIX + '/item/{rating_key}')
|
||||
def RefreshItem(rating_key=None, came_from="/recent", force=False):
|
||||
@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
|
||||
Thread.Create(refreshItem, rating_key=rating_key, force=force)
|
||||
return fatality(randomize=timestamp(), header="%s of item %s triggered" % ("Refresh" if not force else "Forced-refresh", rating_key))
|
||||
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')
|
||||
def RefreshMissing(randomize=None):
|
||||
Thread.CreateTimer(1.0, lambda: scheduler.run_task("searchAllRecentlyAddedMissing"))
|
||||
return fatality(header="Refresh of recently added items with missing subtitles triggered")
|
||||
@debounce
|
||||
def RefreshMissing(randomize=None, trigger=True):
|
||||
header = " "
|
||||
if trigger:
|
||||
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 = ObjectContainer(header=header or "Internal stuff, pay attention!", message=message, no_cache=True, no_history=True, title2="Advanced")
|
||||
|
||||
oc = ObjectContainer(header=header or "Internal stuff, pay attention!", message=message, no_cache=True, no_history=True,
|
||||
replace_parent=True, title2="Advanced")
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(TriggerRestart),
|
||||
title=pad_title("Restart the plugin")
|
||||
key=Callback(TriggerRestart, randomize=timestamp()),
|
||||
title=pad_title("Restart the plugin"),
|
||||
))
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(RefreshToken, randomize=timestamp()),
|
||||
title=pad_title("Re-request the API token from plex.tv")
|
||||
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")
|
||||
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")
|
||||
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')
|
||||
|
||||
@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
|
||||
|
||||
@route(PREFIX + '/advanced/restart/trigger')
|
||||
def TriggerRestart(randomize=None):
|
||||
|
||||
def DispatchRestart():
|
||||
Thread.CreateTimer(1.0, Restart)
|
||||
return fatality(header="Restart triggered, please wait about 5 seconds", only_refresh=True)
|
||||
|
||||
|
||||
@route(PREFIX + '/advanced/restart/trigger')
|
||||
@debounce
|
||||
def TriggerRestart(randomize=None, trigger=True):
|
||||
if trigger:
|
||||
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 = ObjectContainer(no_history=True, title1="Reset subtitle storage", title2="Are you sure?")
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ResetStorage, key=key, sure=True, randomize=timestamp()),
|
||||
title=pad_title("Are you really sure?")
|
||||
))
|
||||
return oc
|
||||
oc = ObjectContainer(no_history=True, title1="Reset subtitle storage", title2="Are you sure?")
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(ResetStorage, key=key, sure=True, randomize=timestamp()),
|
||||
title=pad_title("Are you really sure?"),
|
||||
|
||||
))
|
||||
return oc
|
||||
|
||||
reset_storage(key)
|
||||
|
||||
if key == "tasks":
|
||||
# reinitialize the scheduler
|
||||
scheduler.init_storage()
|
||||
scheduler.setup_tasks()
|
||||
|
||||
resetStorage(key)
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
randomize=timestamp(),
|
||||
header='Success',
|
||||
message='Subtitle Information Storage reset'
|
||||
message='Information Storage (%s) reset' % key
|
||||
)
|
||||
|
||||
@route(PREFIX + '/refresh_token')
|
||||
def RefreshToken(randomize=None):
|
||||
result = refresh_plex_token()
|
||||
if result:
|
||||
msg = "Token successfully refreshed."
|
||||
else:
|
||||
msg = "Couldn't refresh the token, please check your credentials"
|
||||
|
||||
return AdvancedMenu(header=msg)
|
||||
|
||||
@route(PREFIX + '/storage/log')
|
||||
def LogStorage(key, randomize=None):
|
||||
log_storage(key)
|
||||
return AdvancedMenu(
|
||||
randomize=timestamp(),
|
||||
header='Success',
|
||||
message='Information Storage (%s) logged' % key
|
||||
)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# coding=utf-8
|
||||
import types
|
||||
|
||||
from support.items import get_kind, get_item_thumb
|
||||
from subzero import intent
|
||||
from support.helpers import format_video
|
||||
from support.ignore import ignore_list
|
||||
from subzero.constants import ICON
|
||||
from subzero.func import debouncer
|
||||
|
||||
default_thumb = R(ICON)
|
||||
|
||||
|
||||
def should_display_ignore(items, previous=None):
|
||||
kind = get_kind(items)
|
||||
return items and (
|
||||
(kind in ("show", "season")) or
|
||||
(kind == "episode" and previous != "season")
|
||||
)
|
||||
|
||||
|
||||
def add_ignore_options(oc, kind, callback_menu=None, title=None, rating_key=None, add_kind=True):
|
||||
"""
|
||||
|
||||
:param oc: oc to add our options to
|
||||
:param kind: movie, show, episode ... - gets translated to the ignore key (sections, series, items)
|
||||
:param callback_menu: menu to inject
|
||||
:param title:
|
||||
:param rating_key:
|
||||
:return:
|
||||
"""
|
||||
# try to translate kind to the ignore key
|
||||
use_kind = kind
|
||||
if kind not in ignore_list:
|
||||
use_kind = ignore_list.translate_key(kind)
|
||||
if not use_kind or use_kind not in ignore_list:
|
||||
return
|
||||
|
||||
in_list = rating_key in ignore_list[use_kind]
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(callback_menu, kind=use_kind, rating_key=rating_key, title=title),
|
||||
title=u"%s %s \"%s\" %s the ignore list" % (
|
||||
"Remove" if in_list else "Add", ignore_list.verbose(kind) if add_kind else "", unicode(title), "from" if in_list else "to")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def dig_tree(oc, items, menu_callback, menu_determination_callback=None, force_rating_key=None, fill_args=None, pass_kwargs=None,
|
||||
thumb=default_thumb):
|
||||
for kind, title, key, dig_deeper, item in items:
|
||||
thumb = get_item_thumb(item) or thumb
|
||||
|
||||
add_kwargs = {}
|
||||
if fill_args:
|
||||
add_kwargs = dict((name, getattr(item, k)) for k, name in fill_args.iteritems() if item and hasattr(item, k))
|
||||
if pass_kwargs:
|
||||
add_kwargs.update(pass_kwargs)
|
||||
|
||||
oc.add(DirectoryObject(
|
||||
key=Callback(menu_callback or menu_determination_callback(kind, item), title=title, rating_key=force_rating_key or key,
|
||||
**add_kwargs),
|
||||
title=title, thumb=thumb
|
||||
))
|
||||
return oc
|
||||
|
||||
|
||||
def set_refresh_menu_state(state_or_media, media_type="movies"):
|
||||
"""
|
||||
|
||||
:param state_or_media: string, None, or Media argument from Agent.update()
|
||||
:param media_type: movies or series
|
||||
:return:
|
||||
"""
|
||||
if not state_or_media:
|
||||
# store it in last state and remove the current
|
||||
Dict["last_refresh_state"] = Dict["current_refresh_state"]
|
||||
Dict["current_refresh_state"] = None
|
||||
return
|
||||
|
||||
if isinstance(state_or_media, types.StringTypes):
|
||||
Dict["current_refresh_state"] = state_or_media
|
||||
return
|
||||
|
||||
media = state_or_media
|
||||
media_id = media.id
|
||||
title = None
|
||||
if media_type == "series":
|
||||
for season in media.seasons:
|
||||
for episode in media.seasons[season].episodes:
|
||||
ep = media.seasons[season].episodes[episode]
|
||||
media_id = ep.id
|
||||
title = format_video("show", ep.title, parent_title=media.title, season=int(season), episode=int(episode))
|
||||
else:
|
||||
title = format_video("movie", media.title)
|
||||
force_refresh = intent.get("force", media_id)
|
||||
|
||||
Dict["current_refresh_state"] = u"%sRefreshing %s" % ("Force-" if force_refresh else "", unicode(title))
|
||||
|
||||
|
||||
def enable_channel_wrapper(func):
|
||||
"""
|
||||
returns the original wrapper :func: (route or handler) if applicable, else the plain to-be-wrapped function
|
||||
:param func: original wrapper
|
||||
:return: original wrapper or wrapped function
|
||||
"""
|
||||
def noop(*args, **kwargs):
|
||||
def inner(*a, **k):
|
||||
"""
|
||||
:param a: args
|
||||
:param k: kwargs
|
||||
:return: originally to-be-wrapped function
|
||||
"""
|
||||
return a[0]
|
||||
|
||||
return inner
|
||||
|
||||
def wrap(*args, **kwargs):
|
||||
enforce_route = kwargs.pop("enforce_route", None)
|
||||
return (func if Prefs["enable_channel"] or enforce_route else noop)(*args, **kwargs)
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def debounce(func):
|
||||
"""
|
||||
prevent func from being called twice with the same arguments
|
||||
:param func:
|
||||
:return:
|
||||
"""
|
||||
def wrap(*args, **kwargs):
|
||||
if "randomize" in kwargs:
|
||||
if ([func] + list(args), kwargs) in debouncer:
|
||||
kwargs["trigger"] = False
|
||||
Log.Debug("not triggering %s twice with %s, %s" % (func, args, kwargs))
|
||||
else:
|
||||
debouncer.add([func] + list(args), kwargs)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrap
|
||||
+20
-8
@@ -1,15 +1,22 @@
|
||||
import logging
|
||||
|
||||
def registerLoggingHander(dependencies):
|
||||
plexHandler = PlexLoggerHandler()
|
||||
for dependency in dependencies:
|
||||
Log.Debug("Registering LoggerHandler for dependency: %s" % dependency)
|
||||
|
||||
def register_logging_handler(dependencies, level="ERROR"):
|
||||
plex_handler = PlexLoggerHandler()
|
||||
for dependency in dependencies:
|
||||
Log.Debug("Registering LoggerHandler for dependency: %s" % dependency)
|
||||
log = logging.getLogger(dependency)
|
||||
log.setLevel('DEBUG')
|
||||
log.addHandler(plexHandler)
|
||||
# remove previous plex logging handlers
|
||||
# fixme: this is not the most elegant solution...
|
||||
for handler in log.handlers:
|
||||
if isinstance(handler, PlexLoggerHandler):
|
||||
log.removeHandler(handler)
|
||||
|
||||
log.setLevel(level)
|
||||
log.addHandler(plex_handler)
|
||||
|
||||
|
||||
class PlexLoggerHandler(logging.StreamHandler):
|
||||
|
||||
def __init__(self, level=0):
|
||||
super(PlexLoggerHandler, self).__init__(level)
|
||||
|
||||
@@ -30,4 +37,9 @@ class PlexLoggerHandler(logging.StreamHandler):
|
||||
elif record.levelno == logging.FATAL:
|
||||
Log.Exception(self.getFormattedString(record))
|
||||
else:
|
||||
Log.Error("UNKNOWN LEVEL: %s", record.getMessage())
|
||||
Log.Error("UNKNOWN LEVEL: %s", record.getMessage())
|
||||
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_formatter = Framework.core.LogFormatter('%(asctime)-15s - %(name)-32s (%(thread)x) : %(levelname)s (%(module)s:%(lineno)d) - %(message)s')
|
||||
console_handler.setFormatter(console_formatter)
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
import sys
|
||||
|
||||
# thanks, https://github.com/trakt/Plex-Trakt-Scrobbler/blob/master/Trakttv.bundle/Contents/Code/core/__init__.py
|
||||
|
||||
import config
|
||||
|
||||
sys.modules["support.config"] = config
|
||||
|
||||
import helpers
|
||||
|
||||
sys.modules["support.helpers"] = helpers
|
||||
|
||||
import lib
|
||||
|
||||
sys.modules["support.lib"] = lib
|
||||
|
||||
import plex_media
|
||||
sys.modules["support.plex_media"] = plex_media
|
||||
|
||||
import localmedia
|
||||
|
||||
sys.modules["subzero.localmedia"] = localmedia
|
||||
|
||||
import subtitlehelpers
|
||||
|
||||
sys.modules["support.subtitlehelpers"] = subtitlehelpers
|
||||
|
||||
import items
|
||||
|
||||
sys.modules["support.items"] = items
|
||||
|
||||
import missing_subtitles
|
||||
|
||||
sys.modules["support.missing_subtitles"] = missing_subtitles
|
||||
|
||||
import background
|
||||
|
||||
sys.modules["support.background"] = background
|
||||
|
||||
import tasks
|
||||
|
||||
sys.modules["support.tasks"] = tasks
|
||||
|
||||
import storage
|
||||
|
||||
sys.modules["support.storage"] = storage
|
||||
|
||||
import auth
|
||||
sys.modules["support.auth"] = auth
|
||||
import ignore
|
||||
|
||||
sys.modules["support.ignore"] = ignore
|
||||
|
||||
@@ -1,44 +1,42 @@
|
||||
# coding=utf-8
|
||||
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
def refresh_plex_token():
|
||||
username = Prefs["plex_username"]
|
||||
password = Prefs["plex_password"]
|
||||
|
||||
if not username or not password:
|
||||
if "token" in Dict:
|
||||
del Dict["token"]
|
||||
Dict.Save()
|
||||
return
|
||||
if "token" in Dict:
|
||||
del Dict["token"]
|
||||
Dict.Save()
|
||||
return
|
||||
|
||||
if not "uuid" in Dict:
|
||||
Dict["uuid"] = uuid.uuid1()
|
||||
Dict.Save()
|
||||
if "uuid" not in Dict:
|
||||
Dict["uuid"] = String.UUID()
|
||||
Dict.Save()
|
||||
|
||||
current_uuid = Dict["uuid"]
|
||||
|
||||
headers = {
|
||||
'X-Plex-Device-Name': 'Sub-Zero',
|
||||
'X-Plex-Product': 'Sub-Zero',
|
||||
'X-Plex-Version': '1.3.0',
|
||||
'X-Plex-Client-Identifier': "%s" % current_uuid,
|
||||
}
|
||||
'X-Plex-Device-Name': 'Sub-Zero',
|
||||
'X-Plex-Product': 'Sub-Zero',
|
||||
'X-Plex-Version': '1.3.0',
|
||||
'X-Plex-Client-Identifier': "%s" % current_uuid,
|
||||
}
|
||||
|
||||
request = HTTP.Request("https://plex.tv/users/sign_in.json", headers=headers, values={'user[login]': Prefs["plex_username"], 'user[password]': Prefs["plex_password"]}, immediate=True)
|
||||
request = HTTP.Request("https://plex.tv/users/sign_in.json", headers=headers,
|
||||
values={'user[login]': Prefs["plex_username"], 'user[password]': Prefs["plex_password"]}, immediate=True)
|
||||
token = None
|
||||
if request:
|
||||
try:
|
||||
data = JSON.ObjectFromString(request.content)
|
||||
token = data["user"]["authentication_token"]
|
||||
log_data = data.copy()
|
||||
log_data["user"]["authentication_token"] = "xxxxxxxxxxxxxxxxxx"
|
||||
Log.Debug("Data returned from plex.tv: %s", log_data)
|
||||
except:
|
||||
pass
|
||||
if token:
|
||||
Dict["token"] = token
|
||||
Dict.Save()
|
||||
return True
|
||||
|
||||
try:
|
||||
data = JSON.ObjectFromString(request.content)
|
||||
token = data["user"]["authentication_token"]
|
||||
log_data = data.copy()
|
||||
log_data["user"]["authentication_token"] = "xxxxxxxxxxxxxxxxxx"
|
||||
Log.Debug("Data returned from plex.tv: %s", log_data)
|
||||
except:
|
||||
pass
|
||||
if token:
|
||||
Dict["token"] = token
|
||||
Dict.Save()
|
||||
return True
|
||||
|
||||
Regular → Executable
+81
-76
@@ -4,119 +4,124 @@ import datetime
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
|
||||
def parse_frequency(s):
|
||||
if s == "never":
|
||||
return None, None
|
||||
return None, None
|
||||
kind, num, unit = s.split()
|
||||
return int(num), unit
|
||||
|
||||
|
||||
class DefaultScheduler(object):
|
||||
thread = None
|
||||
running = False
|
||||
registry = None
|
||||
|
||||
def __init__(self):
|
||||
self.thread = None
|
||||
self.running = False
|
||||
self.registry = []
|
||||
self.thread = None
|
||||
self.running = False
|
||||
self.registry = []
|
||||
|
||||
self.tasks = {}
|
||||
if not "tasks" in Dict:
|
||||
Dict["tasks"] = {}
|
||||
self.tasks = {}
|
||||
self.init_storage()
|
||||
|
||||
# reset tasks' running state in case anything went wrong before, or we're dealing with an old version
|
||||
try:
|
||||
for task, info in Dict["tasks"].iteritems():
|
||||
info["running"] = False
|
||||
except:
|
||||
Dict["tasks"] = {}
|
||||
Dict.Save()
|
||||
def init_storage(self):
|
||||
if "tasks" not in Dict:
|
||||
Dict["tasks"] = {}
|
||||
Dict.Save()
|
||||
|
||||
def register(self, task):
|
||||
self.registry.append(task)
|
||||
self.registry.append(task)
|
||||
|
||||
def setup_tasks(self):
|
||||
# discover tasks; todo: add registry
|
||||
for cls in self.registry:
|
||||
task = cls(self)
|
||||
self.tasks[task.name] = {"task": task, "frequency": parse_frequency(Prefs["scheduler.tasks.%s" % task.name])}
|
||||
# discover tasks;
|
||||
self.tasks = {}
|
||||
for cls in self.registry:
|
||||
task = cls(self)
|
||||
self.tasks[task.name] = {"task": task, "frequency": parse_frequency(Prefs["scheduler.tasks.%s" % task.name])}
|
||||
|
||||
def run(self):
|
||||
self.setup_tasks()
|
||||
self.running = True
|
||||
self.thread = Thread.Create(self.worker)
|
||||
self.running = True
|
||||
self.thread = Thread.Create(self.worker)
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
self.running = False
|
||||
|
||||
def task(self, name):
|
||||
if name not in self.tasks:
|
||||
return None
|
||||
return self.tasks[name]["task"]
|
||||
if name not in self.tasks:
|
||||
return None
|
||||
return self.tasks[name]["task"]
|
||||
|
||||
def last_run(self, task):
|
||||
if task not in self.tasks:
|
||||
return None
|
||||
return self.tasks[task]["task"].last_run
|
||||
if task not in self.tasks:
|
||||
return None
|
||||
return self.tasks[task]["task"].last_run
|
||||
|
||||
def next_run(self, task):
|
||||
if task not in self.tasks:
|
||||
return None
|
||||
frequency_num, frequency_key = self.tasks[task]["frequency"]
|
||||
if not frequency_num:
|
||||
return None
|
||||
last = self.tasks[task]["task"].last_run
|
||||
use_date = last
|
||||
now = datetime.datetime.now()
|
||||
if not use_date:
|
||||
use_date = now
|
||||
return max(use_date + datetime.timedelta(**{frequency_key: frequency_num}), now)
|
||||
if task not in self.tasks:
|
||||
return None
|
||||
frequency_num, frequency_key = self.tasks[task]["frequency"]
|
||||
if not frequency_num:
|
||||
return None
|
||||
last = self.tasks[task]["task"].last_run
|
||||
use_date = last
|
||||
now = datetime.datetime.now()
|
||||
if not use_date:
|
||||
use_date = now
|
||||
return max(use_date + datetime.timedelta(**{frequency_key: frequency_num}), now)
|
||||
|
||||
def run_task(self, name):
|
||||
task = self.tasks[name]["task"]
|
||||
if task.running:
|
||||
Log.Debug("Not running %s, as it's currently running." % name)
|
||||
return
|
||||
task = self.tasks[name]["task"]
|
||||
if task.running:
|
||||
Log.Debug("Scheduler: Not running %s, as it's currently running.", name)
|
||||
return
|
||||
|
||||
task.running = True
|
||||
try:
|
||||
task.run()
|
||||
except Exception, e:
|
||||
Log.Error("Something went wrong when running %s: %s", name, traceback.format_exc())
|
||||
finally:
|
||||
task.last_run = datetime.datetime.now()
|
||||
task.running = False
|
||||
Log.Debug("Scheduler: Running task %s", name)
|
||||
try:
|
||||
task.prepare()
|
||||
task.run()
|
||||
except Exception, e:
|
||||
Log.Error("Scheduler: Something went wrong when running %s: %s", name, traceback.format_exc())
|
||||
finally:
|
||||
task.post_run()
|
||||
|
||||
def signal(self, name, *args, **kwargs):
|
||||
for task_name, info in self.tasks.iteritems():
|
||||
task = info["task"]
|
||||
if task.running:
|
||||
task.signal(name, *args, **kwargs)
|
||||
for task_name, info in self.tasks.iteritems():
|
||||
task = info["task"]
|
||||
if task.running:
|
||||
Log.Debug("Scheduler: Sending signal %s to task %s (%s, %s)", name, task_name, args, kwargs)
|
||||
status = task.signal(name, *args, **kwargs)
|
||||
if status:
|
||||
Log.Debug("Scheduler: Signal accepted by %s", task_name)
|
||||
else:
|
||||
Log.Debug("Scheduler: Signal not accepted by %s", task_name)
|
||||
continue
|
||||
Log.Debug("Scheduler: Not sending signal %s to task %s, because: not running", name, task_name)
|
||||
|
||||
def worker(self):
|
||||
while 1:
|
||||
if not self.running:
|
||||
break
|
||||
Thread.Sleep(10.0)
|
||||
while 1:
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
for name, info in self.tasks.iteritems():
|
||||
now = datetime.datetime.now()
|
||||
task = info["task"]
|
||||
for name, info in self.tasks.iteritems():
|
||||
now = datetime.datetime.now()
|
||||
task = info["task"]
|
||||
|
||||
if name not in Dict["tasks"]:
|
||||
Dict["tasks"][name] = {"last_run": None, "running": False}
|
||||
Dict.Save()
|
||||
continue
|
||||
if name not in Dict["tasks"]:
|
||||
continue
|
||||
|
||||
if task.running:
|
||||
continue
|
||||
|
||||
frequency_num, frequency_key = info["frequency"]
|
||||
if not frequency_num:
|
||||
continue
|
||||
if task.running:
|
||||
continue
|
||||
|
||||
frequency_num, frequency_key = info["frequency"]
|
||||
if not frequency_num:
|
||||
continue
|
||||
|
||||
if not task.last_run or task.last_run + datetime.timedelta(**{frequency_key: frequency_num}) <= now:
|
||||
self.run_task(name)
|
||||
|
||||
Thread.Sleep(10.0)
|
||||
|
||||
if not task.last_run or task.last_run + datetime.timedelta(**{frequency_key: frequency_num}) <= now:
|
||||
self.run_task(name)
|
||||
|
||||
Thread.Sleep(10.0)
|
||||
|
||||
scheduler = DefaultScheduler()
|
||||
|
||||
+162
-54
@@ -4,63 +4,170 @@ import os
|
||||
import re
|
||||
import inspect
|
||||
from babelfish import Language
|
||||
from subzero.lib.io import FileIO
|
||||
from subzero.constants import PLUGIN_NAME
|
||||
from auth import refresh_plex_token
|
||||
from lib import configure_plex, Plex
|
||||
from subzero.lib.io import FileIO, get_viable_encoding
|
||||
from subzero.constants import PLUGIN_NAME, PLUGIN_IDENTIFIER, MOVIE, SHOW
|
||||
from lib import Plex
|
||||
from helpers import check_write_permissions
|
||||
|
||||
SUBTITLE_EXTS = ['utf','utf8','utf-8','srt','smi','rt','ssa','aqt','jss','ass','idx','sub','txt', 'psb']
|
||||
VIDEO_EXTS = ['3g2', '3gp', 'asf', 'asx', 'avc', 'avi', 'avs', 'bivx', 'bup', 'divx', 'dv', 'dvr-ms', 'evo', 'fli', 'flv',
|
||||
'm2t', 'm2ts', 'm2v', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'mts', 'nsv', 'nuv', 'ogm', 'ogv', 'tp',
|
||||
'pva', 'qt', 'rm', 'rmvb', 'sdp', 'svq3', 'strm', 'ts', 'ty', 'vdr', 'viv', 'vob', 'vp3', 'wmv', 'wpl', 'wtv', 'xsp', 'xvid', 'webm']
|
||||
SUBTITLE_EXTS = ['utf', 'utf8', 'utf-8', 'srt', 'smi', 'rt', 'ssa', 'aqt', 'jss', 'ass', 'idx', 'sub', 'txt', 'psb']
|
||||
VIDEO_EXTS = ['3g2', '3gp', 'asf', 'asx', 'avc', 'avi', 'avs', 'bivx', 'bup', 'divx', 'dv', 'dvr-ms', 'evo', 'fli', 'flv',
|
||||
'm2t', 'm2ts', 'm2v', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'mts', 'nsv', 'nuv', 'ogm', 'ogv', 'tp',
|
||||
'pva', 'qt', 'rm', 'rmvb', 'sdp', 'svq3', 'strm', 'ts', 'ty', 'vdr', 'viv', 'vob', 'vp3', 'wmv', 'wpl', 'wtv', 'xsp', 'xvid',
|
||||
'webm']
|
||||
|
||||
IGNORE_FN = ("subzero.ignore", ".subzero.ignore", ".nosz")
|
||||
|
||||
VERSION_RE = re.compile(ur'CFBundleVersion.+?<string>([0-9\.]+)</string>', re.DOTALL)
|
||||
|
||||
|
||||
def int_or_default(s, default):
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
class Config(object):
|
||||
version = None
|
||||
langList = None
|
||||
subtitleDestinationFolder = None
|
||||
full_version = None
|
||||
lang_list = None
|
||||
subtitle_destination_folder = None
|
||||
providers = None
|
||||
providerSettings = None
|
||||
scheduler_section_blacklist = None
|
||||
scheduler_season_blacklist = None
|
||||
scheduler_item_blacklist = None
|
||||
provider_settings = None
|
||||
max_recent_items_per_library = 200
|
||||
permissions_ok = False
|
||||
missing_permissions = None
|
||||
ignore_paths = None
|
||||
fs_encoding = None
|
||||
notify_executable = None
|
||||
sections = None
|
||||
enabled_sections = None
|
||||
|
||||
initialized = False
|
||||
|
||||
def initialize(self):
|
||||
self.version = self.getVersion()
|
||||
self.full_version = u"%s %s" % (PLUGIN_NAME, self.version)
|
||||
self.langList = self.getLangList()
|
||||
self.subtitleDestinationFolder = self.getSubtitleDestinationFolder()
|
||||
self.providers = self.getProviders()
|
||||
self.providerSettings = self.getProviderSettings()
|
||||
self.scheduler_section_blacklist = self.getBlacklist("scheduler.section_blacklist")
|
||||
self.scheduler_series_blacklist = self.getBlacklist("scheduler.series_blacklist")
|
||||
self.scheduler_item_blacklist = self.getBlacklist("scheduler.item_blacklist")
|
||||
self.initialized = True
|
||||
configure_plex()
|
||||
self.plex_api_working = self.checkPlexAPI()
|
||||
self.fs_encoding = get_viable_encoding()
|
||||
self.version = self.get_version()
|
||||
self.full_version = u"%s %s" % (PLUGIN_NAME, self.version)
|
||||
self.lang_list = self.get_lang_list()
|
||||
self.subtitle_destination_folder = self.get_subtitle_destination_folder()
|
||||
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"], 200)
|
||||
self.sections = list(Plex["library"].sections())
|
||||
self.missing_permissions = []
|
||||
self.ignore_paths = self.parse_ignore_paths()
|
||||
self.permissions_ok = self.check_permissions()
|
||||
self.notify_executable = self.check_notify_executable()
|
||||
self.enabled_sections = self.check_enabled_sections()
|
||||
self.initialized = True
|
||||
|
||||
def checkPlexAPI(self):
|
||||
return bool(Plex["library"].sections())
|
||||
def check_permissions(self):
|
||||
if not Prefs["subtitles.save.filesystem"] or not Prefs["check_permissions"]:
|
||||
return True
|
||||
|
||||
def getVersion(self):
|
||||
curDir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
info_file_path = os.path.abspath(os.path.join(curDir, "..", "..", "Info.plist"))
|
||||
data = FileIO.read(info_file_path)
|
||||
result = VERSION_RE.search(data)
|
||||
if result:
|
||||
return result.group(1)
|
||||
use_ignore_fs = Prefs["subtitles.ignore_fs"]
|
||||
all_permissions_ok = True
|
||||
for section in self.sections:
|
||||
title = section.title
|
||||
for location in section:
|
||||
path_str = location.path
|
||||
if isinstance(path_str, unicode):
|
||||
path_str = path_str.encode(self.fs_encoding)
|
||||
|
||||
def getBlacklist(self, key):
|
||||
return map(lambda id: id.strip(), (Prefs[key] or "").split(","))
|
||||
|
||||
if use_ignore_fs:
|
||||
# check whether we've got an ignore file inside the section path
|
||||
if self.is_physically_ignored(path_str):
|
||||
continue
|
||||
|
||||
if self.is_path_ignored(path_str):
|
||||
# is the path in our ignored paths setting?
|
||||
continue
|
||||
|
||||
# section not ignored, check for write permissions
|
||||
if not check_write_permissions(path_str):
|
||||
# not enough permissions
|
||||
self.missing_permissions.append((title, location.path))
|
||||
all_permissions_ok = False
|
||||
|
||||
return all_permissions_ok
|
||||
|
||||
def get_version(self):
|
||||
curDir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
|
||||
info_file_path = os.path.abspath(os.path.join(curDir, "..", "..", "Info.plist"))
|
||||
data = FileIO.read(info_file_path)
|
||||
result = VERSION_RE.search(data)
|
||||
if result:
|
||||
return result.group(1)
|
||||
|
||||
def parse_ignore_paths(self):
|
||||
paths = Prefs["subtitles.ignore_paths"]
|
||||
if paths:
|
||||
try:
|
||||
return [path.strip() for path in paths.split(",")]
|
||||
except:
|
||||
Log.Error("Couldn't parse your ignore paths settings: %s" % paths)
|
||||
return []
|
||||
|
||||
def is_physically_ignored(self, folder):
|
||||
# check whether we've got an ignore file inside the path
|
||||
for ifn in IGNORE_FN:
|
||||
if os.path.isfile(os.path.join(folder, ifn)):
|
||||
Log.Info(u'Ignoring "%s" because "%s" exists', folder, ifn)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_path_ignored(self, fn):
|
||||
for path in self.ignore_paths:
|
||||
if fn.startswith(path):
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_notify_executable(self):
|
||||
fn = Prefs["notify_executable"]
|
||||
if not fn:
|
||||
return
|
||||
|
||||
splitted_fn = fn.split()
|
||||
exe_fn = splitted_fn[0]
|
||||
arguments = [arg.strip() for arg in splitted_fn[1:]]
|
||||
|
||||
if os.path.isfile(exe_fn) and os.access(exe_fn, os.X_OK):
|
||||
return exe_fn, arguments
|
||||
Log.Error("Notify executable not existing or not executable: %s" % exe_fn)
|
||||
|
||||
def check_enabled_sections(self):
|
||||
enabled_for_primary_agents = []
|
||||
enabled_sections = {}
|
||||
|
||||
# find which agents we're enabled for
|
||||
for agent in Plex.agents():
|
||||
if not agent.primary:
|
||||
continue
|
||||
|
||||
for t in list(agent.media_types):
|
||||
if t.media_type in (MOVIE, SHOW):
|
||||
related_agents = Plex.primary_agent(agent.identifier, t.media_type)
|
||||
for a in related_agents:
|
||||
if a.identifier == PLUGIN_IDENTIFIER and a.enabled:
|
||||
enabled_for_primary_agents.append(agent.identifier)
|
||||
|
||||
# find the libraries that use them
|
||||
for library in self.sections:
|
||||
if library.agent in enabled_for_primary_agents:
|
||||
enabled_sections[library.key] = library
|
||||
|
||||
Log.Debug(u"I'm enabled for: %s" % [lib.title for key, lib in enabled_sections.iteritems()])
|
||||
return enabled_sections
|
||||
|
||||
# Prepare a list of languages we want subs for
|
||||
def getLangList(self):
|
||||
def get_lang_list(self):
|
||||
l = {Language.fromietf(Prefs["langPref1"])}
|
||||
langCustom = Prefs["langPrefCustom"].strip()
|
||||
lang_custom = Prefs["langPrefCustom"].strip()
|
||||
|
||||
if Prefs['subtitles.only_one']:
|
||||
return l
|
||||
|
||||
if Prefs["langPref2"] != "None":
|
||||
l.update({Language.fromietf(Prefs["langPref2"])})
|
||||
@@ -68,8 +175,8 @@ class Config(object):
|
||||
if Prefs["langPref3"] != "None":
|
||||
l.update({Language.fromietf(Prefs["langPref3"])})
|
||||
|
||||
if len(langCustom) and langCustom != "None":
|
||||
for lang in langCustom.split(u","):
|
||||
if len(lang_custom) and lang_custom != "None":
|
||||
for lang in lang_custom.split(u","):
|
||||
lang = lang.strip()
|
||||
try:
|
||||
real_lang = Language.fromietf(lang)
|
||||
@@ -82,33 +189,34 @@ class Config(object):
|
||||
|
||||
return l
|
||||
|
||||
def getSubtitleDestinationFolder(self):
|
||||
def get_subtitle_destination_folder(self):
|
||||
if not Prefs["subtitles.save.filesystem"]:
|
||||
return
|
||||
|
||||
fld_custom = Prefs["subtitles.save.subFolder.Custom"].strip() if bool(Prefs["subtitles.save.subFolder.Custom"]) else None
|
||||
return fld_custom or (Prefs["subtitles.save.subFolder"] if Prefs["subtitles.save.subFolder"] != "current folder" else None)
|
||||
|
||||
def getProviders(self):
|
||||
providers = {'opensubtitles' : Prefs['provider.opensubtitles.enabled'],
|
||||
'thesubdb' : Prefs['provider.thesubdb.enabled'],
|
||||
'podnapisi' : Prefs['provider.podnapisi.enabled'],
|
||||
'addic7ed' : Prefs['provider.addic7ed.enabled'],
|
||||
'tvsubtitles' : Prefs['provider.tvsubtitles.enabled']
|
||||
def get_providers(self):
|
||||
providers = {'opensubtitles': Prefs['provider.opensubtitles.enabled'],
|
||||
#'thesubdb': Prefs['provider.thesubdb.enabled'],
|
||||
'podnapisi': Prefs['provider.podnapisi.enabled'],
|
||||
'addic7ed': Prefs['provider.addic7ed.enabled'],
|
||||
'tvsubtitles': Prefs['provider.tvsubtitles.enabled']
|
||||
}
|
||||
return filter(lambda prov: providers[prov], providers)
|
||||
|
||||
def getProviderSettings(self):
|
||||
def get_provider_settings(self):
|
||||
provider_settings = {'addic7ed': {'username': Prefs['provider.addic7ed.username'],
|
||||
'password': Prefs['provider.addic7ed.password'],
|
||||
'use_random_agents': Prefs['provider.addic7ed.use_random_agents'],
|
||||
},
|
||||
'opensubtitles': {'username': Prefs['provider.opensubtitles.username'],
|
||||
'password': Prefs['provider.opensubtitles.password'],
|
||||
},
|
||||
}
|
||||
'password': Prefs['provider.opensubtitles.password'],
|
||||
'use_tag_search': Prefs['provider.opensubtitles.use_tags']
|
||||
},
|
||||
}
|
||||
|
||||
return provider_settings
|
||||
|
||||
config = Config()
|
||||
|
||||
config = Config()
|
||||
|
||||
Regular → Executable
+151
-38
@@ -1,50 +1,61 @@
|
||||
# coding=utf-8
|
||||
|
||||
import os
|
||||
import traceback
|
||||
import unicodedata
|
||||
import datetime
|
||||
import urllib
|
||||
import time
|
||||
import re
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
# Unicode control characters can appear in ID3v2 tags but are not legal in XML.
|
||||
RE_UNICODE_CONTROL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + \
|
||||
u'|' + \
|
||||
u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' % \
|
||||
(
|
||||
unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff),
|
||||
unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff),
|
||||
unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff)
|
||||
)
|
||||
RE_UNICODE_CONTROL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + \
|
||||
u'|' + \
|
||||
u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' % \
|
||||
(
|
||||
unichr(0xd800), unichr(0xdbff), unichr(0xdc00), unichr(0xdfff),
|
||||
unichr(0xd800), unichr(0xdbff), unichr(0xdc00), unichr(0xdfff),
|
||||
unichr(0xd800), unichr(0xdbff), unichr(0xdc00), unichr(0xdfff)
|
||||
)
|
||||
|
||||
|
||||
# A platform independent way to split paths which might come in with different separators.
|
||||
def splitPath(str):
|
||||
if str.find('\\') != -1:
|
||||
return str.split('\\')
|
||||
else:
|
||||
return str.split('/')
|
||||
def split_path(str):
|
||||
if str.find('\\') != -1:
|
||||
return str.split('\\')
|
||||
else:
|
||||
return str.split('/')
|
||||
|
||||
|
||||
def unicodize(s):
|
||||
filename = s
|
||||
try:
|
||||
filename = unicodedata.normalize('NFC', unicode(s.decode('utf-8')))
|
||||
except:
|
||||
Log('Failed to unicodize: ' + filename)
|
||||
try:
|
||||
filename = re.sub(RE_UNICODE_CONTROL, '', filename)
|
||||
except:
|
||||
Log('Couldn\'t strip control characters: ' + filename)
|
||||
return filename
|
||||
filename = s
|
||||
try:
|
||||
filename = unicodedata.normalize('NFC', unicode(s.decode('utf-8')))
|
||||
except:
|
||||
Log('Failed to unicodize: ' + filename)
|
||||
try:
|
||||
filename = re.sub(RE_UNICODE_CONTROL, '', filename)
|
||||
except:
|
||||
Log('Couldn\'t strip control characters: ' + filename)
|
||||
return filename
|
||||
|
||||
def cleanFilename(filename):
|
||||
#this will remove any whitespace and punctuation chars and replace them with spaces, strip and return as lowercase
|
||||
return string.translate(filename.encode('utf-8'), string.maketrans(string.punctuation + string.whitespace, ' ' * len (string.punctuation + string.whitespace))).strip().lower()
|
||||
|
||||
now = datetime.datetime.now()
|
||||
def is_recent(item):
|
||||
addedAt = datetime.datetime.fromtimestamp(item.added_at)
|
||||
def clean_filename(filename):
|
||||
# this will remove any whitespace and punctuation chars and replace them with spaces, strip and return as lowercase
|
||||
return string.translate(filename.encode('utf-8'), string.maketrans(string.punctuation + string.whitespace,
|
||||
' ' * len(
|
||||
string.punctuation + string.whitespace))).strip().lower()
|
||||
|
||||
|
||||
def is_recent(t):
|
||||
now = datetime.datetime.now()
|
||||
when = datetime.datetime.fromtimestamp(t)
|
||||
value, key = Prefs["scheduler.item_is_recent_age"].split()
|
||||
if now - datetime.timedelta(**{key: int(value)}) > addedAt:
|
||||
return False
|
||||
return True
|
||||
if now - datetime.timedelta(**{key: int(value)}) < when:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# thanks, Plex-Trakt-Scrobbler
|
||||
def str_pad(s, length, align='left', pad_char=' ', trim=False):
|
||||
@@ -72,20 +83,122 @@ def str_pad(s, length, align='left', pad_char=' ', trim=False):
|
||||
else:
|
||||
raise ValueError("Unknown align type, expected either 'left' or 'right'")
|
||||
|
||||
|
||||
def pad_title(value):
|
||||
"""Pad a title to 30 characters to force the 'details' view."""
|
||||
return str_pad(value, 30, pad_char=' ')
|
||||
|
||||
def format_video(item, kind, parent=None, parentTitle=None):
|
||||
if kind == "episode" and parent:
|
||||
return unicode('%s S%02dE%02d' % (parentTitle or parent.show.title, parent.index, item.index)).encode("ascii", errors="ignore")
|
||||
return unicode(item.title).encode("ascii", errors="ignore")
|
||||
|
||||
def format_item(item, kind, parent=None, parent_title=None, section_title=None, add_section_title=False):
|
||||
"""
|
||||
:param item: plex item
|
||||
:param kind: show or movie
|
||||
:param parent: season or None
|
||||
:param parent_title: parentTitle or None
|
||||
:return:
|
||||
"""
|
||||
return format_video(kind, item.title,
|
||||
section_title=(
|
||||
section_title or (parent.section.title if parent and getattr(parent, "section") else None)),
|
||||
parent_title=(parent_title or (parent.show.title if parent else None)),
|
||||
season=parent.index if parent else None,
|
||||
episode=item.index if kind == "show" else None,
|
||||
add_section_title=add_section_title)
|
||||
|
||||
|
||||
def format_video(kind, title, section_title=None, parent_title=None, season=None, episode=None,
|
||||
add_section_title=False):
|
||||
section_add = ""
|
||||
if add_section_title:
|
||||
section_add = ("%s: " % section_title) if section_title else ""
|
||||
|
||||
if kind == "show" and parent_title:
|
||||
if season and episode:
|
||||
return '%s%s S%02dE%02d, %s' % (section_add, parent_title, season or 0, episode or 0, title)
|
||||
return '%s%s, %s' % (section_add, parent_title, title)
|
||||
return "%s%s" % (section_add, title)
|
||||
|
||||
|
||||
def encode_message(base, s):
|
||||
return "%s?message=%s" % (base, urllib.quote_plus(s))
|
||||
|
||||
|
||||
def decode_message(s):
|
||||
return urllib.unquote_plus(s)
|
||||
|
||||
|
||||
def timestamp():
|
||||
return int(time.time())
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def query_plex(url, args):
|
||||
"""
|
||||
simple http query to the plex API without parsing anything too complicated
|
||||
:param url:
|
||||
:param args:
|
||||
:return:
|
||||
"""
|
||||
use_args = args.copy()
|
||||
|
||||
computed_args = "&".join(["%s=%s" % (key, String.Quote(value)) for key, value in use_args.iteritems()])
|
||||
|
||||
return HTTP.Request(url + ("?%s" % computed_args) if computed_args else "", immediate=True)
|
||||
|
||||
|
||||
def check_write_permissions(path):
|
||||
if platform.system() == "Windows":
|
||||
# physical access check
|
||||
check_path = os.path.join(os.path.realpath(path), ".sz_perm_chk")
|
||||
try:
|
||||
if os.path.exists(check_path):
|
||||
os.rmdir(check_path)
|
||||
os.mkdir(check_path)
|
||||
os.rmdir(check_path)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
else:
|
||||
# os.access check
|
||||
return os.access(path, os.W_OK | os.X_OK)
|
||||
return False
|
||||
|
||||
|
||||
def get_item_hints(title, kind, series=None):
|
||||
hints = {"expected_title": [title]}
|
||||
hints.update({"type": "episode", "expected_series": [series]} if kind == "series" else {"type": "movie"})
|
||||
return hints
|
||||
|
||||
|
||||
def notify_executable(exe_info, videos, subtitles, storage):
|
||||
variables = (
|
||||
"subtitle_language", "subtitle_path", "subtitle_filename", "provider", "score", "storage", "series_id",
|
||||
"series", "title", "section", "filename", "path", "folder", "season_id", "type", "id", "season"
|
||||
)
|
||||
exe, arguments = exe_info
|
||||
for video, video_subtitles in subtitles.items():
|
||||
for subtitle in video_subtitles:
|
||||
lang = Locale.Language.Match(subtitle.language.alpha2)
|
||||
data = video.plexapi_metadata.copy()
|
||||
data.update({
|
||||
"subtitle_language": lang,
|
||||
"provider": subtitle.provider_name,
|
||||
"score": subtitle.score,
|
||||
"storage": storage,
|
||||
"subtitle_path": subtitle.storage_path,
|
||||
"subtitle_filename": os.path.basename(subtitle.storage_path)
|
||||
})
|
||||
|
||||
# fill missing data with None
|
||||
prepared_data = dict((v, data.get(v)) for v in variables)
|
||||
|
||||
prepared_arguments = [arg % prepared_data for arg in arguments]
|
||||
|
||||
Log.Debug(u"Calling %s with arguments: %s" % (exe, prepared_arguments))
|
||||
try:
|
||||
output = subprocess.check_output([exe] + prepared_arguments, stderr=subprocess.STDOUT)
|
||||
except subprocess.CalledProcessError:
|
||||
Log.Error(u"Calling %s failed: %s" % (exe, traceback.format_exc()))
|
||||
else:
|
||||
Log.Debug(u"Process output: %s" % output)
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# coding=utf-8
|
||||
|
||||
from subzero.lib.dict import DictProxy
|
||||
|
||||
|
||||
class IgnoreDict(DictProxy):
|
||||
store = "ignore"
|
||||
|
||||
# single item keys returned by helpers.items.getItems mapped to their parents
|
||||
translate_keys = {
|
||||
"section": "sections",
|
||||
"show": "series",
|
||||
"movie": "videos",
|
||||
"episode": "videos"
|
||||
}
|
||||
|
||||
# getItems types mapped to their verbose names
|
||||
keys_verbose = {
|
||||
"sections": "Section",
|
||||
"series": "Series",
|
||||
"videos": "Item",
|
||||
}
|
||||
|
||||
key_order = ("sections", "series", "videos")
|
||||
|
||||
def __len__(self):
|
||||
try:
|
||||
return sum(len(self.Dict[self.store][key]) for key in self.key_order)
|
||||
except KeyError:
|
||||
# old version
|
||||
self.Dict[self.store] = self.setup_defaults()
|
||||
return 0
|
||||
|
||||
def translate_key(self, name):
|
||||
return self.translate_keys.get(name)
|
||||
|
||||
def verbose(self, name):
|
||||
return self.keys_verbose.get(name)
|
||||
|
||||
def get_title_key(self, kind, key):
|
||||
return "%s_%s" % (kind, key)
|
||||
|
||||
def add_title(self, kind, key, title):
|
||||
self["titles"][self.get_title_key(kind, key)] = title
|
||||
|
||||
def remove_title(self, kind, key):
|
||||
title_key = self.get_title_key(kind, key)
|
||||
if title_key in self.titles:
|
||||
del self.titles[title_key]
|
||||
|
||||
def get_title(self, kind, key):
|
||||
title_key = self.get_title_key(kind, key)
|
||||
if title_key in self.titles:
|
||||
return self.titles[title_key]
|
||||
|
||||
def save(self):
|
||||
Dict.Save()
|
||||
|
||||
def setup_defaults(self):
|
||||
return {"sections": [], "series": [], "videos": [], "titles": {}}
|
||||
|
||||
ignore_list = IgnoreDict(Dict)
|
||||
+239
-23
@@ -1,43 +1,259 @@
|
||||
# coding=utf-8
|
||||
|
||||
import logging
|
||||
from helpers import is_recent, format_video
|
||||
import re
|
||||
import types
|
||||
import os
|
||||
from ignore import ignore_list
|
||||
from helpers import is_recent, format_item, query_plex
|
||||
from subzero import intent
|
||||
from lib import Plex
|
||||
from config import config
|
||||
from config import config, IGNORE_FN
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MI_KIND, MI_TITLE, MI_ITEM = 0, 1, 2
|
||||
def getMergedItems(key="recently_added"):
|
||||
MI_KIND, MI_TITLE, MI_KEY, MI_DEEPER, MI_ITEM = 0, 1, 2, 3, 4
|
||||
|
||||
container_size_re = re.compile(ur'totalSize="(\d+)"')
|
||||
|
||||
|
||||
def get_item(key):
|
||||
item_id = int(key)
|
||||
item_container = Plex["library"].metadata(item_id)
|
||||
|
||||
item = list(item_container)[0]
|
||||
return item
|
||||
|
||||
|
||||
def get_item_kind(item):
|
||||
return type(item).__name__
|
||||
|
||||
|
||||
def get_item_thumb(item):
|
||||
kind = get_item_kind(item)
|
||||
if kind == "Episode":
|
||||
return item.show.thumb
|
||||
elif kind == "Section":
|
||||
return item.art
|
||||
return item.thumb
|
||||
|
||||
|
||||
def get_items_info(items):
|
||||
return items[0][MI_KIND], items[0][MI_DEEPER]
|
||||
|
||||
|
||||
def get_kind(items):
|
||||
return items[0][MI_KIND]
|
||||
|
||||
|
||||
def get_section_size(key):
|
||||
"""
|
||||
plex has certain views that return multiple item types. recently_added and on_deck for example
|
||||
quick query to determine the section size
|
||||
:param key:
|
||||
:return:
|
||||
"""
|
||||
size = None
|
||||
url = "http://127.0.0.1:32400/library/sections/%s/all" % int(key)
|
||||
use_args = {
|
||||
"X-Plex-Container-Size": "0",
|
||||
"X-Plex-Container-Start": "0"
|
||||
}
|
||||
response = query_plex(url, use_args)
|
||||
matches = container_size_re.findall(response.content)
|
||||
if matches:
|
||||
size = int(matches[0])
|
||||
|
||||
return size
|
||||
|
||||
|
||||
def get_items(key="recently_added", base="library", value=None, flat=False, add_section_title=False):
|
||||
"""
|
||||
try to handle all return types plex throws at us and return a generalized item tuple
|
||||
"""
|
||||
items = []
|
||||
for item in getattr(Plex['library'], key)():
|
||||
if item.type == "season":
|
||||
for child in item.children():
|
||||
#print u"Series: %s, Season: %s, Episode: %s %s" % (item.show.title, item.title, child.index, child.title)
|
||||
items.append(("episode", format_video(child, "episode", parent=item), child))
|
||||
|
||||
elif item.type == "episode":
|
||||
items.append(("episode", format_video(item, "episode", parent=item.season, parentTitle=item.show.title), item))
|
||||
apply_value = None
|
||||
if value:
|
||||
if isinstance(value, types.ListType):
|
||||
apply_value = value
|
||||
else:
|
||||
apply_value = [value]
|
||||
result = getattr(Plex[base], key)(*(apply_value or []))
|
||||
|
||||
elif item.type == "movie":
|
||||
items.append(("movie", format_video(item, "movie"), item))
|
||||
for item in result:
|
||||
cls = getattr(getattr(item, "__class__"), "__name__")
|
||||
if hasattr(item, "scanner"):
|
||||
kind = "section"
|
||||
elif cls == "Directory":
|
||||
kind = "directory"
|
||||
else:
|
||||
kind = item.type
|
||||
|
||||
# only return items for our enabled sections
|
||||
section_key = None
|
||||
if kind == "section":
|
||||
section_key = item.key
|
||||
else:
|
||||
if hasattr(item, "section_key"):
|
||||
section_key = getattr(item, "section_key")
|
||||
|
||||
if section_key and section_key not in config.enabled_sections:
|
||||
continue
|
||||
|
||||
if kind == "season":
|
||||
# fixme: i think this case is unused now
|
||||
if flat:
|
||||
# return episodes
|
||||
for child in item.children():
|
||||
items.append(("episode", format_item(child, "show", parent=item, add_section_title=add_section_title), int(item.rating_key),
|
||||
False, child))
|
||||
else:
|
||||
# return seasons
|
||||
items.append(("season", item.title, int(item.rating_key), True, item))
|
||||
|
||||
elif kind == "directory":
|
||||
items.append(("directory", item.title, item.key, True, item))
|
||||
|
||||
elif kind == "section":
|
||||
if item.type in ['movie', 'show']:
|
||||
item.size = get_section_size(item.key)
|
||||
items.append(("section", item.title, int(item.key), True, item))
|
||||
|
||||
elif kind == "episode":
|
||||
items.append(
|
||||
(kind, format_item(item, "show", parent=item.season, parent_title=item.show.title, section_title=item.section.title,
|
||||
add_section_title=add_section_title), int(item.rating_key), False, item))
|
||||
|
||||
elif kind in ("movie", "artist", "photo"):
|
||||
items.append((kind, format_item(item, kind, section_title=item.section.title, add_section_title=add_section_title),
|
||||
int(item.rating_key), False, item))
|
||||
|
||||
elif kind == "show":
|
||||
items.append((
|
||||
kind, format_item(item, kind, section_title=item.section.title, add_section_title=add_section_title), int(item.rating_key), True,
|
||||
item))
|
||||
|
||||
return items
|
||||
|
||||
def getRecentlyAddedItems():
|
||||
items = getMergedItems(key="recently_added")
|
||||
return filter(lambda x: is_recent(x[MI_ITEM]), items)
|
||||
|
||||
def getOnDeckItems():
|
||||
return getMergedItems(key="on_deck")
|
||||
|
||||
def get_recently_added_items():
|
||||
items = get_items(key="recently_added")
|
||||
return filter(lambda x: is_recent(x[MI_ITEM].added_at), items)
|
||||
|
||||
def refreshItem(rating_key, force=False, timeout=8000):
|
||||
|
||||
def get_recent_items():
|
||||
"""
|
||||
actually get the recent items, not limited like /library/recentlyAdded
|
||||
:return:
|
||||
"""
|
||||
args = {
|
||||
"sort": "addedAt:desc",
|
||||
"X-Plex-Container-Start": "0",
|
||||
"X-Plex-Container-Size": "%s" % config.max_recent_items_per_library
|
||||
}
|
||||
|
||||
episode_re = re.compile(ur'ratingKey="(?P<key>\d+)"'
|
||||
ur'.+?grandparentRatingKey="(?P<parent_key>\d+)"'
|
||||
ur'.+?title="(?P<title>.*?)"'
|
||||
ur'.+?grandparentTitle="(?P<parent_title>.*?)"'
|
||||
ur'.+?index="(?P<episode>\d+?)"'
|
||||
ur'.+?parentIndex="(?P<season>\d+?)".+?addedAt="(?P<added>\d+)"')
|
||||
movie_re = re.compile(ur'ratingKey="(?P<key>\d+)".+?title="(?P<title>.*?)".+?addedAt="(?P<added>\d+)"')
|
||||
available_keys = ("key", "title", "parent_key", "parent_title", "season", "episode", "added")
|
||||
recent = []
|
||||
|
||||
for section in Plex["library"].sections():
|
||||
if section.type not in ("movie", "show") \
|
||||
or section.key not in config.enabled_sections \
|
||||
or section.key in ignore_list.sections:
|
||||
Log.Debug(u"Skipping section: %s" % section.title)
|
||||
continue
|
||||
|
||||
use_args = args.copy()
|
||||
if section.type == "show":
|
||||
use_args["type"] = "4"
|
||||
|
||||
url = "http://127.0.0.1:32400/library/sections/%s/all" % int(section.key)
|
||||
response = query_plex(url, use_args)
|
||||
|
||||
matcher = episode_re if section.type == "show" else movie_re
|
||||
matches = [m.groupdict() for m in matcher.finditer(response.content)]
|
||||
for match in matches:
|
||||
data = dict((key, match[key] if key in match else None) for key in available_keys)
|
||||
if section.type == "show" and data["parent_key"] in ignore_list.series:
|
||||
Log.Debug(u"Skipping series: %s" % data["parent_title"])
|
||||
continue
|
||||
if data["key"] in ignore_list.videos:
|
||||
Log.Debug(u"Skipping item: %s" % data["title"])
|
||||
continue
|
||||
if is_recent(int(data["added"])):
|
||||
recent.append((int(data["added"]), section.type, section.title, data["key"]))
|
||||
|
||||
return recent
|
||||
|
||||
|
||||
def get_on_deck_items():
|
||||
return get_items(key="on_deck", add_section_title=True)
|
||||
|
||||
|
||||
def get_all_items(key, base="library", value=None, flat=False):
|
||||
return get_items(key, base=base, value=value, flat=flat)
|
||||
|
||||
|
||||
def is_ignored(rating_key, item=None):
|
||||
"""
|
||||
check whether an item, its show/season/section is in the soft or the hard ignore list
|
||||
:param rating_key:
|
||||
:param item:
|
||||
:return:
|
||||
"""
|
||||
# item in soft ignore list
|
||||
if rating_key in ignore_list["videos"]:
|
||||
Log.Debug("Item %s is in the soft ignore list" % rating_key)
|
||||
return True
|
||||
|
||||
item = item or get_item(rating_key)
|
||||
kind = get_item_kind(item)
|
||||
|
||||
# show in soft ignore list
|
||||
if kind == "Episode" and item.show.rating_key in ignore_list["series"]:
|
||||
Log.Debug("Item %s's show is in the soft ignore list" % rating_key)
|
||||
return True
|
||||
|
||||
# section in soft ignore list
|
||||
if item.section.key in ignore_list["sections"]:
|
||||
Log.Debug("Item %s's section is in the soft ignore list" % rating_key)
|
||||
return True
|
||||
|
||||
# physical/path ignore
|
||||
if Prefs["subtitles.ignore_fs"] or config.ignore_paths:
|
||||
# normally check current item folder and the library
|
||||
check_ignore_paths = [".", "../"]
|
||||
if kind == "Episode":
|
||||
# series/episode, we've got a season folder here, also
|
||||
check_ignore_paths.append("../../")
|
||||
|
||||
for part in item.media.parts:
|
||||
if config.ignore_paths and config.is_path_ignored(part.file):
|
||||
Log.Debug("Item %s's path is manually ignored" % rating_key)
|
||||
return True
|
||||
|
||||
if Prefs["subtitles.ignore_fs"]:
|
||||
for sub_path in check_ignore_paths:
|
||||
if config.is_physically_ignored(os.path.abspath(os.path.join(os.path.dirname(part.file), sub_path))):
|
||||
Log.Debug("An ignore file exists in either the items or its parent folders")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def refresh_item(rating_key, force=False, timeout=8000, refresh_kind=None, parent_rating_key=None):
|
||||
# timeout actually is the time for which the intent will be valid
|
||||
if force:
|
||||
intent.set("force", rating_key, timeout=timeout)
|
||||
intent.set("force", rating_key, timeout=timeout)
|
||||
|
||||
if refresh_kind == "episode":
|
||||
# season refresh
|
||||
rating_key = parent_rating_key
|
||||
|
||||
Log.Info("%s item %s", "Refreshing" if not force else "Forced-refreshing", rating_key)
|
||||
Plex["library/metadata"].refresh(rating_key)
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
# coding=utf-8
|
||||
|
||||
from plex import Plex
|
||||
from auth import refresh_plex_token
|
||||
import plex
|
||||
from subzero.lib.httpfake import PlexPyNativeResponseProxy
|
||||
|
||||
def configure_plex():
|
||||
# this may be the only viable usage of global :O (correct me if i'm wrong)
|
||||
global Plex
|
||||
if not "token" in Dict or not (Prefs["plex_username"] and Prefs["plex_password"]):
|
||||
refresh_plex_token()
|
||||
|
||||
# initialize Plex api
|
||||
Plex.configuration.defaults.authentication(Dict["token"] if "token" in Dict else None)
|
||||
class PlexPyNativeRequestProxy(object):
|
||||
"""
|
||||
A really dumb object that tries to mimic requests.Request in an incomplete way, so that plex.Plex
|
||||
uses native plex HTTPRequests instead of the better requests.Request class.
|
||||
|
||||
lib_unaccessible_error = "\n\n\n!!!!!!!!!!!!!! ATTENTION !!!!!!!!!!!!! \nCan't access your Plex Media Servers' API.\nAre you using Plex Home? Please configure your Plex.tv credentials! Advanced features disabled!\n\n\n"
|
||||
This allows us to operate freely on 127.0.0.1's PMS.
|
||||
|
||||
To be used in conjunction with subzero.lib.httpfake.PlexPyNativeResponseProxy
|
||||
"""
|
||||
url = None
|
||||
data = None
|
||||
headers = None
|
||||
method = None
|
||||
|
||||
def prepare(self):
|
||||
return self
|
||||
|
||||
def send(self):
|
||||
# fixme: add self.data to HTTP.Request
|
||||
data = None
|
||||
status_code = 200
|
||||
try:
|
||||
data = HTTP.Request(self.url, headers=self.headers, immediate=True, method=self.method)
|
||||
except Ex.HTTPError as e:
|
||||
status_code = e.code
|
||||
return PlexPyNativeResponseProxy(data, status_code, self)
|
||||
|
||||
|
||||
plex.request.Request = PlexPyNativeRequestProxy
|
||||
|
||||
Plex = plex.Plex
|
||||
|
||||
Regular → Executable
+95
-92
@@ -1,116 +1,119 @@
|
||||
# coding=utf-8
|
||||
|
||||
import os, unicodedata
|
||||
import os
|
||||
import config
|
||||
import helpers
|
||||
import subtitlehelpers
|
||||
|
||||
def findSubtitles(part):
|
||||
lang_sub_map = {}
|
||||
part_filename = helpers.unicodize(part.file)
|
||||
part_basename = os.path.splitext(os.path.basename(part_filename))[0]
|
||||
paths = [ os.path.dirname(part_filename) ]
|
||||
from config import config as sz_config
|
||||
|
||||
# Check for local subtitles subdirectory
|
||||
sub_dirs_default = ["sub", "subs", "subtitle", "subtitles"]
|
||||
sub_dir_base = paths[0]
|
||||
|
||||
sub_dir_list = []
|
||||
def find_subtitles(part):
|
||||
lang_sub_map = {}
|
||||
part_filename = helpers.unicodize(part.file)
|
||||
part_basename = os.path.splitext(os.path.basename(part_filename))[0]
|
||||
use_filesystem = bool(Prefs["subtitles.save.filesystem"])
|
||||
paths = [os.path.dirname(part_filename)] if use_filesystem else []
|
||||
|
||||
if Prefs["subtitles.save.subFolder"] != "current folder":
|
||||
# got selected subfolder
|
||||
sub_dir_list.append(os.path.join(sub_dir_base, Prefs["subtitles.save.subFolder"]))
|
||||
global_subtitle_folder = None
|
||||
|
||||
sub_dir_custom = Prefs["subtitles.save.subFolder.Custom"].strip() if bool(Prefs["subtitles.save.subFolder.Custom"]) else None
|
||||
if sub_dir_custom:
|
||||
# got custom subfolder
|
||||
if sub_dir_custom.startswith("/"):
|
||||
# absolute folder
|
||||
sub_dir_list.append(sub_dir_custom)
|
||||
else:
|
||||
# relative folder
|
||||
sub_dir_list.append(os.path.join(sub_dir_base, sub_dir_custom))
|
||||
if use_filesystem:
|
||||
# Check for local subtitles subdirectory
|
||||
sub_dir_base = paths[0]
|
||||
|
||||
for sub_dir in sub_dir_list:
|
||||
if os.path.isdir(sub_dir):
|
||||
paths.append(sub_dir)
|
||||
sub_dir_list = []
|
||||
|
||||
# Check for a global subtitle location
|
||||
global_subtitle_folder = os.path.join(Core.app_support_path, 'Subtitles')
|
||||
if os.path.exists(global_subtitle_folder):
|
||||
paths.append(global_subtitle_folder)
|
||||
if Prefs["subtitles.save.subFolder"] != "current folder":
|
||||
# got selected subfolder
|
||||
sub_dir_list.append(os.path.join(sub_dir_base, Prefs["subtitles.save.subFolder"]))
|
||||
|
||||
|
||||
sub_dir_custom = Prefs["subtitles.save.subFolder.Custom"].strip() if bool(Prefs["subtitles.save.subFolder.Custom"]) else None
|
||||
if sub_dir_custom:
|
||||
# got custom subfolder
|
||||
if sub_dir_custom.startswith("/"):
|
||||
# absolute folder
|
||||
sub_dir_list.append(sub_dir_custom)
|
||||
else:
|
||||
# relative folder
|
||||
sub_dir_list.append(os.path.join(sub_dir_base, sub_dir_custom))
|
||||
|
||||
# We start by building a dictionary of files to their absolute paths. We also need to know
|
||||
# the number of media files that are actually present, in case the found local media asset
|
||||
# is limited to a single instance per media file.
|
||||
#
|
||||
file_paths = {}
|
||||
total_media_files = 0
|
||||
for path in paths:
|
||||
path = helpers.unicodize(path)
|
||||
for file_path_listing in os.listdir(path):
|
||||
for sub_dir in sub_dir_list:
|
||||
if os.path.isdir(sub_dir):
|
||||
paths.append(sub_dir)
|
||||
|
||||
# When using os.listdir with a unicode path, it will always return a string using the
|
||||
# NFD form. However, we internally are using the form NFC and therefore need to convert
|
||||
# it to allow correct regex / comparisons to be performed.
|
||||
#
|
||||
file_path_listing = helpers.unicodize(file_path_listing)
|
||||
if os.path.isfile(os.path.join(path, file_path_listing)):
|
||||
file_paths[file_path_listing.lower()] = os.path.join(path, file_path_listing)
|
||||
# Check for a global subtitle location
|
||||
global_subtitle_folder = os.path.join(Core.app_support_path, 'Subtitles')
|
||||
if os.path.exists(global_subtitle_folder):
|
||||
paths.append(global_subtitle_folder)
|
||||
|
||||
# If we've found an actual media file, we should record it.
|
||||
(root, ext) = os.path.splitext(file_path_listing)
|
||||
if ext.lower()[1:] in config.VIDEO_EXTS:
|
||||
total_media_files += 1
|
||||
|
||||
Log('Looking for subtitle media in %d paths with %d media files.', len(paths), total_media_files)
|
||||
Log('Paths: %s', ", ".join([ helpers.unicodize(p) for p in paths ]))
|
||||
|
||||
for file_path in file_paths.values():
|
||||
|
||||
local_basename = helpers.unicodize(os.path.splitext(os.path.basename(file_path))[0])
|
||||
local_basename2 = local_basename.rsplit('.', 1)[0]
|
||||
filename_matches_part = local_basename == part_basename or local_basename2 == part_basename
|
||||
|
||||
# If the file is located within the global subtitle folder and it's name doesn't match exactly
|
||||
# then we should simply ignore it.
|
||||
# We start by building a dictionary of files to their absolute paths. We also need to know
|
||||
# the number of media files that are actually present, in case the found local media asset
|
||||
# is limited to a single instance per media file.
|
||||
#
|
||||
if file_path.count(global_subtitle_folder) and not filename_matches_part:
|
||||
continue
|
||||
file_paths = {}
|
||||
total_media_files = 0
|
||||
for path in paths:
|
||||
path = helpers.unicodize(path)
|
||||
for file_path_listing in os.listdir(path.encode(sz_config.fs_encoding)):
|
||||
|
||||
# If we have more than one media file within the folder and located filename doesn't match
|
||||
# exactly then we should simply ignore it.
|
||||
#
|
||||
if total_media_files > 1 and not filename_matches_part:
|
||||
continue
|
||||
# When using os.listdir with a unicode path, it will always return a string using the
|
||||
# NFD form. However, we internally are using the form NFC and therefore need to convert
|
||||
# it to allow correct regex / comparisons to be performed.
|
||||
#
|
||||
file_path_listing = helpers.unicodize(file_path_listing)
|
||||
if os.path.isfile(os.path.join(path, file_path_listing).encode(sz_config.fs_encoding)):
|
||||
file_paths[file_path_listing.lower()] = os.path.join(path, file_path_listing)
|
||||
|
||||
subtitle_helper = subtitlehelpers.SubtitleHelpers(file_path)
|
||||
if subtitle_helper != None:
|
||||
local_lang_map = subtitle_helper.process_subtitles(part)
|
||||
for new_language, subtitles in local_lang_map.items():
|
||||
# If we've found an actual media file, we should record it.
|
||||
(root, ext) = os.path.splitext(file_path_listing)
|
||||
if ext.lower()[1:] in config.VIDEO_EXTS:
|
||||
total_media_files += 1
|
||||
|
||||
# Add the possible new language along with the located subtitles so that we can validate them
|
||||
# at the end...
|
||||
Log('Looking for subtitle media in %d paths with %d media files.', len(paths), total_media_files)
|
||||
Log('Paths: %s', ", ".join([helpers.unicodize(p) for p in paths]))
|
||||
|
||||
for file_path in file_paths.values():
|
||||
|
||||
local_basename = helpers.unicodize(os.path.splitext(os.path.basename(file_path))[0])
|
||||
local_basename2 = local_basename.rsplit('.', 1)[0]
|
||||
filename_matches_part = local_basename == part_basename or local_basename2 == part_basename
|
||||
|
||||
# If the file is located within the global subtitle folder and it's name doesn't match exactly
|
||||
# then we should simply ignore it.
|
||||
#
|
||||
if not lang_sub_map.has_key(new_language):
|
||||
lang_sub_map[new_language] = []
|
||||
lang_sub_map[new_language] = lang_sub_map[new_language] + subtitles
|
||||
if global_subtitle_folder and file_path.count(global_subtitle_folder) and not filename_matches_part:
|
||||
continue
|
||||
|
||||
# add known metadata subs to our sub list
|
||||
if not Prefs['subtitles.save.filesystem']:
|
||||
for language, sub_list in subtitlehelpers.getSubtitlesFromMetadata(part).iteritems():
|
||||
if sub_list:
|
||||
if not language in lang_sub_map:
|
||||
lang_sub_map[language] = []
|
||||
lang_sub_map[language] = lang_sub_map[language] + sub_list
|
||||
# If we have more than one media file within the folder and located filename doesn't match
|
||||
# exactly then we should simply ignore it.
|
||||
#
|
||||
if total_media_files > 1 and not filename_matches_part:
|
||||
continue
|
||||
|
||||
# Now whack subtitles that don't exist anymore.
|
||||
for language in lang_sub_map.keys():
|
||||
part.subtitles[language].validate_keys(lang_sub_map[language])
|
||||
|
||||
# Now whack the languages that don't exist anymore.
|
||||
for language in list(set(part.subtitles.keys()) - set(lang_sub_map.keys())):
|
||||
part.subtitles[language].validate_keys({})
|
||||
subtitle_helper = subtitlehelpers.subtitle_helpers(file_path)
|
||||
if subtitle_helper != None:
|
||||
local_lang_map = subtitle_helper.process_subtitles(part)
|
||||
for new_language, subtitles in local_lang_map.items():
|
||||
|
||||
# Add the possible new language along with the located subtitles so that we can validate them
|
||||
# at the end...
|
||||
#
|
||||
if not lang_sub_map.has_key(new_language):
|
||||
lang_sub_map[new_language] = []
|
||||
lang_sub_map[new_language] = lang_sub_map[new_language] + subtitles
|
||||
|
||||
# add known metadata subs to our sub list
|
||||
if not use_filesystem:
|
||||
for language, sub_list in subtitlehelpers.get_subtitles_from_metadata(part).iteritems():
|
||||
if sub_list:
|
||||
if language not in lang_sub_map:
|
||||
lang_sub_map[language] = []
|
||||
lang_sub_map[language] = lang_sub_map[language] + sub_list
|
||||
|
||||
# Now whack subtitles that don't exist anymore.
|
||||
for language in lang_sub_map.keys():
|
||||
part.subtitles[language].validate_keys(lang_sub_map[language])
|
||||
|
||||
# Now whack the languages that don't exist anymore.
|
||||
for language in list(set(part.subtitles.keys()) - set(lang_sub_map.keys())):
|
||||
part.subtitles[language].validate_keys({})
|
||||
|
||||
Regular → Executable
+39
-47
@@ -1,36 +1,22 @@
|
||||
# coding=utf-8
|
||||
import traceback
|
||||
|
||||
import datetime
|
||||
import sys
|
||||
|
||||
from support.items import getRecentlyAddedItems, MI_ITEM
|
||||
from support.config import config
|
||||
from support.helpers import format_video
|
||||
from lib import Plex
|
||||
from support.helpers import format_item
|
||||
from support.items import get_item
|
||||
from support.lib import Plex
|
||||
|
||||
def itemDiscoverMissing(rating_key, kind="episode", internal=False, external=True, languages=[], section_blacklist=[], series_blacklist=[], item_blacklist=[]):
|
||||
|
||||
def item_discover_missing_subs(rating_key, kind="show", added_at=None, section_title=None, internal=False, external=True, languages=()):
|
||||
existing_subs = {"internal": [], "external": [], "count": 0}
|
||||
|
||||
item_id = int(rating_key)
|
||||
item_container = Plex["library"].metadata(item_id)
|
||||
|
||||
# don't process blacklisted sections
|
||||
if item_container.section.key in section_blacklist:
|
||||
return
|
||||
item = get_item(rating_key)
|
||||
|
||||
item = list(item_container)[0]
|
||||
|
||||
if kind == "episode":
|
||||
item_title = format_video(item, kind, parent=item.season, parentTitle=item.show.title)
|
||||
if kind == "show":
|
||||
item_title = format_item(item, kind, parent=item.season, section_title=section_title, parent_title=item.show.title)
|
||||
else:
|
||||
item_title = format_video(item, kind)
|
||||
|
||||
if kind == "episode" and item.show.rating_key in series_blacklist:
|
||||
Log.Info("Skipping show %s in blacklist", item.show.key)
|
||||
return
|
||||
elif item.rating_key in item_blacklist:
|
||||
Log.Info("Skipping item %s in blacklist", item.key)
|
||||
return
|
||||
item_title = format_item(item, kind, section_title=section_title)
|
||||
|
||||
video = item.media
|
||||
|
||||
@@ -44,42 +30,48 @@ def itemDiscoverMissing(rating_key, kind="episode", internal=False, external=Tru
|
||||
|
||||
existing_subs[key].append(Locale.Language.Match(stream.language_code or ""))
|
||||
existing_subs["count"] = existing_subs["count"] + 1
|
||||
|
||||
|
||||
missing = languages
|
||||
if existing_subs["count"]:
|
||||
existing_flat = (existing_subs["internal"] if internal else []) + (existing_subs["external"] if external else [])
|
||||
languages_set = set(languages)
|
||||
if languages_set.issubset(existing_flat):
|
||||
if languages_set.issubset(existing_flat) or (len(existing_flat) >= 1 and Prefs['subtitles.only_one']):
|
||||
# all subs found
|
||||
Log.Info(u"All subtitles exist for '%s'", item_title)
|
||||
return
|
||||
|
||||
|
||||
missing = languages_set - set(existing_flat)
|
||||
Log.Info(u"Subs still missing for '%s': %s", item_title, missing)
|
||||
|
||||
if missing:
|
||||
return item_id, item_title
|
||||
return added_at, item_id, item_title, item
|
||||
|
||||
def getAllRecentlyAddedMissing():
|
||||
items = getRecentlyAddedItems()
|
||||
|
||||
def items_get_all_missing_subs(items):
|
||||
missing = []
|
||||
for kind, title, item in items:
|
||||
state = itemDiscoverMissing(
|
||||
item.rating_key,
|
||||
kind=kind,
|
||||
languages=config.langList,
|
||||
internal=bool(Prefs["subtitles.scan.embedded"]),
|
||||
external=bool(Prefs["subtitles.scan.external"]),
|
||||
section_blacklist=config.scheduler_section_blacklist,
|
||||
series_blacklist=config.scheduler_series_blacklist,
|
||||
item_blacklist=config.scheduler_item_blacklist
|
||||
)
|
||||
if state:
|
||||
# (item_id, title)
|
||||
missing.append(state)
|
||||
for added_at, kind, section_title, key in items:
|
||||
try:
|
||||
state = item_discover_missing_subs(
|
||||
key,
|
||||
kind=kind,
|
||||
added_at=added_at,
|
||||
section_title=section_title,
|
||||
languages=config.lang_list,
|
||||
internal=bool(Prefs["subtitles.scan.embedded"]),
|
||||
external=bool(Prefs["subtitles.scan.external"])
|
||||
)
|
||||
if state:
|
||||
# (added_at, item_id, title)
|
||||
missing.append(state)
|
||||
except:
|
||||
Log.Error("Something went wrong when getting the state of item %s: %s", key, traceback.format_exc())
|
||||
return missing
|
||||
|
||||
def searchMissing(items):
|
||||
|
||||
def refresh_item(item, title):
|
||||
Plex["library/metadata"].refresh(item)
|
||||
|
||||
|
||||
def refresh_items(items):
|
||||
for item, title in items:
|
||||
Log.Info("Triggering refresh for '%s'", title)
|
||||
Plex["library/metadata"].refresh(item)
|
||||
refresh_item(item, title)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# coding=utf-8
|
||||
|
||||
import os
|
||||
import subliminal
|
||||
import helpers
|
||||
|
||||
from items import get_item
|
||||
from subzero import intent
|
||||
|
||||
|
||||
def flatten_media(media, kind="series"):
|
||||
"""
|
||||
iterates through media and returns the associated parts (videos)
|
||||
:param media:
|
||||
:param kind:
|
||||
:return:
|
||||
"""
|
||||
parts = []
|
||||
|
||||
def get_metadata_dict(item, part, add):
|
||||
data = {
|
||||
"section": item.section.title,
|
||||
"path": part.file,
|
||||
"folder": os.path.dirname(part.file),
|
||||
"filename": os.path.basename(part.file)
|
||||
}
|
||||
data.update(add)
|
||||
return data
|
||||
|
||||
if kind == "series":
|
||||
for season in media.seasons:
|
||||
season_object = media.seasons[season]
|
||||
for episode in media.seasons[season].episodes:
|
||||
ep = media.seasons[season].episodes[episode]
|
||||
|
||||
# get plex item via API for additional metadata
|
||||
plex_episode = get_item(ep.id)
|
||||
|
||||
for item in media.seasons[season].episodes[episode].items:
|
||||
for part in item.parts:
|
||||
parts.append(
|
||||
get_metadata_dict(plex_episode, part,
|
||||
{"video": part, "type": "episode", "title": ep.title,
|
||||
"series": media.title, "id": ep.id,
|
||||
"series_id": media.id, "season_id": season_object.id,
|
||||
"season": plex_episode.season.index,
|
||||
})
|
||||
)
|
||||
else:
|
||||
plex_item = get_item(media.id)
|
||||
for item in media.items:
|
||||
for part in item.parts:
|
||||
parts.append(
|
||||
get_metadata_dict(plex_item, part, {"video": part, "type": "movie",
|
||||
"title": media.title, "id": media.id,
|
||||
"series_id": None,
|
||||
"season_id": None,
|
||||
"section": plex_item.section.title})
|
||||
)
|
||||
return parts
|
||||
|
||||
|
||||
IGNORE_FN = ("subzero.ignore", ".subzero.ignore", ".nosz")
|
||||
|
||||
|
||||
def convert_media_to_parts(media, kind="series"):
|
||||
"""
|
||||
returns a list of parts to be used later on; ignores folders with an existing "subzero.ignore" file
|
||||
:param media:
|
||||
:param kind:
|
||||
:return:
|
||||
"""
|
||||
return flatten_media(media, kind=kind)
|
||||
|
||||
|
||||
def get_stream_fps(streams):
|
||||
"""
|
||||
accepts a list of plex streams or a list of the plex api streams
|
||||
"""
|
||||
for stream in streams:
|
||||
# video
|
||||
stream_type = getattr(stream, "type", getattr(stream, "stream_type", None))
|
||||
if stream_type == 1:
|
||||
return getattr(stream, "frameRate", getattr(stream, "frame_rate", "25.000"))
|
||||
return "25.000"
|
||||
|
||||
|
||||
def get_media_item_ids(media, kind="series"):
|
||||
ids = []
|
||||
if kind == "movies":
|
||||
ids.append(media.id)
|
||||
else:
|
||||
for season in media.seasons:
|
||||
for episode in media.seasons[season].episodes:
|
||||
ids.append(media.seasons[season].episodes[episode].id)
|
||||
|
||||
return ids
|
||||
|
||||
|
||||
def scan_video(plex_video, ignore_all=False, hints=None):
|
||||
embedded_subtitles = not ignore_all and Prefs['subtitles.scan.embedded']
|
||||
external_subtitles = not ignore_all and Prefs['subtitles.scan.external']
|
||||
|
||||
if ignore_all:
|
||||
Log.Debug("Force refresh intended.")
|
||||
|
||||
Log.Debug("Scanning video: %s, subtitles=%s, embedded_subtitles=%s" % (plex_video.file, external_subtitles, embedded_subtitles))
|
||||
|
||||
try:
|
||||
return subliminal.video.scan_video(plex_video.file, subtitles=external_subtitles, embedded_subtitles=embedded_subtitles,
|
||||
hints=hints or {}, video_fps=plex_video.fps)
|
||||
|
||||
except ValueError:
|
||||
Log.Warn("File could not be guessed by subliminal")
|
||||
|
||||
|
||||
def scan_parts(parts, kind="series"):
|
||||
"""
|
||||
receives a list of parts containing dictionaries returned by flattenToParts
|
||||
:param parts:
|
||||
:param kind: series or movies
|
||||
:return: dictionary of subliminal.video.scan_video, key=subliminal scanned video, value=plex file part
|
||||
"""
|
||||
ret = {}
|
||||
for part in parts:
|
||||
force_refresh = intent.get("force", part["id"], part["series_id"], part["season_id"])
|
||||
|
||||
hints = helpers.get_item_hints(part["title"], kind, series=part["series"] if kind == "series" else None)
|
||||
part["video"].fps = get_stream_fps(part["video"].streams)
|
||||
scanned_video = scan_video(part["video"], ignore_all=force_refresh, hints=hints)
|
||||
if not scanned_video:
|
||||
continue
|
||||
|
||||
scanned_video.id = part["id"]
|
||||
part_metadata = part.copy()
|
||||
del part_metadata["video"]
|
||||
scanned_video.plexapi_metadata = part_metadata
|
||||
ret[scanned_video] = part["video"]
|
||||
return ret
|
||||
@@ -1,45 +1,92 @@
|
||||
# coding=utf-8
|
||||
|
||||
import datetime
|
||||
import pprint
|
||||
|
||||
def storeSubtitleInfo(videos, subtitles, storage_type):
|
||||
|
||||
def get_subtitle_info(rating_key):
|
||||
return Dict["subs"].get(rating_key)
|
||||
|
||||
|
||||
def whack_missing_parts(videos, existing_parts=None):
|
||||
"""
|
||||
cleans out our internal storage's video parts (parts may get updated/deleted/whatever)
|
||||
:param existing_parts: optional list of part ids known
|
||||
:param videos: videos to check for
|
||||
:return:
|
||||
"""
|
||||
# shortcut
|
||||
|
||||
if not existing_parts:
|
||||
existing_parts = []
|
||||
for part in videos.viewvalues():
|
||||
existing_parts.append(part.id)
|
||||
|
||||
whacked_parts = False
|
||||
for video in videos.keys():
|
||||
if video.id not in Dict["subs"]:
|
||||
continue
|
||||
|
||||
for part_id in Dict["subs"][video.id].keys():
|
||||
if part_id not in existing_parts:
|
||||
del Dict["subs"][video.id][part_id]
|
||||
Log.Info("Whacking part %s in internal storage of video %s", part_id, video.id)
|
||||
whacked_parts = True
|
||||
|
||||
if whacked_parts:
|
||||
Dict.Save()
|
||||
|
||||
|
||||
def store_subtitle_info(videos, subtitles, storage_type):
|
||||
"""
|
||||
stores information about downloaded subtitles in plex's Dict()
|
||||
"""
|
||||
if not "subs" in Dict:
|
||||
Dict["subs"] = {}
|
||||
if "subs" not in Dict:
|
||||
Dict["subs"] = {}
|
||||
|
||||
storage = Dict["subs"]
|
||||
|
||||
existing_parts = []
|
||||
for video, video_subtitles in subtitles.items():
|
||||
part = videos[video]
|
||||
part = videos[video]
|
||||
|
||||
if not video.id in storage:
|
||||
storage[video.id] = {}
|
||||
if video.id not in storage:
|
||||
storage[video.id] = {}
|
||||
|
||||
video_dict = storage[video.id]
|
||||
if not part.id in video_dict:
|
||||
video_dict[part.id] = {}
|
||||
video_dict = storage[video.id]
|
||||
if part.id not in video_dict:
|
||||
video_dict[part.id] = {}
|
||||
|
||||
part_dict = video_dict[part.id]
|
||||
for subtitle in video_subtitles:
|
||||
lang = Locale.Language.Match(subtitle.language.alpha2)
|
||||
if not lang in part_dict:
|
||||
part_dict[lang] = {}
|
||||
lang_dict = part_dict[lang]
|
||||
sub_key = (subtitle.provider_name, subtitle.id)
|
||||
lang_dict[sub_key] = dict(score=subtitle.score, link=subtitle.page_link, storage=storage_type, hash=Hash.MD5(subtitle.content), date_added=datetime.datetime.now())
|
||||
existing_parts.append(part.id)
|
||||
|
||||
part_dict = video_dict[part.id]
|
||||
for subtitle in video_subtitles:
|
||||
lang = Locale.Language.Match(subtitle.language.alpha2)
|
||||
if lang not in part_dict:
|
||||
part_dict[lang] = {}
|
||||
lang_dict = part_dict[lang]
|
||||
sub_key = (subtitle.provider_name, subtitle.id)
|
||||
lang_dict[sub_key] = dict(score=subtitle.score, link=subtitle.page_link, storage=storage_type, hash=Hash.MD5(subtitle.content),
|
||||
date_added=datetime.datetime.now())
|
||||
lang_dict["current"] = sub_key
|
||||
|
||||
if existing_parts:
|
||||
whack_missing_parts(videos, existing_parts=existing_parts)
|
||||
Dict.Save()
|
||||
|
||||
def resetStorage(key):
|
||||
|
||||
def reset_storage(key):
|
||||
"""
|
||||
resets the Dict[key] storage, thanks to https://docs.google.com/document/d/1hhLjV1pI-TA5y91TiJq64BdgKwdLnFt4hWgeOqpz1NA/edit#
|
||||
We can't use the nice Plex interface for this, as it calls get multiple times before set
|
||||
#Plex[":/plugins/*/prefs"].set("com.plexapp.agents.subzero", "reset_storage", False)
|
||||
#Plex[":/plugins/*/prefs"].set("com.plexapp.agents.subzero", "reset_storage", False)
|
||||
"""
|
||||
|
||||
Log.Debug("resetting storage")
|
||||
Dict[key] = {}
|
||||
Dict.Save()
|
||||
|
||||
|
||||
def log_storage(key):
|
||||
if key in Dict:
|
||||
Log.Debug(pprint.pformat(Dict[key]))
|
||||
|
||||
@@ -1,143 +1,167 @@
|
||||
# coding=utf-8
|
||||
|
||||
import re, unicodedata, os
|
||||
import re, os
|
||||
import config
|
||||
import helpers
|
||||
|
||||
from bs4 import UnicodeDammit
|
||||
|
||||
|
||||
class SubtitleHelper(object):
|
||||
def __init__(self, filename):
|
||||
self.filename = filename
|
||||
def __init__(self, filename):
|
||||
self.filename = filename
|
||||
|
||||
|
||||
def subtitle_helpers(filename):
|
||||
filename = helpers.unicodize(filename)
|
||||
for cls in [VobSubSubtitleHelper, DefaultSubtitleHelper]:
|
||||
if cls.is_helper_for(filename):
|
||||
return cls(filename)
|
||||
return None
|
||||
|
||||
def SubtitleHelpers(filename):
|
||||
filename = helpers.unicodize(filename)
|
||||
for cls in [ VobSubSubtitleHelper, DefaultSubtitleHelper ]:
|
||||
if cls.is_helper_for(filename):
|
||||
return cls(filename)
|
||||
return None
|
||||
|
||||
#####################################################################################################################
|
||||
|
||||
class VobSubSubtitleHelper(SubtitleHelper):
|
||||
@classmethod
|
||||
def is_helper_for(cls, filename):
|
||||
(file, file_extension) = os.path.splitext(filename)
|
||||
@classmethod
|
||||
def is_helper_for(cls, filename):
|
||||
(file, file_extension) = os.path.splitext(filename)
|
||||
|
||||
# We only support idx (and maybe sub)
|
||||
if not file_extension.lower() in ['.idx', '.sub']:
|
||||
return False
|
||||
# We only support idx (and maybe sub)
|
||||
if not file_extension.lower() in ['.idx', '.sub']:
|
||||
return False
|
||||
|
||||
# If we've been given a sub, we only support it if there exists a matching idx file
|
||||
return os.path.exists(file + '.idx')
|
||||
# If we've been given a sub, we only support it if there exists a matching idx file
|
||||
return os.path.exists(file + '.idx')
|
||||
|
||||
def process_subtitles(self, part):
|
||||
def process_subtitles(self, part):
|
||||
|
||||
lang_sub_map = {}
|
||||
lang_sub_map = {}
|
||||
|
||||
# We don't directly process the sub file, only the idx. Therefore if we are passed on of these files, we simply
|
||||
# ignore it.
|
||||
(file, ext) = os.path.splitext(self.filename)
|
||||
if ext == '.sub':
|
||||
return lang_sub_map
|
||||
|
||||
# If we have an idx file, we need to confirm there is an identically names sub file before we can proceed.
|
||||
sub_filename = file + ".sub"
|
||||
if os.path.exists(sub_filename) == False:
|
||||
return lang_sub_map
|
||||
# We don't directly process the sub file, only the idx. Therefore if we are passed on of these files, we simply
|
||||
# ignore it.
|
||||
(file, ext) = os.path.splitext(self.filename)
|
||||
if ext == '.sub':
|
||||
return lang_sub_map
|
||||
|
||||
Log('Attempting to parse VobSub file: ' + self.filename)
|
||||
idx = Core.storage.load(os.path.join(self.filename))
|
||||
if idx.count('VobSub index file') == 0:
|
||||
Log('The idx file does not appear to be a VobSub, skipping...')
|
||||
return lang_sub_map
|
||||
# If we have an idx file, we need to confirm there is an identically names sub file before we can proceed.
|
||||
sub_filename = file + ".sub"
|
||||
if not os.path.exists(sub_filename):
|
||||
return lang_sub_map
|
||||
|
||||
languages = {}
|
||||
language_index = 0
|
||||
basename = os.path.basename(self.filename)
|
||||
for language in re.findall('\nid: ([A-Za-z]{2})', idx):
|
||||
Log('Attempting to parse VobSub file: ' + self.filename)
|
||||
idx = Core.storage.load(os.path.join(self.filename))
|
||||
if idx.count('VobSub index file') == 0:
|
||||
Log('The idx file does not appear to be a VobSub, skipping...')
|
||||
return lang_sub_map
|
||||
|
||||
if not languages.has_key(language):
|
||||
languages[language] = []
|
||||
languages = {}
|
||||
language_index = 0
|
||||
basename = os.path.basename(self.filename)
|
||||
for language in re.findall('\nid: ([A-Za-z]{2})', idx):
|
||||
|
||||
Log('Found .idx subtitle file: ' + self.filename + ' language: ' + language + ' stream index: ' + str(language_index))
|
||||
languages[language].append(Proxy.LocalFile(self.filename, index = str(language_index), format = "vobsub"))
|
||||
language_index += 1
|
||||
if not languages.has_key(language):
|
||||
languages[language] = []
|
||||
|
||||
if not lang_sub_map.has_key(language):
|
||||
lang_sub_map[language] = []
|
||||
lang_sub_map[language].append(basename)
|
||||
Log('Found .idx subtitle file: ' + self.filename + ' language: ' + language + ' stream index: ' + str(language_index))
|
||||
languages[language].append(Proxy.LocalFile(self.filename, index=str(language_index), format="vobsub"))
|
||||
language_index += 1
|
||||
|
||||
for language, subs in languages.items():
|
||||
part.subtitles[language][basename] = subs
|
||||
if not lang_sub_map.has_key(language):
|
||||
lang_sub_map[language] = []
|
||||
lang_sub_map[language].append(basename)
|
||||
|
||||
for language, subs in languages.items():
|
||||
part.subtitles[language][basename] = subs
|
||||
|
||||
return lang_sub_map
|
||||
|
||||
return lang_sub_map
|
||||
|
||||
#####################################################################################################################
|
||||
|
||||
class DefaultSubtitleHelper(SubtitleHelper):
|
||||
@classmethod
|
||||
def is_helper_for(cls, filename):
|
||||
(file, file_extension) = os.path.splitext(filename)
|
||||
return file_extension.lower()[1:] in config.SUBTITLE_EXTS
|
||||
@classmethod
|
||||
def is_helper_for(cls, filename):
|
||||
(file, file_extension) = os.path.splitext(filename)
|
||||
return file_extension.lower()[1:] in config.SUBTITLE_EXTS
|
||||
|
||||
def process_subtitles(self, part):
|
||||
def process_subtitles(self, part):
|
||||
|
||||
lang_sub_map = {}
|
||||
lang_sub_map = {}
|
||||
|
||||
basename = os.path.basename(self.filename)
|
||||
(file, ext) = os.path.splitext(self.filename)
|
||||
basename = os.path.basename(self.filename)
|
||||
(file, ext) = os.path.splitext(self.filename)
|
||||
|
||||
# Remove the initial '.' from the extension
|
||||
ext = ext[1:]
|
||||
# Remove the initial '.' from the extension
|
||||
ext = ext[1:]
|
||||
|
||||
# Attempt to extract the language from the filename (e.g. Avatar (2009).eng)
|
||||
language = ""
|
||||
|
||||
# IETF support thanks to https://github.com/hpsbranco/LocalMedia.bundle/commit/4fad9aefedece78a1fa96401304351347f644369
|
||||
language_match = re.match(".+\.([^\.]+)$" if not Prefs["subtitles.language.ietf"] else ".+\.([^-.]+)(?:-[A-Za-z]+)?$", file)
|
||||
if language_match and len(language_match.groups()) == 1:
|
||||
language = language_match.groups()[0]
|
||||
language = Locale.Language.Match(language)
|
||||
# Attempt to extract the language from the filename (e.g. Avatar (2009).eng)
|
||||
language = ""
|
||||
|
||||
codec = None
|
||||
format = None
|
||||
if ext in ['txt', 'sub']:
|
||||
try:
|
||||
# IETF support thanks to https://github.com/hpsbranco/LocalMedia.bundle/commit/4fad9aefedece78a1fa96401304351347f644369
|
||||
language_match = re.match(".+\.([^\.]+)$" if not Prefs["subtitles.language.ietf"] else ".+\.([^-.]+)(?:-[A-Za-z]+)?$", file)
|
||||
if language_match and len(language_match.groups()) == 1:
|
||||
language = language_match.groups()[0]
|
||||
language = Locale.Language.Match(language)
|
||||
|
||||
file_contents = Core.storage.load(self.filename)
|
||||
lines = [ line.strip() for line in file_contents.splitlines(True) ]
|
||||
if re.match('^\{[0-9]+\}\{[0-9]*\}', lines[1]):
|
||||
format = 'microdvd'
|
||||
elif re.match('^[0-9]{1,2}:[0-9]{2}:[0-9]{2}[:=,]', lines[1]):
|
||||
format = 'txt'
|
||||
elif '[SUBTITLE]' in lines[1]:
|
||||
format = 'subviewer'
|
||||
else:
|
||||
Log("The subtitle file does not have a known format, skipping... : " + self.filename)
|
||||
return lang_sub_map
|
||||
except:
|
||||
Log("An error occurred while attempting to parse the subtitle file, skipping... : " + self.filename)
|
||||
codec = None
|
||||
format = None
|
||||
if ext in ['txt', 'sub']:
|
||||
try:
|
||||
|
||||
file_contents = Core.storage.load(self.filename)
|
||||
lines = [line.strip() for line in file_contents.splitlines(True)]
|
||||
if re.match('^\{[0-9]+\}\{[0-9]*\}', lines[1]):
|
||||
format = 'microdvd'
|
||||
elif re.match('^[0-9]{1,2}:[0-9]{2}:[0-9]{2}[:=,]', lines[1]):
|
||||
format = 'txt'
|
||||
elif '[SUBTITLE]' in lines[1]:
|
||||
format = 'subviewer'
|
||||
else:
|
||||
Log("The subtitle file does not have a known format, skipping... : " + self.filename)
|
||||
return lang_sub_map
|
||||
except:
|
||||
Log("An error occurred while attempting to parse the subtitle file, skipping... : " + self.filename)
|
||||
return lang_sub_map
|
||||
|
||||
if codec is None and ext in ['ass', 'ssa', 'smi', 'srt', 'psb']:
|
||||
codec = ext.replace('ass', 'ssa')
|
||||
|
||||
if format is None:
|
||||
format = codec
|
||||
|
||||
Log('Found subtitle file: ' + self.filename + ' language: ' + language + ' codec: ' + str(codec) + ' format: ' + str(format))
|
||||
part.subtitles[language][basename] = Proxy.LocalFile(self.filename, codec=codec, format=format)
|
||||
|
||||
lang_sub_map[language] = [basename]
|
||||
return lang_sub_map
|
||||
|
||||
if codec is None and ext in ['ass', 'ssa', 'smi', 'srt', 'psb']:
|
||||
codec = ext.replace('ass', 'ssa')
|
||||
|
||||
if format is None:
|
||||
format = codec
|
||||
|
||||
Log('Found subtitle file: ' + self.filename + ' language: ' + language + ' codec: ' + str(codec) + ' format: ' + str(format))
|
||||
part.subtitles[language][basename] = Proxy.LocalFile(self.filename, codec = codec, format = format)
|
||||
|
||||
lang_sub_map[language] = [ basename ]
|
||||
return lang_sub_map
|
||||
|
||||
def getSubtitlesFromMetadata(part):
|
||||
def get_subtitles_from_metadata(part):
|
||||
subs = {}
|
||||
for language in part.subtitles:
|
||||
subs[language] = []
|
||||
for key, proxy in getattr(part.subtitles[language], "_proxies").iteritems():
|
||||
p_type, p_value, p_sort, p_index, p_codec, p_format = proxy
|
||||
if p_type == "Media":
|
||||
# metadata subtitle
|
||||
subs[language].append(key)
|
||||
return subs
|
||||
subs[language] = []
|
||||
for key, proxy in getattr(part.subtitles[language], "_proxies").iteritems():
|
||||
if not proxy or not len(proxy) >= 5:
|
||||
Log.Debug("Can't parse metadata: %s" % repr(proxy))
|
||||
continue
|
||||
|
||||
p_type = proxy[0]
|
||||
|
||||
if p_type == "Media":
|
||||
# metadata subtitle
|
||||
Log.Debug(u"Found metadata subtitle: %s, %s" % (language, repr(proxy)))
|
||||
subs[language].append(key)
|
||||
return subs
|
||||
|
||||
|
||||
def force_utf8(content):
|
||||
a = UnicodeDammit(content)
|
||||
|
||||
Log.Debug("detected encoding: %s (None: most likely already successfully decoded)" % a.original_encoding)
|
||||
|
||||
# easy way out - already utf-8
|
||||
if a.original_encoding and a.original_encoding == "utf-8":
|
||||
return content
|
||||
|
||||
return (a.unicode_markup if a.unicode_markup else content.decode('ascii', 'replace')).encode("utf-8")
|
||||
|
||||
Regular → Executable
+99
-40
@@ -3,83 +3,142 @@
|
||||
import datetime
|
||||
import time
|
||||
|
||||
from missing_subtitles import getAllRecentlyAddedMissing, searchMissing
|
||||
from missing_subtitles import items_get_all_missing_subs, refresh_item
|
||||
from background import scheduler
|
||||
from support.items import get_recent_items, is_ignored
|
||||
|
||||
|
||||
class Task(object):
|
||||
name = None
|
||||
scheduler = None
|
||||
running = False
|
||||
time_start = None
|
||||
|
||||
stored_attributes = ("last_run", "running", "last_run_time")
|
||||
stored_attributes = ("last_run", "last_run_time")
|
||||
|
||||
# task ready for being status-displayed?
|
||||
ready_for_display = False
|
||||
|
||||
def __init__(self, scheduler):
|
||||
self.ready_for_display = False
|
||||
self.scheduler = scheduler
|
||||
if not self.name in Dict["tasks"]:
|
||||
Dict["tasks"][self.name] = {"last_run": None, "running": False, "last_run_time": None}
|
||||
self.ready_for_display = False
|
||||
self.running = False
|
||||
self.time_start = None
|
||||
self.scheduler = scheduler
|
||||
if self.name not in Dict["tasks"]:
|
||||
Dict["tasks"][self.name] = {"last_run": None, "last_run_time": None}
|
||||
|
||||
def __getattribute__(self, name):
|
||||
if name in object.__getattribute__(self, "stored_attributes"):
|
||||
return Dict["tasks"].get(self.name, {}).get(name, None)
|
||||
if name in object.__getattribute__(self, "stored_attributes"):
|
||||
return Dict["tasks"].get(self.name, {}).get(name, None)
|
||||
|
||||
return object.__getattribute__(self, name)
|
||||
return object.__getattribute__(self, name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name in object.__getattribute__(self, "stored_attributes"):
|
||||
Dict["tasks"][self.name][name] = value
|
||||
Dict.Save()
|
||||
return
|
||||
if name in object.__getattribute__(self, "stored_attributes"):
|
||||
Dict["tasks"][self.name][name] = value
|
||||
Dict.Save()
|
||||
return
|
||||
|
||||
object.__setattr__(self, name, value)
|
||||
object.__setattr__(self, name, value)
|
||||
|
||||
def signal(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
raise NotImplementedError
|
||||
|
||||
def prepare(self):
|
||||
raise NotImplementedError
|
||||
raise NotImplementedError
|
||||
|
||||
def run(self):
|
||||
raise NotImplementedError
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SearchAllRecentlyAddedMissing(Task):
|
||||
name = "searchAllRecentlyAddedMissing"
|
||||
items_done = None
|
||||
items_searching = None
|
||||
items_searching_ids = None
|
||||
items_failed = None
|
||||
percentage = 0
|
||||
|
||||
|
||||
stall_time = 30
|
||||
|
||||
def __init__(self, scheduler):
|
||||
super(SearchAllRecentlyAddedMissing, self).__init__(scheduler)
|
||||
self.items_done = None
|
||||
self.items_searching = None
|
||||
self.items_searching_ids = None
|
||||
self.items_failed = None
|
||||
self.percentage = 0
|
||||
|
||||
def signal(self, signal_name, *args, **kwargs):
|
||||
if signal_name == "updated_metadata":
|
||||
item_id = int(args[0])
|
||||
self.items_done.append(item_id)
|
||||
handler = getattr(self, "signal_%s" % signal_name)
|
||||
return handler(*args, **kwargs) if handler else None
|
||||
|
||||
def signal_updated_metadata(self, *args, **kwargs):
|
||||
item_id = int(args[0])
|
||||
|
||||
if item_id in self.items_searching_ids:
|
||||
self.items_done.append(item_id)
|
||||
return True
|
||||
|
||||
def prepare(self):
|
||||
self.items_done = []
|
||||
recent_items = get_recent_items()
|
||||
missing = items_get_all_missing_subs(recent_items)
|
||||
ids = set([id for added_at, id, title, item in missing if not is_ignored(id, item=item)])
|
||||
self.items_searching = missing
|
||||
self.items_searching_ids = ids
|
||||
self.items_failed = []
|
||||
self.percentage = 0
|
||||
self.time_start = datetime.datetime.now()
|
||||
self.ready_for_display = True
|
||||
|
||||
def run(self):
|
||||
self.items_done = []
|
||||
missing = getAllRecentlyAddedMissing()
|
||||
ids = set([id for id, title in missing])
|
||||
self.items_searching = ids
|
||||
self.ready_for_display = True
|
||||
self.running = True
|
||||
missing_count = len(self.items_searching)
|
||||
items_done_count = 0
|
||||
|
||||
missing_count = len(ids)
|
||||
|
||||
# dispatch all searches
|
||||
time_start = datetime.datetime.now()
|
||||
searchMissing(missing)
|
||||
for added_at, item_id, title, item in self.items_searching:
|
||||
Log.Debug(u"Task: %s, triggering refresh for %s (%s)", self.name, title, item_id)
|
||||
refresh_item(item_id, title)
|
||||
search_started = datetime.datetime.now()
|
||||
tries = 1
|
||||
while 1:
|
||||
if item_id in self.items_done:
|
||||
items_done_count += 1
|
||||
Log.Debug(u"Task: %s, item %s done", self.name, item_id)
|
||||
self.percentage = int(items_done_count * 100 / missing_count)
|
||||
break
|
||||
|
||||
while 1:
|
||||
if set(self.items_done).intersection(ids) == ids:
|
||||
Log.Debug("Task: %s, all items done", self.name)
|
||||
break
|
||||
self.percentage = int(round(len(self.items_done) * 100 / missing_count))
|
||||
time.sleep(0.1)
|
||||
# item considered stalled after self.stall_time seconds passed after last refresh
|
||||
if (datetime.datetime.now() - search_started).total_seconds() > self.stall_time:
|
||||
if tries > 3:
|
||||
self.items_failed.append(item_id)
|
||||
Log.Debug(u"Task: %s, item stalled for %s times: %s, skipping", self.name, tries, item_id)
|
||||
break
|
||||
|
||||
self.last_run_time = datetime.datetime.now() - time_start
|
||||
self.percentage = 0
|
||||
self.ready_for_display = False
|
||||
Log.Debug(u"Task: %s, item stalled for %s seconds: %s, retrying", self.name, self.stall_time, item_id)
|
||||
tries += 1
|
||||
refresh_item(item_id, title)
|
||||
search_started = datetime.datetime.now()
|
||||
time.sleep(1)
|
||||
time.sleep(0.1)
|
||||
# we can't hammer the PMS, otherwise requests will be stalled
|
||||
time.sleep(1)
|
||||
|
||||
Log.Debug("Task: %s, done. Failed items: %s", self.name, self.items_failed)
|
||||
self.running = False
|
||||
|
||||
def post_run(self):
|
||||
self.ready_for_display = False
|
||||
self.last_run = datetime.datetime.now()
|
||||
if self.time_start:
|
||||
self.last_run_time = self.last_run - self.time_start
|
||||
self.time_start = None
|
||||
self.percentage = 0
|
||||
self.items_done = None
|
||||
self.items_failed = None
|
||||
self.items_searching = None
|
||||
self.items_searching_ids = None
|
||||
|
||||
|
||||
scheduler.register(SearchAllRecentlyAddedMissing)
|
||||
|
||||
+475
-207
@@ -1,209 +1,477 @@
|
||||
[
|
||||
{ "id": "subtitles.try_downloads",
|
||||
"label": "How many download tries per subtitle (on timeout or error)",
|
||||
"type": "enum",
|
||||
"values": ["1", "2", "3", "4"],
|
||||
"default": "2"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.username",
|
||||
"label": "Addic7ed Username",
|
||||
"type": "text",
|
||||
"default": "Username"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.password",
|
||||
"label": "Addic7ed Password",
|
||||
"type": "text",
|
||||
"option": "hidden",
|
||||
"default": "",
|
||||
"secure": "true"
|
||||
},
|
||||
{
|
||||
"id": "plex_username",
|
||||
"label": "Plex.tv Username (needed for Plex Home users)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "plex_password",
|
||||
"label": "Plex.tv Password",
|
||||
"type": "text",
|
||||
"option": "hidden",
|
||||
"default": "",
|
||||
"secure": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.username",
|
||||
"label": "Opensubtitles Username (VIP)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.password",
|
||||
"label": "Opensubtitles Password",
|
||||
"type": "text",
|
||||
"option": "hidden",
|
||||
"default": "",
|
||||
"secure": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.use_random_agents",
|
||||
"label": "Addic7ed: Use random user agents (should not be necessary)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "langPref1",
|
||||
"label": "Subtitle Language (1)",
|
||||
"type": "enum",
|
||||
"values": ["sq","ar","be","bs","bg","ca","zh","cs","da","nl","en","et","fi","fr","de","el","he","hi","hu","is","id","it","ja","ko","lv","lt","mk","ms","no","fa","pl","pt","pt-br","ro","ru","sr","sk","sl","es","sv","th","tr","uk","vi","hr"],
|
||||
"default": "en"
|
||||
},
|
||||
{
|
||||
"id": "langPref2",
|
||||
"label": "Subtitle Language (2)",
|
||||
"type": "enum",
|
||||
"values": ["None", "sq","ar","be","bs","bg","ca","zh","cs","da","nl","en","et","fi","fr","de","el","he","hi","hu","is","id","it","ja","ko","lv","lt","mk","ms","no","fa","pl","pt","pt-br","ro","ru","sr","sk","sl","es","sv","th","tr","uk","vi","hr"],
|
||||
"default": "None"
|
||||
},
|
||||
{
|
||||
"id": "langPref3",
|
||||
"label": "Subtitle Language (3)",
|
||||
"type": "enum",
|
||||
"values": ["None", "sq","ar","be","bs","bg","ca","zh","cs","da","nl","en","et","fi","fr","de","el","he","hi","hu","is","id","it","ja","ko","lv","lt","mk","ms","no","fa","pl","pt","pt-br","ro","ru","sr","sk","sl","es","sv","th","tr","uk","vi","hr"],
|
||||
"default": "None"
|
||||
},
|
||||
{
|
||||
"id": "langPrefCustom",
|
||||
"label": "Additional Subtitle Languages (use ISO-639-1 codes; comma-separated)",
|
||||
"type": "text",
|
||||
"default": "None"
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.enabled",
|
||||
"label": "Provider: Enable OpenSubtitles",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.thesubdb.enabled",
|
||||
"label": "Provider: Enable TheSubDB",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.podnapisi.enabled",
|
||||
"label": "Provider: Enable Podnapisi.NET",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.enabled",
|
||||
"label": "Provider: Enable Addic7ed",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.boost",
|
||||
"label": "Addic7ed: boost over hash score if requirements met (prefer over other providers)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "provider.tvsubtitles.enabled",
|
||||
"label": "Provider: Enable TVsubtitles.net",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.scan.embedded",
|
||||
"label": "Scan: include embedded subtitles (skip if existing)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.scan.external",
|
||||
"label": "Scan: include external subtitles (skip if existing)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.minimumTVScore",
|
||||
"label": "Minimum score for TV subtitles to download",
|
||||
"type": "enum",
|
||||
"values": ["100","95","90","85","80","75","70","65","60","55","50","45","40","35","30","25","20","15","10","5","0"],
|
||||
"default": "85"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.minimumMovieScore",
|
||||
"label": "Minimum score for movie subtitles to download",
|
||||
"type": "enum",
|
||||
"values": ["100","95","90","85","80","75","70","65","60","55","50","45","40","35","30","25","23","20","15","10","5","0"],
|
||||
"default": "23"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.hearingImpaired",
|
||||
"label": "Download hearing impaired subtitles.",
|
||||
"type": "enum",
|
||||
"values": ["prefer", "don't prefer", "force HI", "force non-HI"],
|
||||
"default": "don't prefer"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.filesystem",
|
||||
"label": "Store subtitles next to media files (instead of metadata)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.subFolder",
|
||||
"label": "Subtitle Folder (\"current folder\" is the folder the current media file lives in)",
|
||||
"type": "enum",
|
||||
"values": ["current folder", "sub", "subs", "subtitle", "subtitles"],
|
||||
"default": "current folder"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.subFolder.Custom",
|
||||
"label": "Custom Subtitle folder (overrides \"Subtitle Folder\"; computes to real paths)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "subtitles.language.ietf",
|
||||
"label": "Treat IETF language tags as ISO 639-1 (e.g. pt-BR = pt)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.tasks.searchAllRecentlyAddedMissing",
|
||||
"label": "Scheduler: Periodically search for recent items with missing subtitles",
|
||||
"type": "enum",
|
||||
"values": ["never", "every 1 hours", "every 3 hours", "every 6 hours", "every 12 hours", "every 24 hours"],
|
||||
"default": "every 6 hours"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.item_is_recent_age",
|
||||
"label": "Scheduler: Item age to be considered recent",
|
||||
"type": "enum",
|
||||
"values": ["1 days", "2 days", "3 days", "4 days", "1 weeks", "2 weeks", "3 weeks", "4 weeks"],
|
||||
"default": "2 weeks"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.section_blacklist",
|
||||
"label": "Scheduler: Sections to ignore (IDs, comma-separated)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "scheduler.series_blacklist",
|
||||
"label": "Scheduler: Series to ignore (IDs, comma-separated)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "scheduler.item_blacklist",
|
||||
"label": "Scheduler: Items to ignore (IDs, comma-separated)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
}
|
||||
{
|
||||
"id": "enable_channel",
|
||||
"label": "Enable Sub-Zero channel (disabling doesn't affect the subtitle features)?",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.try_downloads",
|
||||
"label": "How many download tries per subtitle (on timeout or error)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"default": "2"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.username",
|
||||
"label": "Addic7ed Username",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.password",
|
||||
"label": "Addic7ed Password",
|
||||
"type": "text",
|
||||
"option": "hidden",
|
||||
"default": "",
|
||||
"secure": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.username",
|
||||
"label": "Opensubtitles Username (VIP)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.password",
|
||||
"label": "Opensubtitles Password",
|
||||
"type": "text",
|
||||
"option": "hidden",
|
||||
"default": "",
|
||||
"secure": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.use_random_agents",
|
||||
"label": "Addic7ed: Use random user agents (should not be necessary)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "langPref1",
|
||||
"label": "Subtitle Language (1)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"sq",
|
||||
"ar",
|
||||
"be",
|
||||
"bs",
|
||||
"bg",
|
||||
"ca",
|
||||
"zh",
|
||||
"cs",
|
||||
"da",
|
||||
"nl",
|
||||
"en",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"de",
|
||||
"el",
|
||||
"he",
|
||||
"hi",
|
||||
"hu",
|
||||
"is",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lv",
|
||||
"lt",
|
||||
"mk",
|
||||
"ms",
|
||||
"no",
|
||||
"fa",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-br",
|
||||
"ro",
|
||||
"ru",
|
||||
"sr",
|
||||
"sk",
|
||||
"sl",
|
||||
"es",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"hr"
|
||||
],
|
||||
"default": "en"
|
||||
},
|
||||
{
|
||||
"id": "langPref2",
|
||||
"label": "Subtitle Language (2)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"None",
|
||||
"sq",
|
||||
"ar",
|
||||
"be",
|
||||
"bs",
|
||||
"bg",
|
||||
"ca",
|
||||
"zh",
|
||||
"cs",
|
||||
"da",
|
||||
"nl",
|
||||
"en",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"de",
|
||||
"el",
|
||||
"he",
|
||||
"hi",
|
||||
"hu",
|
||||
"is",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lv",
|
||||
"lt",
|
||||
"mk",
|
||||
"ms",
|
||||
"no",
|
||||
"fa",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-br",
|
||||
"ro",
|
||||
"ru",
|
||||
"sr",
|
||||
"sk",
|
||||
"sl",
|
||||
"es",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"hr"
|
||||
],
|
||||
"default": "None"
|
||||
},
|
||||
{
|
||||
"id": "langPref3",
|
||||
"label": "Subtitle Language (3)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"None",
|
||||
"sq",
|
||||
"ar",
|
||||
"be",
|
||||
"bs",
|
||||
"bg",
|
||||
"ca",
|
||||
"zh",
|
||||
"cs",
|
||||
"da",
|
||||
"nl",
|
||||
"en",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"de",
|
||||
"el",
|
||||
"he",
|
||||
"hi",
|
||||
"hu",
|
||||
"is",
|
||||
"id",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"lv",
|
||||
"lt",
|
||||
"mk",
|
||||
"ms",
|
||||
"no",
|
||||
"fa",
|
||||
"pl",
|
||||
"pt",
|
||||
"pt-br",
|
||||
"ro",
|
||||
"ru",
|
||||
"sr",
|
||||
"sk",
|
||||
"sl",
|
||||
"es",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"hr"
|
||||
],
|
||||
"default": "None"
|
||||
},
|
||||
{
|
||||
"id": "langPrefCustom",
|
||||
"label": "Additional Subtitle Languages (use ISO-639-1 codes; comma-separated)",
|
||||
"type": "text",
|
||||
"default": "None"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.only_one",
|
||||
"label": "Restrict to one language (skips adding \".lang.\" to the subtitle filename; only uses \"Subtitle Language (1)\")",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.enforce_encoding",
|
||||
"label": "Normalize subtitle encoding to UTF-8",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.enabled",
|
||||
"label": "Provider: Enable OpenSubtitles",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.thesubdb.enabled",
|
||||
"label": "Provider: Enable TheSubDB",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.podnapisi.enabled",
|
||||
"label": "Provider: Enable Podnapisi.NET",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.enabled",
|
||||
"label": "Provider: Enable Addic7ed",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.addic7ed.boost",
|
||||
"label": "Addic7ed: prefer over other providers (if requirements met)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "provider.tvsubtitles.enabled",
|
||||
"label": "Provider: Enable TVsubtitles.net",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "provider.opensubtitles.use_tags",
|
||||
"label": "I keep the exact (release-) filename of my media files",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.scan.embedded",
|
||||
"label": "Scan: include embedded subtitles (in the media file (MKV/MP4), don't download if existing)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.scan.external",
|
||||
"label": "Scan: include external subtitles (metadata/filesystem, don't download if existing)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.minimumTVScore",
|
||||
"label": "Minimum score for TV subtitles to download",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"100",
|
||||
"95",
|
||||
"90",
|
||||
"85",
|
||||
"80",
|
||||
"75",
|
||||
"70",
|
||||
"67",
|
||||
"65",
|
||||
"60",
|
||||
"55",
|
||||
"50",
|
||||
"45",
|
||||
"40",
|
||||
"35",
|
||||
"30",
|
||||
"25",
|
||||
"20",
|
||||
"15",
|
||||
"10",
|
||||
"5",
|
||||
"0"
|
||||
],
|
||||
"default": "85"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.minimumMovieScore",
|
||||
"label": "Minimum score for movie subtitles to download",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"100",
|
||||
"95",
|
||||
"90",
|
||||
"85",
|
||||
"80",
|
||||
"75",
|
||||
"70",
|
||||
"65",
|
||||
"60",
|
||||
"55",
|
||||
"50",
|
||||
"45",
|
||||
"40",
|
||||
"35",
|
||||
"30",
|
||||
"25",
|
||||
"23",
|
||||
"20",
|
||||
"15",
|
||||
"10",
|
||||
"5",
|
||||
"0"
|
||||
],
|
||||
"default": "23"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.search.hearingImpaired",
|
||||
"label": "Download hearing impaired subtitles.",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"prefer",
|
||||
"don't prefer",
|
||||
"force HI",
|
||||
"force non-HI"
|
||||
],
|
||||
"default": "don't prefer"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.filesystem",
|
||||
"label": "Store subtitles next to media files (instead of metadata)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.subFolder",
|
||||
"label": "Subtitle Folder (\"current folder\" is the folder the current media file lives in)",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"current folder",
|
||||
"sub",
|
||||
"subs",
|
||||
"subtitle",
|
||||
"subtitles"
|
||||
],
|
||||
"default": "current folder"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.subFolder.Custom",
|
||||
"label": "Custom Subtitle folder (overrides \"Subtitle Folder\"; computes to real paths)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "subtitles.save.metadata_fallback",
|
||||
"label": "Fall back to metadata storage if filesystem storage failed",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.language.ietf",
|
||||
"label": "Treat IETF language tags as ISO 639-1 (e.g. pt-BR = pt)",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.ignore_fs",
|
||||
"label": "Ignore folders (with \"subzero.ignore/.subzero.ignore/.nosz\" files in them)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
},
|
||||
{
|
||||
"id": "subtitles.ignore_paths",
|
||||
"label": "Ignore anything in the following paths (comma-separated)",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "notify_executable",
|
||||
"label": "Call this executable upon successful subtitle download",
|
||||
"type": "text",
|
||||
"default": ""
|
||||
},
|
||||
{
|
||||
"id": "scheduler.tasks.searchAllRecentlyAddedMissing",
|
||||
"label": "Scheduler: Periodically search for recent items with missing subtitles",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"never",
|
||||
"every 1 hours",
|
||||
"every 3 hours",
|
||||
"every 6 hours",
|
||||
"every 12 hours",
|
||||
"every 24 hours"
|
||||
],
|
||||
"default": "every 6 hours"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.item_is_recent_age",
|
||||
"label": "Scheduler: Item age to be considered recent",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"1 days",
|
||||
"2 days",
|
||||
"3 days",
|
||||
"4 days",
|
||||
"1 weeks",
|
||||
"2 weeks",
|
||||
"3 weeks",
|
||||
"4 weeks",
|
||||
"5 weeks",
|
||||
"6 weeks"
|
||||
],
|
||||
"default": "2 weeks"
|
||||
},
|
||||
{
|
||||
"id": "scheduler.max_recent_items_per_library",
|
||||
"label": "Scheduler: Recent items to consider per library",
|
||||
"type": "text",
|
||||
"default": "200"
|
||||
},
|
||||
{
|
||||
"id": "check_permissions",
|
||||
"label": "Check for correct folder permissions of every library on plugin start",
|
||||
"type": "bool",
|
||||
"default": "true"
|
||||
},
|
||||
{
|
||||
"id": "log_level",
|
||||
"label": "How verbose should the logging be?",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
"CRITICAL",
|
||||
"ERROR",
|
||||
"WARNING",
|
||||
"INFO",
|
||||
"DEBUG"
|
||||
],
|
||||
"default": "WARNING"
|
||||
},
|
||||
{
|
||||
"id": "log_console",
|
||||
"label": "Log to console (for development/debugging)",
|
||||
"type": "bool",
|
||||
"default": "false"
|
||||
}
|
||||
]
|
||||
|
||||
Regular → Executable
+8
-8
@@ -9,11 +9,11 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.3.5</string>
|
||||
<string>1.3.31</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.3.5.281</string>
|
||||
<string>1.3.33.522</string>
|
||||
<key>PlexFrameworkVersion</key>
|
||||
<string>2</string>
|
||||
<key>PlexPluginClass</key>
|
||||
@@ -21,18 +21,18 @@
|
||||
<key>PlexPluginMode</key>
|
||||
<string>Daemon</string>
|
||||
<key>PlexPluginConsoleLogging</key>
|
||||
<string>1</string>
|
||||
<string>0</string>
|
||||
<key>PlexPluginDevMode</key>
|
||||
<string>1</string>
|
||||
<string>0</string>
|
||||
<key>PlexPluginCodePolicy</key>
|
||||
<!-- this allows channels to access some python methods which are otherwise blocked, as well as import external code libraries, and interact with the PMS HTTP API -->
|
||||
<string>Elevated</string>
|
||||
<key>PlexAgentAttributionText</key>
|
||||
<string><div style="white-space: pre;"><img src="https://raw.githubusercontent.com/pannal/Sub-Zero/master/Contents/Resources/subzero.gif" />
|
||||
<string><div style="white-space: pre;"><img src="https://raw.githubusercontent.com/pannal/Sub-Zero.bundle/master/Contents/Resources/subzero.gif" />
|
||||
|
||||
<h1>Sub-Zero for Plex</h1><i>Subtitles done right</i>
|
||||
|
||||
Version 1.3.5.281
|
||||
Version 1.3.33.522
|
||||
|
||||
Originally based on @bramwalet's awesome <a href="https://github.com/bramwalet/Subliminal.bundle">Subliminal.bundle</a>
|
||||
|
||||
@@ -40,9 +40,9 @@ If you like this, buy me a beer: <a href="https://www.paypal.com/cgi-bin
|
||||
|
||||
<strong>Need help?</strong>
|
||||
Plex thread: <a href="https://forums.plex.tv/discussion/186575">https://forums.plex.tv/discussion/186575</a>
|
||||
Github: <a href="https://github.com/pannal/Sub-Zero">https://github.com/pannal/Sub-Zero</a>
|
||||
Github: <a href="https://github.com/pannal/Sub-Zero.bundle">https://github.com/pannal/Sub-Zero</a>
|
||||
|
||||
panni, 2015
|
||||
panni, 2016
|
||||
</div>
|
||||
</string>
|
||||
</dict>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
try:
|
||||
import ast
|
||||
from _markerlib.markers import default_environment, compile, interpret
|
||||
except ImportError:
|
||||
if 'ast' in globals():
|
||||
raise
|
||||
def default_environment():
|
||||
return {}
|
||||
def compile(marker):
|
||||
def marker_fn(environment=None, override=None):
|
||||
# 'empty markers are True' heuristic won't install extra deps.
|
||||
return not marker.strip()
|
||||
marker_fn.__doc__ = marker
|
||||
return marker_fn
|
||||
def interpret(marker, environment=None, override=None):
|
||||
return compile(marker)()
|
||||
@@ -0,0 +1,119 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Interpret PEP 345 environment markers.
|
||||
|
||||
EXPR [in|==|!=|not in] EXPR [or|and] ...
|
||||
|
||||
where EXPR belongs to any of those:
|
||||
|
||||
python_version = '%s.%s' % (sys.version_info[0], sys.version_info[1])
|
||||
python_full_version = sys.version.split()[0]
|
||||
os.name = os.name
|
||||
sys.platform = sys.platform
|
||||
platform.version = platform.version()
|
||||
platform.machine = platform.machine()
|
||||
platform.python_implementation = platform.python_implementation()
|
||||
a free string, like '2.6', or 'win32'
|
||||
"""
|
||||
|
||||
__all__ = ['default_environment', 'compile', 'interpret']
|
||||
|
||||
import ast
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import weakref
|
||||
|
||||
_builtin_compile = compile
|
||||
|
||||
try:
|
||||
from platform import python_implementation
|
||||
except ImportError:
|
||||
if os.name == "java":
|
||||
# Jython 2.5 has ast module, but not platform.python_implementation() function.
|
||||
def python_implementation():
|
||||
return "Jython"
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
# restricted set of variables
|
||||
_VARS = {'sys.platform': sys.platform,
|
||||
'python_version': '%s.%s' % sys.version_info[:2],
|
||||
# FIXME parsing sys.platform is not reliable, but there is no other
|
||||
# way to get e.g. 2.7.2+, and the PEP is defined with sys.version
|
||||
'python_full_version': sys.version.split(' ', 1)[0],
|
||||
'os.name': os.name,
|
||||
'platform.version': platform.version(),
|
||||
'platform.machine': platform.machine(),
|
||||
'platform.python_implementation': python_implementation(),
|
||||
'extra': None # wheel extension
|
||||
}
|
||||
|
||||
for var in list(_VARS.keys()):
|
||||
if '.' in var:
|
||||
_VARS[var.replace('.', '_')] = _VARS[var]
|
||||
|
||||
def default_environment():
|
||||
"""Return copy of default PEP 385 globals dictionary."""
|
||||
return dict(_VARS)
|
||||
|
||||
class ASTWhitelist(ast.NodeTransformer):
|
||||
def __init__(self, statement):
|
||||
self.statement = statement # for error messages
|
||||
|
||||
ALLOWED = (ast.Compare, ast.BoolOp, ast.Attribute, ast.Name, ast.Load, ast.Str)
|
||||
# Bool operations
|
||||
ALLOWED += (ast.And, ast.Or)
|
||||
# Comparison operations
|
||||
ALLOWED += (ast.Eq, ast.Gt, ast.GtE, ast.In, ast.Is, ast.IsNot, ast.Lt, ast.LtE, ast.NotEq, ast.NotIn)
|
||||
|
||||
def visit(self, node):
|
||||
"""Ensure statement only contains allowed nodes."""
|
||||
if not isinstance(node, self.ALLOWED):
|
||||
raise SyntaxError('Not allowed in environment markers.\n%s\n%s' %
|
||||
(self.statement,
|
||||
(' ' * node.col_offset) + '^'))
|
||||
return ast.NodeTransformer.visit(self, node)
|
||||
|
||||
def visit_Attribute(self, node):
|
||||
"""Flatten one level of attribute access."""
|
||||
new_node = ast.Name("%s.%s" % (node.value.id, node.attr), node.ctx)
|
||||
return ast.copy_location(new_node, node)
|
||||
|
||||
def parse_marker(marker):
|
||||
tree = ast.parse(marker, mode='eval')
|
||||
new_tree = ASTWhitelist(marker).generic_visit(tree)
|
||||
return new_tree
|
||||
|
||||
def compile_marker(parsed_marker):
|
||||
return _builtin_compile(parsed_marker, '<environment marker>', 'eval',
|
||||
dont_inherit=True)
|
||||
|
||||
_cache = weakref.WeakValueDictionary()
|
||||
|
||||
def compile(marker):
|
||||
"""Return compiled marker as a function accepting an environment dict."""
|
||||
try:
|
||||
return _cache[marker]
|
||||
except KeyError:
|
||||
pass
|
||||
if not marker.strip():
|
||||
def marker_fn(environment=None, override=None):
|
||||
""""""
|
||||
return True
|
||||
else:
|
||||
compiled_marker = compile_marker(parse_marker(marker))
|
||||
def marker_fn(environment=None, override=None):
|
||||
"""override updates environment"""
|
||||
if override is None:
|
||||
override = {}
|
||||
if environment is None:
|
||||
environment = default_environment()
|
||||
environment.update(override)
|
||||
return eval(compiled_marker, environment)
|
||||
marker_fn.__doc__ = marker
|
||||
_cache[marker] = marker_fn
|
||||
return _cache[marker]
|
||||
|
||||
def interpret(marker, environment=None):
|
||||
return compile(marker)(environment)
|
||||
@@ -1,6 +1,6 @@
|
||||
Beautiful Soup is made available under the MIT license:
|
||||
|
||||
Copyright (c) 2004-2012 Leonard Richardson
|
||||
Copyright (c) 2004-2015 Leonard Richardson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
@@ -20,7 +20,8 @@ Beautiful Soup is made available under the MIT license:
|
||||
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
||||
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE, DAMMIT.
|
||||
SOFTWARE.
|
||||
|
||||
Beautiful Soup incorporates code from the html5lib library, which is
|
||||
also made available under the MIT license.
|
||||
also made available under the MIT license. Copyright (c) 2006-2013
|
||||
James Graham and other contributors
|
||||
|
||||
@@ -1,3 +1,127 @@
|
||||
= 4.4.1 (20150928) =
|
||||
|
||||
* Fixed a bug that deranged the tree when part of it was
|
||||
removed. Thanks to Eric Weiser for the patch and John Wiseman for a
|
||||
test. [bug=1481520]
|
||||
|
||||
* Fixed a parse bug with the html5lib tree-builder. Thanks to Roel
|
||||
Kramer for the patch. [bug=1483781]
|
||||
|
||||
* Improved the implementation of CSS selector grouping. Thanks to
|
||||
Orangain for the patch. [bug=1484543]
|
||||
|
||||
* Fixed the test_detect_utf8 test so that it works when chardet is
|
||||
installed. [bug=1471359]
|
||||
|
||||
* Corrected the output of Declaration objects. [bug=1477847]
|
||||
|
||||
|
||||
= 4.4.0 (20150703) =
|
||||
|
||||
Especially important changes:
|
||||
|
||||
* Added a warning when you instantiate a BeautifulSoup object without
|
||||
explicitly naming a parser. [bug=1398866]
|
||||
|
||||
* __repr__ now returns an ASCII bytestring in Python 2, and a Unicode
|
||||
string in Python 3, instead of a UTF8-encoded bytestring in both
|
||||
versions. In Python 3, __str__ now returns a Unicode string instead
|
||||
of a bytestring. [bug=1420131]
|
||||
|
||||
* The `text` argument to the find_* methods is now called `string`,
|
||||
which is more accurate. `text` still works, but `string` is the
|
||||
argument described in the documentation. `text` may eventually
|
||||
change its meaning, but not for a very long time. [bug=1366856]
|
||||
|
||||
* Changed the way soup objects work under copy.copy(). Copying a
|
||||
NavigableString or a Tag will give you a new NavigableString that's
|
||||
equal to the old one but not connected to the parse tree. Patch by
|
||||
Martijn Peters. [bug=1307490]
|
||||
|
||||
* Started using a standard MIT license. [bug=1294662]
|
||||
|
||||
* Added a Chinese translation of the documentation by Delong .w.
|
||||
|
||||
New features:
|
||||
|
||||
* Introduced the select_one() method, which uses a CSS selector but
|
||||
only returns the first match, instead of a list of
|
||||
matches. [bug=1349367]
|
||||
|
||||
* You can now create a Tag object without specifying a
|
||||
TreeBuilder. Patch by Martijn Pieters. [bug=1307471]
|
||||
|
||||
* You can now create a NavigableString or a subclass just by invoking
|
||||
the constructor. [bug=1294315]
|
||||
|
||||
* Added an `exclude_encodings` argument to UnicodeDammit and to the
|
||||
Beautiful Soup constructor, which lets you prohibit the detection of
|
||||
an encoding that you know is wrong. [bug=1469408]
|
||||
|
||||
* The select() method now supports selector grouping. Patch by
|
||||
Francisco Canas [bug=1191917]
|
||||
|
||||
Bug fixes:
|
||||
|
||||
* Fixed yet another problem that caused the html5lib tree builder to
|
||||
create a disconnected parse tree. [bug=1237763]
|
||||
|
||||
* Force object_was_parsed() to keep the tree intact even when an element
|
||||
from later in the document is moved into place. [bug=1430633]
|
||||
|
||||
* Fixed yet another bug that caused a disconnected tree when html5lib
|
||||
copied an element from one part of the tree to another. [bug=1270611]
|
||||
|
||||
* Fixed a bug where Element.extract() could create an infinite loop in
|
||||
the remaining tree.
|
||||
|
||||
* The select() method can now find tags whose names contain
|
||||
dashes. Patch by Francisco Canas. [bug=1276211]
|
||||
|
||||
* The select() method can now find tags with attributes whose names
|
||||
contain dashes. Patch by Marek Kapolka. [bug=1304007]
|
||||
|
||||
* Improved the lxml tree builder's handling of processing
|
||||
instructions. [bug=1294645]
|
||||
|
||||
* Restored the helpful syntax error that happens when you try to
|
||||
import the Python 2 edition of Beautiful Soup under Python
|
||||
3. [bug=1213387]
|
||||
|
||||
* In Python 3.4 and above, set the new convert_charrefs argument to
|
||||
the html.parser constructor to avoid a warning and future
|
||||
failures. Patch by Stefano Revera. [bug=1375721]
|
||||
|
||||
* The warning when you pass in a filename or URL as markup will now be
|
||||
displayed correctly even if the filename or URL is a Unicode
|
||||
string. [bug=1268888]
|
||||
|
||||
* If the initial <html> tag contains a CDATA list attribute such as
|
||||
'class', the html5lib tree builder will now turn its value into a
|
||||
list, as it would with any other tag. [bug=1296481]
|
||||
|
||||
* Fixed an import error in Python 3.5 caused by the removal of the
|
||||
HTMLParseError class. [bug=1420063]
|
||||
|
||||
* Improved docstring for encode_contents() and
|
||||
decode_contents(). [bug=1441543]
|
||||
|
||||
* Fixed a crash in Unicode, Dammit's encoding detector when the name
|
||||
of the encoding itself contained invalid bytes. [bug=1360913]
|
||||
|
||||
* Improved the exception raised when you call .unwrap() or
|
||||
.replace_with() on an element that's not attached to a tree.
|
||||
|
||||
* Raise a NotImplementedError whenever an unsupported CSS pseudoclass
|
||||
is used in select(). Previously some cases did not result in a
|
||||
NotImplementedError.
|
||||
|
||||
* It's now possible to pickle a BeautifulSoup object no matter which
|
||||
tree builder was used to create it. However, the only tree builder
|
||||
that survives the pickling process is the HTMLParserTreeBuilder
|
||||
('html.parser'). If you unpickle a BeautifulSoup object created with
|
||||
some other tree builder, soup.builder will be None. [bug=1231545]
|
||||
|
||||
= 4.3.2 (20131002) =
|
||||
|
||||
* Fixed a bug in which short Unicode input was improperly encoded to
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
Additions
|
||||
---------
|
||||
|
||||
More of the jQuery API: nextUntil?
|
||||
|
||||
Optimizations
|
||||
-------------
|
||||
|
||||
The html5lib tree builder doesn't use the standard tree-building API,
|
||||
which worries me and has resulted in a number of bugs.
|
||||
|
||||
markup_attr_map can be optimized since it's always a map now.
|
||||
|
||||
Upon encountering UTF-16LE data or some other uncommon serialization
|
||||
of Unicode, UnicodeDammit will convert the data to Unicode, then
|
||||
encode it at UTF-8. This is wasteful because it will just get decoded
|
||||
back to Unicode.
|
||||
|
||||
CDATA
|
||||
-----
|
||||
|
||||
The elementtree XMLParser has a strip_cdata argument that, when set to
|
||||
False, should allow Beautiful Soup to preserve CDATA sections instead
|
||||
of treating them as text. Except it doesn't. (This argument is also
|
||||
present for HTMLParser, and also does nothing there.)
|
||||
|
||||
Currently, htm5lib converts CDATA sections into comments. An
|
||||
as-yet-unreleased version of html5lib changes the parser's handling of
|
||||
CDATA sections to allow CDATA sections in tags like <svg> and
|
||||
<math>. The HTML5TreeBuilder will need to be updated to create CData
|
||||
objects instead of Comment objects in this situation.
|
||||
@@ -17,8 +17,8 @@ http://www.crummy.com/software/BeautifulSoup/bs4/doc/
|
||||
"""
|
||||
|
||||
__author__ = "Leonard Richardson (leonardr@segfault.org)"
|
||||
__version__ = "4.3.2"
|
||||
__copyright__ = "Copyright (c) 2004-2013 Leonard Richardson"
|
||||
__version__ = "4.4.1"
|
||||
__copyright__ = "Copyright (c) 2004-2015 Leonard Richardson"
|
||||
__license__ = "MIT"
|
||||
|
||||
__all__ = ['BeautifulSoup']
|
||||
@@ -45,7 +45,7 @@ from .element import (
|
||||
|
||||
# The very first thing we do is give a useful error if someone is
|
||||
# running this code under Python 3 without converting it.
|
||||
syntax_error = u'You are trying to run the Python 2 version of Beautiful Soup under Python 3. This will not work. You need to convert the code, either by installing it (`python setup.py install`) or by running 2to3 (`2to3 -w bs4`).'
|
||||
'You are trying to run the Python 2 version of Beautiful Soup under Python 3. This will not work.'<>'You need to convert the code, either by installing it (`python setup.py install`) or by running 2to3 (`2to3 -w bs4`).'
|
||||
|
||||
class BeautifulSoup(Tag):
|
||||
"""
|
||||
@@ -77,8 +77,11 @@ class BeautifulSoup(Tag):
|
||||
|
||||
ASCII_SPACES = '\x20\x0a\x09\x0c\x0d'
|
||||
|
||||
NO_PARSER_SPECIFIED_WARNING = "No parser was explicitly specified, so I'm using the best available %(markup_type)s parser for this system (\"%(parser)s\"). This usually isn't a problem, but if you run this code on another system, or in a different virtual environment, it may use a different parser and behave differently.\n\nTo get rid of this warning, change this:\n\n BeautifulSoup([your markup])\n\nto this:\n\n BeautifulSoup([your markup], \"%(parser)s\")\n"
|
||||
|
||||
def __init__(self, markup="", features=None, builder=None,
|
||||
parse_only=None, from_encoding=None, **kwargs):
|
||||
parse_only=None, from_encoding=None, exclude_encodings=None,
|
||||
**kwargs):
|
||||
"""The Soup object is initialized as the 'root tag', and the
|
||||
provided markup (which can be a string or a file-like object)
|
||||
is fed into the underlying parser."""
|
||||
@@ -114,9 +117,9 @@ class BeautifulSoup(Tag):
|
||||
del kwargs['isHTML']
|
||||
warnings.warn(
|
||||
"BS4 does not respect the isHTML argument to the "
|
||||
"BeautifulSoup constructor. You can pass in features='html' "
|
||||
"or features='xml' to get a builder capable of handling "
|
||||
"one or the other.")
|
||||
"BeautifulSoup constructor. Suggest you use "
|
||||
"features='lxml' for HTML and features='lxml-xml' for "
|
||||
"XML.")
|
||||
|
||||
def deprecated_argument(old_name, new_name):
|
||||
if old_name in kwargs:
|
||||
@@ -140,6 +143,7 @@ class BeautifulSoup(Tag):
|
||||
"__init__() got an unexpected keyword argument '%s'" % arg)
|
||||
|
||||
if builder is None:
|
||||
original_features = features
|
||||
if isinstance(features, basestring):
|
||||
features = [features]
|
||||
if features is None or len(features) == 0:
|
||||
@@ -151,6 +155,16 @@ class BeautifulSoup(Tag):
|
||||
"requested: %s. Do you need to install a parser library?"
|
||||
% ",".join(features))
|
||||
builder = builder_class()
|
||||
if not (original_features == builder.NAME or
|
||||
original_features in builder.ALTERNATE_NAMES):
|
||||
if builder.is_xml:
|
||||
markup_type = "XML"
|
||||
else:
|
||||
markup_type = "HTML"
|
||||
warnings.warn(self.NO_PARSER_SPECIFIED_WARNING % dict(
|
||||
parser=builder.NAME,
|
||||
markup_type=markup_type))
|
||||
|
||||
self.builder = builder
|
||||
self.is_xml = builder.is_xml
|
||||
self.builder.soup = self
|
||||
@@ -178,6 +192,8 @@ class BeautifulSoup(Tag):
|
||||
# system. Just let it go.
|
||||
pass
|
||||
if is_file:
|
||||
if isinstance(markup, unicode):
|
||||
markup = markup.encode("utf8")
|
||||
warnings.warn(
|
||||
'"%s" looks like a filename, not markup. You should probably open this file and pass the filehandle into Beautiful Soup.' % markup)
|
||||
if markup[:5] == "http:" or markup[:6] == "https:":
|
||||
@@ -185,12 +201,15 @@ class BeautifulSoup(Tag):
|
||||
# Python 3 otherwise.
|
||||
if ((isinstance(markup, bytes) and not b' ' in markup)
|
||||
or (isinstance(markup, unicode) and not u' ' in markup)):
|
||||
if isinstance(markup, unicode):
|
||||
markup = markup.encode("utf8")
|
||||
warnings.warn(
|
||||
'"%s" looks like a URL. Beautiful Soup is not an HTTP client. You should probably use an HTTP client to get the document behind the URL, and feed that document to Beautiful Soup.' % markup)
|
||||
|
||||
for (self.markup, self.original_encoding, self.declared_html_encoding,
|
||||
self.contains_replacement_characters) in (
|
||||
self.builder.prepare_markup(markup, from_encoding)):
|
||||
self.builder.prepare_markup(
|
||||
markup, from_encoding, exclude_encodings=exclude_encodings)):
|
||||
self.reset()
|
||||
try:
|
||||
self._feed()
|
||||
@@ -203,6 +222,16 @@ class BeautifulSoup(Tag):
|
||||
self.markup = None
|
||||
self.builder.soup = None
|
||||
|
||||
def __copy__(self):
|
||||
return type(self)(self.encode(), builder=self.builder)
|
||||
|
||||
def __getstate__(self):
|
||||
# Frequently a tree builder can't be pickled.
|
||||
d = dict(self.__dict__)
|
||||
if 'builder' in d and not self.builder.picklable:
|
||||
del d['builder']
|
||||
return d
|
||||
|
||||
def _feed(self):
|
||||
# Convert the document to Unicode.
|
||||
self.builder.reset()
|
||||
@@ -229,9 +258,7 @@ class BeautifulSoup(Tag):
|
||||
|
||||
def new_string(self, s, subclass=NavigableString):
|
||||
"""Create a new NavigableString associated with this soup."""
|
||||
navigable = subclass(s)
|
||||
navigable.setup()
|
||||
return navigable
|
||||
return subclass(s)
|
||||
|
||||
def insert_before(self, successor):
|
||||
raise NotImplementedError("BeautifulSoup objects don't support insert_before().")
|
||||
@@ -290,14 +317,49 @@ class BeautifulSoup(Tag):
|
||||
def object_was_parsed(self, o, parent=None, most_recent_element=None):
|
||||
"""Add an object to the parse tree."""
|
||||
parent = parent or self.currentTag
|
||||
most_recent_element = most_recent_element or self._most_recent_element
|
||||
o.setup(parent, most_recent_element)
|
||||
previous_element = most_recent_element or self._most_recent_element
|
||||
|
||||
next_element = previous_sibling = next_sibling = None
|
||||
if isinstance(o, Tag):
|
||||
next_element = o.next_element
|
||||
next_sibling = o.next_sibling
|
||||
previous_sibling = o.previous_sibling
|
||||
if not previous_element:
|
||||
previous_element = o.previous_element
|
||||
|
||||
o.setup(parent, previous_element, next_element, previous_sibling, next_sibling)
|
||||
|
||||
if most_recent_element is not None:
|
||||
most_recent_element.next_element = o
|
||||
self._most_recent_element = o
|
||||
parent.contents.append(o)
|
||||
|
||||
if parent.next_sibling:
|
||||
# This node is being inserted into an element that has
|
||||
# already been parsed. Deal with any dangling references.
|
||||
index = parent.contents.index(o)
|
||||
if index == 0:
|
||||
previous_element = parent
|
||||
previous_sibling = None
|
||||
else:
|
||||
previous_element = previous_sibling = parent.contents[index-1]
|
||||
if index == len(parent.contents)-1:
|
||||
next_element = parent.next_sibling
|
||||
next_sibling = None
|
||||
else:
|
||||
next_element = next_sibling = parent.contents[index+1]
|
||||
|
||||
o.previous_element = previous_element
|
||||
if previous_element:
|
||||
previous_element.next_element = o
|
||||
o.next_element = next_element
|
||||
if next_element:
|
||||
next_element.previous_element = o
|
||||
o.next_sibling = next_sibling
|
||||
if next_sibling:
|
||||
next_sibling.previous_sibling = o
|
||||
o.previous_sibling = previous_sibling
|
||||
if previous_sibling:
|
||||
previous_sibling.next_sibling = o
|
||||
|
||||
def _popToTag(self, name, nsprefix=None, inclusivePop=True):
|
||||
"""Pops the tag stack up to and including the most recent
|
||||
instance of the given tag. If inclusivePop is false, pops the tag
|
||||
|
||||
@@ -80,9 +80,12 @@ builder_registry = TreeBuilderRegistry()
|
||||
class TreeBuilder(object):
|
||||
"""Turn a document into a Beautiful Soup object tree."""
|
||||
|
||||
NAME = "[Unknown tree builder]"
|
||||
ALTERNATE_NAMES = []
|
||||
features = []
|
||||
|
||||
is_xml = False
|
||||
picklable = False
|
||||
preserve_whitespace_tags = set()
|
||||
empty_element_tags = None # A tag will be considered an empty-element
|
||||
# tag when and only when it has no contents.
|
||||
|
||||
@@ -2,6 +2,7 @@ __all__ = [
|
||||
'HTML5TreeBuilder',
|
||||
]
|
||||
|
||||
from pdb import set_trace
|
||||
import warnings
|
||||
from bs4.builder import (
|
||||
PERMISSIVE,
|
||||
@@ -9,7 +10,10 @@ from bs4.builder import (
|
||||
HTML_5,
|
||||
HTMLTreeBuilder,
|
||||
)
|
||||
from bs4.element import NamespacedAttribute
|
||||
from bs4.element import (
|
||||
NamespacedAttribute,
|
||||
whitespace_re,
|
||||
)
|
||||
import html5lib
|
||||
from html5lib.constants import namespaces
|
||||
from bs4.element import (
|
||||
@@ -22,11 +26,20 @@ from bs4.element import (
|
||||
class HTML5TreeBuilder(HTMLTreeBuilder):
|
||||
"""Use html5lib to build a tree."""
|
||||
|
||||
features = ['html5lib', PERMISSIVE, HTML_5, HTML]
|
||||
NAME = "html5lib"
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding):
|
||||
features = [NAME, PERMISSIVE, HTML_5, HTML]
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding,
|
||||
document_declared_encoding=None, exclude_encodings=None):
|
||||
# Store the user-specified encoding for use later on.
|
||||
self.user_specified_encoding = user_specified_encoding
|
||||
|
||||
# document_declared_encoding and exclude_encodings aren't used
|
||||
# ATM because the html5lib TreeBuilder doesn't use
|
||||
# UnicodeDammit.
|
||||
if exclude_encodings:
|
||||
warnings.warn("You provided a value for exclude_encoding, but the html5lib tree builder doesn't support exclude_encoding.")
|
||||
yield (markup, None, None, False)
|
||||
|
||||
# These methods are defined by Beautiful Soup.
|
||||
@@ -101,7 +114,16 @@ class AttrList(object):
|
||||
def __iter__(self):
|
||||
return list(self.attrs.items()).__iter__()
|
||||
def __setitem__(self, name, value):
|
||||
"set attr", name, value
|
||||
# If this attribute is a multi-valued attribute for this element,
|
||||
# turn its value into a list.
|
||||
list_attr = HTML5TreeBuilder.cdata_list_attributes
|
||||
if (name in list_attr['*']
|
||||
or (self.element.name in list_attr
|
||||
and name in list_attr[self.element.name])):
|
||||
# A node that is being cloned may have already undergone
|
||||
# this procedure.
|
||||
if not isinstance(value, list):
|
||||
value = whitespace_re.split(value)
|
||||
self.element[name] = value
|
||||
def items(self):
|
||||
return list(self.attrs.items())
|
||||
@@ -161,6 +183,12 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
# immediately after the parent, if it has no children.)
|
||||
if self.element.contents:
|
||||
most_recent_element = self.element._last_descendant(False)
|
||||
elif self.element.next_element is not None:
|
||||
# Something from further ahead in the parse tree is
|
||||
# being inserted into this earlier element. This is
|
||||
# very annoying because it means an expensive search
|
||||
# for the last element in the tree.
|
||||
most_recent_element = self.soup._last_descendant()
|
||||
else:
|
||||
most_recent_element = self.element
|
||||
|
||||
@@ -172,6 +200,7 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
return AttrList(self.element)
|
||||
|
||||
def setAttributes(self, attributes):
|
||||
|
||||
if attributes is not None and len(attributes) > 0:
|
||||
|
||||
converted_attributes = []
|
||||
@@ -218,6 +247,9 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
|
||||
def reparentChildren(self, new_parent):
|
||||
"""Move all of this tag's children into another tag."""
|
||||
# print "MOVE", self.element.contents
|
||||
# print "FROM", self.element
|
||||
# print "TO", new_parent.element
|
||||
element = self.element
|
||||
new_parent_element = new_parent.element
|
||||
# Determine what this tag's next_element will be once all the children
|
||||
@@ -236,17 +268,28 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
new_parents_last_descendant_next_element = new_parent_element.next_element
|
||||
|
||||
to_append = element.contents
|
||||
append_after = new_parent.element.contents
|
||||
append_after = new_parent_element.contents
|
||||
if len(to_append) > 0:
|
||||
# Set the first child's previous_element and previous_sibling
|
||||
# to elements within the new parent
|
||||
first_child = to_append[0]
|
||||
first_child.previous_element = new_parents_last_descendant
|
||||
if new_parents_last_descendant:
|
||||
first_child.previous_element = new_parents_last_descendant
|
||||
else:
|
||||
first_child.previous_element = new_parent_element
|
||||
first_child.previous_sibling = new_parents_last_child
|
||||
if new_parents_last_descendant:
|
||||
new_parents_last_descendant.next_element = first_child
|
||||
else:
|
||||
new_parent_element.next_element = first_child
|
||||
if new_parents_last_child:
|
||||
new_parents_last_child.next_sibling = first_child
|
||||
|
||||
# Fix the last child's next_element and next_sibling
|
||||
last_child = to_append[-1]
|
||||
last_child.next_element = new_parents_last_descendant_next_element
|
||||
if new_parents_last_descendant_next_element:
|
||||
new_parents_last_descendant_next_element.previous_element = last_child
|
||||
last_child.next_sibling = None
|
||||
|
||||
for child in to_append:
|
||||
@@ -257,6 +300,10 @@ class Element(html5lib.treebuilders._base.Node):
|
||||
element.contents = []
|
||||
element.next_element = final_next_element
|
||||
|
||||
# print "DONE WITH MOVE"
|
||||
# print "FROM", self.element
|
||||
# print "TO", new_parent_element
|
||||
|
||||
def cloneNode(self):
|
||||
tag = self.soup.new_tag(self.element.name, self.namespace)
|
||||
node = Element(tag, self.soup, self.namespace)
|
||||
|
||||
@@ -4,10 +4,16 @@ __all__ = [
|
||||
'HTMLParserTreeBuilder',
|
||||
]
|
||||
|
||||
from HTMLParser import (
|
||||
HTMLParser,
|
||||
HTMLParseError,
|
||||
)
|
||||
from HTMLParser import HTMLParser
|
||||
|
||||
try:
|
||||
from HTMLParser import HTMLParseError
|
||||
except ImportError, e:
|
||||
# HTMLParseError is removed in Python 3.5. Since it can never be
|
||||
# thrown in 3.5, we can just define our own class as a placeholder.
|
||||
class HTMLParseError(Exception):
|
||||
pass
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
@@ -19,10 +25,10 @@ import warnings
|
||||
# At the end of this file, we monkeypatch HTMLParser so that
|
||||
# strict=True works well on Python 3.2.2.
|
||||
major, minor, release = sys.version_info[:3]
|
||||
CONSTRUCTOR_TAKES_STRICT = (
|
||||
major > 3
|
||||
or (major == 3 and minor > 2)
|
||||
or (major == 3 and minor == 2 and release >= 3))
|
||||
CONSTRUCTOR_TAKES_STRICT = major == 3 and minor == 2 and release >= 3
|
||||
CONSTRUCTOR_STRICT_IS_DEPRECATED = major == 3 and minor == 3
|
||||
CONSTRUCTOR_TAKES_CONVERT_CHARREFS = major == 3 and minor >= 4
|
||||
|
||||
|
||||
from bs4.element import (
|
||||
CData,
|
||||
@@ -63,7 +69,8 @@ class BeautifulSoupHTMLParser(HTMLParser):
|
||||
|
||||
def handle_charref(self, name):
|
||||
# XXX workaround for a bug in HTMLParser. Remove this once
|
||||
# it's fixed.
|
||||
# it's fixed in all supported versions.
|
||||
# http://bugs.python.org/issue13633
|
||||
if name.startswith('x'):
|
||||
real_name = int(name.lstrip('x'), 16)
|
||||
elif name.startswith('X'):
|
||||
@@ -113,14 +120,6 @@ class BeautifulSoupHTMLParser(HTMLParser):
|
||||
|
||||
def handle_pi(self, data):
|
||||
self.soup.endData()
|
||||
if data.endswith("?") and data.lower().startswith("xml"):
|
||||
# "An XHTML processing instruction using the trailing '?'
|
||||
# will cause the '?' to be included in data." - HTMLParser
|
||||
# docs.
|
||||
#
|
||||
# Strip the question mark so we don't end up with two
|
||||
# question marks.
|
||||
data = data[:-1]
|
||||
self.soup.handle_data(data)
|
||||
self.soup.endData(ProcessingInstruction)
|
||||
|
||||
@@ -128,15 +127,19 @@ class BeautifulSoupHTMLParser(HTMLParser):
|
||||
class HTMLParserTreeBuilder(HTMLTreeBuilder):
|
||||
|
||||
is_xml = False
|
||||
features = [HTML, STRICT, HTMLPARSER]
|
||||
picklable = True
|
||||
NAME = HTMLPARSER
|
||||
features = [NAME, HTML, STRICT]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if CONSTRUCTOR_TAKES_STRICT:
|
||||
if CONSTRUCTOR_TAKES_STRICT and not CONSTRUCTOR_STRICT_IS_DEPRECATED:
|
||||
kwargs['strict'] = False
|
||||
if CONSTRUCTOR_TAKES_CONVERT_CHARREFS:
|
||||
kwargs['convert_charrefs'] = False
|
||||
self.parser_args = (args, kwargs)
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding=None,
|
||||
document_declared_encoding=None):
|
||||
document_declared_encoding=None, exclude_encodings=None):
|
||||
"""
|
||||
:return: A 4-tuple (markup, original encoding, encoding
|
||||
declared within markup, whether any characters had to be
|
||||
@@ -147,7 +150,8 @@ class HTMLParserTreeBuilder(HTMLTreeBuilder):
|
||||
return
|
||||
|
||||
try_encodings = [user_specified_encoding, document_declared_encoding]
|
||||
dammit = UnicodeDammit(markup, try_encodings, is_html=True)
|
||||
dammit = UnicodeDammit(markup, try_encodings, is_html=True,
|
||||
exclude_encodings=exclude_encodings)
|
||||
yield (dammit.markup, dammit.original_encoding,
|
||||
dammit.declared_html_encoding,
|
||||
dammit.contains_replacement_characters)
|
||||
|
||||
@@ -7,7 +7,12 @@ from io import BytesIO
|
||||
from StringIO import StringIO
|
||||
import collections
|
||||
from lxml import etree
|
||||
from bs4.element import Comment, Doctype, NamespacedAttribute
|
||||
from bs4.element import (
|
||||
Comment,
|
||||
Doctype,
|
||||
NamespacedAttribute,
|
||||
ProcessingInstruction,
|
||||
)
|
||||
from bs4.builder import (
|
||||
FAST,
|
||||
HTML,
|
||||
@@ -25,8 +30,11 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
|
||||
is_xml = True
|
||||
|
||||
NAME = "lxml-xml"
|
||||
ALTERNATE_NAMES = ["xml"]
|
||||
|
||||
# Well, it's permissive by XML parser standards.
|
||||
features = [LXML, XML, FAST, PERMISSIVE]
|
||||
features = [NAME, LXML, XML, FAST, PERMISSIVE]
|
||||
|
||||
CHUNK_SIZE = 512
|
||||
|
||||
@@ -70,6 +78,7 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
return (None, tag)
|
||||
|
||||
def prepare_markup(self, markup, user_specified_encoding=None,
|
||||
exclude_encodings=None,
|
||||
document_declared_encoding=None):
|
||||
"""
|
||||
:yield: A series of 4-tuples.
|
||||
@@ -95,7 +104,8 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
# the document as each one in turn.
|
||||
is_html = not self.is_xml
|
||||
try_encodings = [user_specified_encoding, document_declared_encoding]
|
||||
detector = EncodingDetector(markup, try_encodings, is_html)
|
||||
detector = EncodingDetector(
|
||||
markup, try_encodings, is_html, exclude_encodings)
|
||||
for encoding in detector.encodings:
|
||||
yield (detector.markup, encoding, document_declared_encoding, False)
|
||||
|
||||
@@ -189,7 +199,9 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
self.nsmaps.pop()
|
||||
|
||||
def pi(self, target, data):
|
||||
pass
|
||||
self.soup.endData()
|
||||
self.soup.handle_data(target + ' ' + data)
|
||||
self.soup.endData(ProcessingInstruction)
|
||||
|
||||
def data(self, content):
|
||||
self.soup.handle_data(content)
|
||||
@@ -212,7 +224,10 @@ class LXMLTreeBuilderForXML(TreeBuilder):
|
||||
|
||||
class LXMLTreeBuilder(HTMLTreeBuilder, LXMLTreeBuilderForXML):
|
||||
|
||||
features = [LXML, HTML, FAST, PERMISSIVE]
|
||||
NAME = LXML
|
||||
ALTERNATE_NAMES = ["lxml-html"]
|
||||
|
||||
features = ALTERNATE_NAMES + [NAME, HTML, FAST, PERMISSIVE]
|
||||
is_xml = False
|
||||
|
||||
def default_parser(self, encoding):
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
|
||||
This library converts a bytestream to Unicode through any means
|
||||
necessary. It is heavily based on code from Mark Pilgrim's Universal
|
||||
Feed Parser. It works best on XML and XML, but it does not rewrite the
|
||||
Feed Parser. It works best on XML and HTML, but it does not rewrite the
|
||||
XML or HTML to reflect a new encoding; that's the tree builder's job.
|
||||
"""
|
||||
__license__ = "MIT"
|
||||
|
||||
from pdb import set_trace
|
||||
import codecs
|
||||
from htmlentitydefs import codepoint2name
|
||||
import re
|
||||
@@ -212,8 +214,11 @@ class EncodingDetector:
|
||||
|
||||
5. Windows-1252.
|
||||
"""
|
||||
def __init__(self, markup, override_encodings=None, is_html=False):
|
||||
def __init__(self, markup, override_encodings=None, is_html=False,
|
||||
exclude_encodings=None):
|
||||
self.override_encodings = override_encodings or []
|
||||
exclude_encodings = exclude_encodings or []
|
||||
self.exclude_encodings = set([x.lower() for x in exclude_encodings])
|
||||
self.chardet_encoding = None
|
||||
self.is_html = is_html
|
||||
self.declared_encoding = None
|
||||
@@ -224,6 +229,8 @@ class EncodingDetector:
|
||||
def _usable(self, encoding, tried):
|
||||
if encoding is not None:
|
||||
encoding = encoding.lower()
|
||||
if encoding in self.exclude_encodings:
|
||||
return False
|
||||
if encoding not in tried:
|
||||
tried.add(encoding)
|
||||
return True
|
||||
@@ -266,6 +273,9 @@ class EncodingDetector:
|
||||
def strip_byte_order_mark(cls, data):
|
||||
"""If a byte-order mark is present, strip it and return the encoding it implies."""
|
||||
encoding = None
|
||||
if isinstance(data, unicode):
|
||||
# Unicode data cannot have a byte-order mark.
|
||||
return data, encoding
|
||||
if (len(data) >= 4) and (data[:2] == b'\xfe\xff') \
|
||||
and (data[2:4] != '\x00\x00'):
|
||||
encoding = 'utf-16be'
|
||||
@@ -306,7 +316,7 @@ class EncodingDetector:
|
||||
declared_encoding_match = html_meta_re.search(markup, endpos=html_endpos)
|
||||
if declared_encoding_match is not None:
|
||||
declared_encoding = declared_encoding_match.groups()[0].decode(
|
||||
'ascii')
|
||||
'ascii', 'replace')
|
||||
if declared_encoding:
|
||||
return declared_encoding.lower()
|
||||
return None
|
||||
@@ -331,13 +341,14 @@ class UnicodeDammit:
|
||||
]
|
||||
|
||||
def __init__(self, markup, override_encodings=[],
|
||||
smart_quotes_to=None, is_html=False):
|
||||
smart_quotes_to=None, is_html=False, exclude_encodings=[]):
|
||||
self.smart_quotes_to = smart_quotes_to
|
||||
self.tried_encodings = []
|
||||
self.contains_replacement_characters = False
|
||||
self.is_html = is_html
|
||||
|
||||
self.detector = EncodingDetector(markup, override_encodings, is_html)
|
||||
self.detector = EncodingDetector(
|
||||
markup, override_encodings, is_html, exclude_encodings)
|
||||
|
||||
# Short-circuit if the data is in Unicode to begin with.
|
||||
if isinstance(markup, unicode) or markup == '':
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Diagnostic functions, mainly for use when doing tech support."""
|
||||
|
||||
__license__ = "MIT"
|
||||
|
||||
import cProfile
|
||||
from StringIO import StringIO
|
||||
from HTMLParser import HTMLParser
|
||||
@@ -33,12 +36,21 @@ def diagnose(data):
|
||||
|
||||
if 'lxml' in basic_parsers:
|
||||
basic_parsers.append(["lxml", "xml"])
|
||||
from lxml import etree
|
||||
print "Found lxml version %s" % ".".join(map(str,etree.LXML_VERSION))
|
||||
try:
|
||||
from lxml import etree
|
||||
print "Found lxml version %s" % ".".join(map(str,etree.LXML_VERSION))
|
||||
except ImportError, e:
|
||||
print (
|
||||
"lxml is not installed or couldn't be imported.")
|
||||
|
||||
|
||||
if 'html5lib' in basic_parsers:
|
||||
import html5lib
|
||||
print "Found html5lib version %s" % html5lib.__version__
|
||||
try:
|
||||
import html5lib
|
||||
print "Found html5lib version %s" % html5lib.__version__
|
||||
except ImportError, e:
|
||||
print (
|
||||
"html5lib is not installed or couldn't be imported.")
|
||||
|
||||
if hasattr(data, 'read'):
|
||||
data = data.read()
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
__license__ = "MIT"
|
||||
|
||||
from pdb import set_trace
|
||||
import collections
|
||||
import re
|
||||
import sys
|
||||
@@ -185,24 +188,40 @@ class PageElement(object):
|
||||
return self.HTML_FORMATTERS.get(
|
||||
name, HTMLAwareEntitySubstitution.substitute_xml)
|
||||
|
||||
def setup(self, parent=None, previous_element=None):
|
||||
def setup(self, parent=None, previous_element=None, next_element=None,
|
||||
previous_sibling=None, next_sibling=None):
|
||||
"""Sets up the initial relations between this element and
|
||||
other elements."""
|
||||
self.parent = parent
|
||||
|
||||
self.previous_element = previous_element
|
||||
if previous_element is not None:
|
||||
self.previous_element.next_element = self
|
||||
self.next_element = None
|
||||
self.previous_sibling = None
|
||||
self.next_sibling = None
|
||||
if self.parent is not None and self.parent.contents:
|
||||
self.previous_sibling = self.parent.contents[-1]
|
||||
|
||||
self.next_element = next_element
|
||||
if self.next_element:
|
||||
self.next_element.previous_element = self
|
||||
|
||||
self.next_sibling = next_sibling
|
||||
if self.next_sibling:
|
||||
self.next_sibling.previous_sibling = self
|
||||
|
||||
if (not previous_sibling
|
||||
and self.parent is not None and self.parent.contents):
|
||||
previous_sibling = self.parent.contents[-1]
|
||||
|
||||
self.previous_sibling = previous_sibling
|
||||
if previous_sibling:
|
||||
self.previous_sibling.next_sibling = self
|
||||
|
||||
nextSibling = _alias("next_sibling") # BS3
|
||||
previousSibling = _alias("previous_sibling") # BS3
|
||||
|
||||
def replace_with(self, replace_with):
|
||||
if not self.parent:
|
||||
raise ValueError(
|
||||
"Cannot replace one element with another when the"
|
||||
"element to be replaced is not part of a tree.")
|
||||
if replace_with is self:
|
||||
return
|
||||
if replace_with is self.parent:
|
||||
@@ -216,6 +235,10 @@ class PageElement(object):
|
||||
|
||||
def unwrap(self):
|
||||
my_parent = self.parent
|
||||
if not self.parent:
|
||||
raise ValueError(
|
||||
"Cannot replace an element with its contents when that"
|
||||
"element is not part of a tree.")
|
||||
my_index = self.parent.index(self)
|
||||
self.extract()
|
||||
for child in reversed(self.contents[:]):
|
||||
@@ -240,17 +263,20 @@ class PageElement(object):
|
||||
last_child = self._last_descendant()
|
||||
next_element = last_child.next_element
|
||||
|
||||
if self.previous_element is not None:
|
||||
if (self.previous_element is not None and
|
||||
self.previous_element is not next_element):
|
||||
self.previous_element.next_element = next_element
|
||||
if next_element is not None:
|
||||
if next_element is not None and next_element is not self.previous_element:
|
||||
next_element.previous_element = self.previous_element
|
||||
self.previous_element = None
|
||||
last_child.next_element = None
|
||||
|
||||
self.parent = None
|
||||
if self.previous_sibling is not None:
|
||||
if (self.previous_sibling is not None
|
||||
and self.previous_sibling is not self.next_sibling):
|
||||
self.previous_sibling.next_sibling = self.next_sibling
|
||||
if self.next_sibling is not None:
|
||||
if (self.next_sibling is not None
|
||||
and self.next_sibling is not self.previous_sibling):
|
||||
self.next_sibling.previous_sibling = self.previous_sibling
|
||||
self.previous_sibling = self.next_sibling = None
|
||||
return self
|
||||
@@ -263,13 +289,15 @@ class PageElement(object):
|
||||
last_child = self
|
||||
while isinstance(last_child, Tag) and last_child.contents:
|
||||
last_child = last_child.contents[-1]
|
||||
if not accept_self and last_child == self:
|
||||
if not accept_self and last_child is self:
|
||||
last_child = None
|
||||
return last_child
|
||||
# BS3: Not part of the API!
|
||||
_lastRecursiveChild = _last_descendant
|
||||
|
||||
def insert(self, position, new_child):
|
||||
if new_child is None:
|
||||
raise ValueError("Cannot insert None into a tag.")
|
||||
if new_child is self:
|
||||
raise ValueError("Cannot insert a tag into itself.")
|
||||
if (isinstance(new_child, basestring)
|
||||
@@ -478,6 +506,10 @@ class PageElement(object):
|
||||
def _find_all(self, name, attrs, text, limit, generator, **kwargs):
|
||||
"Iterates over a generator looking for things that match."
|
||||
|
||||
if text is None and 'string' in kwargs:
|
||||
text = kwargs['string']
|
||||
del kwargs['string']
|
||||
|
||||
if isinstance(name, SoupStrainer):
|
||||
strainer = name
|
||||
else:
|
||||
@@ -548,17 +580,17 @@ class PageElement(object):
|
||||
|
||||
# Methods for supporting CSS selectors.
|
||||
|
||||
tag_name_re = re.compile('^[a-z0-9]+$')
|
||||
tag_name_re = re.compile('^[a-zA-Z0-9][-.a-zA-Z0-9:_]*$')
|
||||
|
||||
# /^(\w+)\[(\w+)([=~\|\^\$\*]?)=?"?([^\]"]*)"?\]$/
|
||||
# \---/ \---/\-------------/ \-------/
|
||||
# | | | |
|
||||
# | | | The value
|
||||
# | | ~,|,^,$,* or =
|
||||
# | Attribute
|
||||
# /^([a-zA-Z0-9][-.a-zA-Z0-9:_]*)\[(\w+)([=~\|\^\$\*]?)=?"?([^\]"]*)"?\]$/
|
||||
# \---------------------------/ \---/\-------------/ \-------/
|
||||
# | | | |
|
||||
# | | | The value
|
||||
# | | ~,|,^,$,* or =
|
||||
# | Attribute
|
||||
# Tag
|
||||
attribselect_re = re.compile(
|
||||
r'^(?P<tag>\w+)?\[(?P<attribute>\w+)(?P<operator>[=~\|\^\$\*]?)' +
|
||||
r'^(?P<tag>[a-zA-Z0-9][-.a-zA-Z0-9:_]*)?\[(?P<attribute>[\w-]+)(?P<operator>[=~\|\^\$\*]?)' +
|
||||
r'=?"?(?P<value>[^\]"]*)"?\]$'
|
||||
)
|
||||
|
||||
@@ -654,11 +686,17 @@ class NavigableString(unicode, PageElement):
|
||||
how to handle non-ASCII characters.
|
||||
"""
|
||||
if isinstance(value, unicode):
|
||||
return unicode.__new__(cls, value)
|
||||
return unicode.__new__(cls, value, DEFAULT_OUTPUT_ENCODING)
|
||||
u = unicode.__new__(cls, value)
|
||||
else:
|
||||
u = unicode.__new__(cls, value, DEFAULT_OUTPUT_ENCODING)
|
||||
u.setup()
|
||||
return u
|
||||
|
||||
def __copy__(self):
|
||||
return self
|
||||
"""A copy of a NavigableString has the same contents and class
|
||||
as the original, but it is not connected to the parse tree.
|
||||
"""
|
||||
return type(self)(self)
|
||||
|
||||
def __getnewargs__(self):
|
||||
return (unicode(self),)
|
||||
@@ -707,7 +745,7 @@ class CData(PreformattedString):
|
||||
class ProcessingInstruction(PreformattedString):
|
||||
|
||||
PREFIX = u'<?'
|
||||
SUFFIX = u'?>'
|
||||
SUFFIX = u'>'
|
||||
|
||||
class Comment(PreformattedString):
|
||||
|
||||
@@ -716,8 +754,8 @@ class Comment(PreformattedString):
|
||||
|
||||
|
||||
class Declaration(PreformattedString):
|
||||
PREFIX = u'<!'
|
||||
SUFFIX = u'!>'
|
||||
PREFIX = u'<?'
|
||||
SUFFIX = u'?>'
|
||||
|
||||
|
||||
class Doctype(PreformattedString):
|
||||
@@ -759,9 +797,12 @@ class Tag(PageElement):
|
||||
self.prefix = prefix
|
||||
if attrs is None:
|
||||
attrs = {}
|
||||
elif attrs and builder.cdata_list_attributes:
|
||||
attrs = builder._replace_cdata_list_attribute_values(
|
||||
self.name, attrs)
|
||||
elif attrs:
|
||||
if builder is not None and builder.cdata_list_attributes:
|
||||
attrs = builder._replace_cdata_list_attribute_values(
|
||||
self.name, attrs)
|
||||
else:
|
||||
attrs = dict(attrs)
|
||||
else:
|
||||
attrs = dict(attrs)
|
||||
self.attrs = attrs
|
||||
@@ -778,6 +819,18 @@ class Tag(PageElement):
|
||||
|
||||
parserClass = _alias("parser_class") # BS3
|
||||
|
||||
def __copy__(self):
|
||||
"""A copy of a Tag is a new Tag, unconnected to the parse tree.
|
||||
Its contents are a copy of the old Tag's contents.
|
||||
"""
|
||||
clone = type(self)(None, self.builder, self.name, self.namespace,
|
||||
self.nsprefix, self.attrs)
|
||||
for attr in ('can_be_empty_element', 'hidden'):
|
||||
setattr(clone, attr, getattr(self, attr))
|
||||
for child in self.contents:
|
||||
clone.append(child.__copy__())
|
||||
return clone
|
||||
|
||||
@property
|
||||
def is_empty_element(self):
|
||||
"""Is this tag an empty-element tag? (aka a self-closing tag)
|
||||
@@ -971,15 +1024,25 @@ class Tag(PageElement):
|
||||
as defined in __eq__."""
|
||||
return not self == other
|
||||
|
||||
def __repr__(self, encoding=DEFAULT_OUTPUT_ENCODING):
|
||||
def __repr__(self, encoding="unicode-escape"):
|
||||
"""Renders this tag as a string."""
|
||||
return self.encode(encoding)
|
||||
if PY3K:
|
||||
# "The return value must be a string object", i.e. Unicode
|
||||
return self.decode()
|
||||
else:
|
||||
# "The return value must be a string object", i.e. a bytestring.
|
||||
# By convention, the return value of __repr__ should also be
|
||||
# an ASCII string.
|
||||
return self.encode(encoding)
|
||||
|
||||
def __unicode__(self):
|
||||
return self.decode()
|
||||
|
||||
def __str__(self):
|
||||
return self.encode()
|
||||
if PY3K:
|
||||
return self.decode()
|
||||
else:
|
||||
return self.encode()
|
||||
|
||||
if PY3K:
|
||||
__str__ = __repr__ = __unicode__
|
||||
@@ -1103,12 +1166,18 @@ class Tag(PageElement):
|
||||
formatter="minimal"):
|
||||
"""Renders the contents of this tag as a Unicode string.
|
||||
|
||||
:param indent_level: Each line of the rendering will be
|
||||
indented this many spaces.
|
||||
|
||||
:param eventual_encoding: The tag is destined to be
|
||||
encoded into this encoding. This method is _not_
|
||||
responsible for performing that encoding. This information
|
||||
is passed in so that it can be substituted in if the
|
||||
document contains a <META> tag that mentions the document's
|
||||
encoding.
|
||||
|
||||
:param formatter: The output formatter responsible for converting
|
||||
entities to Unicode characters.
|
||||
"""
|
||||
# First off, turn a string formatter into a function. This
|
||||
# will stop the lookup from happening over and over again.
|
||||
@@ -1137,7 +1206,17 @@ class Tag(PageElement):
|
||||
def encode_contents(
|
||||
self, indent_level=None, encoding=DEFAULT_OUTPUT_ENCODING,
|
||||
formatter="minimal"):
|
||||
"""Renders the contents of this tag as a bytestring."""
|
||||
"""Renders the contents of this tag as a bytestring.
|
||||
|
||||
:param indent_level: Each line of the rendering will be
|
||||
indented this many spaces.
|
||||
|
||||
:param eventual_encoding: The bytestring will be in this encoding.
|
||||
|
||||
:param formatter: The output formatter responsible for converting
|
||||
entities to Unicode characters.
|
||||
"""
|
||||
|
||||
contents = self.decode_contents(indent_level, encoding, formatter)
|
||||
return contents.encode(encoding)
|
||||
|
||||
@@ -1201,26 +1280,57 @@ class Tag(PageElement):
|
||||
|
||||
_selector_combinators = ['>', '+', '~']
|
||||
_select_debug = False
|
||||
def select(self, selector, _candidate_generator=None):
|
||||
def select_one(self, selector):
|
||||
"""Perform a CSS selection operation on the current element."""
|
||||
value = self.select(selector, limit=1)
|
||||
if value:
|
||||
return value[0]
|
||||
return None
|
||||
|
||||
def select(self, selector, _candidate_generator=None, limit=None):
|
||||
"""Perform a CSS selection operation on the current element."""
|
||||
|
||||
# Handle grouping selectors if ',' exists, ie: p,a
|
||||
if ',' in selector:
|
||||
context = []
|
||||
for partial_selector in selector.split(','):
|
||||
partial_selector = partial_selector.strip()
|
||||
if partial_selector == '':
|
||||
raise ValueError('Invalid group selection syntax: %s' % selector)
|
||||
candidates = self.select(partial_selector, limit=limit)
|
||||
for candidate in candidates:
|
||||
if candidate not in context:
|
||||
context.append(candidate)
|
||||
|
||||
if limit and len(context) >= limit:
|
||||
break
|
||||
return context
|
||||
|
||||
tokens = selector.split()
|
||||
current_context = [self]
|
||||
|
||||
if tokens[-1] in self._selector_combinators:
|
||||
raise ValueError(
|
||||
'Final combinator "%s" is missing an argument.' % tokens[-1])
|
||||
|
||||
if self._select_debug:
|
||||
print 'Running CSS selector "%s"' % selector
|
||||
|
||||
for index, token in enumerate(tokens):
|
||||
if self._select_debug:
|
||||
print ' Considering token "%s"' % token
|
||||
recursive_candidate_generator = None
|
||||
tag_name = None
|
||||
new_context = []
|
||||
new_context_ids = set([])
|
||||
|
||||
if tokens[index-1] in self._selector_combinators:
|
||||
# This token was consumed by the previous combinator. Skip it.
|
||||
if self._select_debug:
|
||||
print ' Token was consumed by the previous combinator.'
|
||||
continue
|
||||
|
||||
if self._select_debug:
|
||||
print ' Considering token "%s"' % token
|
||||
recursive_candidate_generator = None
|
||||
tag_name = None
|
||||
|
||||
# Each operation corresponds to a checker function, a rule
|
||||
# for determining whether a candidate matches the
|
||||
# selector. Candidates are generated by the active
|
||||
@@ -1256,35 +1366,38 @@ class Tag(PageElement):
|
||||
"A pseudo-class must be prefixed with a tag name.")
|
||||
pseudo_attributes = re.match('([a-zA-Z\d-]+)\(([a-zA-Z\d]+)\)', pseudo)
|
||||
found = []
|
||||
if pseudo_attributes is not None:
|
||||
if pseudo_attributes is None:
|
||||
pseudo_type = pseudo
|
||||
pseudo_value = None
|
||||
else:
|
||||
pseudo_type, pseudo_value = pseudo_attributes.groups()
|
||||
if pseudo_type == 'nth-of-type':
|
||||
try:
|
||||
pseudo_value = int(pseudo_value)
|
||||
except:
|
||||
raise NotImplementedError(
|
||||
'Only numeric values are currently supported for the nth-of-type pseudo-class.')
|
||||
if pseudo_value < 1:
|
||||
raise ValueError(
|
||||
'nth-of-type pseudo-class value must be at least 1.')
|
||||
class Counter(object):
|
||||
def __init__(self, destination):
|
||||
self.count = 0
|
||||
self.destination = destination
|
||||
|
||||
def nth_child_of_type(self, tag):
|
||||
self.count += 1
|
||||
if self.count == self.destination:
|
||||
return True
|
||||
if self.count > self.destination:
|
||||
# Stop the generator that's sending us
|
||||
# these things.
|
||||
raise StopIteration()
|
||||
return False
|
||||
checker = Counter(pseudo_value).nth_child_of_type
|
||||
else:
|
||||
if pseudo_type == 'nth-of-type':
|
||||
try:
|
||||
pseudo_value = int(pseudo_value)
|
||||
except:
|
||||
raise NotImplementedError(
|
||||
'Only the following pseudo-classes are implemented: nth-of-type.')
|
||||
'Only numeric values are currently supported for the nth-of-type pseudo-class.')
|
||||
if pseudo_value < 1:
|
||||
raise ValueError(
|
||||
'nth-of-type pseudo-class value must be at least 1.')
|
||||
class Counter(object):
|
||||
def __init__(self, destination):
|
||||
self.count = 0
|
||||
self.destination = destination
|
||||
|
||||
def nth_child_of_type(self, tag):
|
||||
self.count += 1
|
||||
if self.count == self.destination:
|
||||
return True
|
||||
if self.count > self.destination:
|
||||
# Stop the generator that's sending us
|
||||
# these things.
|
||||
raise StopIteration()
|
||||
return False
|
||||
checker = Counter(pseudo_value).nth_child_of_type
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
'Only the following pseudo-classes are implemented: nth-of-type.')
|
||||
|
||||
elif token == '*':
|
||||
# Star selector -- matches everything
|
||||
@@ -1311,7 +1424,6 @@ class Tag(PageElement):
|
||||
else:
|
||||
raise ValueError(
|
||||
'Unsupported or invalid CSS selector: "%s"' % token)
|
||||
|
||||
if recursive_candidate_generator:
|
||||
# This happens when the selector looks like "> foo".
|
||||
#
|
||||
@@ -1361,8 +1473,7 @@ class Tag(PageElement):
|
||||
else:
|
||||
_use_candidate_generator = _candidate_generator
|
||||
|
||||
new_context = []
|
||||
new_context_ids = set([])
|
||||
count = 0
|
||||
for tag in current_context:
|
||||
if self._select_debug:
|
||||
print " Running candidate generator on %s %s" % (
|
||||
@@ -1387,9 +1498,12 @@ class Tag(PageElement):
|
||||
# don't include it in the context more than once.
|
||||
new_context.append(candidate)
|
||||
new_context_ids.add(id(candidate))
|
||||
if limit and len(new_context) >= limit:
|
||||
break
|
||||
elif self._select_debug:
|
||||
print " FAILURE %s %s" % (candidate.name, repr(candidate.attrs))
|
||||
|
||||
|
||||
current_context = new_context
|
||||
|
||||
if self._select_debug:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Helper classes for tests."""
|
||||
|
||||
__license__ = "MIT"
|
||||
|
||||
import pickle
|
||||
import copy
|
||||
import functools
|
||||
import unittest
|
||||
@@ -43,6 +46,16 @@ class SoupTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(obj.decode(), self.document_for(compare_parsed_to))
|
||||
|
||||
def assertConnectedness(self, element):
|
||||
"""Ensure that next_element and previous_element are properly
|
||||
set for all descendants of the given element.
|
||||
"""
|
||||
earlier = None
|
||||
for e in element.descendants:
|
||||
if earlier:
|
||||
self.assertEqual(e, earlier.next_element)
|
||||
self.assertEqual(earlier, e.previous_element)
|
||||
earlier = e
|
||||
|
||||
class HTMLTreeBuilderSmokeTest(object):
|
||||
|
||||
@@ -54,6 +67,15 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
markup in these tests, there's not much room for interpretation.
|
||||
"""
|
||||
|
||||
def test_pickle_and_unpickle_identity(self):
|
||||
# Pickling a tree, then unpickling it, yields a tree identical
|
||||
# to the original.
|
||||
tree = self.soup("<a><b>foo</a>")
|
||||
dumped = pickle.dumps(tree, 2)
|
||||
loaded = pickle.loads(dumped)
|
||||
self.assertEqual(loaded.__class__, BeautifulSoup)
|
||||
self.assertEqual(loaded.decode(), tree.decode())
|
||||
|
||||
def assertDoctypeHandled(self, doctype_fragment):
|
||||
"""Assert that a given doctype string is handled correctly."""
|
||||
doctype_str, soup = self._document_with_doctype(doctype_fragment)
|
||||
@@ -114,6 +136,11 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
soup.encode("utf-8").replace(b"\n", b""),
|
||||
markup.replace(b"\n", b""))
|
||||
|
||||
def test_processing_instruction(self):
|
||||
markup = b"""<?PITarget PIContent?>"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(markup, soup.encode("utf8"))
|
||||
|
||||
def test_deepcopy(self):
|
||||
"""Make sure you can copy the tree builder.
|
||||
|
||||
@@ -155,6 +182,23 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
def test_nested_formatting_elements(self):
|
||||
self.assertSoupEquals("<em><em></em></em>")
|
||||
|
||||
def test_double_head(self):
|
||||
html = '''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Ordinary HEAD element test</title>
|
||||
</head>
|
||||
<script type="text/javascript">
|
||||
alert("Help!");
|
||||
</script>
|
||||
<body>
|
||||
Hello, world!
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
soup = self.soup(html)
|
||||
self.assertEqual("text/javascript", soup.find('script')['type'])
|
||||
|
||||
def test_comment(self):
|
||||
# Comments are represented as Comment objects.
|
||||
markup = "<p>foo<!--foobar-->baz</p>"
|
||||
@@ -221,6 +265,14 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(["css"], soup.div.div['class'])
|
||||
|
||||
def test_multivalued_attribute_on_html(self):
|
||||
# html5lib uses a different API to set the attributes ot the
|
||||
# <html> tag. This has caused problems with multivalued
|
||||
# attributes.
|
||||
markup = '<html class="a b"></html>'
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(["a", "b"], soup.html['class'])
|
||||
|
||||
def test_angle_brackets_in_attribute_values_are_escaped(self):
|
||||
self.assertSoupEquals('<a b="<a>"></a>', '<a b="<a>"></a>')
|
||||
|
||||
@@ -253,6 +305,35 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
soup = self.soup("<html><h2>\nfoo</h2><p></p></html>")
|
||||
self.assertEqual("p", soup.h2.string.next_element.name)
|
||||
self.assertEqual("p", soup.p.name)
|
||||
self.assertConnectedness(soup)
|
||||
|
||||
def test_head_tag_between_head_and_body(self):
|
||||
"Prevent recurrence of a bug in the html5lib treebuilder."
|
||||
content = """<html><head></head>
|
||||
<link></link>
|
||||
<body>foo</body>
|
||||
</html>
|
||||
"""
|
||||
soup = self.soup(content)
|
||||
self.assertNotEqual(None, soup.html.body)
|
||||
self.assertConnectedness(soup)
|
||||
|
||||
def test_multiple_copies_of_a_tag(self):
|
||||
"Prevent recurrence of a bug in the html5lib treebuilder."
|
||||
content = """<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<article id="a" >
|
||||
<div><a href="1"></div>
|
||||
<footer>
|
||||
<a href="2"></a>
|
||||
</footer>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
soup = self.soup(content)
|
||||
self.assertConnectedness(soup.article)
|
||||
|
||||
def test_basic_namespaces(self):
|
||||
"""Parsers don't need to *understand* namespaces, but at the
|
||||
@@ -463,11 +544,25 @@ class HTMLTreeBuilderSmokeTest(object):
|
||||
|
||||
class XMLTreeBuilderSmokeTest(object):
|
||||
|
||||
def test_pickle_and_unpickle_identity(self):
|
||||
# Pickling a tree, then unpickling it, yields a tree identical
|
||||
# to the original.
|
||||
tree = self.soup("<a><b>foo</a>")
|
||||
dumped = pickle.dumps(tree, 2)
|
||||
loaded = pickle.loads(dumped)
|
||||
self.assertEqual(loaded.__class__, BeautifulSoup)
|
||||
self.assertEqual(loaded.decode(), tree.decode())
|
||||
|
||||
def test_docstring_generated(self):
|
||||
soup = self.soup("<root/>")
|
||||
self.assertEqual(
|
||||
soup.encode(), b'<?xml version="1.0" encoding="utf-8"?>\n<root/>')
|
||||
|
||||
def test_xml_declaration(self):
|
||||
markup = b"""<?xml version="1.0" encoding="utf8"?>\n<foo/>"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(markup, soup.encode("utf8"))
|
||||
|
||||
def test_real_xhtml_document(self):
|
||||
"""A real XHTML document should come out *exactly* the same as it went in."""
|
||||
markup = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
@@ -485,7 +580,7 @@ class XMLTreeBuilderSmokeTest(object):
|
||||
<script type="text/javascript">
|
||||
</script>
|
||||
"""
|
||||
soup = BeautifulSoup(doc, "xml")
|
||||
soup = BeautifulSoup(doc, "lxml-xml")
|
||||
# lxml would have stripped this while parsing, but we can add
|
||||
# it later.
|
||||
soup.script.string = 'console.log("< < hey > > ");'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests of the builder registry."""
|
||||
|
||||
import unittest
|
||||
import warnings
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.builder import (
|
||||
@@ -67,10 +68,15 @@ class BuiltInRegistryTest(unittest.TestCase):
|
||||
HTMLParserTreeBuilder)
|
||||
|
||||
def test_beautifulsoup_constructor_does_lookup(self):
|
||||
# You can pass in a string.
|
||||
BeautifulSoup("", features="html")
|
||||
# Or a list of strings.
|
||||
BeautifulSoup("", features=["html", "fast"])
|
||||
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
# This will create a warning about not explicitly
|
||||
# specifying a parser, but we'll ignore it.
|
||||
|
||||
# You can pass in a string.
|
||||
BeautifulSoup("", features="html")
|
||||
# Or a list of strings.
|
||||
BeautifulSoup("", features=["html", "fast"])
|
||||
|
||||
# You'll get an exception if BS can't find an appropriate
|
||||
# builder.
|
||||
|
||||
@@ -83,3 +83,16 @@ class HTML5LibBuilderSmokeTest(SoupTest, HTML5TreeBuilderSmokeTest):
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(u"<body><p><em>foo</em></p><em>\n</em><p><em>bar<a></a></em></p>\n</body>", soup.body.decode())
|
||||
self.assertEqual(2, len(soup.find_all('p')))
|
||||
|
||||
def test_processing_instruction(self):
|
||||
"""Processing instructions become comments."""
|
||||
markup = b"""<?PITarget PIContent?>"""
|
||||
soup = self.soup(markup)
|
||||
assert str(soup).startswith("<!--?PITarget PIContent?-->")
|
||||
|
||||
def test_cloned_multivalue_node(self):
|
||||
markup = b"""<a class="my_class"><p></a>"""
|
||||
soup = self.soup(markup)
|
||||
a1, a2 = soup.find_all('a')
|
||||
self.assertEqual(a1, a2)
|
||||
assert a1 is not a2
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests to ensure that the html.parser tree builder generates good
|
||||
trees."""
|
||||
|
||||
from pdb import set_trace
|
||||
import pickle
|
||||
from bs4.testing import SoupTest, HTMLTreeBuilderSmokeTest
|
||||
from bs4.builder import HTMLParserTreeBuilder
|
||||
|
||||
@@ -17,3 +19,14 @@ class HTMLParserTreeBuilderSmokeTest(SoupTest, HTMLTreeBuilderSmokeTest):
|
||||
def test_namespaced_public_doctype(self):
|
||||
# html.parser can't handle namespaced doctypes, so skip this one.
|
||||
pass
|
||||
|
||||
def test_builder_is_pickled(self):
|
||||
"""Unlike most tree builders, HTMLParserTreeBuilder and will
|
||||
be restored after pickling.
|
||||
"""
|
||||
tree = self.soup("<a><b>foo</a>")
|
||||
dumped = pickle.dumps(tree, 2)
|
||||
loaded = pickle.loads(dumped)
|
||||
self.assertTrue(isinstance(loaded.builder, type(tree.builder)))
|
||||
|
||||
|
||||
|
||||
@@ -65,21 +65,6 @@ class LXMLTreeBuilderSmokeTest(SoupTest, HTMLTreeBuilderSmokeTest):
|
||||
self.assertEqual(u"<b/>", unicode(soup.b))
|
||||
self.assertTrue("BeautifulStoneSoup class is deprecated" in str(w[0].message))
|
||||
|
||||
def test_real_xhtml_document(self):
|
||||
"""lxml strips the XML definition from an XHTML doc, which is fine."""
|
||||
markup = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Hello.</title></head>
|
||||
<body>Goodbye.</body>
|
||||
</html>"""
|
||||
soup = self.soup(markup)
|
||||
self.assertEqual(
|
||||
soup.encode("utf-8").replace(b"\n", b''),
|
||||
markup.replace(b'\n', b'').replace(
|
||||
b'<?xml version="1.0" encoding="utf-8"?>', b''))
|
||||
|
||||
|
||||
@skipIf(
|
||||
not LXML_PRESENT,
|
||||
"lxml seems not to be present, not testing its XML tree builder.")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests of Beautiful Soup as a whole."""
|
||||
|
||||
from pdb import set_trace
|
||||
import logging
|
||||
import unittest
|
||||
import sys
|
||||
@@ -20,6 +21,7 @@ import bs4.dammit
|
||||
from bs4.dammit import (
|
||||
EntitySubstitution,
|
||||
UnicodeDammit,
|
||||
EncodingDetector,
|
||||
)
|
||||
from bs4.testing import (
|
||||
SoupTest,
|
||||
@@ -48,8 +50,34 @@ class TestConstructor(SoupTest):
|
||||
soup = self.soup(data)
|
||||
self.assertEqual(u"foo\0bar", soup.h1.string)
|
||||
|
||||
def test_exclude_encodings(self):
|
||||
utf8_data = u"Räksmörgås".encode("utf-8")
|
||||
soup = self.soup(utf8_data, exclude_encodings=["utf-8"])
|
||||
self.assertEqual("windows-1252", soup.original_encoding)
|
||||
|
||||
class TestDeprecatedConstructorArguments(SoupTest):
|
||||
|
||||
class TestWarnings(SoupTest):
|
||||
|
||||
def _no_parser_specified(self, s, is_there=True):
|
||||
v = s.startswith(BeautifulSoup.NO_PARSER_SPECIFIED_WARNING[:80])
|
||||
self.assertTrue(v)
|
||||
|
||||
def test_warning_if_no_parser_specified(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("<a><b></b></a>")
|
||||
msg = str(w[0].message)
|
||||
self._assert_no_parser_specified(msg)
|
||||
|
||||
def test_warning_if_parser_specified_too_vague(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("<a><b></b></a>", "html")
|
||||
msg = str(w[0].message)
|
||||
self._assert_no_parser_specified(msg)
|
||||
|
||||
def test_no_warning_if_explicit_parser_specified(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
soup = self.soup("<a><b></b></a>", "html.parser")
|
||||
self.assertEquals([], w)
|
||||
|
||||
def test_parseOnlyThese_renamed_to_parse_only(self):
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
@@ -271,10 +299,11 @@ class TestUnicodeDammit(unittest.TestCase):
|
||||
dammit.unicode_markup, """<foo>''""</foo>""")
|
||||
|
||||
def test_detect_utf8(self):
|
||||
utf8 = b"\xc3\xa9"
|
||||
utf8 = b"Sacr\xc3\xa9 bleu! \xe2\x98\x83"
|
||||
dammit = UnicodeDammit(utf8)
|
||||
self.assertEqual(dammit.unicode_markup, u'\xe9')
|
||||
self.assertEqual(dammit.original_encoding.lower(), 'utf-8')
|
||||
self.assertEqual(dammit.unicode_markup, u'Sacr\xe9 bleu! \N{SNOWMAN}')
|
||||
|
||||
|
||||
def test_convert_hebrew(self):
|
||||
hebrew = b"\xed\xe5\xec\xf9"
|
||||
@@ -299,6 +328,26 @@ class TestUnicodeDammit(unittest.TestCase):
|
||||
dammit = UnicodeDammit(utf8_data, [bad_encoding])
|
||||
self.assertEqual(dammit.original_encoding.lower(), 'utf-8')
|
||||
|
||||
def test_exclude_encodings(self):
|
||||
# This is UTF-8.
|
||||
utf8_data = u"Räksmörgås".encode("utf-8")
|
||||
|
||||
# But if we exclude UTF-8 from consideration, the guess is
|
||||
# Windows-1252.
|
||||
dammit = UnicodeDammit(utf8_data, exclude_encodings=["utf-8"])
|
||||
self.assertEqual(dammit.original_encoding.lower(), 'windows-1252')
|
||||
|
||||
# And if we exclude that, there is no valid guess at all.
|
||||
dammit = UnicodeDammit(
|
||||
utf8_data, exclude_encodings=["utf-8", "windows-1252"])
|
||||
self.assertEqual(dammit.original_encoding, None)
|
||||
|
||||
def test_encoding_detector_replaces_junk_in_encoding_name_with_replacement_character(self):
|
||||
detected = EncodingDetector(
|
||||
b'<?xml version="1.0" encoding="UTF-\xdb" ?>')
|
||||
encodings = list(detected.encodings)
|
||||
assert u'utf-\N{REPLACEMENT CHARACTER}' in encodings
|
||||
|
||||
def test_detect_html5_style_meta_tag(self):
|
||||
|
||||
for data in (
|
||||
|
||||
@@ -9,6 +9,7 @@ same markup, but all Beautiful Soup trees can be traversed with the
|
||||
methods tested here.
|
||||
"""
|
||||
|
||||
from pdb import set_trace
|
||||
import copy
|
||||
import pickle
|
||||
import re
|
||||
@@ -19,8 +20,10 @@ from bs4.builder import (
|
||||
HTMLParserTreeBuilder,
|
||||
)
|
||||
from bs4.element import (
|
||||
PY3K,
|
||||
CData,
|
||||
Comment,
|
||||
Declaration,
|
||||
Doctype,
|
||||
NavigableString,
|
||||
SoupStrainer,
|
||||
@@ -68,7 +71,13 @@ class TestFind(TreeTest):
|
||||
|
||||
def test_unicode_text_find(self):
|
||||
soup = self.soup(u'<h1>Räksmörgås</h1>')
|
||||
self.assertEqual(soup.find(text=u'Räksmörgås'), u'Räksmörgås')
|
||||
self.assertEqual(soup.find(string=u'Räksmörgås'), u'Räksmörgås')
|
||||
|
||||
def test_unicode_attribute_find(self):
|
||||
soup = self.soup(u'<h1 id="Räksmörgås">here it is</h1>')
|
||||
str(soup)
|
||||
self.assertEqual("here it is", soup.find(id=u'Räksmörgås').text)
|
||||
|
||||
|
||||
def test_find_everything(self):
|
||||
"""Test an optimization that finds all tags."""
|
||||
@@ -87,6 +96,7 @@ class TestFindAll(TreeTest):
|
||||
"""You can search the tree for text nodes."""
|
||||
soup = self.soup("<html>Foo<b>bar</b>\xbb</html>")
|
||||
# Exact match.
|
||||
self.assertEqual(soup.find_all(string="bar"), [u"bar"])
|
||||
self.assertEqual(soup.find_all(text="bar"), [u"bar"])
|
||||
# Match any of a number of strings.
|
||||
self.assertEqual(
|
||||
@@ -688,7 +698,7 @@ class TestTagCreation(SoupTest):
|
||||
|
||||
def test_tag_inherits_self_closing_rules_from_builder(self):
|
||||
if XML_BUILDER_PRESENT:
|
||||
xml_soup = BeautifulSoup("", "xml")
|
||||
xml_soup = BeautifulSoup("", "lxml-xml")
|
||||
xml_br = xml_soup.new_tag("br")
|
||||
xml_p = xml_soup.new_tag("p")
|
||||
|
||||
@@ -697,7 +707,7 @@ class TestTagCreation(SoupTest):
|
||||
self.assertEqual(b"<br/>", xml_br.encode())
|
||||
self.assertEqual(b"<p/>", xml_p.encode())
|
||||
|
||||
html_soup = BeautifulSoup("", "html")
|
||||
html_soup = BeautifulSoup("", "html.parser")
|
||||
html_br = html_soup.new_tag("br")
|
||||
html_p = html_soup.new_tag("p")
|
||||
|
||||
@@ -773,6 +783,14 @@ class TestTreeModification(SoupTest):
|
||||
new_a = a.unwrap()
|
||||
self.assertEqual(a, new_a)
|
||||
|
||||
def test_replace_with_and_unwrap_give_useful_exception_when_tag_has_no_parent(self):
|
||||
soup = self.soup("<a><b>Foo</b></a><c>Bar</c>")
|
||||
a = soup.a
|
||||
a.extract()
|
||||
self.assertEqual(None, a.parent)
|
||||
self.assertRaises(ValueError, a.unwrap)
|
||||
self.assertRaises(ValueError, a.replace_with, soup.c)
|
||||
|
||||
def test_replace_tag_with_itself(self):
|
||||
text = "<a><b></b><c>Foo<d></d></c></a><a><e></e></a>"
|
||||
soup = self.soup(text)
|
||||
@@ -1067,6 +1085,31 @@ class TestTreeModification(SoupTest):
|
||||
self.assertEqual(foo_2, soup.a.string)
|
||||
self.assertEqual(bar_2, soup.b.string)
|
||||
|
||||
def test_extract_multiples_of_same_tag(self):
|
||||
soup = self.soup("""
|
||||
<html>
|
||||
<head>
|
||||
<script>foo</script>
|
||||
</head>
|
||||
<body>
|
||||
<script>bar</script>
|
||||
<a></a>
|
||||
</body>
|
||||
<script>baz</script>
|
||||
</html>""")
|
||||
[soup.script.extract() for i in soup.find_all("script")]
|
||||
self.assertEqual("<body>\n\n<a></a>\n</body>", unicode(soup.body))
|
||||
|
||||
|
||||
def test_extract_works_when_element_is_surrounded_by_identical_strings(self):
|
||||
soup = self.soup(
|
||||
'<html>\n'
|
||||
'<body>hi</body>\n'
|
||||
'</html>')
|
||||
soup.find('body').extract()
|
||||
self.assertEqual(None, soup.find('body'))
|
||||
|
||||
|
||||
def test_clear(self):
|
||||
"""Tag.clear()"""
|
||||
soup = self.soup("<p><a>String <em>Italicized</em></a> and another</p>")
|
||||
@@ -1293,6 +1336,51 @@ class TestPersistence(SoupTest):
|
||||
loaded = pickle.loads(dumped)
|
||||
self.assertEqual(loaded.decode(), soup.decode())
|
||||
|
||||
def test_copy_navigablestring_is_not_attached_to_tree(self):
|
||||
html = u"<b>Foo<a></a></b><b>Bar</b>"
|
||||
soup = self.soup(html)
|
||||
s1 = soup.find(string="Foo")
|
||||
s2 = copy.copy(s1)
|
||||
self.assertEqual(s1, s2)
|
||||
self.assertEqual(None, s2.parent)
|
||||
self.assertEqual(None, s2.next_element)
|
||||
self.assertNotEqual(None, s1.next_sibling)
|
||||
self.assertEqual(None, s2.next_sibling)
|
||||
self.assertEqual(None, s2.previous_element)
|
||||
|
||||
def test_copy_navigablestring_subclass_has_same_type(self):
|
||||
html = u"<b><!--Foo--></b>"
|
||||
soup = self.soup(html)
|
||||
s1 = soup.string
|
||||
s2 = copy.copy(s1)
|
||||
self.assertEqual(s1, s2)
|
||||
self.assertTrue(isinstance(s2, Comment))
|
||||
|
||||
def test_copy_entire_soup(self):
|
||||
html = u"<div><b>Foo<a></a></b><b>Bar</b></div>end"
|
||||
soup = self.soup(html)
|
||||
soup_copy = copy.copy(soup)
|
||||
self.assertEqual(soup, soup_copy)
|
||||
|
||||
def test_copy_tag_copies_contents(self):
|
||||
html = u"<div><b>Foo<a></a></b><b>Bar</b></div>end"
|
||||
soup = self.soup(html)
|
||||
div = soup.div
|
||||
div_copy = copy.copy(div)
|
||||
|
||||
# The two tags look the same, and evaluate to equal.
|
||||
self.assertEqual(unicode(div), unicode(div_copy))
|
||||
self.assertEqual(div, div_copy)
|
||||
|
||||
# But they're not the same object.
|
||||
self.assertFalse(div is div_copy)
|
||||
|
||||
# And they don't have the same relation to the parse tree. The
|
||||
# copy is not associated with a parse tree at all.
|
||||
self.assertEqual(None, div_copy.parent)
|
||||
self.assertEqual(None, div_copy.previous_element)
|
||||
self.assertEqual(None, div_copy.find(string='Bar').next_element)
|
||||
self.assertNotEqual(None, div.find(string='Bar').next_element)
|
||||
|
||||
class TestSubstitutions(SoupTest):
|
||||
|
||||
@@ -1366,7 +1454,7 @@ class TestSubstitutions(SoupTest):
|
||||
console.log("< < hey > > ");
|
||||
</script>
|
||||
"""
|
||||
encoded = BeautifulSoup(doc).encode()
|
||||
encoded = BeautifulSoup(doc, 'html.parser').encode()
|
||||
self.assertTrue(b"< < hey > >" in encoded)
|
||||
|
||||
def test_formatter_skips_style_tag_for_html_documents(self):
|
||||
@@ -1375,7 +1463,7 @@ class TestSubstitutions(SoupTest):
|
||||
console.log("< < hey > > ");
|
||||
</style>
|
||||
"""
|
||||
encoded = BeautifulSoup(doc).encode()
|
||||
encoded = BeautifulSoup(doc, 'html.parser').encode()
|
||||
self.assertTrue(b"< < hey > >" in encoded)
|
||||
|
||||
def test_prettify_leaves_preformatted_text_alone(self):
|
||||
@@ -1387,7 +1475,7 @@ class TestSubstitutions(SoupTest):
|
||||
soup.div.prettify())
|
||||
|
||||
def test_prettify_accepts_formatter(self):
|
||||
soup = BeautifulSoup("<html><body>foo</body></html>")
|
||||
soup = BeautifulSoup("<html><body>foo</body></html>", 'html.parser')
|
||||
pretty = soup.prettify(formatter = lambda x: x.upper())
|
||||
self.assertTrue("FOO" in pretty)
|
||||
|
||||
@@ -1484,6 +1572,14 @@ class TestEncoding(SoupTest):
|
||||
self.assertEqual(
|
||||
u"\N{SNOWMAN}".encode("utf8"), soup.b.renderContents())
|
||||
|
||||
def test_repr(self):
|
||||
html = u"<b>\N{SNOWMAN}</b>"
|
||||
soup = self.soup(html)
|
||||
if PY3K:
|
||||
self.assertEqual(html, repr(soup))
|
||||
else:
|
||||
self.assertEqual(b'<b>\\u2603</b>', repr(soup))
|
||||
|
||||
class TestNavigableStringSubclasses(SoupTest):
|
||||
|
||||
def test_cdata(self):
|
||||
@@ -1522,6 +1618,9 @@ class TestNavigableStringSubclasses(SoupTest):
|
||||
soup.insert(1, doctype)
|
||||
self.assertEqual(soup.encode(), b"<!DOCTYPE foo>\n")
|
||||
|
||||
def test_declaration(self):
|
||||
d = Declaration("foo")
|
||||
self.assertEqual("<?foo?>", d.output_ready())
|
||||
|
||||
class TestSoupSelector(TreeTest):
|
||||
|
||||
@@ -1534,7 +1633,7 @@ class TestSoupSelector(TreeTest):
|
||||
<link rel="stylesheet" href="blah.css" type="text/css" id="l1">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<custom-dashed-tag class="dashed" id="dash1">Hello there.</custom-dashed-tag>
|
||||
<div id="main" class="fancy">
|
||||
<div id="inner">
|
||||
<h1 id="header1">An H1</h1>
|
||||
@@ -1552,8 +1651,18 @@ class TestSoupSelector(TreeTest):
|
||||
<a href="#" id="s2a1">span2a1</a>
|
||||
</span>
|
||||
<span class="span3"></span>
|
||||
<custom-dashed-tag class="dashed" id="dash2"/>
|
||||
<div data-tag="dashedvalue" id="data1"/>
|
||||
</span>
|
||||
</div>
|
||||
<x id="xid">
|
||||
<z id="zida"/>
|
||||
<z id="zidab"/>
|
||||
<z id="zidac"/>
|
||||
</x>
|
||||
<y id="yid">
|
||||
<z id="zidb"/>
|
||||
</y>
|
||||
<p lang="en" id="lang-en">English</p>
|
||||
<p lang="en-gb" id="lang-en-gb">English UK</p>
|
||||
<p lang="en-us" id="lang-en-us">English US</p>
|
||||
@@ -1565,7 +1674,7 @@ class TestSoupSelector(TreeTest):
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.soup = BeautifulSoup(self.HTML)
|
||||
self.soup = BeautifulSoup(self.HTML, 'html.parser')
|
||||
|
||||
def assertSelects(self, selector, expected_ids):
|
||||
el_ids = [el['id'] for el in self.soup.select(selector)]
|
||||
@@ -1591,17 +1700,25 @@ class TestSoupSelector(TreeTest):
|
||||
|
||||
def test_one_tag_many(self):
|
||||
els = self.soup.select('div')
|
||||
self.assertEqual(len(els), 3)
|
||||
self.assertEqual(len(els), 4)
|
||||
for div in els:
|
||||
self.assertEqual(div.name, 'div')
|
||||
|
||||
el = self.soup.select_one('div')
|
||||
self.assertEqual('main', el['id'])
|
||||
|
||||
def test_select_one_returns_none_if_no_match(self):
|
||||
match = self.soup.select_one('nonexistenttag')
|
||||
self.assertEqual(None, match)
|
||||
|
||||
|
||||
def test_tag_in_tag_one(self):
|
||||
els = self.soup.select('div div')
|
||||
self.assertSelects('div div', ['inner'])
|
||||
self.assertSelects('div div', ['inner', 'data1'])
|
||||
|
||||
def test_tag_in_tag_many(self):
|
||||
for selector in ('html div', 'html body div', 'body div'):
|
||||
self.assertSelects(selector, ['main', 'inner', 'footer'])
|
||||
self.assertSelects(selector, ['data1', 'main', 'inner', 'footer'])
|
||||
|
||||
def test_tag_no_match(self):
|
||||
self.assertEqual(len(self.soup.select('del')), 0)
|
||||
@@ -1609,6 +1726,20 @@ class TestSoupSelector(TreeTest):
|
||||
def test_invalid_tag(self):
|
||||
self.assertRaises(ValueError, self.soup.select, 'tag%t')
|
||||
|
||||
def test_select_dashed_tag_ids(self):
|
||||
self.assertSelects('custom-dashed-tag', ['dash1', 'dash2'])
|
||||
|
||||
def test_select_dashed_by_id(self):
|
||||
dashed = self.soup.select('custom-dashed-tag[id=\"dash2\"]')
|
||||
self.assertEqual(dashed[0].name, 'custom-dashed-tag')
|
||||
self.assertEqual(dashed[0]['id'], 'dash2')
|
||||
|
||||
def test_dashed_tag_text(self):
|
||||
self.assertEqual(self.soup.select('body > custom-dashed-tag')[0].text, u'Hello there.')
|
||||
|
||||
def test_select_dashed_matches_find_all(self):
|
||||
self.assertEqual(self.soup.select('custom-dashed-tag'), self.soup.find_all('custom-dashed-tag'))
|
||||
|
||||
def test_header_tags(self):
|
||||
self.assertSelectMultiple(
|
||||
('h1', ['header1']),
|
||||
@@ -1709,6 +1840,7 @@ class TestSoupSelector(TreeTest):
|
||||
('[id^="m"]', ['me', 'main']),
|
||||
('div[id^="m"]', ['main']),
|
||||
('a[id^="m"]', ['me']),
|
||||
('div[data-tag^="dashed"]', ['data1'])
|
||||
)
|
||||
|
||||
def test_attribute_endswith(self):
|
||||
@@ -1716,8 +1848,8 @@ class TestSoupSelector(TreeTest):
|
||||
('[href$=".css"]', ['l1']),
|
||||
('link[href$=".css"]', ['l1']),
|
||||
('link[id$="1"]', ['l1']),
|
||||
('[id$="1"]', ['l1', 'p1', 'header1', 's1a1', 's2a1', 's1a2s1']),
|
||||
('div[id$="1"]', []),
|
||||
('[id$="1"]', ['data1', 'l1', 'p1', 'header1', 's1a1', 's2a1', 's1a2s1', 'dash1']),
|
||||
('div[id$="1"]', ['data1']),
|
||||
('[id$="noending"]', []),
|
||||
)
|
||||
|
||||
@@ -1730,7 +1862,6 @@ class TestSoupSelector(TreeTest):
|
||||
('[rel*="notstyle"]', []),
|
||||
('link[rel*="notstyle"]', []),
|
||||
('link[href*="bla"]', ['l1']),
|
||||
('a[href*="http://"]', ['bob', 'me']),
|
||||
('[href*="http://"]', ['bob', 'me']),
|
||||
('[id*="p"]', ['pmulti', 'p1']),
|
||||
('div[id*="m"]', ['main']),
|
||||
@@ -1739,8 +1870,8 @@ class TestSoupSelector(TreeTest):
|
||||
('[href*=".css"]', ['l1']),
|
||||
('link[href*=".css"]', ['l1']),
|
||||
('link[id*="1"]', ['l1']),
|
||||
('[id*="1"]', ['l1', 'p1', 'header1', 's1a1', 's1a2', 's2a1', 's1a2s1']),
|
||||
('div[id*="1"]', []),
|
||||
('[id*="1"]', ['data1', 'l1', 'p1', 'header1', 's1a1', 's1a2', 's2a1', 's1a2s1', 'dash1']),
|
||||
('div[id*="1"]', ['data1']),
|
||||
('[id*="noending"]', []),
|
||||
# New for this test
|
||||
('[href*="."]', ['bob', 'me', 'l1']),
|
||||
@@ -1748,6 +1879,7 @@ class TestSoupSelector(TreeTest):
|
||||
('link[href*="."]', ['l1']),
|
||||
('div[id*="n"]', ['main', 'inner']),
|
||||
('div[id*="nn"]', ['inner']),
|
||||
('div[data-tag*="edval"]', ['data1'])
|
||||
)
|
||||
|
||||
def test_attribute_exact_or_hypen(self):
|
||||
@@ -1767,8 +1899,17 @@ class TestSoupSelector(TreeTest):
|
||||
('p[class]', ['p1', 'pmulti']),
|
||||
('[blah]', []),
|
||||
('p[blah]', []),
|
||||
('div[data-tag]', ['data1'])
|
||||
)
|
||||
|
||||
def test_unsupported_pseudoclass(self):
|
||||
self.assertRaises(
|
||||
NotImplementedError, self.soup.select, "a:no-such-pseudoclass")
|
||||
|
||||
self.assertRaises(
|
||||
NotImplementedError, self.soup.select, "a:nth-of-type(a)")
|
||||
|
||||
|
||||
def test_nth_of_type(self):
|
||||
# Try to select first paragraph
|
||||
els = self.soup.select('div#inner p:nth-of-type(1)')
|
||||
@@ -1803,7 +1944,7 @@ class TestSoupSelector(TreeTest):
|
||||
selected = inner.select("div")
|
||||
# The <div id="inner"> tag was selected. The <div id="footer">
|
||||
# tag was not.
|
||||
self.assertSelectsIDs(selected, ['inner'])
|
||||
self.assertSelectsIDs(selected, ['inner', 'data1'])
|
||||
|
||||
def test_overspecified_child_id(self):
|
||||
self.assertSelects(".fancy #inner", ['inner'])
|
||||
@@ -1827,3 +1968,44 @@ class TestSoupSelector(TreeTest):
|
||||
|
||||
def test_sibling_combinator_wont_select_same_tag_twice(self):
|
||||
self.assertSelects('p[lang] ~ p', ['lang-en-gb', 'lang-en-us', 'lang-fr'])
|
||||
|
||||
# Test the selector grouping operator (the comma)
|
||||
def test_multiple_select(self):
|
||||
self.assertSelects('x, y', ['xid', 'yid'])
|
||||
|
||||
def test_multiple_select_with_no_space(self):
|
||||
self.assertSelects('x,y', ['xid', 'yid'])
|
||||
|
||||
def test_multiple_select_with_more_space(self):
|
||||
self.assertSelects('x, y', ['xid', 'yid'])
|
||||
|
||||
def test_multiple_select_duplicated(self):
|
||||
self.assertSelects('x, x', ['xid'])
|
||||
|
||||
def test_multiple_select_sibling(self):
|
||||
self.assertSelects('x, y ~ p[lang=fr]', ['xid', 'lang-fr'])
|
||||
|
||||
def test_multiple_select_tag_and_direct_descendant(self):
|
||||
self.assertSelects('x, y > z', ['xid', 'zidb'])
|
||||
|
||||
def test_multiple_select_direct_descendant_and_tags(self):
|
||||
self.assertSelects('div > x, y, z', ['xid', 'yid', 'zida', 'zidb', 'zidab', 'zidac'])
|
||||
|
||||
def test_multiple_select_indirect_descendant(self):
|
||||
self.assertSelects('div x,y, z', ['xid', 'yid', 'zida', 'zidb', 'zidab', 'zidac'])
|
||||
|
||||
def test_invalid_multiple_select(self):
|
||||
self.assertRaises(ValueError, self.soup.select, ',x, y')
|
||||
self.assertRaises(ValueError, self.soup.select, 'x,,y')
|
||||
|
||||
def test_multiple_select_attrs(self):
|
||||
self.assertSelects('p[lang=en], p[lang=en-gb]', ['lang-en', 'lang-en-gb'])
|
||||
|
||||
def test_multiple_select_ids(self):
|
||||
self.assertSelects('x, y > z[id=zida], z[id=zidab], z[id=zidb]', ['xid', 'zidb', 'zidab'])
|
||||
|
||||
def test_multiple_select_nested(self):
|
||||
self.assertSelects('body > div > x, y > z', ['xid', 'zidb'])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Chardet: The Universal Character Encoding Detector
|
||||
Detects
|
||||
- ASCII, UTF-8, UTF-16 (2 variants), UTF-32 (4 variants)
|
||||
- Big5, GB2312, EUC-TW, HZ-GB-2312, ISO-2022-CN (Traditional and Simplified Chinese)
|
||||
- EUC-JP, SHIFT_JIS, ISO-2022-JP (Japanese)
|
||||
- EUC-JP, SHIFT_JIS, CP932, ISO-2022-JP (Japanese)
|
||||
- EUC-KR, ISO-2022-KR (Korean)
|
||||
- KOI8-R, MacCyrillic, IBM855, IBM866, ISO-8859-5, windows-1251 (Cyrillic)
|
||||
- ISO-8859-2, windows-1250 (Hungarian)
|
||||
@@ -16,6 +16,14 @@ Detects
|
||||
|
||||
Requires Python 2.6 or later
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
Install from `PyPI <https://pypi.python.org/pypi/chardet>`_::
|
||||
|
||||
pip install chardet
|
||||
|
||||
|
||||
Command-line Tool
|
||||
-----------------
|
||||
|
||||
@@ -31,7 +39,7 @@ About
|
||||
|
||||
This is a continuation of Mark Pilgrim's excellent chardet. Previously, two
|
||||
versions needed to be maintained: one that supported python 2.x and one that
|
||||
supported python 3.x. We've recently merged with `Ian Corduscano <https://github.com/sigmavirus24>`_'s
|
||||
supported python 3.x. We've recently merged with `Ian Cordasco <https://github.com/sigmavirus24>`_'s
|
||||
`charade <https://github.com/sigmavirus24/charade>`_ fork, so now we have one
|
||||
coherent version that works for Python 2.6+.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# 02110-1301 USA
|
||||
######################### END LICENSE BLOCK #########################
|
||||
|
||||
__version__ = "2.2.1"
|
||||
__version__ = "2.3.0"
|
||||
from sys import version_info
|
||||
|
||||
|
||||
|
||||
@@ -12,34 +12,68 @@ Example::
|
||||
If no paths are provided, it takes its input from stdin.
|
||||
|
||||
"""
|
||||
from io import open
|
||||
from sys import argv, stdin
|
||||
|
||||
from __future__ import absolute_import, print_function, unicode_literals
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from io import open
|
||||
|
||||
from chardet import __version__
|
||||
from chardet.universaldetector import UniversalDetector
|
||||
|
||||
|
||||
def description_of(file, name='stdin'):
|
||||
"""Return a string describing the probable encoding of a file."""
|
||||
def description_of(lines, name='stdin'):
|
||||
"""
|
||||
Return a string describing the probable encoding of a file or
|
||||
list of strings.
|
||||
|
||||
:param lines: The lines to get the encoding of.
|
||||
:type lines: Iterable of bytes
|
||||
:param name: Name of file or collection of lines
|
||||
:type name: str
|
||||
"""
|
||||
u = UniversalDetector()
|
||||
for line in file:
|
||||
for line in lines:
|
||||
u.feed(line)
|
||||
u.close()
|
||||
result = u.result
|
||||
if result['encoding']:
|
||||
return '%s: %s with confidence %s' % (name,
|
||||
result['encoding'],
|
||||
result['confidence'])
|
||||
return '{0}: {1} with confidence {2}'.format(name, result['encoding'],
|
||||
result['confidence'])
|
||||
else:
|
||||
return '%s: no result' % name
|
||||
return '{0}: no result'.format(name)
|
||||
|
||||
|
||||
def main():
|
||||
if len(argv) <= 1:
|
||||
print(description_of(stdin))
|
||||
else:
|
||||
for path in argv[1:]:
|
||||
with open(path, 'rb') as f:
|
||||
print(description_of(f, path))
|
||||
def main(argv=None):
|
||||
'''
|
||||
Handles command line arguments and gets things started.
|
||||
|
||||
:param argv: List of arguments, as if specified on the command-line.
|
||||
If None, ``sys.argv[1:]`` is used instead.
|
||||
:type argv: list of str
|
||||
'''
|
||||
# Get command line arguments
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Takes one or more file paths and reports their detected \
|
||||
encodings",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
conflict_handler='resolve')
|
||||
parser.add_argument('input',
|
||||
help='File whose encoding we would like to determine.',
|
||||
type=argparse.FileType('rb'), nargs='*',
|
||||
default=[sys.stdin])
|
||||
parser.add_argument('--version', action='version',
|
||||
version='%(prog)s {0}'.format(__version__))
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
for f in args.input:
|
||||
if f.isatty():
|
||||
print("You are running chardetect interactively. Press " +
|
||||
"CTRL-D twice at the start of a blank line to signal the " +
|
||||
"end of your input. If you want help, run chardetect " +
|
||||
"--help\n", file=sys.stderr)
|
||||
print(description_of(f, f.name))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -177,6 +177,12 @@ class JapaneseContextAnalysis:
|
||||
return -1, 1
|
||||
|
||||
class SJISContextAnalysis(JapaneseContextAnalysis):
|
||||
def __init__(self):
|
||||
self.charset_name = "SHIFT_JIS"
|
||||
|
||||
def get_charset_name(self):
|
||||
return self.charset_name
|
||||
|
||||
def get_order(self, aBuf):
|
||||
if not aBuf:
|
||||
return -1, 1
|
||||
@@ -184,6 +190,8 @@ class SJISContextAnalysis(JapaneseContextAnalysis):
|
||||
first_char = wrap_ord(aBuf[0])
|
||||
if ((0x81 <= first_char <= 0x9F) or (0xE0 <= first_char <= 0xFC)):
|
||||
charLen = 2
|
||||
if (first_char == 0x87) or (0xFA <= first_char <= 0xFC):
|
||||
self.charset_name = "CP932"
|
||||
else:
|
||||
charLen = 1
|
||||
|
||||
|
||||
@@ -129,11 +129,11 @@ class Latin1Prober(CharSetProber):
|
||||
if total < 0.01:
|
||||
confidence = 0.0
|
||||
else:
|
||||
confidence = ((self._mFreqCounter[3] / total)
|
||||
- (self._mFreqCounter[1] * 20.0 / total))
|
||||
confidence = ((self._mFreqCounter[3] - self._mFreqCounter[1] * 20.0)
|
||||
/ total)
|
||||
if confidence < 0.0:
|
||||
confidence = 0.0
|
||||
# lower the confidence of latin1 so that other more accurate
|
||||
# detector can take priority.
|
||||
confidence = confidence * 0.5
|
||||
confidence = confidence * 0.73
|
||||
return confidence
|
||||
|
||||
@@ -353,7 +353,7 @@ SJIS_cls = (
|
||||
2,2,2,2,2,2,2,2, # 68 - 6f
|
||||
2,2,2,2,2,2,2,2, # 70 - 77
|
||||
2,2,2,2,2,2,2,1, # 78 - 7f
|
||||
3,3,3,3,3,3,3,3, # 80 - 87
|
||||
3,3,3,3,3,2,2,3, # 80 - 87
|
||||
3,3,3,3,3,3,3,3, # 88 - 8f
|
||||
3,3,3,3,3,3,3,3, # 90 - 97
|
||||
3,3,3,3,3,3,3,3, # 98 - 9f
|
||||
@@ -369,9 +369,8 @@ SJIS_cls = (
|
||||
2,2,2,2,2,2,2,2, # d8 - df
|
||||
3,3,3,3,3,3,3,3, # e0 - e7
|
||||
3,3,3,3,3,4,4,4, # e8 - ef
|
||||
4,4,4,4,4,4,4,4, # f0 - f7
|
||||
4,4,4,4,4,0,0,0 # f8 - ff
|
||||
)
|
||||
3,3,3,3,3,3,3,3, # f0 - f7
|
||||
3,3,3,3,3,0,0,0) # f8 - ff
|
||||
|
||||
|
||||
SJIS_st = (
|
||||
@@ -571,5 +570,3 @@ UTF8SMModel = {'classTable': UTF8_cls,
|
||||
'stateTable': UTF8_st,
|
||||
'charLenTable': UTF8CharLenTable,
|
||||
'name': 'UTF-8'}
|
||||
|
||||
# flake8: noqa
|
||||
|
||||
@@ -47,7 +47,7 @@ class SJISProber(MultiByteCharSetProber):
|
||||
self._mContextAnalyzer.reset()
|
||||
|
||||
def get_charset_name(self):
|
||||
return "SHIFT_JIS"
|
||||
return self._mContextAnalyzer.get_charset_name()
|
||||
|
||||
def feed(self, aBuf):
|
||||
aLen = len(aBuf)
|
||||
|
||||
@@ -71,9 +71,9 @@ class UniversalDetector:
|
||||
|
||||
if not self._mGotData:
|
||||
# If the data starts with BOM, we know it is UTF
|
||||
if aBuf[:3] == codecs.BOM:
|
||||
if aBuf[:3] == codecs.BOM_UTF8:
|
||||
# EF BB BF UTF-8 with BOM
|
||||
self.result = {'encoding': "UTF-8", 'confidence': 1.0}
|
||||
self.result = {'encoding': "UTF-8-SIG", 'confidence': 1.0}
|
||||
elif aBuf[:4] == codecs.BOM_UTF32_LE:
|
||||
# FF FE 00 00 UTF-32, little-endian BOM
|
||||
self.result = {'encoding': "UTF-32LE", 'confidence': 1.0}
|
||||
|
||||
Executable → Regular
@@ -73,17 +73,18 @@ class HttpClient(object):
|
||||
return response
|
||||
|
||||
# TODO retrying requests on 502, 503 errors?
|
||||
try:
|
||||
response = self.session.send(prepared)
|
||||
except socket.gaierror as e:
|
||||
code, _ = e
|
||||
|
||||
if code != 8:
|
||||
raise e
|
||||
|
||||
log.warn('Encountered socket.gaierror (code: 8)')
|
||||
|
||||
response = self._build().send(prepared)
|
||||
# try:
|
||||
# response = self.session.send(prepared)
|
||||
# except socket.gaierror as e:
|
||||
# code, _ = e
|
||||
#
|
||||
# if code != 8:
|
||||
# raise e
|
||||
#
|
||||
# log.warn('Encountered socket.gaierror (code: 8)')
|
||||
#
|
||||
# response = self._build().send(prepared)
|
||||
response = request.request.send()
|
||||
|
||||
# Store response in cache
|
||||
self._cache_store(prepared, response)
|
||||
|
||||
@@ -34,7 +34,7 @@ class LibraryInterface(Interface):
|
||||
'MediaContainer': ('MediaContainer', idict({
|
||||
'Video': {
|
||||
'movie': 'Movie',
|
||||
'episode': 'Episode'
|
||||
'episode': 'Episode'
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -40,3 +40,20 @@ class RootInterface(Interface):
|
||||
'Server': 'Server'
|
||||
}))
|
||||
}))
|
||||
|
||||
def agents(self):
|
||||
response = self.http.get('system/agents')
|
||||
|
||||
return self.parse(response, idict({
|
||||
'MediaContainer': ('Container', idict({
|
||||
'Agent': 'Agent'
|
||||
}))
|
||||
}))
|
||||
|
||||
def primary_agent(self, guid, media_type):
|
||||
response = self.http.get('/system/agents/%s/config/%s' % (guid, media_type))
|
||||
return self.parse(response, idict({
|
||||
'MediaContainer': ('Container', idict({
|
||||
'Agent': 'Agent'
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -19,3 +19,34 @@ class SectionInterface(Interface):
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
def first_character(self, key, character=None):
|
||||
if character:
|
||||
response = self.http.get(key, ['firstCharacter', character])
|
||||
|
||||
# somehow plex wrongly returns items of other libraries when character is #
|
||||
return self.parse(response, idict({
|
||||
'MediaContainer': ('MediaContainer', idict({
|
||||
'Directory': {
|
||||
'album': 'Album',
|
||||
'artist': 'Artist',
|
||||
|
||||
'season': 'Season',
|
||||
'show': 'Show'
|
||||
},
|
||||
'Video': {
|
||||
'episode': 'Episode',
|
||||
'clip': 'Clip',
|
||||
'movie': 'Movie'
|
||||
},
|
||||
'Track': 'Track'
|
||||
}))
|
||||
}))
|
||||
|
||||
response = self.http.get(key, 'firstCharacter')
|
||||
|
||||
return self.parse(response, idict({
|
||||
'MediaContainer': ('MediaContainer', idict({
|
||||
'Directory': 'Directory'
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from plex.objects.core.base import Descriptor, Property
|
||||
|
||||
|
||||
class MediaType(Descriptor):
|
||||
name = Property
|
||||
media_type = Property("mediaType", type=int)
|
||||
|
||||
@classmethod
|
||||
def from_node(cls, client, node):
|
||||
items = []
|
||||
|
||||
for t in cls.helpers.findall(node, 'MediaType'):
|
||||
_, obj = MediaType.construct(client, t, child=True)
|
||||
|
||||
items.append(obj)
|
||||
|
||||
return [], items
|
||||
|
||||
|
||||
class Agent(Descriptor):
|
||||
name = Property
|
||||
enabled = Property(type=int)
|
||||
identifier = Property
|
||||
primary = Property(type=int)
|
||||
has_prefs = Property("hasPrefs", type=int)
|
||||
has_attribution = Property("hasAttribution", type=int)
|
||||
|
||||
media_types = Property(resolver=lambda: MediaType.from_node)
|
||||
|
||||
@@ -7,6 +7,8 @@ class Directory(Descriptor):
|
||||
|
||||
title = Property
|
||||
|
||||
size = Property
|
||||
|
||||
art = Property
|
||||
thumb = Property
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ class MediaContainer(Container):
|
||||
media_tag_prefix = Property('mediaTagPrefix')
|
||||
media_tag_version = Property('mediaTagVersion')
|
||||
|
||||
size = Property('size', int)
|
||||
total_size = Property('totalSize', int)
|
||||
|
||||
allow_sync = Property('allowSync', bool)
|
||||
mixed_parents = Property('mixedParents', bool)
|
||||
no_cache = Property('nocache', bool)
|
||||
|
||||
@@ -5,6 +5,9 @@ from plex.objects.library.section import Section
|
||||
class Metadata(Descriptor):
|
||||
section = Property(resolver=lambda: Metadata.construct_section)
|
||||
|
||||
# somehow section doesn't resolve on onDeck, add key manually
|
||||
section_key = Property('librarySectionID')
|
||||
|
||||
key = Property
|
||||
guid = Property
|
||||
rating_key = Property('ratingKey')
|
||||
@@ -32,3 +35,4 @@ class Metadata(Descriptor):
|
||||
}
|
||||
|
||||
return Section.construct(client, node, attribute_map, child=True)
|
||||
|
||||
|
||||
@@ -3,93 +3,107 @@
|
||||
import datetime
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from plex import Plex
|
||||
from plex.client import PlexClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
|
||||
def is_recent(item):
|
||||
addedAt = datetime.datetime.fromtimestamp(item.added_at)
|
||||
addedAt = datetime.datetime.fromtimestamp(item.added_at)
|
||||
if now - datetime.timedelta(weeks=2) > addedAt:
|
||||
return False
|
||||
return False
|
||||
return True
|
||||
|
||||
def findMissingSubtitles(list_item, _type="episode", internal=False, external=True, languages=["eng"], section_blacklist=["3"], series_blacklist=["26059"], dry_run=False):
|
||||
|
||||
def findMissingSubtitles(list_item, _type="episode", internal=False, external=True, languages=["eng"], section_blacklist=["3"],
|
||||
series_blacklist=["26059"], dry_run=False):
|
||||
existing_subs = {"internal": [], "external": [], "count": 0}
|
||||
|
||||
# get requested item again to have access to the streams - should not be necessary
|
||||
item_id = int(list_item.key.split("/")[-1])
|
||||
item_container = Plex["library"].metadata(item_id)
|
||||
|
||||
|
||||
# don't process blacklisted sections
|
||||
if item_container.section.key in section_blacklist:
|
||||
return
|
||||
return
|
||||
|
||||
item = list(item_container)[0]
|
||||
|
||||
if _type == "episode" and item.show.rating_key in series_blacklist:
|
||||
logger.debug("Skipping show %s in blacklist", item.show.key)
|
||||
return
|
||||
logger.debug("Skipping show %s in blacklist", item.show.key)
|
||||
return
|
||||
elif _type == "movie" and item.rating_key in movie_blacklist:
|
||||
logger.debug("Skipping movie %s in blacklist", item.key)
|
||||
return
|
||||
logger.debug("Skipping movie %s in blacklist", item.key)
|
||||
return
|
||||
|
||||
video = item.media
|
||||
|
||||
for part in video.parts:
|
||||
for stream in part.streams:
|
||||
if stream.stream_type == 3:
|
||||
if stream.index:
|
||||
key = "internal"
|
||||
else:
|
||||
key = "external"
|
||||
|
||||
existing_subs[key].append(stream.language_code)
|
||||
existing_subs["count"] += 1
|
||||
for stream in part.streams:
|
||||
if stream.stream_type == 3:
|
||||
if stream.index:
|
||||
key = "internal"
|
||||
else:
|
||||
key = "external"
|
||||
|
||||
existing_subs[key].append(stream.language_code)
|
||||
existing_subs["count"] += 1
|
||||
|
||||
missing = languages
|
||||
if existing_subs["count"]:
|
||||
existing_flat = existing_subs["internal"] if internal else [] + existing_subs["external"] if external else []
|
||||
languages_set = set(languages)
|
||||
if languages_set.issubset(existing_flat):
|
||||
# all subs found
|
||||
logger.debug(u"All subtitles existing for %s", item.title)
|
||||
return
|
||||
else:
|
||||
missing = languages_set - set(existing_flat)
|
||||
logger.info(u"Subs still missing: %s", missing)
|
||||
existing_flat = existing_subs["internal"] if internal else [] + existing_subs["external"] if external else []
|
||||
languages_set = set(languages)
|
||||
if languages_set.issubset(existing_flat):
|
||||
# all subs found
|
||||
logger.debug(u"All subtitles existing for %s", item.title)
|
||||
return
|
||||
else:
|
||||
missing = languages_set - set(existing_flat)
|
||||
logger.info(u"Subs still missing: %s", missing)
|
||||
|
||||
if missing:
|
||||
logger.info("Triggering refresh for '%s'", item.title)
|
||||
if not dry_run:
|
||||
Plex["library/metadata"].refresh(item_id)
|
||||
logger.info("Triggering refresh for '%s'", item.title)
|
||||
if not dry_run:
|
||||
Plex["library/metadata"].refresh(item_id)
|
||||
|
||||
|
||||
def run():
|
||||
token = sys.argv[1]
|
||||
Plex.configuration.defaults.authentication(token)
|
||||
sections = Plex["library"].sections()
|
||||
#section = list(sections)[0]
|
||||
#print section.title, section.path, dir(section), list(section._children)[0].path
|
||||
#return
|
||||
for container in sections:
|
||||
print container.title
|
||||
for location in container:
|
||||
print location.path
|
||||
|
||||
return
|
||||
itemCount = 0
|
||||
dry_run = "--dry-run" in sys.argv
|
||||
with Plex.configuration.authentication("asdfasdfasdf"):
|
||||
print Plex.configuration.stack[1].data
|
||||
#Plex[":/plugins"].restart("com.plexapp.agents.subzero")
|
||||
#Plex[":/plugins/*/prefs"].set("com.plexapp.agents.subzero", "reset_storage", True)
|
||||
print Plex.configuration.stack[1].data
|
||||
# Plex[":/plugins"].restart("com.plexapp.agents.subzero")
|
||||
# Plex[":/plugins/*/prefs"].set("com.plexapp.agents.subzero", "reset_storage", True)
|
||||
return
|
||||
|
||||
for item in Plex['library'].recently_added():
|
||||
if item.type == "season":
|
||||
for child in item.children():
|
||||
if is_recent(child):
|
||||
print u"Series: %s, Season: %s, Episode: %s %s" % (item.show.title, item.title, child.index, child.title)
|
||||
findMissingSubtitles(child, _type="episode", dry_run=dry_run)
|
||||
itemCount += 1
|
||||
|
||||
elif item.type == "movie":
|
||||
if is_recent(item):
|
||||
print "Movie: ", item.title
|
||||
findMissingSubtitles(item, _type="movie", dry_run=dry_run)
|
||||
itemCount += 1
|
||||
if item.type == "season":
|
||||
for child in item.children():
|
||||
if is_recent(child):
|
||||
print u"Series: %s, Season: %s, Episode: %s %s" % (item.show.title, item.title, child.index, child.title)
|
||||
findMissingSubtitles(child, _type="episode", dry_run=dry_run)
|
||||
itemCount += 1
|
||||
|
||||
elif item.type == "movie":
|
||||
if is_recent(item):
|
||||
print "Movie: ", item.title
|
||||
findMissingSubtitles(item, _type="movie", dry_run=dry_run)
|
||||
itemCount += 1
|
||||
|
||||
print "Items: ", itemCount
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from .ssafile import SSAFile
|
||||
from .ssaevent import SSAEvent
|
||||
from .ssastyle import SSAStyle
|
||||
from . import time, formats, cli
|
||||
from .exceptions import *
|
||||
from .common import Color, VERSION
|
||||
|
||||
#: Alias for :meth:`SSAFile.load()`.
|
||||
load = SSAFile.load
|
||||
|
||||
#: Alias for :meth:`pysubs2.time.make_time()`.
|
||||
make_time = time.make_time
|
||||
@@ -0,0 +1,7 @@
|
||||
import sys
|
||||
from .cli import Pysubs2CLI
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli = Pysubs2CLI()
|
||||
rv = cli(sys.argv[1:])
|
||||
sys.exit(rv)
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import unicode_literals, print_function
|
||||
import argparse
|
||||
import codecs
|
||||
import os
|
||||
import re
|
||||
import os.path as op
|
||||
import io
|
||||
from io import open
|
||||
import sys
|
||||
from textwrap import dedent
|
||||
from .formats import get_file_extension
|
||||
from .time import make_time
|
||||
from .ssafile import SSAFile
|
||||
from .common import PY3, VERSION
|
||||
|
||||
|
||||
def positive_float(s):
|
||||
x = float(s)
|
||||
if not x > 0:
|
||||
raise argparse.ArgumentTypeError("%r is not a positive number" % s)
|
||||
return x
|
||||
|
||||
def character_encoding(s):
|
||||
try:
|
||||
codecs.lookup(s)
|
||||
return s
|
||||
except LookupError:
|
||||
raise argparse.ArgumentError
|
||||
|
||||
def time(s):
|
||||
d = {}
|
||||
for v, k in re.findall(r"(\d*\.?\d*)(ms|m|s|h)", s):
|
||||
d[k] = float(v)
|
||||
return make_time(**d)
|
||||
|
||||
|
||||
def change_ext(path, ext):
|
||||
base, _ = op.splitext(path)
|
||||
return base + ext
|
||||
|
||||
|
||||
class Pysubs2CLI(object):
|
||||
def __init__(self):
|
||||
parser = self.parser = argparse.ArgumentParser(formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
prog="pysubs2",
|
||||
description=dedent("""
|
||||
The pysubs2 CLI for processing subtitle files.
|
||||
https://github.com/tkarabela/pysubs2
|
||||
"""),
|
||||
epilog=dedent("""
|
||||
usage examples:
|
||||
python -m pysubs2 --to srt *.ass
|
||||
python -m pysubs2 --to microdvd --fps 23.976 *.ass
|
||||
python -m pysubs2 --shift 0.3s *.srt
|
||||
python -m pysubs2 --shift 0.3s <my_file.srt >retimed_file.srt
|
||||
python -m pysubs2 --shift-back 0.3s --output-dir retimed *.srt
|
||||
python -m pysubs2 --transform-framerate 25 23.976 *.srt"""))
|
||||
|
||||
parser.add_argument("files", nargs="*", metavar="FILE",
|
||||
help="Input subtitle files. Can be in SubStation Alpha (*.ass, *.ssa), SubRip (*.srt) or "
|
||||
"MicroDVD (*.sub) formats. When no files are specified, pysubs2 will work as a pipe, "
|
||||
"reading from standard input and writing to standard output.")
|
||||
|
||||
parser.add_argument("-v", "--version", action="version", version="pysubs2 %s" % VERSION)
|
||||
|
||||
parser.add_argument("-f", "--from", choices=["ass", "ssa", "srt", "microdvd", "json"], dest="input_format",
|
||||
help="By default, subtitle format is detected from the file. This option can be used to "
|
||||
"skip autodetection and force specific format. Generally, it should never be needed.")
|
||||
parser.add_argument("-t", "--to", choices=["ass", "ssa", "srt", "microdvd", "json"], dest="output_format",
|
||||
help="Convert subtitle files to given format. By default, each file is saved in its "
|
||||
"original format.")
|
||||
parser.add_argument("--input-enc", metavar="ENCODING", default="iso-8859-1", type=character_encoding,
|
||||
help="Character encoding for input files. By default, ISO-8859-1 is used for both "
|
||||
"input and output, which should generally work (for 8-bit encodings).")
|
||||
parser.add_argument("--output-enc", metavar="ENCODING", type=character_encoding,
|
||||
help="Character encoding for output files. By default, it is the same as input encoding. "
|
||||
"If you wish to convert between encodings, make sure --input-enc is set correctly! "
|
||||
"Otherwise, your output files will probably be corrupted. It's a good idea to "
|
||||
"back up your files or use the -o option.")
|
||||
parser.add_argument("--fps", metavar="FPS", type=positive_float,
|
||||
help="This argument specifies framerate for MicroDVD files. By default, framerate "
|
||||
"is detected from the file. Use this when framerate specification is missing "
|
||||
"or to force different framerate.")
|
||||
parser.add_argument("-o", "--output-dir", metavar="DIR",
|
||||
help="Use this to save all files to given directory. By default, every file is saved to its parent directory, "
|
||||
"ie. unless it's being saved in different subtitle format (and thus with different file extension), "
|
||||
"it overwrites the original file.")
|
||||
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
|
||||
group.add_argument("--shift", metavar="TIME", type=time,
|
||||
help="Delay all subtitles by given time amount. Time is specified like this: '1m30s', '0.5s', ...")
|
||||
group.add_argument("--shift-back", metavar="TIME", type=time,
|
||||
help="The opposite of --shift (subtitles will appear sooner).")
|
||||
group.add_argument("--transform-framerate", nargs=2, metavar=("FPS1", "FPS2"), type=positive_float,
|
||||
help="Multiply all timestamps by FPS1/FPS2 ratio.")
|
||||
|
||||
def __call__(self, argv):
|
||||
try:
|
||||
self.main(argv)
|
||||
except KeyboardInterrupt:
|
||||
exit("\nAborted by user.")
|
||||
|
||||
def main(self, argv):
|
||||
args = self.parser.parse_args(argv)
|
||||
errors = 0
|
||||
|
||||
if args.output_dir and not op.exists(args.output_dir):
|
||||
os.makedirs(args.output_dir)
|
||||
|
||||
if args.output_enc is None:
|
||||
args.output_enc = args.input_enc
|
||||
|
||||
if args.files:
|
||||
for path in args.files:
|
||||
if not op.exists(path):
|
||||
print("Skipping", path, "(does not exist)")
|
||||
errors += 1
|
||||
elif not op.isfile(path):
|
||||
print("Skipping", path, "(not a file)")
|
||||
errors += 1
|
||||
else:
|
||||
with open(path, encoding=args.input_enc) as infile:
|
||||
subs = SSAFile.from_file(infile, args.input_format, args.fps)
|
||||
|
||||
self.process(subs, args)
|
||||
|
||||
if args.output_format is None:
|
||||
outpath = path
|
||||
output_format = subs.format
|
||||
else:
|
||||
ext = get_file_extension(args.output_format)
|
||||
outpath = change_ext(path, ext)
|
||||
output_format = args.output_format
|
||||
|
||||
if args.output_dir is not None:
|
||||
_, filename = op.split(outpath)
|
||||
outpath = op.join(args.output_dir, filename)
|
||||
|
||||
with open(outpath, "w", encoding=args.output_enc) as outfile:
|
||||
subs.to_file(outfile, output_format, args.fps)
|
||||
else:
|
||||
if PY3:
|
||||
infile = io.TextIOWrapper(sys.stdin.buffer, args.input_enc)
|
||||
outfile = io.TextIOWrapper(sys.stdout.buffer, args.output_enc)
|
||||
else:
|
||||
infile = io.TextIOWrapper(sys.stdin, args.input_enc)
|
||||
outfile = io.TextIOWrapper(sys.stdout, args.output_enc)
|
||||
|
||||
subs = SSAFile.from_file(infile, args.input_format, args.fps)
|
||||
self.process(subs, args)
|
||||
output_format = args.output_format or subs.format
|
||||
subs.to_file(outfile, output_format, args.fps)
|
||||
|
||||
return (0 if errors == 0 else 1)
|
||||
|
||||
@staticmethod
|
||||
def process(subs, args):
|
||||
if args.shift is not None:
|
||||
subs.shift(ms=args.shift)
|
||||
elif args.shift_back is not None:
|
||||
subs.shift(ms=-args.shift_back)
|
||||
elif args.transform_framerate is not None:
|
||||
in_fps, out_fps = args.transform_framerate
|
||||
subs.transform_framerate(in_fps, out_fps)
|
||||
@@ -0,0 +1,28 @@
|
||||
from collections import namedtuple
|
||||
import sys
|
||||
|
||||
_Color = namedtuple("Color", "r g b a")
|
||||
|
||||
class Color(_Color):
|
||||
"""
|
||||
(r, g, b, a) namedtuple for 8-bit RGB color with alpha channel.
|
||||
|
||||
All values are ints from 0 to 255.
|
||||
"""
|
||||
def __new__(cls, r, g, b, a=0):
|
||||
for value in r, g, b, a:
|
||||
if value not in range(256):
|
||||
raise ValueError("Color channels must have values 0-255")
|
||||
|
||||
return _Color.__new__(cls, r, g, b, a)
|
||||
|
||||
#: Version of the pysubs2 library.
|
||||
VERSION = "0.2.1"
|
||||
|
||||
|
||||
PY3 = sys.version_info.major == 3
|
||||
|
||||
if PY3:
|
||||
text_type = str
|
||||
else:
|
||||
text_type = unicode
|
||||
@@ -0,0 +1,14 @@
|
||||
class Pysubs2Error(Exception):
|
||||
"""Base class for pysubs2 exceptions."""
|
||||
|
||||
class UnknownFPSError(Pysubs2Error):
|
||||
"""Framerate was not specified and couldn't be inferred otherwise."""
|
||||
|
||||
class UnknownFileExtensionError(Pysubs2Error):
|
||||
"""File extension does not pertain to any known subtitle format."""
|
||||
|
||||
class UnknownFormatIdentifierError(Pysubs2Error):
|
||||
"""Unknown subtitle format identifier (ie. string like ``"srt"``)."""
|
||||
|
||||
class FormatAutodetectionError(Pysubs2Error):
|
||||
"""Subtitle format is ambiguous or unknown."""
|
||||
@@ -0,0 +1,76 @@
|
||||
class FormatBase(object):
|
||||
"""
|
||||
Base class for subtitle format implementations.
|
||||
|
||||
How to implement a new subtitle format:
|
||||
|
||||
1. Create a subclass of FormatBase and override the methods you want to support.
|
||||
2. Decide on a format identifier, like the ``"srt"`` or ``"microdvd"`` already used in the library.
|
||||
3. Add your identifier and class to :data:`pysubs2.formats.FORMAT_IDENTIFIER_TO_FORMAT_CLASS`.
|
||||
4. (optional) Add your file extension and class to :data:`pysubs2.formats.FILE_EXTENSION_TO_FORMAT_IDENTIFIER`.
|
||||
|
||||
After finishing these steps, you can call :meth:`SSAFile.load()` and :meth:`SSAFile.save()` with your
|
||||
format, including autodetection from content and file extension (if you provided these).
|
||||
|
||||
"""
|
||||
@classmethod
|
||||
def from_file(cls, subs, fp, format_, **kwargs):
|
||||
"""
|
||||
Load subtitle file into an empty SSAFile.
|
||||
|
||||
If the parser autodetects framerate, set it as ``subs.fps``.
|
||||
|
||||
Arguments:
|
||||
subs (SSAFile): An empty :class:`SSAFile`.
|
||||
fp (file object): Text file object, the subtitle file.
|
||||
format_ (str): Format identifier. Used when one format class
|
||||
implements multiple formats (see :class:`SubstationFormat`).
|
||||
kwargs: Extra options, eg. `fps`.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
pysubs2.exceptions.UnknownFPSError: Framerate was not provided and cannot
|
||||
be detected.
|
||||
"""
|
||||
raise NotImplementedError("Parsing is not supported for this format")
|
||||
|
||||
@classmethod
|
||||
def to_file(cls, subs, fp, format_, **kwargs):
|
||||
"""
|
||||
Write SSAFile into a file.
|
||||
|
||||
If you need framerate and it is not passed in keyword arguments,
|
||||
use ``subs.fps``.
|
||||
|
||||
Arguments:
|
||||
subs (SSAFile): Subtitle file to write.
|
||||
fp (file object): Text file object used as output.
|
||||
format_ (str): Format identifier of desired output format.
|
||||
Used when one format class implements multiple formats
|
||||
(see :class:`SubstationFormat`).
|
||||
kwargs: Extra options, eg. `fps`.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
pysubs2.exceptions.UnknownFPSError: Framerate was not provided and
|
||||
``subs.fps is None``.
|
||||
"""
|
||||
raise NotImplementedError("Writing is not supported for this format")
|
||||
|
||||
@classmethod
|
||||
def guess_format(self, text):
|
||||
"""
|
||||
Return format identifier of recognized format, or None.
|
||||
|
||||
Arguments:
|
||||
text (str): Content of subtitle file. When the file is long,
|
||||
this may be only its first few thousand characters.
|
||||
|
||||
Returns:
|
||||
format identifier (eg. ``"srt"``) or None (unknown format)
|
||||
"""
|
||||
return None
|
||||
@@ -0,0 +1,64 @@
|
||||
from .formatbase import FormatBase
|
||||
from .microdvd import MicroDVDFormat
|
||||
from .subrip import SubripFormat
|
||||
from .jsonformat import JSONFormat
|
||||
from .substation import SubstationFormat
|
||||
from .exceptions import *
|
||||
|
||||
#: Dict mapping file extensions to format identifiers.
|
||||
FILE_EXTENSION_TO_FORMAT_IDENTIFIER = {
|
||||
".srt": "srt",
|
||||
".ass": "ass",
|
||||
".ssa": "ssa",
|
||||
".sub": "microdvd",
|
||||
".json": "json"
|
||||
}
|
||||
|
||||
#: Dict mapping format identifiers to implementations (FormatBase subclasses).
|
||||
FORMAT_IDENTIFIER_TO_FORMAT_CLASS = {
|
||||
"srt": SubripFormat,
|
||||
"ass": SubstationFormat,
|
||||
"ssa": SubstationFormat,
|
||||
"microdvd": MicroDVDFormat,
|
||||
"json": JSONFormat
|
||||
}
|
||||
|
||||
def get_format_class(format_):
|
||||
"""Format identifier -> format class (ie. subclass of FormatBase)"""
|
||||
try:
|
||||
return FORMAT_IDENTIFIER_TO_FORMAT_CLASS[format_]
|
||||
except KeyError:
|
||||
raise UnknownFormatIdentifierError(format_)
|
||||
|
||||
def get_format_identifier(ext):
|
||||
"""File extension -> format identifier"""
|
||||
try:
|
||||
return FILE_EXTENSION_TO_FORMAT_IDENTIFIER[ext]
|
||||
except KeyError:
|
||||
raise UnknownFileExtensionError(ext)
|
||||
|
||||
def get_file_extension(format_):
|
||||
"""Format identifier -> file extension"""
|
||||
if format_ not in FORMAT_IDENTIFIER_TO_FORMAT_CLASS:
|
||||
raise UnknownFormatIdentifierError(format_)
|
||||
|
||||
for ext, f in FILE_EXTENSION_TO_FORMAT_IDENTIFIER.items():
|
||||
if f == format_:
|
||||
return ext
|
||||
|
||||
raise RuntimeError("No file extension for format %r" % format_)
|
||||
|
||||
def autodetect_format(content):
|
||||
"""Return format identifier for given fragment or raise FormatAutodetectionError."""
|
||||
formats = set()
|
||||
for impl in FORMAT_IDENTIFIER_TO_FORMAT_CLASS.values():
|
||||
guess = impl.guess_format(content)
|
||||
if guess is not None:
|
||||
formats.add(guess)
|
||||
|
||||
if len(formats) == 1:
|
||||
return formats.pop()
|
||||
elif not formats:
|
||||
raise FormatAutodetectionError("No suitable formats")
|
||||
else:
|
||||
raise FormatAutodetectionError("Multiple suitable formats (%r)" % formats)
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import unicode_literals, print_function
|
||||
|
||||
import json
|
||||
from .common import Color, PY3
|
||||
from .ssaevent import SSAEvent
|
||||
from .ssastyle import SSAStyle
|
||||
from .formatbase import FormatBase
|
||||
|
||||
|
||||
class JSONFormat(FormatBase):
|
||||
@classmethod
|
||||
def guess_format(cls, text):
|
||||
if text.startswith("{\""):
|
||||
return "json"
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, subs, fp, format_, **kwargs):
|
||||
data = json.load(fp)
|
||||
|
||||
subs.info.clear()
|
||||
subs.info.update(data["info"])
|
||||
|
||||
subs.styles.clear()
|
||||
for name, fields in data["styles"].items():
|
||||
subs.styles[name] = sty = SSAStyle()
|
||||
for k, v in fields.items():
|
||||
if "color" in k:
|
||||
setattr(sty, k, Color(*v))
|
||||
else:
|
||||
setattr(sty, k, v)
|
||||
|
||||
subs.events = [SSAEvent(**fields) for fields in data["events"]]
|
||||
|
||||
@classmethod
|
||||
def to_file(cls, subs, fp, format_, **kwargs):
|
||||
data = {
|
||||
"info": dict(**subs.info),
|
||||
"styles": {name: sty.as_dict() for name, sty in subs.styles.items()},
|
||||
"events": [ev.as_dict() for ev in subs.events]
|
||||
}
|
||||
|
||||
if PY3:
|
||||
json.dump(data, fp)
|
||||
else:
|
||||
text = json.dumps(data, fp)
|
||||
fp.write(unicode(text))
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import unicode_literals, print_function
|
||||
|
||||
from functools import partial
|
||||
import re
|
||||
from .common import text_type
|
||||
from .exceptions import UnknownFPSError
|
||||
from .ssaevent import SSAEvent
|
||||
from .ssastyle import SSAStyle
|
||||
from .formatbase import FormatBase
|
||||
from .substation import parse_tags
|
||||
from .time import ms_to_frames, frames_to_ms
|
||||
|
||||
#: Matches a MicroDVD line.
|
||||
MICRODVD_LINE = re.compile(r" *\{ *(\d+) *\} *\{ *(\d+) *\}(.+)")
|
||||
|
||||
|
||||
class MicroDVDFormat(FormatBase):
|
||||
@classmethod
|
||||
def guess_format(cls, text):
|
||||
if any(map(MICRODVD_LINE.match, text.splitlines())):
|
||||
return "microdvd"
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, subs, fp, format_, fps=None, **kwargs):
|
||||
for line in fp:
|
||||
match = MICRODVD_LINE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
fstart, fend, text = match.groups()
|
||||
fstart, fend = map(int, (fstart, fend))
|
||||
|
||||
if fps is None:
|
||||
# We don't know the framerate, but it is customary to include
|
||||
# it as text of the first subtitle. In that case, we skip
|
||||
# this auxiliary subtitle and proceed with reading.
|
||||
try:
|
||||
fps = float(text)
|
||||
subs.fps = fps
|
||||
continue
|
||||
except ValueError:
|
||||
raise UnknownFPSError("Framerate was not specified and "
|
||||
"cannot be read from "
|
||||
"the MicroDVD file.")
|
||||
|
||||
start, end = map(partial(frames_to_ms, fps=fps), (fstart, fend))
|
||||
|
||||
def prepare_text(text):
|
||||
text = text.replace("|", r"\N")
|
||||
|
||||
def style_replacer(match):
|
||||
tags = [c for c in "biu" if c in match.group(0)]
|
||||
return "{%s}" % "".join(r"\%s1" % c for c in tags)
|
||||
|
||||
text = re.sub(r"\{[Yy]:[^}]+\}", style_replacer, text)
|
||||
text = re.sub(r"\{[Ff]:([^}]+)\}", r"{\\fn\1}", text)
|
||||
text = re.sub(r"\{[Ss]:([^}]+)\}", r"{\\fs\1}", text)
|
||||
text = re.sub(r"\{P:(\d+),(\d+)\}", r"{\\pos(\1,\2)}", text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
ev = SSAEvent(start=start, end=end, text=prepare_text(text))
|
||||
subs.append(ev)
|
||||
|
||||
@classmethod
|
||||
def to_file(cls, subs, fp, format_, fps=None, write_fps_declaration=True, **kwargs):
|
||||
if fps is None:
|
||||
fps = subs.fps
|
||||
|
||||
if fps is None:
|
||||
raise UnknownFPSError("Framerate must be specified when writing MicroDVD.")
|
||||
to_frames = partial(ms_to_frames, fps=fps)
|
||||
|
||||
def is_entirely_italic(line):
|
||||
style = subs.styles.get(line.style, SSAStyle.DEFAULT_STYLE)
|
||||
for fragment, sty in parse_tags(line.text, style, subs.styles):
|
||||
fragment = fragment.replace(r"\h", " ")
|
||||
fragment = fragment.replace(r"\n", "\n")
|
||||
fragment = fragment.replace(r"\N", "\n")
|
||||
if not sty.italic and fragment and not fragment.isspace():
|
||||
return False
|
||||
return True
|
||||
|
||||
# insert an artificial first line telling the framerate
|
||||
if write_fps_declaration:
|
||||
subs.insert(0, SSAEvent(start=0, end=0, text=text_type(fps)))
|
||||
|
||||
for line in (ev for ev in subs if not ev.is_comment):
|
||||
text = "|".join(line.plaintext.splitlines())
|
||||
if is_entirely_italic(line):
|
||||
text = "{Y:i}" + text
|
||||
|
||||
start, end = map(to_frames, (line.start, line.end))
|
||||
|
||||
# XXX warn on underflow?
|
||||
if start < 0: start = 0
|
||||
if end < 0: end = 0
|
||||
|
||||
print("{%d}{%d}%s" % (start, end, text), file=fp)
|
||||
|
||||
# remove the artificial framerate-telling line
|
||||
if write_fps_declaration:
|
||||
subs.pop(0)
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import unicode_literals
|
||||
import re
|
||||
from .time import ms_to_str, make_time
|
||||
from .common import PY3
|
||||
|
||||
|
||||
class SSAEvent(object):
|
||||
"""
|
||||
A SubStation Event, ie. one subtitle.
|
||||
|
||||
In SubStation, each subtitle consists of multiple "fields" like Start, End and Text.
|
||||
These are exposed as attributes (note that they are lowercase; see :attr:`SSAEvent.FIELDS` for a list).
|
||||
Additionaly, there are some convenience properties like :attr:`SSAEvent.plaintext` or :attr:`SSAEvent.duration`.
|
||||
|
||||
This class defines an ordering with respect to (start, end) timestamps.
|
||||
|
||||
.. tip :: Use :func:`pysubs2.make_time()` to get times in milliseconds.
|
||||
|
||||
Example::
|
||||
|
||||
>>> ev = SSAEvent(start=make_time(s=1), end=make_time(s=2.5), text="Hello World!")
|
||||
|
||||
"""
|
||||
OVERRIDE_SEQUENCE = re.compile(r"{[^}]*}")
|
||||
|
||||
#: All fields in SSAEvent.
|
||||
FIELDS = frozenset([
|
||||
"start", "end", "text", "marked", "layer", "style",
|
||||
"name", "marginl", "marginr", "marginv", "effect", "type"
|
||||
])
|
||||
|
||||
def __init__(self, **fields):
|
||||
self.start = 0 #: Subtitle start time (in milliseconds)
|
||||
self.end = 10000 #: Subtitle end time (in milliseconds)
|
||||
self.text = "" #: Text of subtitle (with SubStation override tags)
|
||||
self.marked = False #: (SSA only)
|
||||
self.layer = 0 #: Layer number, 0 is the lowest layer (ASS only)
|
||||
self.style = "Default" #: Style name
|
||||
self.name = "" #: Actor name
|
||||
self.marginl = 0 #: Left margin
|
||||
self.marginr = 0 #: Right margin
|
||||
self.marginv = 0 #: Vertical margin
|
||||
self.effect = "" #: Line effect
|
||||
self.type = "Dialogue" #: Line type (Dialogue/Comment)
|
||||
|
||||
for k, v in fields.items():
|
||||
if k in self.FIELDS:
|
||||
setattr(self, k, v)
|
||||
else:
|
||||
raise ValueError("SSAEvent has no field named %r" % k)
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
"""
|
||||
Subtitle duration in milliseconds (read/write property).
|
||||
|
||||
Writing to this property adjusts :attr:`SSAEvent.end`.
|
||||
Setting negative durations raises :exc:`ValueError`.
|
||||
"""
|
||||
return self.end - self.start
|
||||
|
||||
@duration.setter
|
||||
def duration(self, ms):
|
||||
if ms >= 0:
|
||||
self.end = self.start + ms
|
||||
else:
|
||||
raise ValueError("Subtitle duration cannot be negative")
|
||||
|
||||
@property
|
||||
def is_comment(self):
|
||||
"""
|
||||
When true, the subtitle is a comment, ie. not visible (read/write property).
|
||||
|
||||
Setting this property is equivalent to changing
|
||||
:attr:`SSAEvent.type` to ``"Dialogue"`` or ``"Comment"``.
|
||||
"""
|
||||
return self.type == "Comment"
|
||||
|
||||
@is_comment.setter
|
||||
def is_comment(self, value):
|
||||
if value:
|
||||
self.type = "Comment"
|
||||
else:
|
||||
self.type = "Dialogue"
|
||||
|
||||
@property
|
||||
def plaintext(self):
|
||||
"""
|
||||
Subtitle text as multi-line string with no tags (read/write property).
|
||||
|
||||
Writing to this property replaces :attr:`SSAEvent.text` with given plain
|
||||
text. Newlines are converted to ``\\N`` tags.
|
||||
"""
|
||||
text = self.text
|
||||
text = self.OVERRIDE_SEQUENCE.sub("", text)
|
||||
text = text.replace(r"\h", " ")
|
||||
text = text.replace(r"\n", "\n")
|
||||
text = text.replace(r"\N", "\n")
|
||||
return text
|
||||
|
||||
@plaintext.setter
|
||||
def plaintext(self, text):
|
||||
self.text = text.replace("\n", r"\N")
|
||||
|
||||
def shift(self, h=0, m=0, s=0, ms=0, frames=None, fps=None):
|
||||
"""
|
||||
Shift start and end times.
|
||||
|
||||
See :meth:`SSAFile.shift()` for full description.
|
||||
|
||||
"""
|
||||
delta = make_time(h=h, m=m, s=s, ms=ms, frames=frames, fps=fps)
|
||||
self.start += delta
|
||||
self.end += delta
|
||||
|
||||
def copy(self):
|
||||
"""Return a copy of the SSAEvent."""
|
||||
return SSAEvent(**self.as_dict())
|
||||
|
||||
def as_dict(self):
|
||||
return {field: getattr(self, field) for field in self.FIELDS}
|
||||
|
||||
def equals(self, other):
|
||||
"""Field-based equality for SSAEvents."""
|
||||
if isinstance(other, SSAEvent):
|
||||
return self.as_dict() == other.as_dict()
|
||||
else:
|
||||
raise TypeError("Cannot compare to non-SSAEvent object")
|
||||
|
||||
def __eq__(self, other):
|
||||
# XXX document this
|
||||
return self.start == other.start and self.end == other.end
|
||||
|
||||
def __ne__(self, other):
|
||||
return self.start != other.start or self.end != other.end
|
||||
|
||||
def __lt__(self, other):
|
||||
return (self.start, self.end) < (other.start, other.end)
|
||||
|
||||
def __le__(self, other):
|
||||
return (self.start, self.end) <= (other.start, other.end)
|
||||
|
||||
def __gt__(self, other):
|
||||
return (self.start, self.end) > (other.start, other.end)
|
||||
|
||||
def __ge__(self, other):
|
||||
return (self.start, self.end) >= (other.start, other.end)
|
||||
|
||||
def __repr__(self):
|
||||
s = "<SSAEvent type={self.type} start={start} end={end} text='{self.text}'>".format(
|
||||
self=self, start=ms_to_str(self.start), end=ms_to_str(self.end))
|
||||
if not PY3: s = s.encode("utf-8")
|
||||
return s
|
||||
@@ -0,0 +1,419 @@
|
||||
from __future__ import print_function, unicode_literals, division
|
||||
from collections import MutableSequence, OrderedDict
|
||||
import io
|
||||
from io import open
|
||||
from itertools import starmap, chain
|
||||
import os.path
|
||||
import logging
|
||||
from .formats import autodetect_format, get_format_class, get_format_identifier
|
||||
from .substation import is_valid_field_content
|
||||
from .ssaevent import SSAEvent
|
||||
from .ssastyle import SSAStyle
|
||||
from .time import make_time, ms_to_str
|
||||
from .common import PY3
|
||||
|
||||
|
||||
class SSAFile(MutableSequence):
|
||||
"""
|
||||
Subtitle file in SubStation Alpha format.
|
||||
|
||||
This class has a list-like interface which exposes :attr:`SSAFile.events`,
|
||||
list of subtitles in the file::
|
||||
|
||||
subs = SSAFile.load("subtitles.srt")
|
||||
|
||||
for line in subs:
|
||||
print(line.text)
|
||||
|
||||
subs.insert(0, SSAEvent(start=0, end=make_time(s=2.5), text="New first subtitle"))
|
||||
|
||||
del subs[0]
|
||||
|
||||
"""
|
||||
|
||||
DEFAULT_INFO = OrderedDict([
|
||||
("WrapStyle", "0"),
|
||||
("ScaledBorderAndShadow", "yes"),
|
||||
("Collisions", "Normal")])
|
||||
|
||||
def __init__(self):
|
||||
self.events = [] #: List of :class:`SSAEvent` instances, ie. individual subtitles.
|
||||
self.styles = OrderedDict([("Default", SSAStyle.DEFAULT_STYLE.copy())]) #: Dict of :class:`SSAStyle` instances.
|
||||
self.info = self.DEFAULT_INFO.copy() #: Dict with script metadata, ie. ``[Script Info]``.
|
||||
self.aegisub_project = OrderedDict() #: Dict with Aegisub project, ie. ``[Aegisub Project Garbage]``.
|
||||
self.fps = None #: Framerate used when reading the file, if applicable.
|
||||
self.format = None #: Format of source subtitle file, if applicable, eg. ``"srt"``.
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# I/O methods
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def load(cls, path, encoding="utf-8", format_=None, fps=None, **kwargs):
|
||||
"""
|
||||
Load subtitle file from given path.
|
||||
|
||||
Arguments:
|
||||
path (str): Path to subtitle file.
|
||||
encoding (str): Character encoding of input file.
|
||||
Defaults to UTF-8, you may need to change this.
|
||||
format_ (str): Optional, forces use of specific parser
|
||||
(eg. `"srt"`, `"ass"`). Otherwise, format is detected
|
||||
automatically from file contents. This argument should
|
||||
be rarely needed.
|
||||
fps (float): Framerate for frame-based formats (MicroDVD),
|
||||
for other formats this argument is ignored. Framerate might
|
||||
be detected from the file, in which case you don't need
|
||||
to specify it here (when given, this argument overrides
|
||||
autodetection).
|
||||
kwargs: Extra options for the parser.
|
||||
|
||||
Returns:
|
||||
SSAFile
|
||||
|
||||
Raises:
|
||||
IOError
|
||||
UnicodeDecodeError
|
||||
pysubs2.exceptions.UnknownFPSError
|
||||
pysubs2.exceptions.UnknownFormatIdentifierError
|
||||
pysubs2.exceptions.FormatAutodetectionError
|
||||
|
||||
Note:
|
||||
pysubs2 may autodetect subtitle format and/or framerate. These
|
||||
values are set as :attr:`SSAFile.format` and :attr:`SSAFile.fps`
|
||||
attributes.
|
||||
|
||||
Example:
|
||||
>>> subs1 = pysubs2.load("subrip-subtitles.srt")
|
||||
>>> subs2 = pysubs2.load("microdvd-subtitles.sub", fps=23.976)
|
||||
|
||||
"""
|
||||
with open(path, encoding=encoding) as fp:
|
||||
return cls.from_file(fp, format_, fps=fps, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, string, format_=None, fps=None, **kwargs):
|
||||
"""
|
||||
Load subtitle file from string.
|
||||
|
||||
See :meth:`SSAFile.load()` for full description.
|
||||
|
||||
Arguments:
|
||||
string (str): Subtitle file in a string. Note that the string
|
||||
must be Unicode (in Python 2).
|
||||
|
||||
Returns:
|
||||
SSAFile
|
||||
|
||||
Example:
|
||||
>>> text = '''
|
||||
... 1
|
||||
... 00:00:00,000 --> 00:00:05,000
|
||||
... An example SubRip file.
|
||||
... '''
|
||||
>>> subs = SSAFile.from_string(text)
|
||||
|
||||
"""
|
||||
fp = io.StringIO(string)
|
||||
return cls.from_file(fp, format_, fps=fps, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, fp, format_=None, fps=None, **kwargs):
|
||||
"""
|
||||
Read subtitle file from file object.
|
||||
|
||||
See :meth:`SSAFile.load()` for full description.
|
||||
|
||||
Note:
|
||||
This is a low-level method. Usually, one of :meth:`SSAFile.load()`
|
||||
or :meth:`SSAFile.from_string()` is preferable.
|
||||
|
||||
Arguments:
|
||||
fp (file object): A file object, ie. :class:`io.TextIOBase` instance.
|
||||
Note that the file must be opened in text mode (as opposed to binary).
|
||||
|
||||
Returns:
|
||||
SSAFile
|
||||
|
||||
"""
|
||||
if format_ is None:
|
||||
# Autodetect subtitle format, then read again using correct parser.
|
||||
# The file might be a pipe and we need to read it twice,
|
||||
# so just buffer everything.
|
||||
text = fp.read()
|
||||
fragment = text[:10000]
|
||||
format_ = autodetect_format(fragment)
|
||||
fp = io.StringIO(text)
|
||||
|
||||
impl = get_format_class(format_)
|
||||
subs = cls() # an empty subtitle file
|
||||
subs.format = format_
|
||||
subs.fps = fps
|
||||
impl.from_file(subs, fp, format_, fps=fps, **kwargs)
|
||||
return subs
|
||||
|
||||
def save(self, path, encoding="utf-8", format_=None, fps=None, **kwargs):
|
||||
"""
|
||||
Save subtitle file to given path.
|
||||
|
||||
Arguments:
|
||||
path (str): Path to subtitle file.
|
||||
encoding (str): Character encoding of output file.
|
||||
Defaults to UTF-8, which should be fine for most purposes.
|
||||
format_ (str): Optional, specifies desired subtitle format
|
||||
(eg. `"srt"`, `"ass"`). Otherwise, format is detected
|
||||
automatically from file extension. Thus, this argument
|
||||
is rarely needed.
|
||||
fps (float): Framerate for frame-based formats (MicroDVD),
|
||||
for other formats this argument is ignored. When omitted,
|
||||
:attr:`SSAFile.fps` value is used (ie. the framerate used
|
||||
for loading the file, if any). When the :class:`SSAFile`
|
||||
wasn't loaded from MicroDVD, or if you wish save it with
|
||||
different framerate, use this argument. See also
|
||||
:meth:`SSAFile.transform_framerate()` for fixing bad
|
||||
frame-based to time-based conversions.
|
||||
kwargs: Extra options for the writer.
|
||||
|
||||
Raises:
|
||||
IOError
|
||||
UnicodeEncodeError
|
||||
pysubs2.exceptions.UnknownFPSError
|
||||
pysubs2.exceptions.UnknownFormatIdentifierError
|
||||
pysubs2.exceptions.UnknownFileExtensionError
|
||||
|
||||
"""
|
||||
if format_ is None:
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
format_ = get_format_identifier(ext)
|
||||
|
||||
with open(path, "w", encoding=encoding) as fp:
|
||||
self.to_file(fp, format_, fps=fps, **kwargs)
|
||||
|
||||
def to_string(self, format_, fps=None, **kwargs):
|
||||
"""
|
||||
Get subtitle file as a string.
|
||||
|
||||
See :meth:`SSAFile.save()` for full description.
|
||||
|
||||
Returns:
|
||||
str
|
||||
|
||||
"""
|
||||
fp = io.StringIO()
|
||||
self.to_file(fp, format_, fps=fps, **kwargs)
|
||||
return fp.getvalue()
|
||||
|
||||
def to_file(self, fp, format_, fps=None, **kwargs):
|
||||
"""
|
||||
Write subtitle file to file object.
|
||||
|
||||
See :meth:`SSAFile.save()` for full description.
|
||||
|
||||
Note:
|
||||
This is a low-level method. Usually, one of :meth:`SSAFile.save()`
|
||||
or :meth:`SSAFile.to_string()` is preferable.
|
||||
|
||||
Arguments:
|
||||
fp (file object): A file object, ie. :class:`io.TextIOBase` instance.
|
||||
Note that the file must be opened in text mode (as opposed to binary).
|
||||
|
||||
"""
|
||||
impl = get_format_class(format_)
|
||||
impl.to_file(self, fp, format_, fps=fps, **kwargs)
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Retiming subtitles
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
def shift(self, h=0, m=0, s=0, ms=0, frames=None, fps=None):
|
||||
"""
|
||||
Shift all subtitles by constant time amount.
|
||||
|
||||
Shift may be time-based (the default) or frame-based. In the latter
|
||||
case, specify both frames and fps. h, m, s, ms will be ignored.
|
||||
|
||||
Arguments:
|
||||
h, m, s, ms: Integer or float values, may be positive or negative.
|
||||
frames (int): When specified, must be an integer number of frames.
|
||||
May be positive or negative. fps must be also specified.
|
||||
fps (float): When specified, must be a positive number.
|
||||
|
||||
Raises:
|
||||
ValueError: Invalid fps or missing number of frames.
|
||||
|
||||
"""
|
||||
delta = make_time(h=h, m=m, s=s, ms=ms, frames=frames, fps=fps)
|
||||
for line in self:
|
||||
line.start += delta
|
||||
line.end += delta
|
||||
|
||||
def transform_framerate(self, in_fps, out_fps):
|
||||
"""
|
||||
Rescale all timestamps by ratio of in_fps/out_fps.
|
||||
|
||||
Can be used to fix files converted from frame-based to time-based
|
||||
with wrongly assumed framerate.
|
||||
|
||||
Arguments:
|
||||
in_fps (float)
|
||||
out_fps (float)
|
||||
|
||||
Raises:
|
||||
ValueError: Non-positive framerate given.
|
||||
|
||||
"""
|
||||
if in_fps <= 0 or out_fps <= 0:
|
||||
raise ValueError("Framerates must be positive, cannot transform %f -> %f" % (in_fps, out_fps))
|
||||
|
||||
ratio = in_fps / out_fps
|
||||
for line in self:
|
||||
line.start = int(round(line.start * ratio))
|
||||
line.end = int(round(line.end * ratio))
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Working with styles
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
def rename_style(self, old_name, new_name):
|
||||
"""
|
||||
Rename a style, including references to it.
|
||||
|
||||
Arguments:
|
||||
old_name (str): Style to be renamed.
|
||||
new_name (str): New name for the style (must be unused).
|
||||
|
||||
Raises:
|
||||
KeyError: No style named old_name.
|
||||
ValueError: new_name is not a legal name (cannot use commas)
|
||||
or new_name is taken.
|
||||
|
||||
"""
|
||||
if old_name not in self.styles:
|
||||
raise KeyError("Style %r not found" % old_name)
|
||||
if new_name in self.styles:
|
||||
raise ValueError("There is already a style called %r" % new_name)
|
||||
if not is_valid_field_content(new_name):
|
||||
raise ValueError("%r is not a valid name" % new_name)
|
||||
|
||||
self.styles[new_name] = self.styles[old_name]
|
||||
del self.styles[old_name]
|
||||
|
||||
for line in self:
|
||||
# XXX also handle \r override tag
|
||||
if line.style == old_name:
|
||||
line.style = new_name
|
||||
|
||||
def import_styles(self, subs, overwrite=True):
|
||||
"""
|
||||
Merge in styles from other SSAFile.
|
||||
|
||||
Arguments:
|
||||
subs (SSAFile): Subtitle file imported from.
|
||||
overwrite (bool): On name conflict, use style from the other file
|
||||
(default: True).
|
||||
|
||||
"""
|
||||
if not isinstance(subs, SSAFile):
|
||||
raise TypeError("Must supply an SSAFile.")
|
||||
|
||||
for name, style in subs.styles.items():
|
||||
if name not in self.styles or overwrite:
|
||||
self.styles[name] = style
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Helper methods
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
def equals(self, other):
|
||||
"""
|
||||
Equality of two SSAFiles.
|
||||
|
||||
Compares :attr:`SSAFile.info`, :attr:`SSAFile.styles` and :attr:`SSAFile.events`.
|
||||
Order of entries in OrderedDicts does not matter. "ScriptType" key in info is
|
||||
considered an implementation detail and thus ignored.
|
||||
|
||||
Useful mostly in unit tests. Differences are logged at DEBUG level.
|
||||
|
||||
"""
|
||||
|
||||
if isinstance(other, SSAFile):
|
||||
for key in set(chain(self.info.keys(), other.info.keys())) - {"ScriptType"}:
|
||||
sv, ov = self.info.get(key), other.info.get(key)
|
||||
if sv is None:
|
||||
logging.debug("%r missing in self.info", key)
|
||||
return False
|
||||
elif ov is None:
|
||||
logging.debug("%r missing in other.info", key)
|
||||
return False
|
||||
elif sv != ov:
|
||||
logging.debug("info %r differs (self=%r, other=%r)", key, sv, ov)
|
||||
return False
|
||||
|
||||
for key in set(chain(self.styles.keys(), other.styles.keys())):
|
||||
sv, ov = self.styles.get(key), other.styles.get(key)
|
||||
if sv is None:
|
||||
logging.debug("%r missing in self.styles", key)
|
||||
return False
|
||||
elif ov is None:
|
||||
logging.debug("%r missing in other.styles", key)
|
||||
return False
|
||||
elif sv != ov:
|
||||
for k in sv.FIELDS:
|
||||
if getattr(sv, k) != getattr(ov, k): logging.debug("difference in field %r", k)
|
||||
logging.debug("style %r differs (self=%r, other=%r)", key, sv.as_dict(), ov.as_dict())
|
||||
return False
|
||||
|
||||
if len(self) != len(other):
|
||||
logging.debug("different # of subtitles (self=%d, other=%d)", len(self), len(other))
|
||||
return False
|
||||
|
||||
for i, (se, oe) in enumerate(zip(self.events, other.events)):
|
||||
if not se.equals(oe):
|
||||
for k in se.FIELDS:
|
||||
if getattr(se, k) != getattr(oe, k): logging.debug("difference in field %r", k)
|
||||
logging.debug("event %d differs (self=%r, other=%r)", i, se.as_dict(), oe.as_dict())
|
||||
return False
|
||||
|
||||
return True
|
||||
else:
|
||||
raise TypeError("Cannot compare to non-SSAFile object")
|
||||
|
||||
def __repr__(self):
|
||||
if self.events:
|
||||
max_time = max(ev.end for ev in self)
|
||||
s = "<SSAFile with %d events and %d styles, last timestamp %s>" % \
|
||||
(len(self), len(self.styles), ms_to_str(max_time))
|
||||
else:
|
||||
s = "<SSAFile with 0 events and %d styles>" % len(self.styles)
|
||||
|
||||
if not PY3: s = s.encode("utf-8")
|
||||
return s
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# MutableSequence implementation + sort()
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
def sort(self):
|
||||
"""Sort subtitles time-wise, in-place."""
|
||||
self.events.sort()
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self.events[item]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if isinstance(value, SSAEvent):
|
||||
self.events[key] = value
|
||||
else:
|
||||
raise TypeError("SSAFile.events must contain only SSAEvent objects")
|
||||
|
||||
def __delitem__(self, key):
|
||||
del self.events[key]
|
||||
|
||||
def __len__(self):
|
||||
return len(self.events)
|
||||
|
||||
def insert(self, index, value):
|
||||
if isinstance(value, SSAEvent):
|
||||
self.events.insert(index, value)
|
||||
else:
|
||||
raise TypeError("SSAFile.events must contain only SSAEvent objects")
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import unicode_literals
|
||||
from .common import Color, PY3
|
||||
|
||||
|
||||
class SSAStyle(object):
|
||||
"""
|
||||
A SubStation Style.
|
||||
|
||||
In SubStation, each subtitle (:class:`SSAEvent`) is associated with a style which defines its font, color, etc.
|
||||
Like a subtitle event, a style also consists of "fields"; see :attr:`SSAStyle.FIELDS` for a list
|
||||
(note the spelling, which is different from SubStation proper).
|
||||
|
||||
Subtitles and styles are connected via an :class:`SSAFile` they belong to. :attr:`SSAEvent.style` is a string
|
||||
which is (or should be) a key in the :attr:`SSAFile.styles` dict. Note that style name is stored separately;
|
||||
a given :class:`SSAStyle` instance has no particular name itself.
|
||||
|
||||
This class defines equality (equality of all fields).
|
||||
|
||||
"""
|
||||
DEFAULT_STYLE = None
|
||||
|
||||
#: All fields in SSAStyle.
|
||||
FIELDS = frozenset([
|
||||
"fontname", "fontsize", "primarycolor", "secondarycolor",
|
||||
"tertiarycolor", "outlinecolor", "backcolor",
|
||||
"bold", "italic", "underline", "strikeout",
|
||||
"scalex", "scaley", "spacing", "angle", "borderstyle",
|
||||
"outline", "shadow", "alignment",
|
||||
"marginl", "marginr", "marginv", "alphalevel", "encoding"
|
||||
])
|
||||
|
||||
def __init__(self, **fields):
|
||||
self.fontname = "Arial" #: Font name
|
||||
self.fontsize = 20.0 #: Font size (in pixels)
|
||||
self.primarycolor = Color(255, 255, 255, 0) #: Primary color (:class:`pysubs2.Color` instance)
|
||||
self.secondarycolor = Color(255, 0, 0, 0) #: Secondary color (:class:`pysubs2.Color` instance)
|
||||
self.tertiarycolor = Color(0, 0, 0, 0) #: Tertiary color (:class:`pysubs2.Color` instance)
|
||||
self.outlinecolor = Color(0, 0, 0, 0) #: Outline color (:class:`pysubs2.Color` instance)
|
||||
self.backcolor = Color(0, 0, 0, 0) #: Back, ie. shadow color (:class:`pysubs2.Color` instance)
|
||||
self.bold = False #: Bold
|
||||
self.italic = False #: Italic
|
||||
self.underline = False #: Underline (ASS only)
|
||||
self.strikeout = False #: Strikeout (ASS only)
|
||||
self.scalex = 100.0 #: Horizontal scaling (ASS only)
|
||||
self.scaley = 100.0 #: Vertical scaling (ASS only)
|
||||
self.spacing = 0.0 #: Letter spacing (ASS only)
|
||||
self.angle = 0.0 #: Rotation (ASS only)
|
||||
self.borderstyle = 1 #: Border style
|
||||
self.outline = 2.0 #: Outline width (in pixels)
|
||||
self.shadow = 2.0 #: Shadow depth (in pixels)
|
||||
self.alignment = 2 #: Numpad-style alignment, eg. 7 is "top left" (that is, ASS alignment semantics)
|
||||
self.marginl = 10 #: Left margin (in pixels)
|
||||
self.marginr = 10 #: Right margin (in pixels)
|
||||
self.marginv = 10 #: Vertical margin (in pixels)
|
||||
self.alphalevel = 0 #: Old, unused SSA-only field
|
||||
self.encoding = 1 #: Charset
|
||||
|
||||
for k, v in fields.items():
|
||||
if k in self.FIELDS:
|
||||
setattr(self, k, v)
|
||||
else:
|
||||
raise ValueError("SSAStyle has no field named %r" % k)
|
||||
|
||||
def copy(self):
|
||||
return SSAStyle(**self.as_dict())
|
||||
|
||||
def as_dict(self):
|
||||
return {field: getattr(self, field) for field in self.FIELDS}
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.as_dict() == other.as_dict()
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self == other
|
||||
|
||||
def __repr__(self):
|
||||
s = "<SSAStyle "
|
||||
s += "%rpx " % self.fontsize
|
||||
if self.bold: s += "bold "
|
||||
if self.italic: s += "italic "
|
||||
s += "'%s'>" % self.fontname
|
||||
if not PY3: s = s.encode("utf-8")
|
||||
return s
|
||||
|
||||
|
||||
SSAStyle.DEFAULT_STYLE = SSAStyle()
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import re
|
||||
from .formatbase import FormatBase
|
||||
from .ssaevent import SSAEvent
|
||||
from .ssastyle import SSAStyle
|
||||
from .substation import parse_tags
|
||||
from .time import ms_to_times, make_time, TIMESTAMP, timestamp_to_ms
|
||||
|
||||
#: Largest timestamp allowed in SubRip, ie. 99:59:59,999.
|
||||
MAX_REPRESENTABLE_TIME = make_time(h=100) - 1
|
||||
|
||||
def ms_to_timestamp(ms):
|
||||
"""Convert ms to 'HH:MM:SS,mmm'"""
|
||||
# XXX throw on overflow/underflow?
|
||||
if ms < 0: ms = 0
|
||||
if ms > MAX_REPRESENTABLE_TIME: ms = MAX_REPRESENTABLE_TIME
|
||||
h, m, s, ms = ms_to_times(ms)
|
||||
return "%02d:%02d:%02d,%03d" % (h, m, s, ms)
|
||||
|
||||
|
||||
class SubripFormat(FormatBase):
|
||||
@classmethod
|
||||
def guess_format(cls, text):
|
||||
if "[Script Info]" in text or "[V4+ Styles]" in text:
|
||||
# disambiguation vs. SSA/ASS
|
||||
return None
|
||||
|
||||
for line in text.splitlines():
|
||||
if len(TIMESTAMP.findall(line)) == 2:
|
||||
return "srt"
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, subs, fp, format_, **kwargs):
|
||||
timestamps = [] # (start, end)
|
||||
following_lines = [] # contains lists of lines following each timestamp
|
||||
|
||||
for line in fp:
|
||||
stamps = TIMESTAMP.findall(line)
|
||||
if len(stamps) == 2: # timestamp line
|
||||
start, end = map(timestamp_to_ms, stamps)
|
||||
timestamps.append((start, end))
|
||||
following_lines.append([])
|
||||
else:
|
||||
if timestamps:
|
||||
following_lines[-1].append(line)
|
||||
|
||||
def prepare_text(lines):
|
||||
s = "".join(lines).strip()
|
||||
s = re.sub(r"\n+ *\d+ *$", "", s) # strip number of next subtitle
|
||||
s = re.sub(r"< *i *>", r"{\i1}", s)
|
||||
s = re.sub(r"< */ *i *>", r"{\i0}", s)
|
||||
s = re.sub(r"< *s *>", r"{\s1}", s)
|
||||
s = re.sub(r"< */ *s *>", r"{\s0}", s)
|
||||
s = re.sub(r"< *u *>", "{\\u1}", s) # not r" for Python 2.7 compat, triggers unicodeescape
|
||||
s = re.sub(r"< */ *u *>", "{\\u0}", s)
|
||||
s = re.sub(r"< */? *[a-zA-Z][^>]*>", "", s) # strip other HTML tags
|
||||
s = re.sub(r"\n", r"\N", s) # convert newlines
|
||||
return s
|
||||
|
||||
subs.events = [SSAEvent(start=start, end=end, text=prepare_text(lines))
|
||||
for (start, end), lines in zip(timestamps, following_lines)]
|
||||
|
||||
@classmethod
|
||||
def to_file(cls, subs, fp, format_, **kwargs):
|
||||
def prepare_text(text, style):
|
||||
body = []
|
||||
for fragment, sty in parse_tags(text, style, subs.styles):
|
||||
fragment = fragment.replace(r"\h", " ")
|
||||
fragment = fragment.replace(r"\n", "\n")
|
||||
fragment = fragment.replace(r"\N", "\n")
|
||||
if sty.italic: fragment = "<i>%s</i>" % fragment
|
||||
if sty.underline: fragment = "<u>%s</u>" % fragment
|
||||
if sty.strikeout: fragment = "<s>%s</s>" % fragment
|
||||
body.append(fragment)
|
||||
|
||||
return re.sub("\n+", "\n", "".join(body).strip())
|
||||
|
||||
visible_lines = (line for line in subs if not line.is_comment)
|
||||
|
||||
for i, line in enumerate(visible_lines, 1):
|
||||
start = ms_to_timestamp(line.start)
|
||||
end = ms_to_timestamp(line.end)
|
||||
text = prepare_text(line.text, subs.styles.get(line.style, SSAStyle.DEFAULT_STYLE))
|
||||
|
||||
print("%d" % i, file=fp) # Python 2.7 compat
|
||||
print(start, "-->", end, file=fp)
|
||||
print(text, end="\n\n", file=fp)
|
||||
@@ -0,0 +1,255 @@
|
||||
from __future__ import print_function, division, unicode_literals
|
||||
import re
|
||||
from numbers import Number
|
||||
from .formatbase import FormatBase
|
||||
from .ssaevent import SSAEvent
|
||||
from .ssastyle import SSAStyle
|
||||
from .common import text_type, Color
|
||||
from .time import make_time, ms_to_times, timestamp_to_ms, TIMESTAMP
|
||||
|
||||
SSA_ALIGNMENT = (1, 2, 3, 9, 10, 11, 5, 6, 7)
|
||||
|
||||
def ass_to_ssa_alignment(i):
|
||||
return SSA_ALIGNMENT[i-1]
|
||||
|
||||
def ssa_to_ass_alignment(i):
|
||||
return SSA_ALIGNMENT.index(i) + 1
|
||||
|
||||
SECTION_HEADING = re.compile(r"^.{,3}\[[^\]]+\]") # allow for UTF-8 BOM, which is 3 bytes
|
||||
|
||||
STYLE_FORMAT_LINE = {
|
||||
"ass": "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic,"
|
||||
" Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment,"
|
||||
" MarginL, MarginR, MarginV, Encoding",
|
||||
"ssa": "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic,"
|
||||
" BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding"
|
||||
}
|
||||
|
||||
STYLE_FIELDS = {
|
||||
"ass": ["fontname", "fontsize", "primarycolor", "secondarycolor", "outlinecolor", "backcolor", "bold", "italic",
|
||||
"underline", "strikeout", "scalex", "scaley", "spacing", "angle", "borderstyle", "outline", "shadow",
|
||||
"alignment", "marginl", "marginr", "marginv", "encoding"],
|
||||
"ssa": ["fontname", "fontsize", "primarycolor", "secondarycolor", "tertiarycolor", "backcolor", "bold", "italic",
|
||||
"borderstyle", "outline", "shadow", "alignment", "marginl", "marginr", "marginv", "alphalevel", "encoding"]
|
||||
}
|
||||
|
||||
EVENT_FORMAT_LINE = {
|
||||
"ass": "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
||||
"ssa": "Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"
|
||||
}
|
||||
|
||||
EVENT_FIELDS = {
|
||||
"ass": ["layer", "start", "end", "style", "name", "marginl", "marginr", "marginv", "effect", "text"],
|
||||
"ssa": ["marked", "start", "end", "style", "name", "marginl", "marginr", "marginv", "effect", "text"]
|
||||
}
|
||||
|
||||
#: Largest timestamp allowed in SubStation, ie. 9:59:59.99.
|
||||
MAX_REPRESENTABLE_TIME = make_time(h=10) - 10
|
||||
|
||||
def ms_to_timestamp(ms):
|
||||
"""Convert ms to 'H:MM:SS.cc'"""
|
||||
# XXX throw on overflow/underflow?
|
||||
if ms < 0: ms = 0
|
||||
if ms > MAX_REPRESENTABLE_TIME: ms = MAX_REPRESENTABLE_TIME
|
||||
h, m, s, ms = ms_to_times(ms)
|
||||
return "%01d:%02d:%02d.%02d" % (h, m, s, ms//10)
|
||||
|
||||
def color_to_ass_rgba(c):
|
||||
return "&H%08X" % ((c.a << 24) | (c.b << 16) | (c.g << 8) | c.r)
|
||||
|
||||
def color_to_ssa_rgb(c):
|
||||
return "%d" % ((c.b << 16) | (c.g << 8) | c.r)
|
||||
|
||||
def ass_rgba_to_color(s):
|
||||
x = int(s[2:], base=16)
|
||||
r = x & 0xff
|
||||
g = (x >> 8) & 0xff
|
||||
b = (x >> 16) & 0xff
|
||||
a = (x >> 24) & 0xff
|
||||
return Color(r, g, b, a)
|
||||
|
||||
def ssa_rgb_to_color(s):
|
||||
x = int(s)
|
||||
r = x & 0xff
|
||||
g = (x >> 8) & 0xff
|
||||
b = (x >> 16) & 0xff
|
||||
return Color(r, g, b)
|
||||
|
||||
def is_valid_field_content(s):
|
||||
"""
|
||||
Returns True if string s can be stored in a SubStation field.
|
||||
|
||||
Fields are written in CSV-like manner, thus commas and/or newlines
|
||||
are not acceptable in the string.
|
||||
|
||||
"""
|
||||
return "\n" not in s and "," not in s
|
||||
|
||||
|
||||
def parse_tags(text, style=SSAStyle.DEFAULT_STYLE, styles={}):
|
||||
"""
|
||||
Split text into fragments with computed SSAStyles.
|
||||
|
||||
Returns list of tuples (fragment, style), where fragment is a part of text
|
||||
between two brace-delimited override sequences, and style is the computed
|
||||
styling of the fragment, ie. the original style modified by all override
|
||||
sequences before the fragment.
|
||||
|
||||
Newline and non-breakable space overrides are left as-is.
|
||||
|
||||
Supported override tags:
|
||||
|
||||
- i, b, u, s
|
||||
- r (with or without style name)
|
||||
|
||||
"""
|
||||
|
||||
fragments = SSAEvent.OVERRIDE_SEQUENCE.split(text)
|
||||
if len(fragments) == 1:
|
||||
return [(text, style)]
|
||||
|
||||
def apply_overrides(all_overrides):
|
||||
s = style.copy()
|
||||
for tag in re.findall(r"\\[ibus][10]|\\r[a-zA-Z_0-9 ]*", all_overrides):
|
||||
if tag == r"\r":
|
||||
s = style.copy() # reset to original line style
|
||||
elif tag.startswith(r"\r"):
|
||||
name = tag[2:]
|
||||
if name in styles:
|
||||
s = styles[name].copy() # reset to named style
|
||||
else:
|
||||
if "i" in tag: s.italic = "1" in tag
|
||||
elif "b" in tag: s.bold = "1" in tag
|
||||
elif "u" in tag: s.underline = "1" in tag
|
||||
elif "s" in tag: s.strikeout = "1" in tag
|
||||
return s
|
||||
|
||||
overrides = SSAEvent.OVERRIDE_SEQUENCE.findall(text)
|
||||
overrides_prefix_sum = ["".join(overrides[:i]) for i in range(len(overrides) + 1)]
|
||||
computed_styles = map(apply_overrides, overrides_prefix_sum)
|
||||
return list(zip(fragments, computed_styles))
|
||||
|
||||
|
||||
NOTICE = "Script generated by pysubs2\nhttps://pypi.python.org/pypi/pysubs2"
|
||||
|
||||
class SubstationFormat(FormatBase):
|
||||
@classmethod
|
||||
def guess_format(cls, text):
|
||||
if "V4+ Styles" in text:
|
||||
return "ass"
|
||||
elif "V4 Styles" in text:
|
||||
return "ssa"
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, subs, fp, format_, **kwargs):
|
||||
|
||||
def string_to_field(f, v):
|
||||
if f in {"start", "end"}:
|
||||
return timestamp_to_ms(TIMESTAMP.match(v).groups())
|
||||
elif "color" in f:
|
||||
if format_ == "ass":
|
||||
return ass_rgba_to_color(v)
|
||||
else:
|
||||
return ssa_rgb_to_color(v)
|
||||
elif f in {"bold", "underline", "italic", "strikeout"}:
|
||||
return v == "-1"
|
||||
elif f in {"borderstyle", "encoding", "marginl", "marginr", "marginv", "layer", "alphalevel"}:
|
||||
return int(v)
|
||||
elif f in {"fontsize", "scalex", "scaley", "spacing", "angle", "outline", "shadow"}:
|
||||
return float(v)
|
||||
elif f == "marked":
|
||||
return v.endswith("1")
|
||||
elif f == "alignment":
|
||||
i = int(v)
|
||||
if format_ == "ass":
|
||||
return i
|
||||
else:
|
||||
return ssa_to_ass_alignment(i)
|
||||
else:
|
||||
return v
|
||||
|
||||
subs.info.clear()
|
||||
subs.aegisub_project.clear()
|
||||
subs.styles.clear()
|
||||
|
||||
inside_info_section = False
|
||||
inside_aegisub_section = False
|
||||
|
||||
for line in fp:
|
||||
line = line.strip()
|
||||
|
||||
if SECTION_HEADING.match(line):
|
||||
inside_info_section = "Info" in line
|
||||
inside_aegisub_section = "Aegisub" in line
|
||||
elif inside_info_section or inside_aegisub_section:
|
||||
if line.startswith(";"): continue # skip comments
|
||||
try:
|
||||
k, v = line.split(": ", 1)
|
||||
if inside_info_section:
|
||||
subs.info[k] = v
|
||||
elif inside_aegisub_section:
|
||||
subs.aegisub_project[k] = v
|
||||
except ValueError:
|
||||
pass
|
||||
elif line.startswith("Style:"):
|
||||
_, rest = line.split(": ", 1)
|
||||
buf = rest.strip().split(",")
|
||||
name, raw_fields = buf[0], buf[1:] # splat workaround for Python 2.7
|
||||
field_dict = {f: string_to_field(f, v) for f, v in zip(STYLE_FIELDS[format_], raw_fields)}
|
||||
sty = SSAStyle(**field_dict)
|
||||
subs.styles[name] = sty
|
||||
elif line.startswith("Dialogue:") or line.startswith("Comment:"):
|
||||
ev_type, rest = line.split(": ", 1)
|
||||
raw_fields = rest.strip().split(",", len(EVENT_FIELDS[format_])-1)
|
||||
field_dict = {f: string_to_field(f, v) for f, v in zip(EVENT_FIELDS[format_], raw_fields)}
|
||||
field_dict["type"] = ev_type
|
||||
ev = SSAEvent(**field_dict)
|
||||
subs.events.append(ev)
|
||||
|
||||
|
||||
@classmethod
|
||||
def to_file(cls, subs, fp, format_, header_notice=NOTICE, **kwargs):
|
||||
print("[Script Info]", file=fp)
|
||||
for line in header_notice.splitlines(False):
|
||||
print(";", line, file=fp)
|
||||
|
||||
subs.info["ScriptType"] = "v4.00+" if format_ == "ass" else "v4.00"
|
||||
for k, v in subs.info.items():
|
||||
print(k, v, sep=": ", file=fp)
|
||||
|
||||
if subs.aegisub_project:
|
||||
print("\n[Aegisub Project Garbage]", file=fp)
|
||||
for k, v in subs.aegisub_project.items():
|
||||
print(k, v, sep=": ", file=fp)
|
||||
|
||||
def field_to_string(f, v):
|
||||
if f in {"start", "end"}:
|
||||
return ms_to_timestamp(v)
|
||||
elif f == "marked":
|
||||
return "Marked=%d" % v
|
||||
elif f == "alignment" and format_ == "ssa":
|
||||
return text_type(ass_to_ssa_alignment(v))
|
||||
elif isinstance(v, bool):
|
||||
return "-1" if v else "0"
|
||||
elif isinstance(v, (text_type, Number)):
|
||||
return text_type(v)
|
||||
elif isinstance(v, Color):
|
||||
if format_ == "ass":
|
||||
return color_to_ass_rgba(v)
|
||||
else:
|
||||
return color_to_ssa_rgb(v)
|
||||
else:
|
||||
raise TypeError("Unexpected type when writing a SubStation field")
|
||||
|
||||
print("\n[V4+ Styles]" if format_ == "ass" else "\n[V4 Styles]", file=fp)
|
||||
print(STYLE_FORMAT_LINE[format_], file=fp)
|
||||
for name, sty in subs.styles.items():
|
||||
fields = [field_to_string(f, getattr(sty, f)) for f in STYLE_FIELDS[format_]]
|
||||
print("Style: %s" % name, *fields, sep=",", file=fp)
|
||||
|
||||
print("\n[Events]", file=fp)
|
||||
print(EVENT_FORMAT_LINE[format_], file=fp)
|
||||
for ev in subs.events:
|
||||
fields = [field_to_string(f, getattr(ev, f)) for f in EVENT_FIELDS[format_]]
|
||||
print(ev.type, end=": ", file=fp)
|
||||
print(*fields, sep=",", file=fp)
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import division
|
||||
|
||||
from collections import namedtuple
|
||||
import re
|
||||
|
||||
|
||||
#: Pattern that matches both SubStation and SubRip timestamps.
|
||||
TIMESTAMP = re.compile(r"(\d{1,2}):(\d{2}):(\d{2})[.,](\d{2,3})")
|
||||
|
||||
Times = namedtuple("Times", ["h", "m", "s", "ms"])
|
||||
|
||||
def make_time(h=0, m=0, s=0, ms=0, frames=None, fps=None):
|
||||
"""
|
||||
Convert time to milliseconds.
|
||||
|
||||
See :func:`pysubs2.time.times_to_ms()`. When both frames and fps are specified,
|
||||
:func:`pysubs2.time.frames_to_ms()` is called instead.
|
||||
|
||||
Raises:
|
||||
ValueError: Invalid fps, or one of frames/fps is missing.
|
||||
|
||||
Example:
|
||||
>>> make_time(s=1.5)
|
||||
1500
|
||||
>>> make_time(frames=50, fps=25)
|
||||
2000
|
||||
|
||||
"""
|
||||
if frames is None and fps is None:
|
||||
return times_to_ms(h, m, s, ms)
|
||||
elif frames is not None and fps is not None:
|
||||
return frames_to_ms(frames, fps)
|
||||
else:
|
||||
raise ValueError("Both fps and frames must be specified")
|
||||
|
||||
def timestamp_to_ms(groups):
|
||||
"""
|
||||
Convert groups from :data:`pysubs2.time.TIMESTAMP` match to milliseconds.
|
||||
|
||||
Example:
|
||||
>>> timestamp_to_ms(TIMESTAMP.match("0:00:00.42").groups())
|
||||
420
|
||||
|
||||
"""
|
||||
h, m, s, frac = map(int, groups)
|
||||
ms = frac * 10**(3 - len(groups[-1]))
|
||||
ms += s * 1000
|
||||
ms += m * 60000
|
||||
ms += h * 3600000
|
||||
return ms
|
||||
|
||||
def times_to_ms(h=0, m=0, s=0, ms=0):
|
||||
"""
|
||||
Convert hours, minutes, seconds to milliseconds.
|
||||
|
||||
Arguments may be positive or negative, int or float,
|
||||
need not be normalized (``s=120`` is okay).
|
||||
|
||||
Returns:
|
||||
Number of milliseconds (rounded to int).
|
||||
|
||||
"""
|
||||
ms += s * 1000
|
||||
ms += m * 60000
|
||||
ms += h * 3600000
|
||||
return int(round(ms))
|
||||
|
||||
def frames_to_ms(frames, fps):
|
||||
"""
|
||||
Convert frame-based duration to milliseconds.
|
||||
|
||||
Arguments:
|
||||
frames: Number of frames (should be int).
|
||||
fps: Framerate (must be a positive number, eg. 23.976).
|
||||
|
||||
Returns:
|
||||
Number of milliseconds (rounded to int).
|
||||
|
||||
Raises:
|
||||
ValueError: fps was negative or zero.
|
||||
|
||||
"""
|
||||
if fps <= 0:
|
||||
raise ValueError("Framerate must be positive number (%f)." % fps)
|
||||
|
||||
return int(round(frames * (1000 / fps)))
|
||||
|
||||
def ms_to_frames(ms, fps):
|
||||
"""
|
||||
Convert milliseconds to number of frames.
|
||||
|
||||
Arguments:
|
||||
ms: Number of milliseconds (may be int, float or other numeric class).
|
||||
fps: Framerate (must be a positive number, eg. 23.976).
|
||||
|
||||
Returns:
|
||||
Number of frames (int).
|
||||
|
||||
Raises:
|
||||
ValueError: fps was negative or zero.
|
||||
|
||||
"""
|
||||
if fps <= 0:
|
||||
raise ValueError("Framerate must be positive number (%f)." % fps)
|
||||
|
||||
return int(round((ms / 1000) * fps))
|
||||
|
||||
def ms_to_times(ms):
|
||||
"""
|
||||
Convert milliseconds to normalized tuple (h, m, s, ms).
|
||||
|
||||
Arguments:
|
||||
ms: Number of milliseconds (may be int, float or other numeric class).
|
||||
Should be non-negative.
|
||||
|
||||
Returns:
|
||||
Named tuple (h, m, s, ms) of ints.
|
||||
Invariants: ``ms in range(1000) and s in range(60) and m in range(60)``
|
||||
|
||||
"""
|
||||
ms = int(round(ms))
|
||||
h, ms = divmod(ms, 3600000)
|
||||
m, ms = divmod(ms, 60000)
|
||||
s, ms = divmod(ms, 1000)
|
||||
return Times(h, m, s, ms)
|
||||
|
||||
def ms_to_str(ms, fractions=False):
|
||||
"""
|
||||
Prettyprint milliseconds to [-]H:MM:SS[.mmm]
|
||||
|
||||
Handles huge and/or negative times. Non-negative times with ``fractions=True``
|
||||
are matched by :data:`pysubs2.time.TIMESTAMP`.
|
||||
|
||||
Arguments:
|
||||
ms: Number of milliseconds (int, float or other numeric class).
|
||||
fractions: Whether to print up to millisecond precision.
|
||||
|
||||
Returns:
|
||||
str
|
||||
|
||||
"""
|
||||
sgn = "-" if ms < 0 else ""
|
||||
h, m, s, ms = ms_to_times(abs(ms))
|
||||
if fractions:
|
||||
return sgn + "{:01d}:{:02d}:{:02d}.{:03d}".format(h, m, s, ms)
|
||||
else:
|
||||
return sgn + "{:01d}:{:02d}:{:02d}".format(h, m, s)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Utilities for writing code that runs on Python 2 and 3"""
|
||||
|
||||
# Copyright (c) 2010-2014 Benjamin Peterson
|
||||
# Copyright (c) 2010-2015 Benjamin Peterson
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -20,17 +20,22 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import functools
|
||||
import itertools
|
||||
import operator
|
||||
import sys
|
||||
import types
|
||||
|
||||
__author__ = "Benjamin Peterson <benjamin@python.org>"
|
||||
__version__ = "1.5.2"
|
||||
__version__ = "1.10.0"
|
||||
|
||||
|
||||
# Useful for very coarse version differentiation.
|
||||
PY2 = sys.version_info[0] == 2
|
||||
PY3 = sys.version_info[0] == 3
|
||||
PY34 = sys.version_info[0:2] >= (3, 4)
|
||||
|
||||
if PY3:
|
||||
string_types = str,
|
||||
@@ -53,6 +58,7 @@ else:
|
||||
else:
|
||||
# It's possible to have sizeof(long) != sizeof(Py_ssize_t).
|
||||
class X(object):
|
||||
|
||||
def __len__(self):
|
||||
return 1 << 31
|
||||
try:
|
||||
@@ -84,9 +90,13 @@ class _LazyDescr(object):
|
||||
|
||||
def __get__(self, obj, tp):
|
||||
result = self._resolve()
|
||||
setattr(obj, self.name, result) # Invokes __set__.
|
||||
# This is a bit ugly, but it avoids running this again.
|
||||
delattr(obj.__class__, self.name)
|
||||
setattr(obj, self.name, result) # Invokes __set__.
|
||||
try:
|
||||
# This is a bit ugly, but it avoids running this again by
|
||||
# removing this descriptor.
|
||||
delattr(obj.__class__, self.name)
|
||||
except AttributeError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
@@ -105,14 +115,6 @@ class MovedModule(_LazyDescr):
|
||||
return _import_module(self.mod)
|
||||
|
||||
def __getattr__(self, attr):
|
||||
# Hack around the Django autoreloader. The reloader tries to get
|
||||
# __file__ or __name__ of every module in sys.modules. This doesn't work
|
||||
# well if this MovedModule is for an module that is unavailable on this
|
||||
# machine (like winreg on Unix systems). Thus, we pretend __file__ and
|
||||
# __name__ don't exist if the module hasn't been loaded yet. See issues
|
||||
# #51 and #53.
|
||||
if attr in ("__file__", "__name__") and self.mod not in sys.modules:
|
||||
raise AttributeError
|
||||
_module = self._resolve()
|
||||
value = getattr(_module, attr)
|
||||
setattr(self, attr, value)
|
||||
@@ -159,9 +161,75 @@ class MovedAttribute(_LazyDescr):
|
||||
return getattr(module, self.attr)
|
||||
|
||||
|
||||
class _SixMetaPathImporter(object):
|
||||
|
||||
"""
|
||||
A meta path importer to import six.moves and its submodules.
|
||||
|
||||
This class implements a PEP302 finder and loader. It should be compatible
|
||||
with Python 2.5 and all existing versions of Python3
|
||||
"""
|
||||
|
||||
def __init__(self, six_module_name):
|
||||
self.name = six_module_name
|
||||
self.known_modules = {}
|
||||
|
||||
def _add_module(self, mod, *fullnames):
|
||||
for fullname in fullnames:
|
||||
self.known_modules[self.name + "." + fullname] = mod
|
||||
|
||||
def _get_module(self, fullname):
|
||||
return self.known_modules[self.name + "." + fullname]
|
||||
|
||||
def find_module(self, fullname, path=None):
|
||||
if fullname in self.known_modules:
|
||||
return self
|
||||
return None
|
||||
|
||||
def __get_module(self, fullname):
|
||||
try:
|
||||
return self.known_modules[fullname]
|
||||
except KeyError:
|
||||
raise ImportError("This loader does not know module " + fullname)
|
||||
|
||||
def load_module(self, fullname):
|
||||
try:
|
||||
# in case of a reload
|
||||
return sys.modules[fullname]
|
||||
except KeyError:
|
||||
pass
|
||||
mod = self.__get_module(fullname)
|
||||
if isinstance(mod, MovedModule):
|
||||
mod = mod._resolve()
|
||||
else:
|
||||
mod.__loader__ = self
|
||||
sys.modules[fullname] = mod
|
||||
return mod
|
||||
|
||||
def is_package(self, fullname):
|
||||
"""
|
||||
Return true, if the named module is a package.
|
||||
|
||||
We need this method to get correct spec objects with
|
||||
Python 3.4 (see PEP451)
|
||||
"""
|
||||
return hasattr(self.__get_module(fullname), "__path__")
|
||||
|
||||
def get_code(self, fullname):
|
||||
"""Return None
|
||||
|
||||
Required, if is_package is implemented"""
|
||||
self.__get_module(fullname) # eventually raises ImportError
|
||||
return None
|
||||
get_source = get_code # same as get_code
|
||||
|
||||
_importer = _SixMetaPathImporter(__name__)
|
||||
|
||||
|
||||
class _MovedItems(_LazyModule):
|
||||
|
||||
"""Lazy loading of moved objects"""
|
||||
__path__ = [] # mark as package
|
||||
|
||||
|
||||
_moved_attributes = [
|
||||
@@ -169,26 +237,33 @@ _moved_attributes = [
|
||||
MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"),
|
||||
MovedAttribute("filterfalse", "itertools", "itertools", "ifilterfalse", "filterfalse"),
|
||||
MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"),
|
||||
MovedAttribute("intern", "__builtin__", "sys"),
|
||||
MovedAttribute("map", "itertools", "builtins", "imap", "map"),
|
||||
MovedAttribute("getcwd", "os", "os", "getcwdu", "getcwd"),
|
||||
MovedAttribute("getcwdb", "os", "os", "getcwd", "getcwdb"),
|
||||
MovedAttribute("range", "__builtin__", "builtins", "xrange", "range"),
|
||||
MovedAttribute("reload_module", "__builtin__", "imp", "reload"),
|
||||
MovedAttribute("reload_module", "__builtin__", "importlib" if PY34 else "imp", "reload"),
|
||||
MovedAttribute("reduce", "__builtin__", "functools"),
|
||||
MovedAttribute("shlex_quote", "pipes", "shlex", "quote"),
|
||||
MovedAttribute("StringIO", "StringIO", "io"),
|
||||
MovedAttribute("UserDict", "UserDict", "collections"),
|
||||
MovedAttribute("UserList", "UserList", "collections"),
|
||||
MovedAttribute("UserString", "UserString", "collections"),
|
||||
MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"),
|
||||
MovedAttribute("zip", "itertools", "builtins", "izip", "zip"),
|
||||
MovedAttribute("zip_longest", "itertools", "itertools", "izip_longest", "zip_longest"),
|
||||
|
||||
MovedModule("builtins", "__builtin__"),
|
||||
MovedModule("configparser", "ConfigParser"),
|
||||
MovedModule("copyreg", "copy_reg"),
|
||||
MovedModule("dbm_gnu", "gdbm", "dbm.gnu"),
|
||||
MovedModule("_dummy_thread", "dummy_thread", "_dummy_thread"),
|
||||
MovedModule("http_cookiejar", "cookielib", "http.cookiejar"),
|
||||
MovedModule("http_cookies", "Cookie", "http.cookies"),
|
||||
MovedModule("html_entities", "htmlentitydefs", "html.entities"),
|
||||
MovedModule("html_parser", "HTMLParser", "html.parser"),
|
||||
MovedModule("http_client", "httplib", "http.client"),
|
||||
MovedModule("email_mime_multipart", "email.MIMEMultipart", "email.mime.multipart"),
|
||||
MovedModule("email_mime_nonmultipart", "email.MIMENonMultipart", "email.mime.nonmultipart"),
|
||||
MovedModule("email_mime_text", "email.MIMEText", "email.mime.text"),
|
||||
MovedModule("email_mime_base", "email.MIMEBase", "email.mime.base"),
|
||||
MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"),
|
||||
@@ -222,25 +297,34 @@ _moved_attributes = [
|
||||
MovedModule("urllib", __name__ + ".moves.urllib", __name__ + ".moves.urllib"),
|
||||
MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"),
|
||||
MovedModule("xmlrpc_client", "xmlrpclib", "xmlrpc.client"),
|
||||
MovedModule("winreg", "_winreg"),
|
||||
MovedModule("xmlrpc_server", "SimpleXMLRPCServer", "xmlrpc.server"),
|
||||
]
|
||||
# Add windows specific modules.
|
||||
if sys.platform == "win32":
|
||||
_moved_attributes += [
|
||||
MovedModule("winreg", "_winreg"),
|
||||
]
|
||||
|
||||
for attr in _moved_attributes:
|
||||
setattr(_MovedItems, attr.name, attr)
|
||||
if isinstance(attr, MovedModule):
|
||||
sys.modules[__name__ + ".moves." + attr.name] = attr
|
||||
_importer._add_module(attr, "moves." + attr.name)
|
||||
del attr
|
||||
|
||||
_MovedItems._moved_attributes = _moved_attributes
|
||||
|
||||
moves = sys.modules[__name__ + ".moves"] = _MovedItems(__name__ + ".moves")
|
||||
moves = _MovedItems(__name__ + ".moves")
|
||||
_importer._add_module(moves, "moves")
|
||||
|
||||
|
||||
class Module_six_moves_urllib_parse(_LazyModule):
|
||||
|
||||
"""Lazy loading of moved objects in six.moves.urllib_parse"""
|
||||
|
||||
|
||||
_urllib_parse_moved_attributes = [
|
||||
MovedAttribute("ParseResult", "urlparse", "urllib.parse"),
|
||||
MovedAttribute("SplitResult", "urlparse", "urllib.parse"),
|
||||
MovedAttribute("parse_qs", "urlparse", "urllib.parse"),
|
||||
MovedAttribute("parse_qsl", "urlparse", "urllib.parse"),
|
||||
MovedAttribute("urldefrag", "urlparse", "urllib.parse"),
|
||||
@@ -254,6 +338,14 @@ _urllib_parse_moved_attributes = [
|
||||
MovedAttribute("unquote", "urllib", "urllib.parse"),
|
||||
MovedAttribute("unquote_plus", "urllib", "urllib.parse"),
|
||||
MovedAttribute("urlencode", "urllib", "urllib.parse"),
|
||||
MovedAttribute("splitquery", "urllib", "urllib.parse"),
|
||||
MovedAttribute("splittag", "urllib", "urllib.parse"),
|
||||
MovedAttribute("splituser", "urllib", "urllib.parse"),
|
||||
MovedAttribute("uses_fragment", "urlparse", "urllib.parse"),
|
||||
MovedAttribute("uses_netloc", "urlparse", "urllib.parse"),
|
||||
MovedAttribute("uses_params", "urlparse", "urllib.parse"),
|
||||
MovedAttribute("uses_query", "urlparse", "urllib.parse"),
|
||||
MovedAttribute("uses_relative", "urlparse", "urllib.parse"),
|
||||
]
|
||||
for attr in _urllib_parse_moved_attributes:
|
||||
setattr(Module_six_moves_urllib_parse, attr.name, attr)
|
||||
@@ -261,10 +353,12 @@ del attr
|
||||
|
||||
Module_six_moves_urllib_parse._moved_attributes = _urllib_parse_moved_attributes
|
||||
|
||||
sys.modules[__name__ + ".moves.urllib_parse"] = sys.modules[__name__ + ".moves.urllib.parse"] = Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse")
|
||||
_importer._add_module(Module_six_moves_urllib_parse(__name__ + ".moves.urllib_parse"),
|
||||
"moves.urllib_parse", "moves.urllib.parse")
|
||||
|
||||
|
||||
class Module_six_moves_urllib_error(_LazyModule):
|
||||
|
||||
"""Lazy loading of moved objects in six.moves.urllib_error"""
|
||||
|
||||
|
||||
@@ -279,10 +373,12 @@ del attr
|
||||
|
||||
Module_six_moves_urllib_error._moved_attributes = _urllib_error_moved_attributes
|
||||
|
||||
sys.modules[__name__ + ".moves.urllib_error"] = sys.modules[__name__ + ".moves.urllib.error"] = Module_six_moves_urllib_error(__name__ + ".moves.urllib.error")
|
||||
_importer._add_module(Module_six_moves_urllib_error(__name__ + ".moves.urllib.error"),
|
||||
"moves.urllib_error", "moves.urllib.error")
|
||||
|
||||
|
||||
class Module_six_moves_urllib_request(_LazyModule):
|
||||
|
||||
"""Lazy loading of moved objects in six.moves.urllib_request"""
|
||||
|
||||
|
||||
@@ -327,10 +423,12 @@ del attr
|
||||
|
||||
Module_six_moves_urllib_request._moved_attributes = _urllib_request_moved_attributes
|
||||
|
||||
sys.modules[__name__ + ".moves.urllib_request"] = sys.modules[__name__ + ".moves.urllib.request"] = Module_six_moves_urllib_request(__name__ + ".moves.urllib.request")
|
||||
_importer._add_module(Module_six_moves_urllib_request(__name__ + ".moves.urllib.request"),
|
||||
"moves.urllib_request", "moves.urllib.request")
|
||||
|
||||
|
||||
class Module_six_moves_urllib_response(_LazyModule):
|
||||
|
||||
"""Lazy loading of moved objects in six.moves.urllib_response"""
|
||||
|
||||
|
||||
@@ -346,10 +444,12 @@ del attr
|
||||
|
||||
Module_six_moves_urllib_response._moved_attributes = _urllib_response_moved_attributes
|
||||
|
||||
sys.modules[__name__ + ".moves.urllib_response"] = sys.modules[__name__ + ".moves.urllib.response"] = Module_six_moves_urllib_response(__name__ + ".moves.urllib.response")
|
||||
_importer._add_module(Module_six_moves_urllib_response(__name__ + ".moves.urllib.response"),
|
||||
"moves.urllib_response", "moves.urllib.response")
|
||||
|
||||
|
||||
class Module_six_moves_urllib_robotparser(_LazyModule):
|
||||
|
||||
"""Lazy loading of moved objects in six.moves.urllib_robotparser"""
|
||||
|
||||
|
||||
@@ -362,22 +462,25 @@ del attr
|
||||
|
||||
Module_six_moves_urllib_robotparser._moved_attributes = _urllib_robotparser_moved_attributes
|
||||
|
||||
sys.modules[__name__ + ".moves.urllib_robotparser"] = sys.modules[__name__ + ".moves.urllib.robotparser"] = Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser")
|
||||
_importer._add_module(Module_six_moves_urllib_robotparser(__name__ + ".moves.urllib.robotparser"),
|
||||
"moves.urllib_robotparser", "moves.urllib.robotparser")
|
||||
|
||||
|
||||
class Module_six_moves_urllib(types.ModuleType):
|
||||
|
||||
"""Create a six.moves.urllib namespace that resembles the Python 3 namespace"""
|
||||
parse = sys.modules[__name__ + ".moves.urllib_parse"]
|
||||
error = sys.modules[__name__ + ".moves.urllib_error"]
|
||||
request = sys.modules[__name__ + ".moves.urllib_request"]
|
||||
response = sys.modules[__name__ + ".moves.urllib_response"]
|
||||
robotparser = sys.modules[__name__ + ".moves.urllib_robotparser"]
|
||||
__path__ = [] # mark as package
|
||||
parse = _importer._get_module("moves.urllib_parse")
|
||||
error = _importer._get_module("moves.urllib_error")
|
||||
request = _importer._get_module("moves.urllib_request")
|
||||
response = _importer._get_module("moves.urllib_response")
|
||||
robotparser = _importer._get_module("moves.urllib_robotparser")
|
||||
|
||||
def __dir__(self):
|
||||
return ['parse', 'error', 'request', 'response', 'robotparser']
|
||||
|
||||
|
||||
sys.modules[__name__ + ".moves.urllib"] = Module_six_moves_urllib(__name__ + ".moves.urllib")
|
||||
_importer._add_module(Module_six_moves_urllib(__name__ + ".moves.urllib"),
|
||||
"moves.urllib")
|
||||
|
||||
|
||||
def add_move(move):
|
||||
@@ -404,11 +507,6 @@ if PY3:
|
||||
_func_code = "__code__"
|
||||
_func_defaults = "__defaults__"
|
||||
_func_globals = "__globals__"
|
||||
|
||||
_iterkeys = "keys"
|
||||
_itervalues = "values"
|
||||
_iteritems = "items"
|
||||
_iterlists = "lists"
|
||||
else:
|
||||
_meth_func = "im_func"
|
||||
_meth_self = "im_self"
|
||||
@@ -418,11 +516,6 @@ else:
|
||||
_func_defaults = "func_defaults"
|
||||
_func_globals = "func_globals"
|
||||
|
||||
_iterkeys = "iterkeys"
|
||||
_itervalues = "itervalues"
|
||||
_iteritems = "iteritems"
|
||||
_iterlists = "iterlists"
|
||||
|
||||
|
||||
try:
|
||||
advance_iterator = next
|
||||
@@ -445,6 +538,9 @@ if PY3:
|
||||
|
||||
create_bound_method = types.MethodType
|
||||
|
||||
def create_unbound_method(func, cls):
|
||||
return func
|
||||
|
||||
Iterator = object
|
||||
else:
|
||||
def get_unbound_function(unbound):
|
||||
@@ -453,6 +549,9 @@ else:
|
||||
def create_bound_method(func, obj):
|
||||
return types.MethodType(func, obj, obj.__class__)
|
||||
|
||||
def create_unbound_method(func, cls):
|
||||
return types.MethodType(func, None, cls)
|
||||
|
||||
class Iterator(object):
|
||||
|
||||
def next(self):
|
||||
@@ -471,66 +570,117 @@ get_function_defaults = operator.attrgetter(_func_defaults)
|
||||
get_function_globals = operator.attrgetter(_func_globals)
|
||||
|
||||
|
||||
def iterkeys(d, **kw):
|
||||
"""Return an iterator over the keys of a dictionary."""
|
||||
return iter(getattr(d, _iterkeys)(**kw))
|
||||
if PY3:
|
||||
def iterkeys(d, **kw):
|
||||
return iter(d.keys(**kw))
|
||||
|
||||
def itervalues(d, **kw):
|
||||
"""Return an iterator over the values of a dictionary."""
|
||||
return iter(getattr(d, _itervalues)(**kw))
|
||||
def itervalues(d, **kw):
|
||||
return iter(d.values(**kw))
|
||||
|
||||
def iteritems(d, **kw):
|
||||
"""Return an iterator over the (key, value) pairs of a dictionary."""
|
||||
return iter(getattr(d, _iteritems)(**kw))
|
||||
def iteritems(d, **kw):
|
||||
return iter(d.items(**kw))
|
||||
|
||||
def iterlists(d, **kw):
|
||||
"""Return an iterator over the (key, [values]) pairs of a dictionary."""
|
||||
return iter(getattr(d, _iterlists)(**kw))
|
||||
def iterlists(d, **kw):
|
||||
return iter(d.lists(**kw))
|
||||
|
||||
viewkeys = operator.methodcaller("keys")
|
||||
|
||||
viewvalues = operator.methodcaller("values")
|
||||
|
||||
viewitems = operator.methodcaller("items")
|
||||
else:
|
||||
def iterkeys(d, **kw):
|
||||
return d.iterkeys(**kw)
|
||||
|
||||
def itervalues(d, **kw):
|
||||
return d.itervalues(**kw)
|
||||
|
||||
def iteritems(d, **kw):
|
||||
return d.iteritems(**kw)
|
||||
|
||||
def iterlists(d, **kw):
|
||||
return d.iterlists(**kw)
|
||||
|
||||
viewkeys = operator.methodcaller("viewkeys")
|
||||
|
||||
viewvalues = operator.methodcaller("viewvalues")
|
||||
|
||||
viewitems = operator.methodcaller("viewitems")
|
||||
|
||||
_add_doc(iterkeys, "Return an iterator over the keys of a dictionary.")
|
||||
_add_doc(itervalues, "Return an iterator over the values of a dictionary.")
|
||||
_add_doc(iteritems,
|
||||
"Return an iterator over the (key, value) pairs of a dictionary.")
|
||||
_add_doc(iterlists,
|
||||
"Return an iterator over the (key, [values]) pairs of a dictionary.")
|
||||
|
||||
|
||||
if PY3:
|
||||
def b(s):
|
||||
return s.encode("latin-1")
|
||||
|
||||
def u(s):
|
||||
return s
|
||||
unichr = chr
|
||||
if sys.version_info[1] <= 1:
|
||||
def int2byte(i):
|
||||
return bytes((i,))
|
||||
else:
|
||||
# This is about 2x faster than the implementation above on 3.2+
|
||||
int2byte = operator.methodcaller("to_bytes", 1, "big")
|
||||
import struct
|
||||
int2byte = struct.Struct(">B").pack
|
||||
del struct
|
||||
byte2int = operator.itemgetter(0)
|
||||
indexbytes = operator.getitem
|
||||
iterbytes = iter
|
||||
import io
|
||||
StringIO = io.StringIO
|
||||
BytesIO = io.BytesIO
|
||||
_assertCountEqual = "assertCountEqual"
|
||||
if sys.version_info[1] <= 1:
|
||||
_assertRaisesRegex = "assertRaisesRegexp"
|
||||
_assertRegex = "assertRegexpMatches"
|
||||
else:
|
||||
_assertRaisesRegex = "assertRaisesRegex"
|
||||
_assertRegex = "assertRegex"
|
||||
else:
|
||||
def b(s):
|
||||
return s
|
||||
# Workaround for standalone backslash
|
||||
|
||||
def u(s):
|
||||
return unicode(s.replace(r'\\', r'\\\\'), "unicode_escape")
|
||||
unichr = unichr
|
||||
int2byte = chr
|
||||
|
||||
def byte2int(bs):
|
||||
return ord(bs[0])
|
||||
|
||||
def indexbytes(buf, i):
|
||||
return ord(buf[i])
|
||||
def iterbytes(buf):
|
||||
return (ord(byte) for byte in buf)
|
||||
iterbytes = functools.partial(itertools.imap, ord)
|
||||
import StringIO
|
||||
StringIO = BytesIO = StringIO.StringIO
|
||||
_assertCountEqual = "assertItemsEqual"
|
||||
_assertRaisesRegex = "assertRaisesRegexp"
|
||||
_assertRegex = "assertRegexpMatches"
|
||||
_add_doc(b, """Byte literal""")
|
||||
_add_doc(u, """Text literal""")
|
||||
|
||||
|
||||
def assertCountEqual(self, *args, **kwargs):
|
||||
return getattr(self, _assertCountEqual)(*args, **kwargs)
|
||||
|
||||
|
||||
def assertRaisesRegex(self, *args, **kwargs):
|
||||
return getattr(self, _assertRaisesRegex)(*args, **kwargs)
|
||||
|
||||
|
||||
def assertRegex(self, *args, **kwargs):
|
||||
return getattr(self, _assertRegex)(*args, **kwargs)
|
||||
|
||||
|
||||
if PY3:
|
||||
exec_ = getattr(moves.builtins, "exec")
|
||||
|
||||
|
||||
def reraise(tp, value, tb=None):
|
||||
if value is None:
|
||||
value = tp()
|
||||
if value.__traceback__ is not tb:
|
||||
raise value.with_traceback(tb)
|
||||
raise value
|
||||
@@ -548,12 +698,26 @@ else:
|
||||
_locs_ = _globs_
|
||||
exec("""exec _code_ in _globs_, _locs_""")
|
||||
|
||||
|
||||
exec_("""def reraise(tp, value, tb=None):
|
||||
raise tp, value, tb
|
||||
""")
|
||||
|
||||
|
||||
if sys.version_info[:2] == (3, 2):
|
||||
exec_("""def raise_from(value, from_value):
|
||||
if from_value is None:
|
||||
raise value
|
||||
raise value from from_value
|
||||
""")
|
||||
elif sys.version_info[:2] > (3, 2):
|
||||
exec_("""def raise_from(value, from_value):
|
||||
raise value from from_value
|
||||
""")
|
||||
else:
|
||||
def raise_from(value, from_value):
|
||||
raise value
|
||||
|
||||
|
||||
print_ = getattr(moves.builtins, "print", None)
|
||||
if print_ is None:
|
||||
def print_(*args, **kwargs):
|
||||
@@ -561,13 +725,14 @@ if print_ is None:
|
||||
fp = kwargs.pop("file", sys.stdout)
|
||||
if fp is None:
|
||||
return
|
||||
|
||||
def write(data):
|
||||
if not isinstance(data, basestring):
|
||||
data = str(data)
|
||||
# If the file has an encoding, encode unicode with it.
|
||||
if (isinstance(fp, file) and
|
||||
isinstance(data, unicode) and
|
||||
fp.encoding is not None):
|
||||
isinstance(data, unicode) and
|
||||
fp.encoding is not None):
|
||||
errors = getattr(fp, "errors", None)
|
||||
if errors is None:
|
||||
errors = "strict"
|
||||
@@ -608,25 +773,96 @@ if print_ is None:
|
||||
write(sep)
|
||||
write(arg)
|
||||
write(end)
|
||||
if sys.version_info[:2] < (3, 3):
|
||||
_print = print_
|
||||
|
||||
def print_(*args, **kwargs):
|
||||
fp = kwargs.get("file", sys.stdout)
|
||||
flush = kwargs.pop("flush", False)
|
||||
_print(*args, **kwargs)
|
||||
if flush and fp is not None:
|
||||
fp.flush()
|
||||
|
||||
_add_doc(reraise, """Reraise an exception.""")
|
||||
|
||||
if sys.version_info[0:2] < (3, 4):
|
||||
def wraps(wrapped, assigned=functools.WRAPPER_ASSIGNMENTS,
|
||||
updated=functools.WRAPPER_UPDATES):
|
||||
def wrapper(f):
|
||||
f = functools.wraps(wrapped, assigned, updated)(f)
|
||||
f.__wrapped__ = wrapped
|
||||
return f
|
||||
return wrapper
|
||||
else:
|
||||
wraps = functools.wraps
|
||||
|
||||
|
||||
def with_metaclass(meta, *bases):
|
||||
"""Create a base class with a metaclass."""
|
||||
return meta("NewBase", bases, {})
|
||||
# This requires a bit of explanation: the basic idea is to make a dummy
|
||||
# metaclass for one level of class instantiation that replaces itself with
|
||||
# the actual metaclass.
|
||||
class metaclass(meta):
|
||||
|
||||
def __new__(cls, name, this_bases, d):
|
||||
return meta(name, bases, d)
|
||||
return type.__new__(metaclass, 'temporary_class', (), {})
|
||||
|
||||
|
||||
def add_metaclass(metaclass):
|
||||
"""Class decorator for creating a class with a metaclass."""
|
||||
def wrapper(cls):
|
||||
orig_vars = cls.__dict__.copy()
|
||||
orig_vars.pop('__dict__', None)
|
||||
orig_vars.pop('__weakref__', None)
|
||||
slots = orig_vars.get('__slots__')
|
||||
if slots is not None:
|
||||
if isinstance(slots, str):
|
||||
slots = [slots]
|
||||
for slots_var in slots:
|
||||
orig_vars.pop(slots_var)
|
||||
orig_vars.pop('__dict__', None)
|
||||
orig_vars.pop('__weakref__', None)
|
||||
return metaclass(cls.__name__, cls.__bases__, orig_vars)
|
||||
return wrapper
|
||||
|
||||
|
||||
def python_2_unicode_compatible(klass):
|
||||
"""
|
||||
A decorator that defines __unicode__ and __str__ methods under Python 2.
|
||||
Under Python 3 it does nothing.
|
||||
|
||||
To support Python 2 and 3 with a single code base, define a __str__ method
|
||||
returning text and apply this decorator to the class.
|
||||
"""
|
||||
if PY2:
|
||||
if '__str__' not in klass.__dict__:
|
||||
raise ValueError("@python_2_unicode_compatible cannot be applied "
|
||||
"to %s because it doesn't define __str__()." %
|
||||
klass.__name__)
|
||||
klass.__unicode__ = klass.__str__
|
||||
klass.__str__ = lambda self: self.__unicode__().encode('utf-8')
|
||||
return klass
|
||||
|
||||
|
||||
# Complete the moves implementation.
|
||||
# This code is at the end of this module to speed up module loading.
|
||||
# Turn this module into a package.
|
||||
__path__ = [] # required for PEP 302 and PEP 451
|
||||
__package__ = __name__ # see PEP 366 @ReservedAssignment
|
||||
if globals().get("__spec__") is not None:
|
||||
__spec__.submodule_search_locations = [] # PEP 451 @UndefinedVariable
|
||||
# Remove other six meta path importers, since they cause problems. This can
|
||||
# happen if six is removed from sys.modules and then reloaded. (Setuptools does
|
||||
# this for some reason.)
|
||||
if sys.meta_path:
|
||||
for i, importer in enumerate(sys.meta_path):
|
||||
# Here's some real nastiness: Another "instance" of the six module might
|
||||
# be floating around. Therefore, we can't use isinstance() to check for
|
||||
# the six meta path importer, since the other six instance will have
|
||||
# inserted an importer with different class.
|
||||
if (type(importer).__name__ == "_SixMetaPathImporter" and
|
||||
importer.name == __name__):
|
||||
del sys.meta_path[i]
|
||||
break
|
||||
del i, importer
|
||||
# Finally, add the importer to the meta path import hook.
|
||||
sys.meta_path.append(_importer)
|
||||
|
||||
@@ -17,6 +17,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
series_year_re = re.compile('^(?P<series>[ \w]+)(?: \((?P<year>\d{4})\))?$')
|
||||
|
||||
|
||||
class Addic7edSubtitle(Subtitle):
|
||||
provider_name = 'addic7ed'
|
||||
|
||||
|
||||
Executable → Regular
Regular → Executable
+23
-6
@@ -2,24 +2,41 @@
|
||||
|
||||
import subliminal
|
||||
import babelfish
|
||||
import logging
|
||||
|
||||
# patch subliminal's subtitle encoding detection
|
||||
from .patch_subtitle import PatchedSubtitle
|
||||
subliminal.subtitle.Subtitle = PatchedSubtitle
|
||||
from subliminal.providers.addic7ed import Addic7edSubtitle
|
||||
from subliminal.providers.podnapisi import PodnapisiSubtitle
|
||||
from subliminal.providers.tvsubtitles import TVsubtitlesSubtitle
|
||||
from subliminal.providers.opensubtitles import OpenSubtitlesSubtitle
|
||||
setattr(Addic7edSubtitle, "__bases__", (PatchedSubtitle,))
|
||||
setattr(PodnapisiSubtitle, "__bases__", (PatchedSubtitle,))
|
||||
setattr(TVsubtitlesSubtitle, "__bases__", (PatchedSubtitle,))
|
||||
setattr(OpenSubtitlesSubtitle, "__bases__", (PatchedSubtitle,))
|
||||
|
||||
from .patch_provider_pool import PatchedProviderPool
|
||||
from .patch_video import patched_search_external_subtitles, scan_video
|
||||
from .patch_providers import addic7ed, podnapisi, tvsubtitles, opensubtitles
|
||||
from .patch_api import save_subtitles
|
||||
|
||||
|
||||
# patch subliminal's ProviderPool
|
||||
# patch subliminal's ProviderPool
|
||||
subliminal.api.ProviderPool = PatchedProviderPool
|
||||
|
||||
# patch subliminal's save_subtitles function
|
||||
subliminal.api.save_subtitles = save_subtitles
|
||||
|
||||
# patch subliminal's subtitle classes
|
||||
def subtitleRepr(self):
|
||||
link = self.page_link
|
||||
|
||||
# specialcasing addic7ed; eww
|
||||
if self.__class__.__name__ == "Addic7edSubtitle":
|
||||
link = u"http://www.addic7ed.com/%s" % self.download_link
|
||||
link = u"http://www.addic7ed.com/%s" % self.download_link
|
||||
return '<%s %r [%s]>' % (self.__class__.__name__, link, self.language)
|
||||
|
||||
|
||||
subliminal.subtitle.Subtitle.__repr__ = subtitleRepr
|
||||
|
||||
# patch subliminal's providers
|
||||
@@ -28,8 +45,6 @@ subliminal.providers.podnapisi.PodnapisiProvider = podnapisi.PatchedPodnapisiPro
|
||||
subliminal.providers.tvsubtitles.TVsubtitlesProvider = tvsubtitles.PatchedTVsubtitlesProvider
|
||||
subliminal.providers.opensubtitles.OpenSubtitlesProvider = opensubtitles.PatchedOpenSubtitlesProvider
|
||||
|
||||
|
||||
|
||||
# add language converters
|
||||
babelfish.language_converters.register('addic7ed = subliminal_patch.patch_language:PatchedAddic7edConverter')
|
||||
babelfish.language_converters.register('tvsubtitles = subliminal.converters.tvsubtitles:TVsubtitlesConverter')
|
||||
@@ -40,4 +55,6 @@ subliminal.video.search_external_subtitles = patched_search_external_subtitles
|
||||
# patch subliminal's scan_video function
|
||||
subliminal.video.scan_video = scan_video
|
||||
|
||||
subliminal.video.Episode.scores["boost"] = 40
|
||||
subliminal.video.Episode.scores["boost"] = 40
|
||||
|
||||
subliminal.video.Episode.scores["title"] = 0
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
# coding=utf-8
|
||||
import os
|
||||
import logging
|
||||
from bs4 import UnicodeDammit
|
||||
from subliminal.api import get_subtitle_path, io
|
||||
from subzero.lib.io import get_viable_encoding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def save_subtitles(video, subtitles, single=False, directory=None, encoding=None, encode_with=None):
|
||||
"""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
|
||||
with the same language are silently ignored.
|
||||
|
||||
The extension used is `.lang.srt` by default or `.srt` is `single` is `True`, with `lang` being the IETF code for
|
||||
the :attr:`~subliminal.subtitle.Subtitle.language` of the subtitle.
|
||||
|
||||
:param video: video of the subtitles.
|
||||
:type video: :class:`~subliminal.video.Video`
|
||||
:param subtitles: subtitles to save.
|
||||
:type subtitles: list of :class:`~subliminal.subtitle.Subtitle`
|
||||
:param bool single: save a single subtitle, default is to save one subtitle per language.
|
||||
:param str directory: path to directory where to save the subtitles, default is next to the video.
|
||||
:param str encoding: encoding in which to save the subtitles, default is to keep original encoding.
|
||||
:return: the saved subtitles
|
||||
:rtype: list of :class:`~subliminal.subtitle.Subtitle`
|
||||
|
||||
patch: unicode path probems
|
||||
"""
|
||||
saved_subtitles = []
|
||||
for subtitle in subtitles:
|
||||
# check content
|
||||
if subtitle.content is None:
|
||||
logger.error('Skipping subtitle %r: no content', subtitle)
|
||||
continue
|
||||
|
||||
# check language
|
||||
if subtitle.language in set(s.language for s in saved_subtitles):
|
||||
logger.debug('Skipping subtitle %r: language already saved', subtitle)
|
||||
continue
|
||||
|
||||
# create subtitle path
|
||||
subtitle_path = get_subtitle_path(video.name, None if single else subtitle.language)
|
||||
if directory is not None:
|
||||
subtitle_path = os.path.join(directory, os.path.split(subtitle_path)[1])
|
||||
|
||||
# force unicode
|
||||
subtitle_path = UnicodeDammit(subtitle_path).unicode_markup
|
||||
|
||||
subtitle.storage_path = subtitle_path
|
||||
|
||||
# save content as is or in the specified encoding
|
||||
logger.info('Saving %r to %r', subtitle, subtitle_path)
|
||||
has_encoder = callable(encode_with)
|
||||
|
||||
if has_encoder:
|
||||
logger.info('Using encoder %s' % encode_with.__name__)
|
||||
|
||||
# save normalized subtitle if encoder or no encoding is given
|
||||
if has_encoder or encoding is None:
|
||||
content = encode_with(subtitle.text) if has_encoder else subtitle.content
|
||||
with io.open(subtitle_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
if single:
|
||||
break
|
||||
continue
|
||||
|
||||
# save subtitle if encoding given
|
||||
if encoding is not None:
|
||||
with io.open(subtitle_path, 'w', encoding=encoding) as f:
|
||||
f.write(subtitle.text)
|
||||
|
||||
saved_subtitles.append(subtitle)
|
||||
|
||||
# check single
|
||||
if single:
|
||||
break
|
||||
|
||||
return saved_subtitles
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
from subliminal.converters.addic7ed import Addic7edConverter
|
||||
|
||||
|
||||
class PatchedAddic7edConverter(Addic7edConverter):
|
||||
def __init__(self):
|
||||
super(PatchedAddic7edConverter, self).__init__()
|
||||
self.from_addic7ed.update({
|
||||
"French (Canadian)": ("fra", "CA"),
|
||||
})
|
||||
self.to_addic7ed.update({
|
||||
("fra", "CA"): "French (Canadian)",
|
||||
})
|
||||
super(PatchedAddic7edConverter, self).__init__()
|
||||
self.from_addic7ed.update({
|
||||
"French (Canadian)": ("fra", "CA"),
|
||||
})
|
||||
self.to_addic7ed.update({
|
||||
("fra", "CA"): "French (Canadian)",
|
||||
})
|
||||
|
||||
@@ -7,28 +7,29 @@ import socket
|
||||
import operator
|
||||
import time
|
||||
from babelfish.exceptions import LanguageReverseError
|
||||
|
||||
from pkg_resources import EntryPoint, iter_entry_points
|
||||
|
||||
from subliminal import ProviderError
|
||||
from subliminal.api import ProviderPool
|
||||
from subliminal_patch.patch_subtitle import compute_score
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DOWNLOAD_TRIES = 0
|
||||
DOWNLOAD_RETRY_SLEEP = 2
|
||||
|
||||
|
||||
class OldToNewProvider(object):
|
||||
"""
|
||||
Simple proxy class to support the .plugin property which would normally exist
|
||||
when this was a stevedore.extension
|
||||
"""
|
||||
|
||||
def __init__(self, provider):
|
||||
self.provider = provider
|
||||
|
||||
self.provider = provider
|
||||
|
||||
def plugin(self):
|
||||
return self.provider
|
||||
return self.provider
|
||||
|
||||
plugin = property(plugin)
|
||||
|
||||
|
||||
@@ -55,7 +56,7 @@ class LegacyProviderManager(object):
|
||||
'thesubdb = subliminal.providers.thesubdb:TheSubDBProvider',
|
||||
'tvsubtitles = subliminal.providers.tvsubtitles:TVsubtitlesProvider']
|
||||
|
||||
self.enabled_providers = enabled_providers or []
|
||||
self.enabled_providers = enabled_providers or []
|
||||
|
||||
#: Loaded providers
|
||||
self.providers = {}
|
||||
@@ -70,7 +71,7 @@ class LegacyProviderManager(object):
|
||||
|
||||
def __getitem__(self, name):
|
||||
"""Get a provider, lazy loading it if necessary"""
|
||||
|
||||
|
||||
if name in self.enabled_providers and name in self.providers:
|
||||
return self.providers[name]
|
||||
for ep in iter_entry_points(self.entry_point):
|
||||
@@ -116,8 +117,8 @@ class LegacyProviderManager(object):
|
||||
def __contains__(self, name):
|
||||
return name in self.providers
|
||||
|
||||
provider_manager = LegacyProviderManager()
|
||||
|
||||
provider_manager = LegacyProviderManager()
|
||||
|
||||
|
||||
class PatchedProviderPool(ProviderPool):
|
||||
@@ -126,6 +127,7 @@ class PatchedProviderPool(ProviderPool):
|
||||
because the new ProviderManager in the current subliminal package relies on stevedore, which has
|
||||
problems detecting subliminal's provider extensions when running in the Plex sandbox
|
||||
"""
|
||||
|
||||
def __init__(self, providers=None, provider_configs=None):
|
||||
#: Name of providers to use
|
||||
self.providers = providers or provider_manager.available_providers
|
||||
@@ -140,9 +142,9 @@ class PatchedProviderPool(ProviderPool):
|
||||
self.discarded_providers = set()
|
||||
|
||||
#: Dedicated :data:`provider_manager` as :class:`~stevedore.enabled.EnabledExtensionManager`
|
||||
#self.manager = EnabledExtensionManager(provider_manager.namespace, lambda e: e.name in self.providers)
|
||||
self.manager = provider_manager if not providers else LegacyProviderManager(self.providers)
|
||||
|
||||
# self.manager = EnabledExtensionManager(provider_manager.namespace, lambda e: e.name in self.providers)
|
||||
self.manager = provider_manager if not providers else LegacyProviderManager(self.providers)
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
"""List subtitles.
|
||||
:param video: video to list subtitles for.
|
||||
@@ -179,9 +181,9 @@ class PatchedProviderPool(ProviderPool):
|
||||
logger.error('Provider %r timed out, discarding it', name)
|
||||
self.discarded_providers.add(name)
|
||||
continue
|
||||
except LanguageReverseError, e:
|
||||
logger.exception("Unexpected language reverse error in %s, skipping. Error: %s", name, traceback.format_exc())
|
||||
continue
|
||||
except LanguageReverseError, e:
|
||||
logger.exception("Unexpected language reverse error in %s, skipping. Error: %s", name, traceback.format_exc())
|
||||
continue
|
||||
except Exception, e:
|
||||
logger.exception('Unexpected error in provider %r, discarding it, because of: %s', name, traceback.format_exc())
|
||||
self.discarded_providers.add(name)
|
||||
@@ -203,28 +205,31 @@ class PatchedProviderPool(ProviderPool):
|
||||
return False
|
||||
|
||||
logger.info('Downloading subtitle %r', subtitle)
|
||||
tries = 0
|
||||
|
||||
# retry downloading on failure until settings' download retry limit hit
|
||||
while True:
|
||||
tries += 1
|
||||
try:
|
||||
self[subtitle.provider_name].download_subtitle(subtitle)
|
||||
except (requests.Timeout, socket.timeout):
|
||||
logger.error('Provider %r timed out', subtitle.provider_name)
|
||||
except:
|
||||
logger.exception('Unexpected error in provider %r, Traceback: %s', subtitle.provider_name, traceback.format_exc())
|
||||
else:
|
||||
break
|
||||
tries = 0
|
||||
|
||||
if tries == DOWNLOAD_TRIES:
|
||||
self.discarded_providers.add(subtitle.provider_name)
|
||||
logger.error('Maximum retries reached for provider %r, discarding it', subtitle.provider_name)
|
||||
return False
|
||||
# retry downloading on failure until settings' download retry limit hit
|
||||
while True:
|
||||
tries += 1
|
||||
try:
|
||||
self[subtitle.provider_name].download_subtitle(subtitle)
|
||||
except (requests.Timeout, socket.timeout):
|
||||
logger.error('Provider %r timed out', subtitle.provider_name)
|
||||
except ProviderError:
|
||||
logger.error('Unexpected error in provider %r, Traceback: %s', subtitle.provider_name, traceback.format_exc())
|
||||
break
|
||||
except:
|
||||
logger.exception('Unexpected error in provider %r, Traceback: %s', subtitle.provider_name, traceback.format_exc())
|
||||
else:
|
||||
break
|
||||
|
||||
# don't hammer the provider
|
||||
logger.debug('Errors while downloading subtitle, retrying provider %r in %s seconds', subtitle.provider_name, DOWNLOAD_RETRY_SLEEP)
|
||||
time.sleep(DOWNLOAD_RETRY_SLEEP)
|
||||
if tries == DOWNLOAD_TRIES:
|
||||
self.discarded_providers.add(subtitle.provider_name)
|
||||
logger.error('Maximum retries reached for provider %r, discarding it', subtitle.provider_name)
|
||||
return False
|
||||
|
||||
# don't hammer the provider
|
||||
logger.debug('Errors while downloading subtitle, retrying provider %r in %s seconds', subtitle.provider_name, DOWNLOAD_RETRY_SLEEP)
|
||||
time.sleep(DOWNLOAD_RETRY_SLEEP)
|
||||
|
||||
# check subtitle validity
|
||||
if not subtitle.is_valid():
|
||||
@@ -251,14 +256,19 @@ class PatchedProviderPool(ProviderPool):
|
||||
:rtype: list of :class:`~subliminal.subtitle.Subtitle`
|
||||
"""
|
||||
|
||||
use_hearing_impaired = hearing_impaired in ("prefer", "force HI")
|
||||
use_hearing_impaired = hearing_impaired in ("prefer", "force HI")
|
||||
|
||||
# sort subtitles by score
|
||||
unsorted_subtitles = []
|
||||
for s in subtitles:
|
||||
logger.debug("Starting score computation for %s", s)
|
||||
matches = s.get_matches(video, hearing_impaired=use_hearing_impaired)
|
||||
unsorted_subtitles.append((s, compute_score(matches, video, scores=scores), matches))
|
||||
unsorted_subtitles = []
|
||||
for s in subtitles:
|
||||
logger.debug("Starting score computation for %s", s)
|
||||
try:
|
||||
matches = s.get_matches(video, hearing_impaired=use_hearing_impaired)
|
||||
except AttributeError:
|
||||
logger.error("Match computation failed for %s: %s", s, traceback.format_exc())
|
||||
continue
|
||||
|
||||
unsorted_subtitles.append((s, compute_score(matches, video, scores=scores), matches))
|
||||
scored_subtitles = sorted(unsorted_subtitles, key=operator.itemgetter(1), reverse=True)
|
||||
|
||||
# download best subtitles, falling back on the next on error
|
||||
@@ -268,7 +278,7 @@ class PatchedProviderPool(ProviderPool):
|
||||
if score < min_score:
|
||||
logger.info('Score %d is below min_score (%d)', score, min_score)
|
||||
break
|
||||
|
||||
|
||||
# stop when all languages are downloaded
|
||||
if set(s.language for s in downloaded_subtitles) == languages:
|
||||
logger.debug('All languages downloaded')
|
||||
@@ -279,15 +289,15 @@ class PatchedProviderPool(ProviderPool):
|
||||
logger.debug('Skipping subtitle: %r already downloaded', subtitle.language)
|
||||
continue
|
||||
|
||||
# bail out if hearing_impaired was wrong
|
||||
if not "hearing_impaired" in matches and hearing_impaired in ("force HI", "force non-HI"):
|
||||
logger.debug('Skipping subtitle: %r with score %d because hearing-impaired set to %s', subtitle, score, hearing_impaired)
|
||||
continue
|
||||
# bail out if hearing_impaired was wrong
|
||||
if "hearing_impaired" not in matches and hearing_impaired in ("force HI", "force non-HI"):
|
||||
logger.debug('Skipping subtitle: %r with score %d because hearing-impaired set to %s', subtitle, score, hearing_impaired)
|
||||
continue
|
||||
|
||||
# download
|
||||
logger.info('Downloading subtitle %r with score %d', subtitle, score)
|
||||
if self.download_subtitle(subtitle):
|
||||
subtitle.score = score
|
||||
subtitle.score = score
|
||||
downloaded_subtitles.append(subtitle)
|
||||
|
||||
# stop if only one subtitle is requested
|
||||
|
||||
@@ -3,48 +3,50 @@
|
||||
import logging
|
||||
import re
|
||||
from random import randint
|
||||
from subliminal.providers.addic7ed import Addic7edProvider, Addic7edSubtitle, ParserBeautifulSoup, Language, series_year_re
|
||||
from subliminal.providers.addic7ed import Addic7edProvider, Addic7edSubtitle, ParserBeautifulSoup, Language
|
||||
from subliminal.cache import SHOW_EXPIRATION_TIME, region
|
||||
|
||||
from .mixins import PunctuationMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
series_year_re = re.compile('^(?P<series>.+?)(?: \((?P<year>\d{4})\))?$')
|
||||
remove_brackets_re = re.compile("^(.+?)( \([^\d]+\))$")
|
||||
|
||||
USE_BOOST = False
|
||||
|
||||
|
||||
class PatchedAddic7edSubtitle(Addic7edSubtitle):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(PatchedAddic7edSubtitle, self).__init__(*args, **kwargs)
|
||||
super(PatchedAddic7edSubtitle, self).__init__(*args, **kwargs)
|
||||
|
||||
def get_matches(self, video, hearing_impaired=False):
|
||||
matches = super(PatchedAddic7edSubtitle, self).get_matches(video, hearing_impaired=hearing_impaired)
|
||||
if not USE_BOOST:
|
||||
return matches
|
||||
matches = super(PatchedAddic7edSubtitle, self).get_matches(video, hearing_impaired=hearing_impaired)
|
||||
if not USE_BOOST:
|
||||
return matches
|
||||
|
||||
if {"series", "season", "episode", "year"}.issubset(matches) and "format" in matches:
|
||||
matches.add("boost")
|
||||
logger.info("Boosting Addic7ed subtitle")
|
||||
return matches
|
||||
if {"series", "season", "episode", "year"}.issubset(matches) and "format" in matches:
|
||||
matches.add("boost")
|
||||
logger.info("Boosting Addic7ed subtitle")
|
||||
return matches
|
||||
|
||||
|
||||
class PatchedAddic7edProvider(PunctuationMixin, Addic7edProvider):
|
||||
USE_ADDICTED_RANDOM_AGENTS = False
|
||||
|
||||
def __init__(self, username=None, password=None, use_random_agents=False):
|
||||
super(PatchedAddic7edProvider, self).__init__(username=username, password=password)
|
||||
self.USE_ADDICTED_RANDOM_AGENTS = use_random_agents
|
||||
super(PatchedAddic7edProvider, self).__init__(username=username, password=password)
|
||||
self.USE_ADDICTED_RANDOM_AGENTS = use_random_agents
|
||||
|
||||
def initialize(self):
|
||||
# patch: add optional user agent randomization
|
||||
super(PatchedAddic7edProvider, self).initialize()
|
||||
if self.USE_ADDICTED_RANDOM_AGENTS:
|
||||
from .utils import FIRST_THOUSAND_OR_SO_USER_AGENTS as AGENT_LIST
|
||||
logger.debug("addic7ed: using random user agents")
|
||||
self.session.headers = {
|
||||
'User-Agent': AGENT_LIST[randint(0, len(AGENT_LIST)-1)],
|
||||
'Referer': self.server_url,
|
||||
}
|
||||
# patch: add optional user agent randomization
|
||||
super(PatchedAddic7edProvider, self).initialize()
|
||||
if self.USE_ADDICTED_RANDOM_AGENTS:
|
||||
from .utils import FIRST_THOUSAND_OR_SO_USER_AGENTS as AGENT_LIST
|
||||
logger.debug("addic7ed: using random user agents")
|
||||
self.session.headers = {
|
||||
'User-Agent': AGENT_LIST[randint(0, len(AGENT_LIST) - 1)],
|
||||
'Referer': self.server_url,
|
||||
}
|
||||
|
||||
@region.cache_on_arguments(expiration_time=SHOW_EXPIRATION_TIME)
|
||||
def _get_show_ids(self):
|
||||
@@ -52,7 +54,7 @@ class PatchedAddic7edProvider(PunctuationMixin, Addic7edProvider):
|
||||
:return: show id per series, lower case and without quotes.
|
||||
:rtype: dict
|
||||
|
||||
# patch: add punctuation cleaning
|
||||
# patch: add punctuation cleaning
|
||||
"""
|
||||
# get the show page
|
||||
logger.info('Getting show ids')
|
||||
@@ -63,13 +65,13 @@ class PatchedAddic7edProvider(PunctuationMixin, Addic7edProvider):
|
||||
# populate the show ids
|
||||
show_ids = {}
|
||||
for show in soup.select('td.version > h3 > a[href^="/show/"]'):
|
||||
show_clean = self.clean_punctuation(show.text.lower())
|
||||
show_id = int(show['href'][6:])
|
||||
show_clean = self.clean_punctuation(show.text.lower())
|
||||
show_id = int(show['href'][6:])
|
||||
show_ids[show_clean] = show_id
|
||||
match = series_year_re.match(show_clean)
|
||||
if match.group(2) and match.group(1) not in show_ids:
|
||||
# year found, also add it without year
|
||||
show_ids[match.group(1)] = show_id
|
||||
match = series_year_re.match(show_clean)
|
||||
if match.group(2) and match.group(1) not in show_ids:
|
||||
# year found, also add it without year
|
||||
show_ids[match.group(1)] = show_id
|
||||
|
||||
logger.debug('Found %d show ids', len(show_ids))
|
||||
|
||||
@@ -105,6 +107,15 @@ class PatchedAddic7edProvider(PunctuationMixin, Addic7edProvider):
|
||||
logger.debug('Getting show id')
|
||||
show_id = show_ids.get(series_clean)
|
||||
|
||||
if not show_id:
|
||||
# show not found, try to match it without modifiers (mostly country codes such as US/UK)
|
||||
match = remove_brackets_re.match(series_clean)
|
||||
if match:
|
||||
matched = match.group(1)
|
||||
show_id = show_ids.get(matched)
|
||||
if show_id:
|
||||
logger.debug("show '%s' matched to '%s': %s", series, matched, show_id)
|
||||
|
||||
# search as last resort
|
||||
if not show_id:
|
||||
logger.warning('Series not found in show ids, attempting search')
|
||||
@@ -121,7 +132,7 @@ class PatchedAddic7edProvider(PunctuationMixin, Addic7edProvider):
|
||||
:return: the show id, if found.
|
||||
:rtype: int or None
|
||||
|
||||
# patch: add punctuation cleaning
|
||||
# patch: add punctuation cleaning
|
||||
"""
|
||||
# build the params
|
||||
series_year = '%s (%d)' % (series, year) if year is not None else series
|
||||
@@ -145,9 +156,9 @@ class PatchedAddic7edProvider(PunctuationMixin, Addic7edProvider):
|
||||
logger.debug('Found show id %d', show_id)
|
||||
|
||||
return show_id
|
||||
|
||||
|
||||
def query(self, series, season, year=None, country=None):
|
||||
# patch: fix logging
|
||||
# patch: fix logging
|
||||
# get the show id
|
||||
show_id = self.get_show_id(series, year, country)
|
||||
if show_id is None:
|
||||
@@ -182,7 +193,7 @@ class PatchedAddic7edProvider(PunctuationMixin, Addic7edProvider):
|
||||
download_link = cells[9].a['href'][1:]
|
||||
|
||||
subtitle = PatchedAddic7edSubtitle(language, hearing_impaired, page_link, series, season, episode, title, year,
|
||||
version, download_link)
|
||||
version, download_link)
|
||||
logger.debug('Found subtitle %r', subtitle)
|
||||
subtitles.append(subtitle)
|
||||
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
import re
|
||||
|
||||
clean_whitespace_re = re.compile(r'\s+')
|
||||
|
||||
|
||||
class PunctuationMixin(object):
|
||||
"""
|
||||
provider mixin
|
||||
|
||||
fixes show ids for stuff like "Mr. Petterson", as our matcher already sees it as "Mr Petterson" but addic7ed doesn't
|
||||
"""
|
||||
|
||||
def clean_punctuation(self, s):
|
||||
return s.replace(".", "").replace(":", "").replace("'", "")
|
||||
return s.replace(".", "").replace(":", "").replace("'", "").replace("&", "").replace("-", "")
|
||||
|
||||
def clean_whitespace(self, s):
|
||||
return clean_whitespace_re.sub("", s)
|
||||
return clean_whitespace_re.sub("", s)
|
||||
|
||||
def full_clean(self, s):
|
||||
return self.clean_whitespace(self.clean_punctuation(s))
|
||||
return self.clean_whitespace(self.clean_punctuation(s))
|
||||
|
||||
@@ -1,23 +1,141 @@
|
||||
# coding=utf-8
|
||||
|
||||
import logging
|
||||
from subliminal.providers.opensubtitles import OpenSubtitlesProvider, checked, get_version, __version__
|
||||
import os
|
||||
|
||||
from babelfish import Language
|
||||
from subliminal.exceptions import ConfigurationError
|
||||
from subliminal.providers.opensubtitles import OpenSubtitlesProvider, checked, get_version, __version__, OpenSubtitlesSubtitle, Episode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PatchedOpenSubtitlesSubtitle(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, fps):
|
||||
super(PatchedOpenSubtitlesSubtitle, self).__init__(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)
|
||||
self.query_parameters = query_parameters or {}
|
||||
self.fps = fps
|
||||
|
||||
def get_matches(self, video, hearing_impaired=False):
|
||||
matches = super(PatchedOpenSubtitlesSubtitle, self).get_matches(video, hearing_impaired=hearing_impaired)
|
||||
|
||||
sub_fps = None
|
||||
try:
|
||||
sub_fps = float(self.fps)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 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()
|
||||
|
||||
# matched by tag?
|
||||
if self.matched_by == "tag":
|
||||
# treat a tag match equally to a hash match
|
||||
logger.debug("Subtitle matched by tag, treating it as a hash-match. Tag: '%s'", self.query_parameters.get("tag", None))
|
||||
matches.add("hash")
|
||||
return matches
|
||||
|
||||
|
||||
class PatchedOpenSubtitlesProvider(OpenSubtitlesProvider):
|
||||
def __init__(self, username=None, password=None):
|
||||
if username is not None and password is None or username is None and password is not None:
|
||||
def __init__(self, username=None, password=None, use_tag_search=False):
|
||||
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')
|
||||
|
||||
self.username = username or ''
|
||||
self.password = password or ''
|
||||
self.use_tag_search = use_tag_search
|
||||
|
||||
super(PatchedOpenSubtitlesProvider, self).__init__()
|
||||
if use_tag_search:
|
||||
logger.info("Using tag/exact filename search")
|
||||
|
||||
super(PatchedOpenSubtitlesProvider, self).__init__()
|
||||
|
||||
def initialize(self):
|
||||
logger.info('Logging in')
|
||||
response = checked(self.server.LogIn(self.username, self.password, 'eng', 'subliminal v%s' % get_version(__version__)))
|
||||
self.token = response['token']
|
||||
logger.debug('Logged in with token %r', self.token)
|
||||
logger.debug('Logged in with token %r', self.token)
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
"""
|
||||
:param video:
|
||||
:param languages:
|
||||
:return:
|
||||
|
||||
patch: query movies even if hash is known; add tag parameter
|
||||
"""
|
||||
season = episode = None
|
||||
if isinstance(video, Episode):
|
||||
query = video.series
|
||||
season = video.season
|
||||
episode = video.episode
|
||||
# elif ('opensubtitles' not in video.hashes or not video.size) and not video.imdb_id:
|
||||
# query = video.name.split(os.sep)[-1]
|
||||
else:
|
||||
query = video.title
|
||||
|
||||
return self.query(languages, hash=video.hashes.get('opensubtitles'), size=video.size, imdb_id=video.imdb_id,
|
||||
query=query, season=season, episode=episode, tag=os.path.basename(video.name), use_tag_search=self.use_tag_search)
|
||||
|
||||
def query(self, languages, hash=None, size=None, imdb_id=None, query=None, season=None, episode=None, tag=None, use_tag_search=False):
|
||||
# fill the search criteria
|
||||
criteria = []
|
||||
if hash and size:
|
||||
criteria.append({'moviehash': hash, 'moviebytesize': str(size)})
|
||||
if use_tag_search and tag:
|
||||
criteria.append({'tag': tag})
|
||||
if imdb_id:
|
||||
criteria.append({'imdbid': imdb_id})
|
||||
if query and season and episode:
|
||||
criteria.append({'query': query, 'season': season, 'episode': episode})
|
||||
elif query:
|
||||
criteria.append({'query': query})
|
||||
if not criteria:
|
||||
raise ValueError('Not enough information')
|
||||
|
||||
# add the language
|
||||
for criterion in criteria:
|
||||
criterion['sublanguageid'] = ','.join(sorted(l.opensubtitles for l in languages))
|
||||
|
||||
# query the server
|
||||
logger.info('Searching subtitles %r', criteria)
|
||||
response = checked(self.server.SearchSubtitles(self.token, criteria))
|
||||
subtitles = []
|
||||
|
||||
# exit if no data
|
||||
if not response['data']:
|
||||
logger.info('No subtitles found')
|
||||
return subtitles
|
||||
|
||||
# loop over subtitle items
|
||||
for subtitle_item in response['data']:
|
||||
# read the item
|
||||
language = Language.fromopensubtitles(subtitle_item['SubLanguageID'])
|
||||
hearing_impaired = bool(int(subtitle_item['SubHearingImpaired']))
|
||||
page_link = subtitle_item['SubtitlesLink']
|
||||
subtitle_id = int(subtitle_item['IDSubtitleFile'])
|
||||
matched_by = subtitle_item['MatchedBy']
|
||||
movie_kind = subtitle_item['MovieKind']
|
||||
hash = subtitle_item['MovieHash']
|
||||
movie_name = subtitle_item['MovieName']
|
||||
movie_release_name = subtitle_item['MovieReleaseName']
|
||||
movie_year = int(subtitle_item['MovieYear']) if subtitle_item['MovieYear'] else None
|
||||
movie_imdb_id = int(subtitle_item['IDMovieImdb'])
|
||||
movie_fps = subtitle_item.get('MovieFPS')
|
||||
series_season = int(subtitle_item['SeriesSeason']) if subtitle_item['SeriesSeason'] else None
|
||||
series_episode = int(subtitle_item['SeriesEpisode']) if subtitle_item['SeriesEpisode'] else None
|
||||
query_parameters = subtitle_item.get("QueryParameters")
|
||||
|
||||
subtitle = PatchedOpenSubtitlesSubtitle(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, fps=movie_fps)
|
||||
logger.debug('Found subtitle %r', subtitle)
|
||||
subtitles.append(subtitle)
|
||||
|
||||
return subtitles
|
||||
|
||||
@@ -7,6 +7,7 @@ from subliminal.providers.podnapisi import PodnapisiProvider, fix_line_ending, P
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PatchedPodnapisiProvider(PodnapisiProvider):
|
||||
def download_subtitle(self, subtitle):
|
||||
# download as a zip
|
||||
@@ -19,4 +20,4 @@ class PatchedPodnapisiProvider(PodnapisiProvider):
|
||||
if len(zf.namelist()) > 1:
|
||||
raise ProviderError('More than one file to unzip')
|
||||
|
||||
subtitle.content = fix_line_ending(zf.read(zf.namelist()[0]))
|
||||
subtitle.content = fix_line_ending(zf.read(zf.namelist()[0]))
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
# coding=utf-8
|
||||
|
||||
import re
|
||||
import logging
|
||||
from subliminal.providers import ParserBeautifulSoup
|
||||
from subliminal.cache import SHOW_EXPIRATION_TIME, region
|
||||
from subliminal.providers.tvsubtitles import TVsubtitlesProvider, link_re
|
||||
|
||||
from subliminal.providers.tvsubtitles import TVsubtitlesProvider
|
||||
from .mixins import PunctuationMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# clean_punctuation actually removes the dash in YYYY-YYYY year range
|
||||
# fixme: clean_punctuation is stupid
|
||||
link_re = re.compile('^(?P<series>.+)(?: \(?\d{4}\)?| \((?:US|UK)\))? \((?P<first_year>\d{4})\d{4}\)$')
|
||||
|
||||
|
||||
class PatchedTVsubtitlesProvider(PunctuationMixin, TVsubtitlesProvider):
|
||||
@region.cache_on_arguments(expiration_time=SHOW_EXPIRATION_TIME)
|
||||
@@ -43,4 +47,4 @@ class PatchedTVsubtitlesProvider(PunctuationMixin, TVsubtitlesProvider):
|
||||
logger.debug('Found show id %d', show_id)
|
||||
break
|
||||
|
||||
return show_id
|
||||
return show_id
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,17 @@
|
||||
# coding=utf-8
|
||||
|
||||
import logging
|
||||
|
||||
import chardet
|
||||
import pysrt
|
||||
import pysubs2
|
||||
from bs4 import UnicodeDammit
|
||||
from subliminal.video import Episode, Movie
|
||||
from subliminal import Subtitle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def compute_score(matches, video, scores=None):
|
||||
"""Compute the score of the `matches` against the `video`.
|
||||
Some matches count as much as a combination of others in order to level the final score:
|
||||
@@ -26,20 +33,135 @@ def compute_score(matches, video, scores=None):
|
||||
|
||||
logger.info('Computing score for matches %r and %r', matches, video)
|
||||
|
||||
is_episode = isinstance(video, Episode)
|
||||
|
||||
episode_hash_valid_if = {"series", "season", "episode"}
|
||||
movie_hash_valid_if = {"title", "video_codec"}
|
||||
|
||||
# remove equivalent match combinations
|
||||
if 'hash' in final_matches:
|
||||
final_matches &= {'hash', 'hearing_impaired'}
|
||||
elif isinstance(video, Episode):
|
||||
# hash is error-prone, try to fix that
|
||||
hash_valid_if = episode_hash_valid_if if is_episode else movie_hash_valid_if
|
||||
|
||||
if hash_valid_if <= set(final_matches):
|
||||
# series, season and episode matched, hash is valid
|
||||
logger.debug('Using valid hash, as %s are correct (%r) and (%r)', hash_valid_if, matches, video)
|
||||
final_matches &= {'hash', 'hearing_impaired'}
|
||||
else:
|
||||
# no match, invalidate hash
|
||||
logger.debug('Ignoring hash as other matches are wrong (missing: %r) and (%r)', hash_valid_if - matches, video)
|
||||
final_matches -= {"hash"}
|
||||
|
||||
elif is_episode:
|
||||
if 'imdb_id' in final_matches:
|
||||
final_matches -= {'series', 'tvdb_id', 'season', 'episode', 'title', 'year'}
|
||||
if 'tvdb_id' in final_matches:
|
||||
final_matches -= {'series', 'year'}
|
||||
if 'title' in final_matches:
|
||||
final_matches -= {'season', 'episode'}
|
||||
|
||||
# compute score
|
||||
logger.debug('Final matches: %r', final_matches)
|
||||
score = sum((scores[match] for match in final_matches))
|
||||
logger.info('Computed score %d', score)
|
||||
|
||||
return score
|
||||
return score
|
||||
|
||||
|
||||
class PatchedSubtitle(Subtitle):
|
||||
storage_path = None
|
||||
|
||||
def guess_encoding(self):
|
||||
"""Guess encoding using the language, falling back on chardet.
|
||||
|
||||
:return: the guessed encoding.
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
logger.info('Guessing encoding for language %s', self.language)
|
||||
|
||||
# always try utf-8 first
|
||||
encodings = ['utf-8']
|
||||
|
||||
# add language-specific encodings
|
||||
if self.language.alpha3 == 'zho':
|
||||
encodings.extend(['gb18030', 'big5'])
|
||||
elif self.language.alpha3 == 'jpn':
|
||||
encodings.append('shift-jis')
|
||||
elif self.language.alpha3 == 'ara':
|
||||
encodings.append('windows-1256')
|
||||
elif self.language.alpha3 == 'heb':
|
||||
encodings.append('windows-1255')
|
||||
elif self.language.alpha3 == 'tur':
|
||||
encodings.extend(['iso-8859-9', 'windows-1254'])
|
||||
|
||||
# Polish, Czech, Slovak, Hungarian, Slovene, Bosnian, Croatian, Serbian (Latin script),
|
||||
# Romanian (before 1993 spelling reform) and Albanian
|
||||
elif self.language.alpha3 in ('pol', 'cze', 'svk', 'hun', 'svn', 'bih', 'hrv', 'srb', 'rou', 'alb'):
|
||||
# Eastern European Group 1
|
||||
encodings.extend(['windows-1250'])
|
||||
|
||||
# Bulgarian, Serbian and Macedonian
|
||||
elif self.language.alpha3 in ('bul', 'srb', 'mkd'):
|
||||
# Eastern European Group 2
|
||||
encodings.extend(['windows-1251'])
|
||||
else:
|
||||
# Western European (windows-1252)
|
||||
encodings.append('latin-1')
|
||||
|
||||
# try to decode
|
||||
logger.debug('Trying encodings %r', encodings)
|
||||
for encoding in encodings:
|
||||
try:
|
||||
self.content.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
else:
|
||||
logger.info('Guessed encoding %s', encoding)
|
||||
return encoding
|
||||
|
||||
logger.warning('Could not guess encoding from language')
|
||||
|
||||
# fallback on chardet
|
||||
encoding = chardet.detect(self.content)['encoding']
|
||||
logger.info('Chardet found encoding %s', encoding)
|
||||
|
||||
if not encoding:
|
||||
# fallback on bs4
|
||||
logger.info('Falling back to bs4 detection')
|
||||
a = UnicodeDammit(self.content)
|
||||
|
||||
Log.Debug("bs4 detected encoding: %s" % a.original_encoding)
|
||||
|
||||
if a.original_encoding:
|
||||
return a.original_encoding
|
||||
raise ValueError(u"Couldn't guess the proper encoding for %s" % self)
|
||||
|
||||
return encoding
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if a :attr:`text` is a valid SubRip format.
|
||||
|
||||
:return: whether or not the subtitle is valid.
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
if not self.text:
|
||||
return False
|
||||
|
||||
# valid srt
|
||||
try:
|
||||
pysrt.from_string(self.text, error_handling=pysrt.ERROR_RAISE)
|
||||
except Exception, e:
|
||||
logger.error("PySRT-parsing failed: %s, trying pysubs2", e)
|
||||
else:
|
||||
return True
|
||||
|
||||
# something else, try to return srt
|
||||
try:
|
||||
logger.debug("Trying parsing with PySubs2")
|
||||
subs = pysubs2.SSAFile.from_string(self.text)
|
||||
self.content = subs.to_string("srt")
|
||||
except:
|
||||
logger.exception("Couldn't convert subtitle %s to .srt format", self)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
Regular → Executable
+59
-29
@@ -2,13 +2,18 @@
|
||||
|
||||
import os
|
||||
import logging
|
||||
from subliminal.video import SUBTITLE_EXTENSIONS, VIDEO_EXTENSIONS, Language, Video, EnzymeError, MKV, guess_file_info, hash_opensubtitles, hash_thesubdb
|
||||
import traceback
|
||||
|
||||
from babelfish import Error as BabelfishError
|
||||
from subliminal.video import SUBTITLE_EXTENSIONS, VIDEO_EXTENSIONS, Language, Video, EnzymeError, MKV, guess_file_info, hash_opensubtitles, \
|
||||
hash_thesubdb
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# may be absolute or relative paths; set to selected options
|
||||
CUSTOM_PATHS = []
|
||||
|
||||
|
||||
def _search_external_subtitles(path):
|
||||
dirpath, filename = os.path.split(path)
|
||||
dirpath = dirpath or '.'
|
||||
@@ -36,7 +41,8 @@ def _search_external_subtitles(path):
|
||||
|
||||
logger.debug('Found subtitles %r', subtitles)
|
||||
|
||||
return subtitles
|
||||
return subtitles
|
||||
|
||||
|
||||
def patched_search_external_subtitles(path):
|
||||
"""
|
||||
@@ -46,31 +52,37 @@ def patched_search_external_subtitles(path):
|
||||
video_path, video_filename = os.path.split(path)
|
||||
subtitles = {}
|
||||
for folder_or_subfolder in [video_path] + CUSTOM_PATHS:
|
||||
# folder_or_subfolder may be a relative path or an absolute one
|
||||
try:
|
||||
abspath = unicode(os.path.abspath(os.path.join(*[video_path if not os.path.isabs(folder_or_subfolder) else "", folder_or_subfolder, video_filename])))
|
||||
except Exception, e:
|
||||
logger.error("skipping path %s because of %s", repr(folder_or_subfolder), e)
|
||||
continue
|
||||
logger.debug("external subs: scanning path %s", abspath)
|
||||
# folder_or_subfolder may be a relative path or an absolute one
|
||||
try:
|
||||
abspath = unicode(os.path.abspath(
|
||||
os.path.join(*[video_path if not os.path.isabs(folder_or_subfolder) else "", folder_or_subfolder, video_filename])))
|
||||
except Exception, e:
|
||||
logger.error("skipping path %s because of %s", repr(folder_or_subfolder), e)
|
||||
continue
|
||||
logger.debug("external subs: scanning path %s", abspath)
|
||||
|
||||
if os.path.isdir(os.path.dirname(abspath)):
|
||||
subtitles.update(_search_external_subtitles(abspath))
|
||||
if os.path.isdir(os.path.dirname(abspath)):
|
||||
subtitles.update(_search_external_subtitles(abspath))
|
||||
logger.debug("external subs: found %s", subtitles)
|
||||
return subtitles
|
||||
|
||||
def scan_video(path, subtitles=True, embedded_subtitles=True, video_type=None):
|
||||
|
||||
def scan_video(path, subtitles=True, embedded_subtitles=True, hints=None, video_fps=None, dont_use_actual_file=False):
|
||||
"""Scan a video and its subtitle languages from a video `path`.
|
||||
:param dont_use_actual_file: guess on filename, but don't use the actual file itself
|
||||
:param str path: existing path to the video.
|
||||
:param bool subtitles: scan for subtitles with the same name.
|
||||
:param bool embedded_subtitles: scan for embedded subtitles.
|
||||
:param hints: hints dict for guessit
|
||||
:return: the scanned video.
|
||||
:rtype: :class:`Video`
|
||||
|
||||
# patch: suggest video type to guessit beforehand
|
||||
"""
|
||||
hints = hints or {}
|
||||
|
||||
# check for non-existing path
|
||||
if not os.path.exists(path):
|
||||
if not dont_use_actual_file and not os.path.exists(path):
|
||||
raise ValueError('Path does not exist')
|
||||
|
||||
# check video extension
|
||||
@@ -78,24 +90,36 @@ def scan_video(path, subtitles=True, embedded_subtitles=True, video_type=None):
|
||||
raise ValueError('%s is not a valid video extension' % os.path.splitext(path)[1])
|
||||
|
||||
dirpath, filename = os.path.split(path)
|
||||
logger.info('Scanning video (type: %s) %r in %r', video_type, filename, dirpath)
|
||||
|
||||
# hint guessit the filename itself and its 2 parent directories if we're an episode (most likely Series name/Season/filename), else only one
|
||||
guess_from = os.path.join(*os.path.normpath(path).split(os.path.sep)[-3 if hints.get("type") == "episode" else -2:])
|
||||
hints = hints or {}
|
||||
logger.info('Scanning video (hints: %s) %r', hints, guess_from)
|
||||
|
||||
# guess
|
||||
video = Video.fromguess(path, guess_file_info(path, options={"type": video_type}))
|
||||
try:
|
||||
video = Video.fromguess(path, guess_file_info(guess_from, options=hints))
|
||||
video.fps = video_fps
|
||||
|
||||
# size and hashes
|
||||
video.size = os.path.getsize(path)
|
||||
if video.size > 10485760:
|
||||
logger.debug('Size is %d', video.size)
|
||||
video.hashes['opensubtitles'] = hash_opensubtitles(path)
|
||||
video.hashes['thesubdb'] = hash_thesubdb(path)
|
||||
logger.debug('Computed hashes %r', video.hashes)
|
||||
else:
|
||||
logger.warning('Size is lower than 10MB: hashes not computed')
|
||||
if dont_use_actual_file:
|
||||
return video
|
||||
|
||||
# external subtitles
|
||||
if subtitles:
|
||||
video.subtitle_languages |= set(patched_search_external_subtitles(path).values())
|
||||
# size and hashes
|
||||
video.size = os.path.getsize(path)
|
||||
if video.size > 10485760:
|
||||
logger.debug('Size is %d', video.size)
|
||||
video.hashes['opensubtitles'] = hash_opensubtitles(path)
|
||||
video.hashes['thesubdb'] = hash_thesubdb(path)
|
||||
logger.debug('Computed hashes %r', video.hashes)
|
||||
else:
|
||||
logger.warning('Size is lower than 10MB: hashes not computed')
|
||||
|
||||
# external subtitles
|
||||
if subtitles:
|
||||
video.subtitle_languages |= set(patched_search_external_subtitles(path).values())
|
||||
except Exception:
|
||||
logger.error("Something went wrong when running guessit: %s", traceback.format_exc())
|
||||
return
|
||||
|
||||
# video metadata with enzyme
|
||||
try:
|
||||
@@ -149,6 +173,9 @@ def scan_video(path, subtitles=True, embedded_subtitles=True, video_type=None):
|
||||
if embedded_subtitles:
|
||||
embedded_subtitle_languages = set()
|
||||
for st in mkv.subtitle_tracks:
|
||||
if st.forced:
|
||||
logger.debug("Ignoring forced subtitle track %r", st)
|
||||
continue
|
||||
if st.language:
|
||||
try:
|
||||
embedded_subtitle_languages.add(Language.fromalpha3b(st.language))
|
||||
@@ -169,6 +196,9 @@ def scan_video(path, subtitles=True, embedded_subtitles=True, video_type=None):
|
||||
logger.debug('MKV has no subtitle track')
|
||||
|
||||
except EnzymeError:
|
||||
logger.exception('Parsing video metadata with enzyme failed')
|
||||
logger.error('Parsing video metadata with enzyme failed')
|
||||
|
||||
return video
|
||||
except Exception:
|
||||
logger.error("Parsing video with enzyme has gone terribly wrong: %s", traceback.format_exc())
|
||||
|
||||
return video
|
||||
|
||||
Regular → Executable
-3
@@ -1,8 +1,5 @@
|
||||
# coding=utf-8
|
||||
|
||||
import datetime
|
||||
|
||||
from plex import Plex
|
||||
from intent import intent
|
||||
|
||||
OS_PLEX_USERAGENT = 'plexapp.com v9.0'
|
||||
|
||||
Regular → Executable
+20
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
OS_PLEX_USERAGENT = 'plexapp.com v9.0'
|
||||
|
||||
DEPENDENCY_MODULE_NAMES = ['subliminal', 'subliminal_patch', 'enzyme', 'guessit', 'requests']
|
||||
DEPENDENCY_MODULE_NAMES = ['subliminal', 'subliminal_patch', 'enzyme', 'guessit']
|
||||
PERSONAL_MEDIA_IDENTIFIER = "com.plexapp.agents.none"
|
||||
PLUGIN_IDENTIFIER_SHORT = "subzero"
|
||||
PLUGIN_IDENTIFIER = "com.plexapp.agents.%s" % PLUGIN_IDENTIFIER_SHORT
|
||||
@@ -11,4 +11,22 @@ PREFIX = "/video/%s" % PLUGIN_IDENTIFIER_SHORT
|
||||
|
||||
TITLE = "%s Subtitles" % PLUGIN_NAME
|
||||
ART = 'art-default.jpg'
|
||||
ICON = 'icon-default.png'
|
||||
ICON = 'icon-default.jpg'
|
||||
|
||||
|
||||
# media types as on https://github.com/Arcanemagus/plex-api/wiki/MediaTypes
|
||||
MOVIE = 1
|
||||
SHOW = 2
|
||||
SEASON = 3
|
||||
EPISODE = 4
|
||||
TRAILER = 5
|
||||
COMIC = 6
|
||||
PERSON = 7
|
||||
ARTIST = 8
|
||||
ALBUM = 9
|
||||
TRACK = 10
|
||||
PHOTO_ALBUM = 11
|
||||
PICTURE = 12
|
||||
PHOTO = 13
|
||||
CLIP = 14
|
||||
PLAYLIST_ITEM = 15
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# coding=utf-8
|
||||
import threading
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
class Debouncer(object):
|
||||
call_history = set()
|
||||
|
||||
def get_lookup_key(self, args, kwargs):
|
||||
func_name = list(args).pop(0).__name__
|
||||
return tuple([func_name] + [(key, value) for key, value in kwargs.iteritems()])
|
||||
|
||||
def __contains__(self, item):
|
||||
args, kwargs = item
|
||||
lookup = self.get_lookup_key(args, kwargs)
|
||||
with lock:
|
||||
return lookup in self.call_history
|
||||
|
||||
def add(self, args, kwargs):
|
||||
with lock:
|
||||
self.call_history.add(self.get_lookup_key(args, kwargs))
|
||||
|
||||
debouncer = Debouncer()
|
||||
@@ -1,14 +1,19 @@
|
||||
# coding=utf-8
|
||||
|
||||
import datetime
|
||||
import threading
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
class TempIntent(dict):
|
||||
timeout = 1000 # milliseconds
|
||||
store = None
|
||||
|
||||
def __init__(self, timeout=1000):
|
||||
self.timeout = timeout
|
||||
self.store = {}
|
||||
self.timeout = timeout
|
||||
with lock:
|
||||
self.store = {}
|
||||
|
||||
def __getattr__(self, name):
|
||||
if name in self:
|
||||
@@ -21,43 +26,63 @@ class TempIntent(dict):
|
||||
if name in self:
|
||||
del self[name]
|
||||
|
||||
def get(self, kind, key):
|
||||
if kind in self["store"]:
|
||||
now = datetime.datetime.now()
|
||||
hit = False
|
||||
for intent in self["store"][kind].keys():
|
||||
# may need locking, for now just play it safe
|
||||
ends = self["store"][kind].get(intent, None)
|
||||
if not ends:
|
||||
continue
|
||||
|
||||
timed_out = False
|
||||
if now > ends:
|
||||
timed_out = True
|
||||
|
||||
if intent == key and not timed_out:
|
||||
hit = True
|
||||
|
||||
if timed_out:
|
||||
try:
|
||||
del self["store"][kind][key]
|
||||
except:
|
||||
continue
|
||||
def get(self, kind, *keys):
|
||||
with lock:
|
||||
# iter all requested keys
|
||||
for key in keys:
|
||||
hit = False
|
||||
|
||||
if hit:
|
||||
return True
|
||||
# skip key if invalid
|
||||
if not key:
|
||||
continue
|
||||
|
||||
# valid kind?
|
||||
if kind in self["store"]:
|
||||
now = datetime.datetime.now()
|
||||
|
||||
# iter all known kinds (previously created)
|
||||
for known_key in self["store"][kind].keys():
|
||||
# may need locking, for now just play it safe
|
||||
ends = self["store"][kind].get(known_key, None)
|
||||
if not ends:
|
||||
continue
|
||||
|
||||
timed_out = False
|
||||
if now > ends:
|
||||
timed_out = True
|
||||
|
||||
# key and kind in storage, and not timed out = hit
|
||||
if known_key == key and not timed_out:
|
||||
hit = True
|
||||
|
||||
if timed_out:
|
||||
try:
|
||||
del self["store"][kind][key]
|
||||
except:
|
||||
continue
|
||||
|
||||
if hit:
|
||||
return True
|
||||
return False
|
||||
|
||||
def resolve(self, kind, key):
|
||||
with lock:
|
||||
if kind in self["store"] and key in self["store"][kind]:
|
||||
del self["store"][kind][key]
|
||||
return True
|
||||
return False
|
||||
|
||||
def set(self, kind, key, timeout=None):
|
||||
if not kind in self["store"]:
|
||||
self["store"][kind] = {}
|
||||
self["store"][kind][key] = datetime.datetime.now() + datetime.timedelta(milliseconds=timeout or self.timeout)
|
||||
with lock:
|
||||
if kind not in self["store"]:
|
||||
self["store"][kind] = {}
|
||||
self["store"][kind][key] = datetime.datetime.now() + datetime.timedelta(milliseconds=timeout or self.timeout)
|
||||
|
||||
def has(self, kind, key):
|
||||
if not kind in self["store"]:
|
||||
return False
|
||||
return key in self["store"][kind]
|
||||
|
||||
with lock:
|
||||
if kind not in self["store"]:
|
||||
return False
|
||||
return key in self["store"][kind]
|
||||
|
||||
|
||||
intent = TempIntent()
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# coding=utf-8
|
||||
|
||||
|
||||
class DictProxy(object):
|
||||
store = None
|
||||
|
||||
def __init__(self, d):
|
||||
self.Dict = d
|
||||
super(DictProxy, self).__init__()
|
||||
|
||||
if self.store not in self.Dict or not self.Dict[self.store]:
|
||||
self.Dict[self.store] = self.setup_defaults()
|
||||
|
||||
def __getattr__(self, name):
|
||||
if name in self.Dict[self.store]:
|
||||
return self.Dict[self.store][name]
|
||||
return getattr(super(self.DictProxy, self), name)
|
||||
|
||||
def __cmp__(self, d):
|
||||
return cmp(self.Dict[self.store], d)
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.Dict[self.store]
|
||||
|
||||
def __setitem__(self, key, item):
|
||||
self.Dict[self.store][key] = item
|
||||
self.Dict.Save()
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.Dict[self.store])
|
||||
|
||||
def __getitem__(self, key):
|
||||
if key in self.Dict[self.store]:
|
||||
return self.Dict[self.store][key]
|
||||
|
||||
def __repr__(self):
|
||||
return repr(self.Dict[self.store])
|
||||
|
||||
def __str__(self):
|
||||
return str(self.Dict[self.store])
|
||||
|
||||
def __len__(self):
|
||||
return len(self.Dict[self.store].keys())
|
||||
|
||||
def __delitem__(self, key):
|
||||
del self.Dict[self.store][key]
|
||||
|
||||
def clear(self):
|
||||
del self.Dict[self.store]
|
||||
return None
|
||||
|
||||
def copy(self):
|
||||
return self.Dict[self.store].copy()
|
||||
|
||||
def has_key(self, k):
|
||||
return k in self.Dict[self.store]
|
||||
|
||||
def pop(self, k, d=None):
|
||||
return self.Dict[self.store].pop(k, d)
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
return self.Dict[self.store].update(*args, **kwargs)
|
||||
|
||||
def keys(self):
|
||||
return self.Dict[self.store].keys()
|
||||
|
||||
def values(self):
|
||||
return self.Dict[self.store].values()
|
||||
|
||||
def items(self):
|
||||
return self.Dict[self.store].items()
|
||||
|
||||
def __unicode__(self):
|
||||
return unicode(repr(self.Dict[self.store]))
|
||||
|
||||
def setup_defaults(self):
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,45 @@
|
||||
# coding=utf-8
|
||||
|
||||
|
||||
class PlexPyNativeResponseProxy(object):
|
||||
"""
|
||||
The equally stupid counterpart to Sub-Zero.support.lib.PlexPyNativeRequestProxy.
|
||||
Incompletely mimics a requests response object for the plex.py library to use.
|
||||
"""
|
||||
data = None
|
||||
headers = None
|
||||
response_code = None
|
||||
request = None
|
||||
|
||||
def __init__(self, response, status_code, request):
|
||||
if response:
|
||||
self.data = response.content
|
||||
self.headers = response.headers
|
||||
self.response_code = status_code
|
||||
self.request = request
|
||||
|
||||
def content(self):
|
||||
return self.data
|
||||
|
||||
content = property(content)
|
||||
|
||||
def status_code(self):
|
||||
return self.response_code
|
||||
|
||||
status_code = property(status_code)
|
||||
|
||||
def url(self):
|
||||
return self.request.url
|
||||
|
||||
url = property(url)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.data)
|
||||
|
||||
def __unicode__(self):
|
||||
return unicode(self.data)
|
||||
|
||||
def __repr__(self):
|
||||
return repr(self.data)
|
||||
|
||||
|
||||
Regular → Executable
+12
-1
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# thanks @ plex trakt scrobbler: https://github.com/trakt/Plex-Trakt-Scrobbler/blob/master/Trakttv.bundle/Contents/Libraries/Shared/plugin/core/io.py
|
||||
|
||||
@@ -32,4 +33,14 @@ class FileIO(object):
|
||||
fp.write(data)
|
||||
|
||||
# Close file
|
||||
fp.close()
|
||||
fp.close()
|
||||
|
||||
|
||||
VALID_ENCODINGS = ("latin1", "utf-8", "mbcs")
|
||||
|
||||
|
||||
def get_viable_encoding():
|
||||
# fixme: bad
|
||||
encoding = sys.getfilesystemencoding()
|
||||
return "utf-8" if not encoding or encoding.lower() not in VALID_ENCODINGS else encoding
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# coding=utf-8
|
||||
|
||||
# restore builtins
|
||||
|
||||
|
||||
def restore_builtins(module, base):
|
||||
module.__builtins__ = [x for x in base.__class__.__base__.__subclasses__() if x.__name__ == 'catch_warnings'][0]()._module.__builtins__
|
||||
@@ -0,0 +1,24 @@
|
||||
# coding=utf-8
|
||||
|
||||
import logging, sys
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
import subliminal_patch
|
||||
import subliminal
|
||||
|
||||
subliminal.region.configure('dogpile.cache.memory')
|
||||
from subliminal.video import scan_video
|
||||
|
||||
from subliminal.subtitle import compute_score
|
||||
from babelfish import Language
|
||||
from subliminal.api import download_best_subtitles
|
||||
|
||||
v = scan_video('Series/Midsomer Murders/S4/Midsomer.Murders.S04E02.Destroying_Angel.avi', dont_use_actual_file=True)
|
||||
|
||||
#pool = ProviderPool()
|
||||
#subs = pool.list_subtitles(v, set([Language.fromietf('nl')]))
|
||||
|
||||
#[pool.download_subtitle(sub) for sub in subs];"
|
||||
|
||||
download_best_subtitles([v], set([Language.fromietf('nl')]), providers=["opensubtitles"])
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user