-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpairsubs.py
More file actions
561 lines (475 loc) · 17.6 KB
/
Copy pathpairsubs.py
File metadata and controls
561 lines (475 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
import xmlrpc.client
import http.client
import base64
import zlib
import srt
from srt import SRTParseError
from bs4 import UnicodeDammit
from datetime import timedelta
from urllib.parse import urlparse
import random
import os
import json
import codecs
import re
from time import sleep
import pairsubs_gui
import logging
from logging import NullHandler
logger = logging.getLogger(__name__)
logger.addHandler(NullHandler())
COLUMN_WIDTH = 40
OPENSUBTUTLES_MAX_RETRY = 3
#: Directory in which to store PaiSubs cache.
APP_DIR = '{}/.pairsubs'.format(os.path.expanduser('~'))
FILES_DIR = os.path.join(APP_DIR, 'files')
#: File in which to store details aboud downloaded subtitles
CACHE_DB = '{}/cache.json'.format(APP_DIR)
# Opensubtitles API retry count
MAX_RETRY = 5
RETRY_DELAY = 3
# Parse fail
# https://www.imdb.com/title/tt0583453/?ref_=tt_ep_pr
class OpensubtitlesError(Exception):
def __str__(self):
return 'Max retry number was exceeded during access to Opensubtitles.org'
class ProxiedTransport(xmlrpc.client.Transport):
def set_proxy(self, host, port=None, headers=None):
self.proxy = host, port
self.proxy_headers = headers
def make_connection(self, host):
connection = http.client.HTTPConnection(*self.proxy)
connection.set_tunnel(host, headers=self.proxy_headers)
self._connection = host, connection
return connection
class Opensubtitles:
"""Class for opensuntitles.org access."""
user_agent = "OS Test User Agent"
def __init__(self):
"""Init xml-rpc proxy."""
proxy_url = os.environ.get('http_proxy', '')
if proxy_url:
parsed = urlparse(proxy_url).netloc
transport = ProxiedTransport()
transport.set_proxy(parsed)
self.proxy = xmlrpc.client.ServerProxy(
"https://api.opensubtitles.org/xml-rpc",
transport=transport)
else:
self.proxy = xmlrpc.client.ServerProxy(
"https://api.opensubtitles.org/xml-rpc")
def retry(func):
def wrapper(self, *args, **kwargs):
for i in range(MAX_RETRY):
try:
res = func(self, *args, **kwargs)
except (xmlrpc.client.ProtocolError, http.client.ResponseNotReady) as e:
logger.info("Retry #{}".format(i+1))
sleep(RETRY_DELAY)
else:
return res
raise OpensubtitlesError
return wrapper
@retry
def logout(self):
"""Logout from api.opensubtitles.org."""
logger.info("Opensubtitles: Logout...")
self.proxy.LogOut(self.token)
@retry
def login(self):
"""Login into api.opensubtitles.org."""
logger.info("Opensubtitles: Login...")
login = self.proxy.LogIn("", "", "en", "TemporaryUserAgent")
self.token = login['token']
def _select_sub_(self, subtitles):
"""Select subtitles that have maximal downloads count."""
rate = 0
top_sub = None
for sub in subtitles:
if int(sub['SubDownloadsCnt']) >= rate:
rate = int(sub['SubDownloadsCnt'])
top_sub = sub
return top_sub
@retry
def search_sub(self, imdbid, lang):
"""
Search the subtitles in Opensubtitles database
by IMBD id and a language.
Return dict as described in
http://trac.opensubtitles.org/projects/opensubtitles/wiki/XMLRPC#SearchSubtitles
Args:
`imdbid` (int): Movie's IMDB id
`lang` (str): Language of subtitles in ISO639 format (3-letter)
Returns:
(dict): subtitles info in Opensubtitles API format
"""
logger.info("Opensubtitles: search...")
m = re.search(r'\d+', imdbid)
if m:
imdb = m[0]
result = self.proxy.SearchSubtitles(
self.token,
[{'imdbid': str(imdb), 'sublanguageid': lang}],
[100])
return self._select_sub_(result['data'])
@retry
def download_sub(self, sub):
"""
Download subtitles from subtitles.org.
Args:
`sub` (dict): subtitles info in Opensubtitles API format
Return:
`data_bytes` (bytes): downloaded subtitles
"""
logger.info("Opensubtitles: download...")
result = self.proxy.DownloadSubtitles(self.token,
[sub['IDSubtitleFile']])
data_zipped = base64.b64decode(result['data'][0]['data'])
data_bytes = zlib.decompress(data_zipped, 15+32)
return data_bytes
class Subs:
"""
Base class for subtitles
Args:
`sub_data` (bytes): subtitles in SRT format
`sub_info` (dict): subtitles information
`decode` (bool): True if to decode subtitles as SubLanguageID defines
Attributes:
`sub_info` (dict): subtitles information
`sub` (list of `Subtitles`)
"""
def __init__(self, sub_data, sub_info, decode=True):
info_keys = ('SubLanguageID',
'SubFileName',
'SubEncoding',
'MovieName',
'IDMovieImdb',
'IDSubtitleFile')
self.sub_info = {}
for k in info_keys:
self.sub_info[k] = sub_info.get(k, None)
# Decode bytes to Unicode string
if decode:
data_decoded = self.sub_decode(sub_data,
self.sub_info['SubEncoding'])
else:
data_decoded = sub_data
# Parse bytes into a list of Subtitles objects
self.sub = self._parse_subtitles(data_decoded)
def __repr__(self):
return "Subs: [{}] [{}] [{}]".format(self.sub_info['MovieName'],
self.sub_info['IDMovieImdb'],
self.sub_info['SubLanguageID'])
@staticmethod
def sub_decode(data, encoding):
"""
Args:
`data` (bytes): subtitles in SRT format
`encoding`: (str): encoding
"""
if encoding:
if data.startswith(codecs.BOM_UTF8):
enc = 'utf-8-sig'
else:
enc = encoding
return data.decode(enc, errors='replace')
else:
return UnicodeDammit(data).unicode_markup
def save(self, name=None):
"""Save subtitles file."""
data = srt.compose(self.sub)
file_name = name if name else self.sub_info['SubFileName']
with open(os.path.join(FILES_DIR, file_name), 'w') as f:
f.write(data)
@classmethod
def read(cls, sub_info):
"""
Read sibtitles from file.
Args:
`sub_info` (dict): subtitles information
Returns:
`Subs` object
"""
name = os.path.join(FILES_DIR, sub_info['SubFileName'])
with open(name, 'r') as f:
data = f.read()
return cls(data, sub_info, decode=False)
def get_subs(self, start, end):
"""
Returns list of subtitles whose timedelta is between start and stop.
Args:
`start` (float): start time of subtitles (seconds)
`end` (float): end time of subtitles (seconds)
Returns:
list` of `Subtitles`
"""
subs = []
for s in self.sub:
if (s.start >= self.seconds_to_timedelta(start) and
s.start <= self.seconds_to_timedelta(end)):
subs.append(s)
return subs
def _parse_subtitles(self, data):
"""
Parse subtitles from str.
Args:
`data` (str): subtitles data
Returns:
list of `Subtitles`
"""
try:
sub = list(srt.parse(data))
except (ValueError, SRTParseError) as e:
logger.error("Subtitles parsing failed: {}".format(e))
sub = []
return sub
def seconds_to_timedelta(self, seconds):
s = int(seconds)
ms = seconds - s
return timedelta(seconds=s, milliseconds=ms)
class SubPair:
"""Pair of subtitles.
Attributes:
`subs`: tuple of two `Subs` objects
`first_start` (float):
`first_end` (float):
`second_start` (float):
`second_end` (float):
"""
def __init__(self, subs):
"""
Args:
`subs`: tuple of two `Subs` objects
"""
self.subs = subs
self.first_start = 0
self.first_end = subs[0].sub[-1].start.total_seconds()
self.second_start = 0
self.second_end = subs[0].sub[-1].start.total_seconds()
def __repr__(self):
return "[{}, {}]".format(self.subs[0].__repr__(),
self.subs[1].__repr__())
@classmethod
def download(cls, imdbid, lang1, lang2, enc1=None, enc2=None):
logger.info("Start subtitles download: {} ({}, {})".format(
imdbid, lang1, lang2))
logger.info("Login into Opensubtitles...")
osub = Opensubtitles()
osub.login()
subs = []
for lang, enc in [(lang1, enc1), (lang2, enc2)]:
logger.info("Search {}...".format(lang))
sub = osub.search_sub(imdbid, lang)
if sub:
logger.info("Download {}...".format(lang))
sub_b = osub.download_sub(sub)
s = Subs(sub_b, sub)
if s.sub:
subs.append(s)
else:
logger.info("Failed the subtitles parsing".format(lang))
return None
else:
logger.info("Subtitles #{} aren't found".format(lang))
osub.logout()
return None
osub.logout()
return cls(subs)
@classmethod
def read(cls, info):
subs = []
for sub in info['subs']:
s = Subs.read(sub)
subs.append(s)
return cls(subs)
def get_parallel_subs(self, start, length):
"""
Args:
`start` (float): 0-100 percenrage of full length from the begin
`lenght` (int): duration in seconds
Returns:
list of to two lists of `Subtitles`
"""
first_len = self.first_end - self.first_start
second_len = self.second_end - self.second_start
coeff = first_len/second_len
offset = first_len * start / 100
f_start = self.first_start + offset
f_end = f_start + length
s_start = self.second_start + offset/coeff
s_end = s_start + length/coeff
par_subs = []
for s, params in zip(self.subs, [(f_start, f_end), (s_start, s_end)]):
subs = s.get_subs(*params)
par_subs.append(subs)
return par_subs
def align_subs(self, left_start, right_start, left_end, right_end):
self.first_start = self.subs[0].sub[left_start-1].start.total_seconds()
self.first_end = self.subs[0].sub[left_end-1].start.total_seconds()
self.second_start = self.subs[1].sub[right_start-1].start.total_seconds()
self.second_end = self.subs[1].sub[right_end-1].start.total_seconds()
def save_subs(self):
for sub in self.subs:
sub.save()
def get_id(self):
return '_'.join([
self.subs[0].sub_info['IDSubtitleFile'],
self.subs[1].sub_info['IDSubtitleFile']
])
def get_data(self):
return {'first_start': self.first_start,
'first_end': self.first_end,
'second_start': self.second_start,
'second_end': self.second_end,
'subs': [
self.subs[0].sub_info,
self.subs[1].sub_info
]}
class SubDb():
"""Subtitles Database Class.
Attributes:
data: (dict of dicts) SubPairs info dictionary with fields:
- `sub_id` (str): subpair_info (:obj:`dict`)
subpair_info (:`obj`:`dict`) : SubPairs info dictionary with fields:
`first_start`: (float)
`first_end`: (float)
`second_start`: (float)
`second_end`: (float)
`subs`: sub_info (list of dicts)
sub_info (dict) : sub info dictionary with fields:
`SubLanguageID`: (str)
`SubFileName` : (str)
`SubEncoding` : (str)
`MovieName` :(str)
`IDMovieImdb` : (str)
`IDSubtitleFile` :(str)
cache: (dict of {str: `SubPair`}) SubPairs dictionary with fields:
"""
def __init__(self):
self.data = self.load_data()
self.cache = {}
def load_data(self):
"""Load subtitles info data."""
# verifies that the application directory (~/.pairsubs) exists,
# else create it
if not os.path.exists(APP_DIR):
os.makedirs(APP_DIR)
if not os.path.exists(FILES_DIR):
os.makedirs(FILES_DIR)
# If the cache db doesn't exist we create it.
# Otherwise we only open for reading
if not os.path.isfile(CACHE_DB):
with open(CACHE_DB, 'a'):
os.utime(CACHE_DB, None)
data = {}
# We know from above that this file exists so we open it
# for reading only.
with open(CACHE_DB, 'r') as f:
try:
data = json.load(f)
except ValueError:
pass
return data
def is_in_db(self, sub_pair):
sub_id = sub_pair.get_id()
return sub_id in self.data
def add_subpair(self, sub_pair):
if not self.is_in_db(sub_pair):
sub_id = sub_pair.get_id()
sub_data = sub_pair.get_data()
self.data[sub_id] = sub_data
def download(self, imdbid, lang1, lang2):
"""
Downloads subtitles from Opensubtitles.org.
Args:
`imdbid` (str): INDB id string (or URL)
`lang1` (str): first language
`lang2` (str): second language
Returns:
`SubPair` object
"""
sub_pair = SubPair.download(imdbid, lang1, lang2)
if sub_pair:
self.add_subpair(sub_pair)
self.add_to_cache(sub_pair)
self.write_db()
sub_pair.save_subs()
return sub_pair.get_id()
def write_db(self):
# update db data with the alignment data from cache
keys = ('first_start', 'first_end',
'second_start', 'second_end')
for sub_id in self.cache:
for k in keys:
self.data[sub_id][k] = getattr(self.cache[sub_id], k)
with open(CACHE_DB, 'w') as f:
f.write(json.dumps(self.data))
def add_to_cache(self, sub_pair):
sub_id = sub_pair.get_id()
if sub_id not in self.cache:
self.cache[sub_id] = sub_pair
def read_subpair(self, sub_id):
if sub_id not in self.cache:
sub_info = self.data[sub_id]
sub_pair = SubPair.read(sub_info)
self.add_to_cache(sub_pair)
def get_subs(self, sub_id=None):
if self.data:
if not sub_id: # get random sub
sub_id = random.choice(list(self.data.keys()))
if sub_id not in self.cache:
self.read_subpair(sub_id)
position = random.randint(0, 100)
subs = self.cache[sub_id].get_parallel_subs(position, 20)
return sub_id, subs
def get_subs_to_align(self, sub_id, count=4):
"""
Get subtittles for manual alignment.
To align you need several subtitles from the begin and the end
of each subtitle file.
Args:
`sub_id` (str): SubPair id
`count` (int): number of subtitles from the begin and the end
Returns:
`subs` (tuple): tuple of 4 lists of `Subtitles`
([`first_begin`], [`second_begin`], [`first_end`], [`second_end`])
"""
if self.data:
if sub_id not in self.cache:
self.read_subpair(sub_id)
subs = (self.cache[sub_id].subs[0].sub[:count], # First sub, begin
self.cache[sub_id].subs[1].sub[:count], # Second sub, begin,
self.cache[sub_id].subs[0].sub[-1-count:-1], # First sub, end
self.cache[sub_id].subs[1].sub[-1-count:-1], # Second sub, end
)
return subs
def delete(self, sub_id):
"""Removes subtitles files."""
for s in self.data[sub_id]['subs']:
filename = os.path.join(FILES_DIR, s['SubFileName'])
try:
os.remove(filename)
except FileNotFoundError:
print('File {} is not found'.format(filename))
del self.data[sub_id]
try:
del self.cache[sub_id]
except KeyError:
pass
self.write_db()
def align_subs(self, sub_id, left_start, right_start, left_end, right_end):
if sub_id not in self.cache:
self.read_subpair(sub_id)
self.cache[sub_id].align_subs(left_start, right_start, left_end, right_end)
self.write_db()
return self.cache[sub_id]
if __name__ == '__main__':
# import ipdb; ipdb.set_trace()
logger.setLevel(logging.INFO)
db = SubDb()
app = pairsubs_gui.App(db)
log_box = app.get_search_box()
loop = app.get_loop()
log_handler = logging.StreamHandler(pairsubs_gui.SubsLogStream(log_box, loop))
logger.addHandler(log_handler)
app.run()