forked from KT-Yeh/aniGamerPlus
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Anime.py
1254 lines (1103 loc) · 60.4 KB
/
Anime.py
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
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# @Time : 2019/1/5 16:22
# @Author : Miyouzi
# @File : Anime.py @Software: PyCharm
import ftplib
import shutil
import Config
from Danmu import Danmu
from bs4 import BeautifulSoup
import re, time, os, platform, subprocess, requests, random, sys
from ColorPrint import err_print
from ftplib import FTP, FTP_TLS
import socket
import threading
from urllib.parse import quote
class TryTooManyTimeError(BaseException):
pass
class Anime:
def __init__(self, sn, debug_mode=False, gost_port=34173):
self._settings = Config.read_settings()
self._cookies = Config.read_cookie()
self._working_dir = self._settings['working_dir']
self._bangumi_dir = self._settings['bangumi_dir']
self._temp_dir = self._settings['temp_dir']
self._gost_port = str(gost_port)
self._session = requests.session()
self._title = ''
self._sn = sn
self._bangumi_name = ''
self._episode = ''
self._episode_list = {}
self._device_id = ''
self._playlist = {}
self._m3u8_dict = {}
self.local_video_path = ''
self._video_filename = ''
self._ffmpeg_path = ''
self.video_resolution = 0
self.video_size = 0
self.realtime_show_file_size = False
self.upload_succeed_flag = False
self._danmu = False
if self._settings['use_mobile_api']:
err_print(sn, '解析模式', 'APP解析', display=False)
else:
err_print(sn, '解析模式', 'Web解析', display=False)
if debug_mode:
print('當前為debug模式')
else:
if self._settings['use_proxy']: # 使用代理
self.__init_proxy()
self.__init_header() # http header
self.__get_src() # 获取网页, 产生 self._src (BeautifulSoup)
self.__get_title() # 提取页面标题
self.__get_bangumi_name() # 提取本番名字
self.__get_episode() # 提取剧集码,str
# 提取剧集列表,结构 {'episode': sn},储存到 self._episode_list, sn 为 int, 考慮到 劇場版 sp 等存在, key 為 str
self.__get_episode_list()
def __init_proxy(self):
if self._settings['use_gost']:
# 需要使用 gost 的情况, 代理到 gost
os.environ['HTTP_PROXY'] = 'http://127.0.0.1:' + self._gost_port
os.environ['HTTPS_PROXY'] = 'http://127.0.0.1:' + self._gost_port
else:
# 无需 gost 的情况
os.environ['HTTP_PROXY'] = self._settings['proxy']
os.environ['HTTPS_PROXY'] = self._settings['proxy']
os.environ['NO_PROXY'] = "127.0.0.1,localhost"
def renew(self):
self.__get_src()
self.__get_title()
self.__get_bangumi_name()
self.__get_episode()
self.__get_episode_list()
def get_sn(self):
return self._sn
def get_bangumi_name(self):
if self._bangumi_name == '':
self.__get_bangumi_name()
return self._bangumi_name
def get_episode(self):
if self._episode == '':
self.__get_episode()
return self._episode
def get_episode_list(self):
if self._episode_list == {}:
self.__get_episode_list()
return self._episode_list
def get_title(self):
return self._title
def get_filename(self):
if self.video_resolution == 0:
return self.__get_filename(self._settings['download_resolution'])
else:
return self.__get_filename(str(self.video_resolution))
def __get_src(self):
if self._settings['use_mobile_api']:
self._src = self.__request(f'https://api.gamer.com.tw/mobile_app/anime/v2/video.php?sn={self._sn}', no_cookies=True).json()
else:
req = f'https://ani.gamer.com.tw/animeVideo.php?sn={self._sn}'
f = self.__request(req, no_cookies=True)
self._src = BeautifulSoup(f.content, "lxml")
def __get_title(self):
if self._settings['use_mobile_api']:
try:
self._title = self._src['data']['anime']['title']
except KeyError:
err_print(self._sn, 'ERROR: 該 sn 下真的有動畫?', status=1)
self._episode_list = {}
sys.exit(1)
else:
soup = self._src
try:
self._title = soup.find('div', 'anime_name').h1.string # 提取标题(含有集数)
except (TypeError, AttributeError):
# 该sn下没有动画
err_print(self._sn, 'ERROR: 該 sn 下真的有動畫?', status=1)
self._episode_list = {}
sys.exit(1)
def __get_bangumi_name(self):
self._bangumi_name = self._title.replace('[' + self.get_episode() + ']', '').strip() # 提取番剧名(去掉集数后缀)
self._bangumi_name = re.sub(r'\s+', ' ', self._bangumi_name) # 去除重复空格
def __get_episode(self): # 提取集数
def get_ep():
# 20210719 动画疯的版本位置又瞎蹦跶
# https://github.com/miyouzi/aniGamerPlus/issues/109
# 先查看有沒有數字, 如果沒有再查看有沒有中括號, 如果都沒有直接放棄, 把集數填作 1
self._episode = re.findall(r'\[\d*\.?\d* *\.?[A-Z,a-z]*(?:電影)?\]', self._title)
if len(self._episode) > 0:
self._episode = str(self._episode[0][1:-1])
elif len(re.findall(r'\[.+?\]', self._title)) > 0:
self._episode = re.findall(r'\[.+?\]', self._title)
self._episode = str(self._episode[0][1:-1])
else:
self._episode = "1"
# 20200320 发现多版本标签后置导致原集数提取方法失效
# https://github.com/miyouzi/aniGamerPlus/issues/36
# self._episode = re.findall(r'\[.+?\]', self._title) # 非贪婪匹配
# self._episode = str(self._episode[-1][1:-1]) # 考虑到 .5 集和 sp、ova 等存在,以str储存
if self._settings['use_mobile_api']:
get_ep()
else:
soup = self._src
try:
# 适用于存在剧集列表
self._episode = str(soup.find('li', 'playing').a.string)
except AttributeError:
# 如果这个sn就一集, 不存在剧集列表的情况
# https://github.com/miyouzi/aniGamerPlus/issues/36#issuecomment-605065988
# self._episode = re.findall(r'\[.+?\]', self._title) # 非贪婪匹配
# self._episode = str(self._episode[0][1:-1]) # 考虑到 .5 集和 sp、ova 等存在,以str储存
get_ep()
def __get_episode_list(self):
if self._settings['use_mobile_api']:
for _type in self._src['data']['anime']['volumes']:
for _sn in self._src['data']['anime']['volumes'][_type]:
if _type == '0': # 本篇
self._episode_list[str(_sn['volume'])] = int(_sn["video_sn"])
elif _type == '1': # 電影
self._episode_list['電影'] = int(_sn["video_sn"])
elif _type == '2': # 特別篇
self._episode_list[f'特別篇{_sn["volume"]}'] = int(_sn["video_sn"])
elif _type == '3': # 中文配音
self._episode_list[f'中文配音{_sn["volume"]}'] = int(_sn["video_sn"])
else: # 中文電影
self._episode_list['中文電影'] = int(_sn["video_sn"])
else:
try:
a = self._src.find('section', 'season').find_all('a')
p = self._src.find('section', 'season').find_all('p')
# https://github.com/miyouzi/aniGamerPlus/issues/9
# 样本 https://ani.gamer.com.tw/animeVideo.php?sn=10210
# 20190413 动画疯将特别篇分离
index_counter = {} # 记录剧集数字重复次数, 用作列表类型的索引 ('本篇', '特別篇')
if len(p) > 0:
p = list(map(lambda x: x.contents[0], p))
for i in a:
sn = int(i['href'].replace('?sn=', ''))
ep = str(i.string)
if ep not in index_counter.keys():
index_counter[ep] = 0
if ep in self._episode_list.keys():
index_counter[ep] = index_counter[ep] + 1
ep = p[index_counter[ep]] + ep
self._episode_list[ep] = sn
except AttributeError:
# 当只有一集时,不存在剧集列表,self._episode_list 只有本身
self._episode_list[self._episode] = self._sn
def __init_header(self):
# 伪装为浏览器
host = 'ani.gamer.com.tw'
origin = 'https://' + host
ua = self._settings['ua'] # cookie 自动刷新需要 UA 一致
ref = 'https://' + host + '/animeVideo.php?sn=' + str(self._sn)
lang = 'zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.6'
accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8'
accept_encoding = 'gzip, deflate'
cache_control = 'max-age=0'
self._mobile_header = {
"User-Agent": "Animad/1.12.5 (tw.com.gamer.android.animad; build: 222; Android 5.1.1) okHttp/4.4.0",
"X-Bahamut-App-Android": "tw.com.gamer.android.animad",
"X-Bahamut-App-Version": "222",
"Accept-Encoding": "gzip",
"Connection": "Keep-Alive"
}
self._web_header = {
"user-agent": ua,
"referer": ref,
"accept-language": lang,
"accept": accept,
"accept-encoding": accept_encoding,
"cache-control": cache_control,
"origin": origin
}
if self._settings['use_mobile_api']:
self._req_header = self._mobile_header
else:
self._req_header = self._web_header
def __request(self, req, no_cookies=False, show_fail=True, max_retry=3, addition_header=None):
# 设置 header
current_header = self._req_header
if addition_header is None:
addition_header = {}
if len(addition_header) > 0:
for key in addition_header.keys():
current_header[key] = addition_header[key]
# 获取页面
error_cnt = 0
while True:
try:
if self._cookies and not no_cookies:
f = self._session.get(req, headers=current_header, cookies=self._cookies, timeout=10)
else:
f = self._session.get(req, headers=current_header, cookies={}, timeout=10)
except requests.exceptions.RequestException as e:
if error_cnt >= max_retry >= 0:
raise TryTooManyTimeError('任務狀態: sn=' + str(self._sn) + ' 请求失败次数过多!请求链接:\n%s' % req)
err_detail = 'ERROR: 请求失败!except:\n' + str(e) + '\n3s后重试(最多重试' + str(
max_retry) + '次)'
if show_fail:
err_print(self._sn, '任務狀態', err_detail)
time.sleep(3)
error_cnt += 1
else:
break
# 处理 cookie
if not self._cookies:
# 当实例中尚无 cookie, 则读取
self._cookies = f.cookies.get_dict()
elif 'nologinuser' not in self._cookies.keys() and 'BAHAID' not in self._cookies.keys():
# 处理游客cookie
if 'nologinuser' in f.cookies.get_dict().keys():
# self._cookies['nologinuser'] = f.cookies.get_dict()['nologinuser']
self._cookies = f.cookies.get_dict()
else: # 如果用户提供了 cookie, 则处理cookie刷新
if 'set-cookie' in f.headers.keys(): # 发现server响应了set-cookie
if 'deleted' in f.headers.get('set-cookie'):
# set-cookie刷新cookie只有一次机会, 如果其他线程先收到, 则此处会返回 deleted
# 等待其他线程刷新了cookie, 重新读入cookie
if self._settings['use_mobile_api'] and 'X-Bahamut-App-InstanceId' in self._req_header:
# 使用移动API将无法进行 cookie 刷新, 改回 header 刷新 cookie
self._req_header = self._web_header
self.__request('https://ani.gamer.com.tw/') # 再次尝试获取新 cookie
else:
err_print(self._sn, '收到cookie重置響應', display=False)
time.sleep(2)
try_counter = 0
succeed_flag = False
while try_counter < 3: # 尝试读三次, 不行就算了
old_BAHARUNE = self._cookies['BAHARUNE']
self._cookies = Config.read_cookie()
err_print(self._sn, '讀取cookie',
'cookie.txt最後修改時間: ' + Config.get_cookie_time() + ' 第' + str(try_counter) + '次嘗試',
display=False)
if old_BAHARUNE != self._cookies['BAHARUNE']:
# 新cookie读取成功 (因为有可能其他线程接到了新cookie)
succeed_flag = True
err_print(self._sn, '讀取cookie', '新cookie讀取成功', display=False)
break
else:
err_print(self._sn, '讀取cookie', '新cookie讀取失敗', display=False)
random_wait_time = random.uniform(2, 5)
time.sleep(random_wait_time)
try_counter = try_counter + 1
if not succeed_flag:
self._cookies = {}
err_print(0, '用戶cookie更新失敗! 使用游客身份訪問', status=1, no_sn=True)
Config.invalid_cookie() # 将失效cookie更名
if self._settings['use_mobile_api'] and 'X-Bahamut-App-InstanceId' not in self._req_header:
# 即使切换 header cookie 也无法刷新, 那么恢复 header, 好歹广告只有 3s
self._req_header = self._mobile_header
else:
# 本线程收到了新cookie
# 20220115 简化 cookie 刷新逻辑
err_print(self._sn, '收到新cookie', display=False)
self._cookies.update(f.cookies.get_dict())
Config.renew_cookies(self._cookies, log=False)
key_list_str = ', '.join(f.cookies.get_dict().keys())
err_print(self._sn, f'用戶cookie刷新 {key_list_str} ', display=False)
self.__request('https://ani.gamer.com.tw/')
# 20210724 动画疯一步到位刷新 Cookie
if 'BAHARUNE' in f.headers.get('set-cookie'):
err_print(0, '用戶cookie已更新', status=2, no_sn=True)
return f
def __get_m3u8_dict(self):
# m3u8获取模块参考自 https://github.com/c0re100/BahamutAnimeDownloader
def get_device_id():
req = 'https://ani.gamer.com.tw/ajax/getdeviceid.php'
f = self.__request(req)
self._device_id = f.json()['deviceid']
return self._device_id
def get_playlist():
if self._settings['use_mobile_api']:
req = f'https://api.gamer.com.tw/mobile_app/anime/v2/m3u8.php?sn={str(self._sn)}&device={self._device_id}'
else:
req = 'https://ani.gamer.com.tw/ajax/m3u8.php?sn=' + str(self._sn) + '&device=' + self._device_id
f = self.__request(req)
self._playlist = f.json()
def random_string(num):
chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
random.seed(int(round(time.time() * 1000)))
result = []
for i in range(num):
result.append(chars[random.randint(0, len(chars) - 1)])
return ''.join(result)
def gain_access():
if self._settings['use_mobile_api']:
req = f'https://ani.gamer.com.tw/ajax/token.php?adID=0&sn={str(self._sn)}&device={self._device_id}'
else:
req = 'https://ani.gamer.com.tw/ajax/token.php?adID=0&sn=' + str(
self._sn) + "&device=" + self._device_id + "&hash=" + random_string(12)
# 返回基础信息, 用于判断是不是VIP
return self.__request(req).json()
def unlock():
req = 'https://ani.gamer.com.tw/ajax/unlock.php?sn=' + str(self._sn) + "&ttl=0"
f = self.__request(req) # 无响应正文
def check_lock():
req = 'https://ani.gamer.com.tw/ajax/checklock.php?device=' + self._device_id + '&sn=' + str(self._sn)
f = self.__request(req)
def start_ad():
if self._settings['use_mobile_api']:
req = f"https://api.gamer.com.tw/mobile_app/anime/v1/stat_ad.php?schedule=-1&sn={str(self._sn)}"
else:
req = "https://ani.gamer.com.tw/ajax/videoCastcishu.php?sn=" + str(self._sn) + "&s=194699"
f = self.__request(req) # 无响应正文
def skip_ad():
if self._settings['use_mobile_api']:
req = f"https://api.gamer.com.tw/mobile_app/anime/v1/stat_ad.php?schedule=-1&ad=end&sn={str(self._sn)}"
else:
req = "https://ani.gamer.com.tw/ajax/videoCastcishu.php?sn=" + str(self._sn) + "&s=194699&ad=end"
f = self.__request(req) # 无响应正文
def video_start():
req = "https://ani.gamer.com.tw/ajax/videoStart.php?sn=" + str(self._sn)
f = self.__request(req)
def check_no_ad(error_count=10):
if error_count == 0:
err_print(self._sn, '廣告去除失敗! 請向開發者提交 issue!', status=1)
sys.exit(1)
req = "https://ani.gamer.com.tw/ajax/token.php?sn=" + str(
self._sn) + "&device=" + self._device_id + "&hash=" + random_string(12)
f = self.__request(req)
resp = f.json()
if 'time' in resp.keys():
if not resp['time'] == 1:
err_print(self._sn, '廣告似乎還沒去除, 追加等待2秒, 剩餘重試次數 ' + str(error_count), status=1)
time.sleep(2)
skip_ad()
video_start()
check_no_ad(error_count=error_count - 1)
else:
# 通过广告检查
if error_count != 10:
ads_time = (10-error_count)*2 + ad_time + 2
err_print(self._sn, '通过廣告時間' + str(ads_time) + '秒, 記錄到配置檔案', status=2)
if self._settings['use_mobile_api']:
self._settings['mobile_ads_time'] = ads_time
else:
self._settings['ads_time'] = ads_time
Config.write_settings(self._settings) # 保存到配置文件
else:
err_print(self._sn, '遭到動畫瘋地區限制, 你的IP可能不被動畫瘋認可!', status=1)
sys.exit(1)
def parse_playlist():
req = self._playlist['src']
f = self.__request(req, no_cookies=True, addition_header={'origin': 'https://ani.gamer.com.tw'})
url_prefix = re.sub(r'playlist.+', '', self._playlist['src']) # m3u8 URL 前缀
m3u8_list = re.findall(r'=\d+x\d+\n.+', f.content.decode()) # 将包含分辨率和 m3u8 文件提取
m3u8_dict = {}
for i in m3u8_list:
key = re.findall(r'=\d+x\d+', i)[0] # 提取分辨率
key = re.findall(r'x\d+', key)[0][1:] # 提取纵向像素数,作为 key
value = re.findall(r'.*chunklist.+', i)[0] # 提取 m3u8 文件
value = url_prefix + value # 组成完整的 m3u8 URL
m3u8_dict[key] = value
self._m3u8_dict = m3u8_dict
get_device_id()
user_info = gain_access()
if not self._settings['use_mobile_api']:
unlock()
check_lock()
unlock()
unlock()
# 收到錯誤反饋
# 可能是限制級動畫要求登陸
if 'error' in user_info.keys():
msg = '《' + self._title + '》 '
msg = msg + 'code=' + str(user_info['error']['code']) + ' message: ' + user_info['error']['message']
err_print(self._sn, '收到錯誤', msg, status=1)
sys.exit(1)
if not user_info['vip']:
# 如果用户不是 VIP, 那么等待广告(20s)
# 20200513 网站更新,最低广告更新时间从8s增加到20s https://github.com/miyouzi/aniGamerPlus/issues/41
# 20200806 网站更新,最低广告更新时间从20s增加到25s https://github.com/miyouzi/aniGamerPlus/issues/55
if self._settings['only_use_vip']:
err_print(self._sn, '非VIP','因為已設定只使用VIP下載,故強制停止', status=1, no_sn=True)
sys.exit(1)
if self._settings['use_mobile_api']:
ad_time = self._settings['mobile_ads_time'] # APP解析廣告解析時間不同
else:
ad_time = self._settings['ads_time']
err_print(self._sn, '正在等待', '《' + self.get_title() + '》 由於不是VIP賬戶, 正在等待'+str(ad_time)+'s廣告時間')
start_ad()
time.sleep(ad_time)
skip_ad()
else:
err_print(self._sn, '開始下載', '《' + self.get_title() + '》 識別到VIP賬戶, 立即下載')
if not self._settings['use_mobile_api']:
video_start()
check_no_ad()
get_playlist()
parse_playlist()
def get_m3u8_dict(self):
if not self._m3u8_dict:
self.__get_m3u8_dict()
return self._m3u8_dict
def __get_filename(self, resolution, without_suffix=False):
# 处理剧集名补零
if re.match(r'^[+-]?\d+(\.\d+){0,1}$', self._episode) and self._settings['zerofill'] > 1:
# 正则考虑到了带小数点的剧集
# 如果剧集名为数字, 且用户开启补零
if re.match(r'^\d+\.\d+$', self._episode):
# 如果是浮点数
a = re.findall(r'^\d+\.', self._episode)[0][:-1]
b = re.findall(r'\.\d+$', self._episode)[0]
episode = '[' + a.zfill(self._settings['zerofill']) + b + ']'
else:
# 如果是整数
episode = '[' + self._episode.zfill(self._settings['zerofill']) + ']'
else:
episode = '[' + self._episode + ']'
if self._settings['add_bangumi_name_to_video_filename']:
# 如果用户需要番剧名
bangumi_name = self._settings['customized_video_filename_prefix'] \
+ self._bangumi_name \
+ self._settings['customized_bangumi_name_suffix']
filename = bangumi_name + episode # 有番剧名的文件名
else:
# 如果用户不要将番剧名添加到文件名
filename = self._settings['customized_video_filename_prefix'] + episode
# 添加分辨率后缀
if self._settings['add_resolution_to_video_filename']:
filename = filename + '[' + resolution + 'P]'
if without_suffix:
return filename # 截止至清晰度的文件名, 用于 __get_temp_filename()
# 添加用户后缀及扩展名
filename = filename + self._settings['customized_video_filename_suffix'] \
+ '.' + self._settings['video_filename_extension']
legal_filename = Config.legalize_filename(filename) # 去除非法字符
filename = legal_filename
return filename
def __get_temp_filename(self, resolution, temp_suffix):
filename = self.__get_filename(resolution, without_suffix=True)
# temp_filename 为临时文件名,下载完成后更名正式文件名
temp_filename = filename + self._settings['customized_video_filename_suffix'] + '.' + temp_suffix \
+ '.' + self._settings['video_filename_extension']
temp_filename = Config.legalize_filename(temp_filename)
return temp_filename
def __segment_download_mode(self, resolution=''):
# 设定文件存放路径
filename = self.__get_filename(resolution)
merging_filename = self.__get_temp_filename(resolution, temp_suffix='MERGING')
output_file = os.path.join(self._bangumi_dir, filename) # 完整输出路径
merging_file = os.path.join(self._temp_dir, merging_filename)
url_path = os.path.split(self._m3u8_dict[resolution])[0] # 用于构造完整 chunk 链接
temp_dir = os.path.join(self._temp_dir, str(self._sn) + '-downloading-by-aniGamerPlus') # 临时目录以 sn 命令
if not os.path.exists(temp_dir): # 创建临时目录
os.makedirs(temp_dir)
m3u8_path = os.path.join(temp_dir, str(self._sn) + '.m3u8') # m3u8 存放位置
m3u8_text = self.__request(self._m3u8_dict[resolution], no_cookies=True).text # 请求 m3u8 文件
with open(m3u8_path, 'w', encoding='utf-8') as f: # 保存 m3u8 文件在本地
f.write(m3u8_text)
pass
key_uri = re.search(r'(?<=AES-128,URI=")(.*)(?=")', m3u8_text).group() # 把 key 的链接提取出来
original_key_uri = key_uri
if not re.match(r'http.+', key_uri):
# https://github.com/miyouzi/aniGamerPlus/issues/46
# 如果不是完整的URI
key_uri = url_path + '/' + key_uri # 组成完成的 URI
m3u8_key_path = os.path.join(temp_dir, 'key.m3u8key') # key 的存放位置
with open(m3u8_key_path, 'wb') as f: # 保存 key
f.write(self.__request(key_uri, no_cookies=True).content)
chunk_list = re.findall(r'media_b.+ts.*', m3u8_text) # chunk
limiter = threading.Semaphore(self._settings['multi_downloading_segment']) # chunk 并发下载限制器
total_chunk_num = len(chunk_list)
finished_chunk_counter = 0
failed_flag = False
def download_chunk(uri):
chunk_name = re.findall(r'media_b.+ts', uri)[0] # chunk 文件名
chunk_local_path = os.path.join(temp_dir, chunk_name) # chunk 路径
nonlocal failed_flag
try:
with open(chunk_local_path, 'wb') as f:
f.write(self.__request(uri, no_cookies=True,
show_fail=False,
max_retry=self._settings['segment_max_retry']).content)
except TryTooManyTimeError:
failed_flag = True
err_print(self._sn, '下載狀態', 'Bad segment=' + chunk_name, status=1)
limiter.release()
sys.exit(1)
except BaseException as e:
failed_flag = True
err_print(self._sn, '下載狀態', 'Bad segment=' + chunk_name + ' 發生未知錯誤: ' + str(e), status=1)
limiter.release()
sys.exit(1)
# 显示完成百分比
nonlocal finished_chunk_counter
finished_chunk_counter = finished_chunk_counter + 1
progress_rate = float(finished_chunk_counter / total_chunk_num * 100)
progress_rate = round(progress_rate, 2)
Config.tasks_progress_rate[int(self._sn)]['rate'] = progress_rate
if self.realtime_show_file_size:
sys.stdout.write('\r正在下載: sn=' + str(self._sn) + ' ' + filename + ' ' + str(progress_rate) + '% ')
sys.stdout.flush()
limiter.release()
if self.realtime_show_file_size:
# 是否实时显示文件大小, 设计仅 cui 下载单个文件或线程数=1时适用
sys.stdout.write('正在下載: sn=' + str(self._sn) + ' ' + filename)
sys.stdout.flush()
else:
err_print(self._sn, '正在下載', filename + ' title=' + self._title)
chunk_tasks_list = []
for chunk in chunk_list:
chunk_uri = url_path + '/' + chunk
task = threading.Thread(target=download_chunk, args=(chunk_uri,))
chunk_tasks_list.append(task)
task.setDaemon(True)
limiter.acquire()
task.start()
for task in chunk_tasks_list: # 等待所有任务完成
while True:
if failed_flag:
err_print(self._sn, '下載失败', filename, status=1)
self.video_size = 0
return
if task.is_alive():
time.sleep(1)
else:
break
# m3u8 本地化
# replace('\\', '\\\\') 为转义win路径
m3u8_text_local_version = m3u8_text.replace(original_key_uri, os.path.join(temp_dir, 'key.m3u8key')).replace('\\', '\\\\')
for chunk in chunk_list:
chunk_filename = re.findall(r'media_b.+ts', chunk)[0] # chunk 文件名
chunk_path = os.path.join(temp_dir, chunk_filename).replace('\\', '\\\\') # chunk 本地路径
m3u8_text_local_version = m3u8_text_local_version.replace(chunk, chunk_path)
with open(m3u8_path, 'w', encoding='utf-8') as f: # 保存本地化的 m3u8
f.write(m3u8_text_local_version)
if self.realtime_show_file_size:
sys.stdout.write('\n')
sys.stdout.flush()
err_print(self._sn, '下載狀態', filename + ' 下載完成, 正在解密合并……')
Config.tasks_progress_rate[int(self._sn)]['status'] = '下載完成'
# 构造 ffmpeg 命令
ffmpeg_cmd = [self._ffmpeg_path,
'-allowed_extensions', 'ALL',
'-i', m3u8_path,
'-c', 'copy', merging_file,
'-y']
if self._settings['faststart_movflags']:
# 将 metadata 移至视频文件头部
# 此功能可以更快的在线播放视频
ffmpeg_cmd[7:7] = iter(['-movflags', 'faststart'])
if self._settings['audio_language']:
if self._title.find('中文') == -1:
ffmpeg_cmd[7:7] = iter(['-metadata:s:a:0', 'language=jpn'])
else:
ffmpeg_cmd[7:7] = iter(['-metadata:s:a:0', 'language=chi'])
# 执行 ffmpeg
run_ffmpeg = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
run_ffmpeg.communicate()
# 记录文件大小,单位为 MB
self.video_size = int(os.path.getsize(merging_file) / float(1024 * 1024))
# 重命名
err_print(self._sn, '下載狀態', filename + ' 解密合并完成, 本集 ' + str(self.video_size) + 'MB, 正在移至番劇目錄……')
if os.path.exists(output_file):
os.remove(output_file)
if self._settings['use_copyfile_method']:
shutil.copyfile(merging_file, output_file) # 适配rclone挂载盘
os.remove(merging_file) # 刪除临时合并文件
else:
shutil.move(merging_file, output_file) # 此方法在遇到rclone挂载盘时会出错
# 删除临时目录
shutil.rmtree(temp_dir, ignore_errors=True)
self.local_video_path = output_file # 记录保存路径, FTP上传用
self._video_filename = filename # 记录文件名, FTP上传用
err_print(self._sn, '下載完成', filename, status=2)
def __ffmpeg_download_mode(self, resolution=''):
# 设定文件存放路径
filename = self.__get_filename(resolution)
downloading_filename = self.__get_temp_filename(resolution, temp_suffix='DOWNLOADING')
output_file = os.path.join(self._bangumi_dir, filename) # 完整输出路径
downloading_file = os.path.join(self._temp_dir, downloading_filename)
# 构造 ffmpeg 命令
ffmpeg_cmd = [self._ffmpeg_path,
'-user_agent',
self._settings['ua'],
'-headers', "Origin: https://ani.gamer.com.tw",
'-i', self._m3u8_dict[resolution],
'-c', 'copy', downloading_file,
'-y']
if os.path.exists(downloading_file):
os.remove(downloading_file) # 清理任务失败的尸体
# subprocess.call(ffmpeg_cmd, creationflags=0x08000000) # 仅windows
run_ffmpeg = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE, bufsize=204800, stderr=subprocess.PIPE)
def check_ffmpeg_alive():
# 应对ffmpeg卡死, 资源限速等,若 1min 中内文件大小没有增加超过 3M, 则判定卡死
if self.realtime_show_file_size: # 是否实时显示文件大小, 设计仅 cui 下载单个文件或线程数=1时适用
sys.stdout.write('正在下載: sn=' + str(self._sn) + ' ' + filename)
sys.stdout.flush()
else:
err_print(self._sn, '正在下載', filename + ' title=' + self._title)
time.sleep(2)
time_counter = 1
pre_temp_file_size = 0
while run_ffmpeg.poll() is None:
if self.realtime_show_file_size:
# 实时显示文件大小
if os.path.exists(downloading_file):
size = os.path.getsize(downloading_file)
size = size / float(1024 * 1024)
size = round(size, 2)
sys.stdout.write(
'\r正在下載: sn=' + str(self._sn) + ' ' + filename + ' ' + str(size) + 'MB ')
sys.stdout.flush()
else:
sys.stdout.write('\r正在下載: sn=' + str(self._sn) + ' ' + filename + ' 文件尚未生成 ')
sys.stdout.flush()
if time_counter % 60 == 0 and os.path.exists(downloading_file):
temp_file_size = os.path.getsize(downloading_file)
a = temp_file_size - pre_temp_file_size
if a < (3 * 1024 * 1024):
err_msg_detail = downloading_filename + ' 在一分钟内仅增加' + str(
int(a / float(1024))) + 'KB 判定为卡死, 任务失败!'
err_print(self._sn, '下載失败', err_msg_detail, status=1)
run_ffmpeg.kill()
return
pre_temp_file_size = temp_file_size
time.sleep(1)
time_counter = time_counter + 1
ffmpeg_checker = threading.Thread(target=check_ffmpeg_alive) # 检查线程
ffmpeg_checker.setDaemon(True) # 如果 Anime 线程被 kill, 检查进程也应该结束
ffmpeg_checker.start()
run = run_ffmpeg.communicate()
return_str = str(run[1])
if self.realtime_show_file_size:
sys.stdout.write('\n')
sys.stdout.flush()
if run_ffmpeg.returncode == 0 and (return_str.find('Failed to open segment') < 0):
# 执行成功 (ffmpeg正常结束, 每个分段都成功下载)
if os.path.exists(output_file):
os.remove(output_file)
# 记录文件大小,单位为 MB
self.video_size = int(os.path.getsize(downloading_file) / float(1024 * 1024))
err_print(self._sn, '下載狀態', filename + '本集 ' + str(self.video_size) + 'MB, 正在移至番劇目錄……')
if self._settings['use_copyfile_method']:
shutil.copyfile(downloading_file, output_file) # 适配rclone挂载盘
os.remove(downloading_file) # 刪除临时合并文件
else:
shutil.move(downloading_file, output_file) # 此方法在遇到rclone挂载盘时会出错
self.local_video_path = output_file # 记录保存路径, FTP上传用
self._video_filename = filename # 记录文件名, FTP上传用
err_print(self._sn, '下載完成', filename, status=2)
else:
err_msg_detail = filename + ' ffmpeg_return_code=' + str(
run_ffmpeg.returncode) + ' Bad segment=' + str(return_str.find('Failed to open segment'))
err_print(self._sn, '下載失败', err_msg_detail, status=1)
def download(self, resolution='', save_dir='', bangumi_tag='', realtime_show_file_size=False, rename='', classify=True):
self.realtime_show_file_size = realtime_show_file_size
if not resolution:
resolution = self._settings['download_resolution']
if save_dir:
self._bangumi_dir = save_dir # 用于 cui 用户指定下载在当前目录
if rename:
bangumi_name = self._bangumi_name
# 适配多版本的番剧
version = re.findall(r'\[.+?\]', self._bangumi_name) # 在番剧名中寻找是否存在多版本标记
if version: # 如果这个番剧是多版本的
version = str(version[-1]) # 提取番剧版本名称
bangumi_name = bangumi_name.replace(version, '').strip() # 没有版本名称的 bangumi_name, 且头尾无空格
# 如果设定重命名了番剧
# 将其中的番剧名换成用户设定的, 且不影响版本号后缀(如果有)
self._title = self._title.replace(bangumi_name, rename)
self._bangumi_name = self._bangumi_name.replace(bangumi_name, rename)
# 下载任务开始
Config.tasks_progress_rate[int(self._sn)] = {'rate': 0, 'filename': '《'+self.get_title()+'》', 'status': '正在解析'}
try:
self.__get_m3u8_dict() # 获取 m3u8 列表
except TryTooManyTimeError:
# 如果在获取 m3u8 过程中发生意外, 则取消此次下载
err_print(self._sn, '下載狀態', '獲取 m3u8 失敗!', status=1)
self.video_size = 0
return
check_ffmpeg = subprocess.Popen('ffmpeg -h', shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if check_ffmpeg.stdout.readlines(): # 查找 ffmpeg 是否已放入系统 path
self._ffmpeg_path = 'ffmpeg'
else:
# print('没有在系统PATH中发现ffmpeg,尝试在所在目录寻找')
if 'Windows' in platform.system():
self._ffmpeg_path = os.path.join(self._working_dir, 'ffmpeg.exe')
else:
self._ffmpeg_path = os.path.join(self._working_dir, 'ffmpeg')
if not os.path.exists(self._ffmpeg_path):
err_print(0, '本項目依賴於ffmpeg, 但ffmpeg未找到', status=1, no_sn=True)
raise FileNotFoundError # 如果本地目录下也没有找到 ffmpeg 则丢出异常
# 创建存放番剧的目录,去除非法字符
if bangumi_tag: # 如果指定了番剧分类
self._bangumi_dir = os.path.join(self._bangumi_dir, Config.legalize_filename(bangumi_tag))
if classify: # 控制是否建立番剧文件夹
self._bangumi_dir = os.path.join(self._bangumi_dir, Config.legalize_filename(self._bangumi_name))
if not os.path.exists(self._bangumi_dir):
try:
os.makedirs(self._bangumi_dir) # 按番剧创建文件夹分类
except FileExistsError as e:
err_print(self._sn, '下載狀態', '慾創建的番劇資料夾已存在 ' + str(e), display=False)
if not os.path.exists(self._temp_dir): # 建立临时文件夹
try:
os.makedirs(self._temp_dir)
except FileExistsError as e:
err_print(self._sn, '下載狀態', '慾創建的臨時資料夾已存在 ' + str(e), display=False)
# 如果不存在指定清晰度,则选取最近可用清晰度
if resolution not in self._m3u8_dict.keys():
if self._settings['lock_resolution']:
# 如果用户设定锁定清晰度, 則下載取消
err_msg_detail = '指定清晰度不存在, 因當前鎖定了清晰度, 下載取消. 可用的清晰度: ' + 'P '.join(self._m3u8_dict.keys()) + 'P'
err_print(self._sn, '任務狀態', err_msg_detail, status=1)
return
resolution_list = map(lambda x: int(x), self._m3u8_dict.keys())
resolution_list = list(resolution_list)
flag = 9999
closest_resolution = 0
for i in resolution_list:
a = abs(int(resolution) - i)
if a < flag:
flag = a
closest_resolution = i
# resolution_list.sort()
# resolution = str(resolution_list[-1]) # 选取最高可用清晰度
resolution = str(closest_resolution)
err_msg_detail = '指定清晰度不存在, 選取最近可用清晰度: ' + resolution + 'P'
err_print(self._sn, '任務狀態', err_msg_detail, status=1)
self.video_resolution = int(resolution)
# 解析完成, 开始下载
Config.tasks_progress_rate[int(self._sn)]['status'] = '正在下載'
Config.tasks_progress_rate[int(self._sn)]['filename'] = self.get_filename()
if self._settings['segment_download_mode']:
self.__segment_download_mode(resolution)
else:
self.__ffmpeg_download_mode(resolution)
# 任务完成, 从任务进度表中删除
del Config.tasks_progress_rate[int(self._sn)]
# 下載彈幕
if self._danmu:
full_filename = os.path.join(self._bangumi_dir, self.__get_filename(resolution)).replace('.' + self._settings['video_filename_extension'], '.ass')
d = Danmu(self._sn, full_filename, Config.read_cookie())
d.download(self._settings['danmu_ban_words'])
# 推送 CQ 通知
if self._settings['coolq_notify']:
try:
msg = '【aniGamerPlus消息】\n《' + self._video_filename + '》下载完成, 本集 ' + str(self.video_size) + ' MB'
if self._settings['coolq_settings']['message_suffix']:
# 追加用户信息
msg = msg + '\n\n' + self._settings['coolq_settings']['message_suffix']
for query in self._settings['coolq_settings']['query']:
if '?' not in query:
query = query + '?'
else:
query = query + '&'
req = query + self._settings['coolq_settings']['msg_argument_name'] + '=' + quote(msg)
self.__request(req, no_cookies=True)
except BaseException as e:
err_print(self._sn, 'CQ NOTIFY ERROR', 'Exception: ' + str(e), status=1)
# 推送 TG 通知
if self._settings['telebot_notify']:
try:
msg = '【aniGamerPlus消息】\n《' + self._video_filename + '》下载完成, 本集 ' + str(self.video_size) + ' MB'
vApiTokenTelegram = self._settings['telebot_token']
apiMethod = "getUpdates"
api_url = "https://api.telegram.org/bot" + vApiTokenTelegram + "/" + apiMethod # Telegram bot api url
try:
response = self.__request(api_url).json()
chat_id = response["result"][0]["message"]["chat"]["id"] # Get chat id
try:
api_method = "sendMessage"
req = "https://api.telegram.org/bot" \
+ vApiTokenTelegram \
+ "/" \
+ api_method \
+ "?chat_id=" \
+ str(chat_id) \
+ "&text=" \
+ str(msg)
self.__request(req, no_cookies=True) # Send msg to telegram bot
except:
err_print(self._sn, 'TG NOTIFY ERROR', "Exception: Send msg error\nReq: " + req, status=1) # Send mag error
except:
err_print(self._sn, 'TG NOTIFY ERROR', "Exception: Invalid access token\nToken: " + vApiTokenTelegram, status=1) # Cannot find chat id
except BaseException as e:
err_print(self._sn, 'TG NOTIFY ERROR', 'Exception: ' + str(e), status=1)
# 推送通知至 Discord
if self._settings['discord_notify']:
try:
msg = '【aniGamerPlus消息】\n《' + self._video_filename + '》下載完成,本集 ' + str(self.video_size) + ' MB'
url = self._settings['discord_token']
data = {
'content': None,
'embeds': [{
'title': '下載完成',
'description': msg,
'color': '5814783',
'author': {
'name': '🔔 動畫瘋'
}}]}
r = requests.post(url, json=data)
if r.status_code != 204:
err_print(self._sn, 'discord NOTIFY ERROR', "Exception: Send msg error\nReq: " + r.text, status=1)
except:
err_print(self._sn, 'Discord NOTIFY UNKNOWN ERROR', 'Exception: ' + str(e), status=1)
# plex 自動更新媒體庫
if self._settings['plex_refresh']:
try:
url = 'https://{plex_url}/library/sections/{plex_section}/refresh?X-Plex-Token={plex_token}'.format(
plex_url=self._settings['plex_url'],
plex_section=self._settings['plex_section'],
plex_token=self._settings['plex_token']
)
r = requests.get(url)
if r.status_code != 200:
err_print(self._sn, 'Plex auto Refresh ERROR', status=1)
except:
err_print(self._sn, 'Plex auto Refresh UNKNOWN ERROR', 'Exception: ' + str(e), status=1)
def upload(self, bangumi_tag='', debug_file=''):
first_connect = True # 标记是否是第一次连接, 第一次连接会删除临时缓存目录
tmp_dir = str(self._sn) + '-uploading-by-aniGamerPlus'
if debug_file:
self.local_video_path = debug_file
if not os.path.exists(self.local_video_path): # 如果文件不存在,直接返回失败
return self.upload_succeed_flag
if not self._video_filename: # 用于仅上传, 将文件名提取出来
self._video_filename = os.path.split(self.local_video_path)[-1]
socket.setdefaulttimeout(20) # 超时时间20s
if self._settings['ftp']['tls']:
ftp = FTP_TLS() # FTP over TLS
else:
ftp = FTP()
def connect_ftp(show_err=True):
ftp.encoding = 'utf-8' # 解决中文乱码
err_counter = 0
connect_flag = False
while err_counter <= 3:
try:
ftp.connect(self._settings['ftp']['server'], self._settings['ftp']['port']) # 连接 FTP
ftp.login(self._settings['ftp']['user'], self._settings['ftp']['pwd']) # 登陆
connect_flag = True
break
except ftplib.error_temp as e: