418 lines
15 KiB
Python
418 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
|
import re
|
|
from requests import Session
|
|
from requests.cookies import RequestsCookieJar
|
|
import json
|
|
import logging
|
|
from subzero.language import Language
|
|
from bs4 import BeautifulSoup
|
|
from guessit import guessit
|
|
|
|
from subliminal_patch.providers import Provider
|
|
from subliminal.providers import Episode, Movie
|
|
from subliminal_patch.utils import sanitize
|
|
from subliminal_patch.subtitle import Subtitle, guess_matches
|
|
from subliminal.subtitle import fix_line_ending
|
|
from subliminal.exceptions import ConfigurationError, AuthenticationError
|
|
|
|
from string import hexdigits
|
|
from collections import defaultdict
|
|
import pbkdf2
|
|
from base64 import b64decode, b64encode
|
|
from hashlib import sha256
|
|
import pyaes
|
|
|
|
__author__ = "Dor Nizar"
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class KtuvitSubtitle(Subtitle):
|
|
provider_name = 'ktuvit'
|
|
|
|
def __init__(self, language, title_id, subtitle_id, series, season, episode, release, year):
|
|
super(KtuvitSubtitle, self).__init__(language, subtitle_id)
|
|
self.title_id = title_id
|
|
self.subtitle_id = subtitle_id
|
|
self.series = series
|
|
self.season = season
|
|
self.episode = episode
|
|
self.release = release
|
|
self.year = year
|
|
|
|
def get_matches(self, video):
|
|
matches = set()
|
|
logger.debug("[{}]\n{}".format(self.__class__.__name__, self.__dict__))
|
|
|
|
# episode
|
|
if isinstance(video, Episode):
|
|
# series
|
|
if video.series and sanitize(self.series) == sanitize(video.series):
|
|
matches.add('series')
|
|
# season
|
|
if video.season and self.season == video.season:
|
|
matches.add('season')
|
|
# episode
|
|
if video.episode and self.episode == video.episode:
|
|
matches.add('episode')
|
|
# guess
|
|
matches |= guess_matches(video, guessit(self.release, {'type': 'episode'}))
|
|
# movie
|
|
elif isinstance(video, Movie):
|
|
# title
|
|
if video.title and (sanitize(self.series) in (
|
|
sanitize(name) for name in [video.title] + video.alternative_titles)):
|
|
matches.add('title')
|
|
# year
|
|
if video.year and self.year == video.year:
|
|
matches.add('year')
|
|
# guess
|
|
matches |= guess_matches(video, guessit(self.release, {'type': 'movie'}))
|
|
|
|
logger.debug("Ktuvit subtitle criteria match:\n{}".format(matches))
|
|
return matches
|
|
|
|
@property
|
|
def id(self):
|
|
return self.subtitle_id
|
|
|
|
|
|
class KtuvitProvider(Provider):
|
|
subtitle_class = KtuvitSubtitle
|
|
languages = {Language.fromalpha2(l) for l in ['he']}
|
|
URL_SERVER = 'https://www.ktuvit.me/'
|
|
|
|
URI_LOGIN = 'Login.aspx'
|
|
URI_LOGIN_POST = 'Services/MembershipService.svc/Login'
|
|
URI_SEARCH_TITLE = 'Services/ContentProvider.svc/GetSearchForecast'
|
|
URI_SEARCH_SERIES_SUBTITLE = 'Services/GetModuleAjax.ashx'
|
|
URI_SEARCH_MOVIE_SUBTITLE = "MovieInfo.aspx"
|
|
URI_REQ_SUBTITLE_ID = "Services/ContentProvider.svc/RequestSubtitleDownload"
|
|
URI_DOWNLOAD_SUBTITLE = "Services/DownloadFile.ashx"
|
|
|
|
def __init__(self, username=None, password=None):
|
|
if not all((username, password)):
|
|
raise ConfigurationError('Username and password must be specified')
|
|
|
|
self.session = None
|
|
self.username = username
|
|
self.password = password
|
|
self.encrypted_password = None
|
|
self.salt = None
|
|
|
|
def encrypt_password(self):
|
|
logger.debug("password: {}".format(self.password))
|
|
encrypted_password = KtuvitEncryptor(self.username, self.password, self.salt).encrypt()
|
|
if not encrypted_password:
|
|
logger.error("Could not encrypt password")
|
|
return False
|
|
self.encrypted_password = encrypted_password
|
|
return True
|
|
|
|
def get_encryption_salt(self):
|
|
p = re.compile(r"var encryptionSalt = '(.*)';")
|
|
r = self.session.get(self.URL_SERVER + self.URI_LOGIN)
|
|
r.raise_for_status()
|
|
|
|
logger.debug("Searching for encryptionSalt...")
|
|
script_list = [i for i in BeautifulSoup(r.content, 'html.parser').select('div#navbar script') if i.contents]
|
|
for item in script_list:
|
|
search_salt = p.search(item.contents[0])
|
|
if search_salt:
|
|
self.salt = search_salt.group(1)
|
|
return True
|
|
logger.error("Could not get encryptionSalt")
|
|
return False
|
|
|
|
def login(self):
|
|
if not self.get_encryption_salt():
|
|
return False
|
|
if not self.encrypt_password():
|
|
return False
|
|
data = {
|
|
"request": {
|
|
"Email": self.username,
|
|
"Password": self.encrypted_password
|
|
}
|
|
}
|
|
logger.debug("Trying to log in using: {}".format(json.dumps(data)))
|
|
r = self.session.post(self.URL_SERVER + self.URI_LOGIN_POST, json=data)
|
|
r_result = r.json()
|
|
login_results = ""
|
|
if 'd' in r_result:
|
|
try:
|
|
login_results = json.loads(r_result['d'])
|
|
except ValueError:
|
|
logger.error("Could not process JSON from login response")
|
|
return False
|
|
|
|
if 'IsSuccess' not in login_results:
|
|
logger.error("Login response is different than expected")
|
|
return False
|
|
|
|
if not login_results['IsSuccess']:
|
|
logger.error("Wrong username or password!")
|
|
raise AuthenticationError('Wrong username or password!')
|
|
return False
|
|
|
|
if not r.cookies or type(r.cookies) is not RequestsCookieJar:
|
|
logger.error("Could not get the cookie response of the login")
|
|
return False
|
|
|
|
if 'Login' not in r.cookies.keys():
|
|
logger.error("Could not found login cookie!")
|
|
return False
|
|
|
|
logger.info("Connected successfully to Ktuvit!")
|
|
return True
|
|
|
|
def initialize(self):
|
|
logger.debug("Ktuvit initialize")
|
|
self.session = Session()
|
|
self.session.headers[
|
|
'User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; ' \
|
|
'Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36'
|
|
|
|
if not self.login():
|
|
return False
|
|
|
|
def terminate(self):
|
|
logger.debug("Ktuvit terminate")
|
|
self.session.close()
|
|
|
|
def _search_series(self, title):
|
|
logger.debug("Searching '{}'".format(title))
|
|
title_request = {
|
|
"request": {
|
|
"SearchString": title,
|
|
"SearchType": "Film"
|
|
}
|
|
}
|
|
r = self.session.post(self.URL_SERVER + self.URI_SEARCH_TITLE, json=title_request, allow_redirects=False,
|
|
timeout=10)
|
|
r.raise_for_status()
|
|
series_found = r.json()
|
|
if 'd' in series_found:
|
|
try:
|
|
series_found = json.loads(series_found['d'])
|
|
except ValueError:
|
|
series_found = None
|
|
if 'Items' in series_found:
|
|
return series_found['Items']
|
|
return []
|
|
|
|
def _search_subtitles(self, title_id, season=None, episode=None):
|
|
if season and episode:
|
|
params = {
|
|
'moduleName': 'SubtitlesList',
|
|
'SeriesID': title_id,
|
|
'Season': season,
|
|
'Episode': episode
|
|
}
|
|
r = self.session.get(url=self.URL_SERVER + self.URI_SEARCH_SERIES_SUBTITLE, params=params)
|
|
else:
|
|
params = {
|
|
'ID': title_id,
|
|
}
|
|
r = self.session.get(url=self.URL_SERVER + self.URI_SEARCH_MOVIE_SUBTITLE, params=params)
|
|
|
|
r.raise_for_status()
|
|
results = r.content
|
|
if not results:
|
|
return []
|
|
subtitles = BeautifulSoup(results, 'html.parser').select('a.fa')
|
|
logger.debug("[BS4] Elements found:\n{}".format(subtitles))
|
|
subtitle_list = []
|
|
for i in subtitles:
|
|
subtitle_id = i.attrs['data-subtitle-id']
|
|
release = i.findParent().findParent().text.strip().split('\n')[0]
|
|
subtitle_list.append((subtitle_id, release))
|
|
|
|
return subtitle_list # [(Subtitle ID, name), (....)]
|
|
|
|
def _req_download_identifier(self, title_id, subtitle_id):
|
|
logger.debug("Request subtitle identifier for: title id: {}, subtitle id: {}".format(title_id, subtitle_id))
|
|
data = {
|
|
'request': {
|
|
'FilmID': title_id,
|
|
'SubtitleID': subtitle_id,
|
|
'FontSize': 0,
|
|
'FontColor': "",
|
|
'PredefinedLayout': -1
|
|
}
|
|
}
|
|
|
|
r = self.session.post(self.URL_SERVER + self.URI_REQ_SUBTITLE_ID, json=data, allow_redirects=False,
|
|
timeout=10)
|
|
r.raise_for_status()
|
|
try:
|
|
r = json.loads(r.json()['d'])
|
|
except ValueError:
|
|
r = {}
|
|
|
|
if 'DownloadIdentifier' not in r:
|
|
logger.error("Download Identifier not found")
|
|
return None
|
|
return r['DownloadIdentifier']
|
|
|
|
def _download_subtitles(self, download_id):
|
|
logger.debug("Downloading subtitles by download identifier - {}".format(download_id))
|
|
data = {'DownloadIdentifier': download_id}
|
|
r = self.session.get(self.URL_SERVER + self.URI_DOWNLOAD_SUBTITLE, params=data,
|
|
timeout=10)
|
|
r.raise_for_status()
|
|
if not r.content:
|
|
logger.debug("Download subtitle failed")
|
|
return None
|
|
|
|
logger.debug("Download subtitle success")
|
|
return r.content
|
|
|
|
def query(self, title, season=None, episode=None, year=None):
|
|
subtitles = []
|
|
titles = self._search_series(title)
|
|
if season and episode:
|
|
logger.debug("Searching for:\nTitle: {}\nSeason: {}\nEpisode: {}\nYear: {}".format(title, season,
|
|
episode, year))
|
|
else:
|
|
logger.debug("Searching for:\nTitle: {}\nYear: {}\n".format(title, year))
|
|
for title in titles:
|
|
logger.debug("Title Candidate: {}".format(title))
|
|
title_id = title['ID']
|
|
if season and episode:
|
|
result = self._search_subtitles(title_id, season, episode)
|
|
else:
|
|
result = self._search_subtitles(title_id)
|
|
|
|
if not result:
|
|
continue
|
|
|
|
for subtitle_id, release in result:
|
|
subtitles.append(self.subtitle_class(next(iter(self.languages)), title_id, subtitle_id,
|
|
title['EngName'], season, episode, release, year))
|
|
|
|
if subtitles:
|
|
logger.debug("Found Subtitle Candidates: {}".format(subtitles))
|
|
return subtitles
|
|
|
|
def list_subtitles(self, video, languages):
|
|
season = episode = year = title = None
|
|
|
|
if isinstance(video, Episode):
|
|
logger.info("list_subtitles Series: {}, season: {}, episode: {}".format(video.series,
|
|
video.season,
|
|
video.episode))
|
|
title = video.series
|
|
season = video.season
|
|
episode = video.episode
|
|
elif isinstance(video, Movie):
|
|
logger.info("list_subtitles Movie: {}, year: {}".format(video.title, video.year))
|
|
title = video.title
|
|
year = video.year
|
|
|
|
return [s for s in self.query(title, season, episode, year) if s.language in languages]
|
|
|
|
def download_subtitle(self, subtitle):
|
|
# type: (KtuvitSubtitle) -> None
|
|
|
|
logger.info('Downloading subtitle from Ktuvit: %r', subtitle)
|
|
download_id = self._req_download_identifier(subtitle.title_id, subtitle.subtitle_id)
|
|
if not download_id:
|
|
logger.debug('Unable to retrieve download identifier')
|
|
return None
|
|
|
|
content = self._download_subtitles(download_id)
|
|
if not content:
|
|
logger.debug('Unable to download subtitle')
|
|
return None
|
|
|
|
subtitle.content = fix_line_ending(content)
|
|
|
|
|
|
class KtuvitEncryptor:
|
|
def __init__(self, username, password, salt):
|
|
if not all((username, password, salt)):
|
|
raise Exception("Encryptor did not get all required arguments")
|
|
|
|
self.encrypted_password = None
|
|
self.username = username
|
|
self.password = password
|
|
self.salt = salt
|
|
|
|
@staticmethod
|
|
def rshift(val, n):
|
|
return (val % 0x100000000) >> n
|
|
|
|
@staticmethod
|
|
def js_parseint(s, rad=10):
|
|
digits = ''
|
|
for c in str(s).strip():
|
|
if c not in hexdigits:
|
|
break
|
|
digits += c
|
|
|
|
return int(digits, rad) if digits else 0
|
|
|
|
@staticmethod
|
|
def to_signed32(n):
|
|
n = n & 0xffffffff
|
|
return n | (-(n & 0x80000000))
|
|
|
|
def stringify(self, words, length):
|
|
sigbytes = int(length / 2) + int((length % 2) > 0)
|
|
hex_chars = list()
|
|
|
|
for i in xrange(0, sigbytes):
|
|
bite = self.rshift(words[self.rshift(i, 2)], (24 - (i % 4) * 8)) & 0xff
|
|
hex_chars.append(format(self.rshift(bite, 4), 'x'))
|
|
hex_chars.append(format(bite & 0x0f, 'x'))
|
|
return ''.join(hex_chars)
|
|
|
|
def cryptojs_hexparse(self, s):
|
|
words = defaultdict(int)
|
|
for i in range(0, len(s), 2):
|
|
tmp1 = (self.js_parseint(s[i:i + 2], 16))
|
|
tmp2 = (24 - (i % 8) * 4)
|
|
tmp3 = self.to_signed32(tmp1 << tmp2)
|
|
words[self.rshift(i, 3)] |= tmp3
|
|
return self.stringify(words, len(s))
|
|
|
|
def cryptojs_pad_iv(self, iv):
|
|
return str.ljust(self.cryptojs_hexparse(iv), 32, '0').decode('hex')
|
|
|
|
@staticmethod
|
|
def pbkdf2_encrypt(key, salt):
|
|
a = pbkdf2.PBKDF2(salt, key, 3000)
|
|
return a.read(16)
|
|
|
|
@staticmethod
|
|
def pad(m):
|
|
return m + chr(16 - len(m) % 16) * (16 - len(m) % 16)
|
|
|
|
def aes_encrypt(self, msg, key, iv):
|
|
if len(iv) != 16:
|
|
logger.error("iv (Len: {}) - {} is not 16 length".format(len(iv), iv))
|
|
return False
|
|
msg = self.pad(msg)
|
|
|
|
aes = pyaes.AESModeOfOperationCBC(key, iv=iv[:16])
|
|
return b64encode(aes.encrypt(msg))
|
|
|
|
def encrypt(self):
|
|
if not self.salt:
|
|
logger.error("No salt was instantiated!")
|
|
return False
|
|
msg = self.password.encode('utf-8')
|
|
iv = self.cryptojs_pad_iv(self.username)
|
|
key = self.pbkdf2_encrypt(self.username, self.salt.encode('utf-8'))
|
|
|
|
cipher = self.aes_encrypt(msg, key, iv)
|
|
if not cipher:
|
|
return False
|
|
|
|
hash_sha256 = sha256(b64decode(cipher))
|
|
self.encrypted_password = b64encode(hash_sha256.digest())
|
|
logger.debug("Encrypted password: {}".format(self.encrypted_password))
|
|
logger.debug("Original password: {}".format(self.password))
|
|
return self.encrypted_password
|