From 920169c7ba390ef67c687d10eb7366ce16fe814a Mon Sep 17 00:00:00 2001 From: SigureMo Date: Sat, 17 Oct 2020 22:46:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20refactor?= =?UTF-8?q?=20main=20and=20support=20text=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bilili/__main__.py | 456 ++------------------------------- bilili/bootstrap/__init__.py | 0 bilili/bootstrap/cli.py | 161 ++++++++++++ bilili/bootstrap/common.py | 48 ++++ bilili/bootstrap/downloader.py | 232 +++++++++++++++++ bilili/bootstrap/parser.py | 152 +++++++++++ 6 files changed, 610 insertions(+), 439 deletions(-) create mode 100644 bilili/bootstrap/__init__.py create mode 100644 bilili/bootstrap/cli.py create mode 100644 bilili/bootstrap/common.py create mode 100644 bilili/bootstrap/downloader.py create mode 100644 bilili/bootstrap/parser.py diff --git a/bilili/__main__.py b/bilili/__main__.py index 13655e0..50ebc0d 100644 --- a/bilili/__main__.py +++ b/bilili/__main__.py @@ -1,452 +1,30 @@ -import re import sys -import argparse -import os -import time -from typing import List -from bilili.utils.base import repair_filename, touch_dir, touch_file, size_format -from bilili.utils.playlist import Dpl, M3u -from bilili.utils.thread import ThreadPool, Flag -from bilili.utils.console import (Console, Font, Line, String, ProgressBar, - LineList, DynamicSymbol, ColorString) -from bilili.utils.subtitle import Subtitle from bilili.utils.attrdict import AttrDict -from bilili.tools import spider, ass, regex -from bilili.tools import global_status -from bilili.handlers.downloader import RemoteFile -from bilili.handlers.merger import MergingFile -from bilili.video import BililiContainer -from bilili.api.subtitle import get_subtitle -from bilili.api.danmaku import get_danmaku -from bilili.api.exceptions import (ArgumentsError, CannotDownloadError, - UnknownTypeError, UnsupportTypeError, IsPreviewError) - - -def parse_episodes(episodes_str: str, total: int) -> List[int]: - """ 将选集字符串转为列表 """ - - def reslove_negetive(value): - return value if value > 0 else value + total + 1 - - # 解析字符串为列表 - print("全 {} 话".format(total)) - if re.match(r"([\-\d\^\$]+(~[\-\d\^\$]+)?)(,[\-\d\^\$]+(~[\-\d\^\$]+)?)*", episodes_str): - episodes_str = episodes_str.replace("^", "1") - episodes_str = episodes_str.replace("$", "-1") - episode_list = [] - for episode_item in episodes_str.split(","): - if "~" in episode_item: - start, end = episode_item.split("~") - start, end = int(start), int(end) - start, end = reslove_negetive(start), reslove_negetive(end) - assert end >= start, "终点值({})应不小于起点值({})".format(end, start) - episode_list.extend(list(range(start, end + 1))) - else: - episode_item = int(episode_item) - episode_item = reslove_negetive(episode_item) - episode_list.append(episode_item) - else: - episode_list = [] - - episode_list = sorted(list(set(episode_list))) - - # 筛选满足条件的剧集 - out_of_range = [] - episodes = [] - for episode in episode_list: - if episode in range(1, total + 1): - if episode not in episodes: - episodes.append(episode) - else: - out_of_range.append(episode) - if out_of_range: - print("warn: 剧集 {} 不存在".format(",".join(list(map(str, out_of_range))))) - - print("已选择第 {} 话".format(",".join(list(map(str, episodes))))) - assert episodes, "没有选中任何剧集" - return episodes - - -def main(): - """ 解析命令行参数并调用相关模块进行下载 """ +from bilili.bootstrap.cli import parse_args +from bilili.bootstrap.parser import parse_containers +from bilili.bootstrap.downloader import Downloader +if __name__ == "__main__": if (sys.version_info.major, sys.version_info.minor) < (3, 8): print("请使用 Python3.8 及以上版本哦~") sys.exit(1) - parser = argparse.ArgumentParser(description="bilili B 站视频、弹幕下载器") - parser.add_argument("url", help="视频主页地址") - parser.add_argument( - "-t", "--type", default="dash", choices=["flv", "dash", "mp4"], help="选择下载源类型(dash 或 flv 或 mp4)", - ) - parser.add_argument("-d", "--dir", default=r"", help="下载目录") - parser.add_argument( - "-q", - "--quality", - default=120, - choices=[120, 116, 112, 80, 74, 64, 32, 16], - type=int, - help="视频清晰度 120:4K, 116:1080P60, 112:1080P+, 80:1080P, 74:720P60, 64:720P, 32:480P, 16:360P", - ) - parser.add_argument("-n", "--num-threads", default=16, type=int, help="最大下载线程数") - parser.add_argument("-p", "--episodes", default="^~$", help="选集") - parser.add_argument("-w", "--overwrite", action="store_true", help="强制覆盖已下载视频") - parser.add_argument("-c", "--sess-data", default=None, help="输入 cookies") - parser.add_argument("-y", "--yes", action="store_true", help="跳过下载询问") - parser.add_argument( - "--audio-quality", default=30280, - choices=[30280, 30232, 30216], - type=int, - help="音频码率等级 30280:320kbps, 30232:128kbps, 30216:64kbps", - ) - parser.add_argument( - "--playlist-type", default="dpl", choices=["dpl", "m3u", "no"], help="播放列表类型,支持 dpl 和 m3u,输入 no 不生成播放列表", - ) - parser.add_argument( - "--danmaku", default="xml", choices=["xml", "ass", "no"], help="弹幕类型,支持 xml 和 ass,如果设置为 no 则不下载弹幕", - ) - parser.add_argument( - "--block-size", default=128, type=int, help="分块下载器的块大小,单位为 MB,默认为 128MB,设置为 0 时禁用分块下载", - ) - parser.add_argument("--abs-path", action="store_true", help="修改播放列表路径类型为绝对路径") - parser.add_argument("--use-mirrors", action="store_true", help="启用从多个镜像下载功能") - parser.add_argument("--disable-proxy", action="store_true", help="禁用系统代理") - parser.add_argument("--debug", action="store_true", help="debug 模式") - - args = parser.parse_args() - cookies = {"SESSDATA": args.sess_data} - - config = { - "url": args.url, - "dir": args.dir, - "quality": args.quality, - "audio_quality": args.audio_quality, - "episodes": args.episodes, - "playlist_type": args.playlist_type, - "playlist_path_type": "AP" if args.abs_path else "RP", - "overwrite": args.overwrite, - "cookies": cookies, - "type": args.type.lower(), - "block_size": int(args.block_size * 1024 * 1024), - } >> AttrDict() - - # 匹配资源的 id 以及其对应所属类型 - # fmt: off - resource_id = { - "avid": "", - "bvid": "", - "episode_id": "", - "season_id": "", - } >> AttrDict() - - # fmt: off - if (avid_match := regex.acg_video.av.origin.match(args.url)) or \ - (avid_match := regex.acg_video.av.short.match(args.url)): - from bilili.api.acg_video import get_video_info - avid = avid_match.group("avid") - if episode_id := get_video_info(avid=avid)["episode_id"]: - resource_id.episode_id = episode_id - else: - resource_id.avid = avid - elif (bvid_match := regex.acg_video.bv.origin.match(args.url)) or \ - (bvid_match := regex.acg_video.bv.short.match(args.url)): - from bilili.api.acg_video import get_video_info - bvid = bvid_match.group("bvid") - if episode_id := get_video_info(bvid=bvid)["episode_id"]: - resource_id.episode_id = episode_id - else: - resource_id.bvid = bvid - elif media_id_match := regex.bangumi.md.origin.match(args.url): - from bilili.api.bangumi import get_season_id - media_id = media_id_match.group("media_id") - resource_id.season_id = get_season_id(media_id=media_id) - elif (episode_id_match := regex.bangumi.ep.origin.match(args.url)) or \ - (episode_id_match := regex.bangumi.ep.short.match(args.url)): - episode_id = episode_id_match.group("episode_id") - resource_id.episode_id = episode_id - elif (season_id_match := regex.bangumi.ss.origin.match(args.url)) or \ - (season_id_match := regex.bangumi.ss.short.match(args.url)): - season_id = season_id_match.group("season_id") - resource_id.season_id = season_id - else: - print("视频地址有误!") - sys.exit(1) - - if resource_id.avid or resource_id.bvid: - from bilili.parser.acg_video import get_title, get_list, get_playurl - bili_type = "acg_video" - elif resource_id.season_id or resource_id.episode_id: - from bilili.parser.bangumi import get_title, get_list, get_playurl - bili_type = "bangumi" - - # 获取标题 - spider.set_cookies(config["cookies"]) - if args.disable_proxy: - spider.trust_env = False - title = get_title(resource_id) - print(title) - - # 创建所需目录结构 - base_dir = touch_dir(os.path.join(config["dir"], repair_filename(title + " - bilibili"))) - video_dir = touch_dir(os.path.join(base_dir, "Videos")) - - # 获取需要的信息 - containers = [BililiContainer(video_dir=video_dir, type=args.type, **video) for video in get_list(resource_id)] - - # 解析并过滤不需要的选集 - episodes = parse_episodes(config["episodes"], len(containers)) - containers, containers_need_filter = [], containers - for container in containers_need_filter: - if container.id not in episodes: - container._.downloaded = True - container._.merged = True - else: - containers.append(container) - - # 初始化播放列表 - if config["playlist_type"] == "dpl": - playlist = Dpl(os.path.join(base_dir, "Playlist.dpl"), path_type=config["playlist_path_type"]) - elif config["playlist_type"] == "m3u": - playlist = M3u(os.path.join(base_dir, "Playlist.m3u"), path_type=config["playlist_path_type"]) - else: - playlist = None - - # 解析片段信息及视频 url - for i, container in enumerate(containers): - print( - "{:02}/{:02} parsing segments info...".format(i + 1, len(containers)), end="\r", - ) - - # 解析视频 url - try: - for playinfo in get_playurl(container, config["quality"], config["audio_quality"]): - container.append_media( - block_size=config["block_size"], - **playinfo - ) - except CannotDownloadError as e: - print('[warn] {} 无法下载,原因:{}'.format(container.name, e.message)) - except IsPreviewError: - print('[warn] {} 是预览视频'.format(container.name)) + options_list, global_options = parse_args() + global_options = global_options >> AttrDict() + containers = [] + for options in options_list: + containers.extend(parse_containers(options)) - # 写入播放列表 - if playlist is not None: - playlist.write_path(container.path) - - # 下载弹幕 - if bili_type == "acg_video": - for sub_info in get_subtitle(avid=resource_id.avid, bvid=resource_id.bvid, cid=container.meta['cid']): - sub_path = '{}_{}.srt'.format(os.path.splitext(container.path)[0], sub_info['lang']) - subtitle = Subtitle(sub_path) - for sub_line in sub_info['lines']: - subtitle.write_line(sub_line["content"], sub_line["from"], sub_line["to"]) - - # 生成弹幕 - if args.danmaku != "no": - with open(os.path.splitext(container.path)[0] + ".xml", 'w', encoding='utf-8') as f: - f.write(get_danmaku(container.meta['cid'])) - - # 转换弹幕为 ASS - if args.danmaku == "ass": - ass.convert_danmaku_from_xml( - os.path.splitext(container.path)[0] + ".xml", container.height, container.width, - ) - if playlist is not None: - playlist.flush() - - # 准备下载 if containers: - # 状态检查与校正 - for i, container in enumerate(containers): - container_downloaded = not container.check_needs_download(args.overwrite) - symbol = "✓" if container_downloaded else "✖" - if container_downloaded: - container._.merged = True - print("{} {}".format(symbol, str(container))) - for media in container.medias: - media_downloaded = not media.check_needs_download(args.overwrite) or container_downloaded - symbol = "✓" if media_downloaded else "✖" - if not container_downloaded: - print(" {} {}".format(symbol, media.name)) - for block in media.blocks: - block_downloaded = not block.check_needs_download(args.overwrite) or media_downloaded - symbol = "✓" if block_downloaded else "✖" - block._.downloaded = block_downloaded - if not media_downloaded and args.debug: - print(" {} {}".format(symbol, block.name)) - - # 询问是否下载,通过参数 -y 可以跳过 - if not args.yes: - answer = None - while answer is None: - result = input("以上标 ✖ 为需要进行下载的视频,是否立刻进行下载?[Y/n]") - if result == "" or result[0].lower() == "y": - answer = True - elif result[0].lower() == "n": - answer = False - else: - answer = None - if not answer: - sys.exit(0) - - # 部署下载与合并任务 - merge_wait_flag = Flag(False) # 合并线程池不能因为没有任务就结束 - # 因此要设定一个 flag,待最后合并结束后改变其值 - merge_pool = ThreadPool(3, wait=merge_wait_flag, daemon=True) - download_pool = ThreadPool(args.num_threads, daemon=True, thread_globals_creator={ - "thread_spider":spider.clone # 为每个线程创建一个全新的 Session,因为 requests.Session 不是线程安全的 - # https://github.com/psf/requests/issues/1871 - }) - for container in containers: - merging_file = MergingFile(container.type, [media.path for media in container.medias], container.path,) - for media in container.medias: - - block_merging_file = MergingFile(None, [block.path for block in media.blocks], media.path) - for block in media.blocks: - - mirrors = block.mirrors if args.use_mirrors else [] - remote_file = RemoteFile(block.url, block.path, mirrors=mirrors, range=block.range) - - # 为下载挂载各种钩子,以修改状态,注意外部变量应当作为默认参数传入 - @remote_file.on("before_download") - def before_download(file, status=block._): - status.downloading = True - - @remote_file.on("updated") - def updated(file, status=block._): - status.size = file.size - - @remote_file.on("downloaded") - def downloaded(file, status=block._, merging_file=merging_file, block_merging_file=block_merging_file): - status.downloaded = True - - if status.parent.downloaded: - # 当前 media 的最后一个 block 所在线程进行合并(直接执行,不放线程池) - status.downloaded = False - block_merging_file.merge() - status.downloaded = True - - # 如果该线程同时也是当前 container 的最后一个 block,就部署合并任务(放到线程池) - if status.parent.parent.downloaded and not status.parent.parent.merged: - # 为合并挂载各种钩子 - @merging_file.on("before_merge") - def before_merge(file, status=status.parent.parent): - status.merging = True - - @merging_file.on("merged") - def merged(file, status=status.parent.parent): - status.merging = False - status.merged = True - - merge_pool.add_task(merging_file.merge, args=()) - - status.downloading = False - - # 下载过的不应继续部署任务 - if block._.downloaded: - continue - download_pool.add_task(remote_file.download, args=()) - - # 启动线程池 - merge_pool.run() - download_pool.run() - - # 初始化界面 - console = Console(debug=args.debug) - console.add_component(Line(center=Font(char_a="𝓪", char_A="𝓐"), fillchar=" ")) - console.add_component(Line(left=ColorString(fore="cyan"), fillchar=" ")) - console.add_component(LineList(Line(left=String(), right=String(), fillchar="-"))) - console.add_component( - Line( - left=ColorString(fore="green", back="white", subcomponent=ProgressBar(symbols=" ▏▎▍▌▋▊▉█", width=65),), - right=String(), - fillchar=" ", - ) - ) - console.add_component(Line(left=ColorString(fore="blue"), fillchar=" ")) - console.add_component(LineList(Line(left=String(), right=DynamicSymbol(symbols="🌑🌒🌓🌔🌕🌖🌗🌘"), fillchar=" "))) - console.add_component( - Line( - left=ColorString(fore="yellow", back="white", subcomponent=ProgressBar(symbols=" ▏▎▍▌▋▊▉█", width=65),), - right=String(), - fillchar=" ", - ) + downloader = Downloader( + containers, + overwrite=global_options.overwrite, + debug=global_options.debug, + yes=global_options.yes, + num_threads=global_options.num_threads, + use_mirrors=global_options.use_mirrors, ) - - # 准备监控 - size, t = global_status.size, time.time() - while True: - now_size, now_t = global_status.size, time.time() - delta_size, delta_t = ( - max(now_size - size, 0), - (now_t - t) if now_t - t > 1e-6 else 1e-6, - ) - speed = delta_size / delta_t - size, t = now_size, now_t - - # 数据传入,界面渲染 - console.refresh( - # fmt: off - [ - { - "center": " 🍻 bilili ", - }, - { - "left": "🌠 Downloading videos: " - } if global_status.downloading else None, - [ - { - "left": "{} ".format(str(container)), - "right": " {}/{}".format( - size_format(container._.size), size_format(container._.total_size), - ), - } if container._.downloading else None - for container in containers - ] if global_status.downloading else None, - { - "left": global_status.size / global_status.total_size, - "right": " {}/{} {}/s ⚡".format( - size_format(global_status.size), - size_format(global_status.total_size), - size_format(speed), - ), - } if global_status.downloading else None, - { - "left": "🍰 Merging videos: " - } if global_status.merging else None, - [ - { - "left": "{} ".format(str(container)), - "right": True - } if container._.merging else None - for container in containers - ] if global_status.merging else None, - { - "left": sum([container._.merged for container in containers]) / len(containers), - "right": " {}/{} 🚀".format( - sum([container._.merged for container in containers]), len(containers), - ), - } if global_status.merging else None, - ] - ) - - # 检查是否已经全部完成 - if global_status.downloaded and global_status.merged: - merge_wait_flag.value = True - download_pool.join() - merge_pool.join() - break - try: - # 将刷新率稳定在 2fps - refresh_rate = 2 - time.sleep(max(1 / refresh_rate - (time.time() - now_t), 0.01)) - except (SystemExit, KeyboardInterrupt): - raise - print("已全部下载完成!") + downloader.run(containers) else: print("没有需要下载的视频!") - - -if __name__ == "__main__": - main() diff --git a/bilili/bootstrap/__init__.py b/bilili/bootstrap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bilili/bootstrap/cli.py b/bilili/bootstrap/cli.py new file mode 100644 index 0000000..d6a0c0d --- /dev/null +++ b/bilili/bootstrap/cli.py @@ -0,0 +1,161 @@ +import re +import sys +import argparse + +from typing import List, Tuple + +OPTIONS = [ + "uri", + "type", + "dir", + "quality", + "num_threads", # 仅全局 + "episodes", + "overwrite", # 仅全局 + "sess_data", # 仅全局 + "yes", # 仅全局 + "audio_quality", + "playlist_type", + "danmaku", + "block_size", # 仅全局 + "abs_path", + "use_mirrors", # 仅全局 + "disable_proxy", # 仅全局 + "debug", # 仅全局 +] + + +def get_parser(): + parser = argparse.ArgumentParser(description="bilili B 站视频、弹幕下载器") + parser.add_argument("uri", nargs="?", default=sys.stdin, help="视频主页地址") + parser.add_argument( + "-t", + "--type", + default="dash", + choices=["flv", "dash", "mp4"], + help="选择下载源类型(dash 或 flv 或 mp4)", + ) + parser.add_argument("-d", "--dir", default=r"", help="下载目录") + parser.add_argument( + "-q", + "--quality", + default=120, + choices=[120, 116, 112, 80, 74, 64, 32, 16], + type=int, + help="视频清晰度 120:4K, 116:1080P60, 112:1080P+, 80:1080P, 74:720P60, 64:720P, 32:480P, 16:360P", + ) + parser.add_argument("-n", "--num-threads", default=16, type=int, help="最大下载线程数") + parser.add_argument("-p", "--episodes", default="^~$", help="选集") + parser.add_argument("-w", "--overwrite", action="store_true", help="强制覆盖已下载视频") + parser.add_argument("-c", "--sess-data", default=None, help="输入 cookies") + parser.add_argument("-y", "--yes", action="store_true", help="跳过下载询问") + parser.add_argument( + "--audio-quality", + default=30280, + choices=[30280, 30232, 30216], + type=int, + help="音频码率等级 30280:320kbps, 30232:128kbps, 30216:64kbps", + ) + parser.add_argument( + "--playlist-type", + default="dpl", + choices=["dpl", "m3u", "no"], + help="播放列表类型,支持 dpl 和 m3u,输入 no 不生成播放列表", + ) + parser.add_argument( + "--danmaku", + default="xml", + choices=["xml", "ass", "no"], + help="弹幕类型,支持 xml 和 ass,如果设置为 no 则不下载弹幕", + ) + parser.add_argument( + "--block-size", + default=128, + type=int, + help="分块下载器的块大小,单位为 MB,默认为 128MB,设置为 0 时禁用分块下载", + ) + parser.add_argument("--abs-path", action="store_true", help="修改播放列表路径类型为绝对路径") + parser.add_argument("--use-mirrors", action="store_true", help="启用从多个镜像下载功能") + parser.add_argument("--disable-proxy", action="store_true", help="禁用系统代理") + parser.add_argument("--debug", action="store_true", help="debug 模式") + return parser + + +def parse_text_options(text): + # TODO: 支持 JSON、YAML 的解析 + allow_options = list(OPTIONS) + for exclude_option in [ + "num_threads", + "overwrite", + "sess_data", + "yes", + "block_size", + "use_mirrors", + "disable_proxy", + "debug", + ]: + allow_options.pop(allow_options.index(exclude_option)) + options_list = [] + for line in text.split("\n"): + if not line or re.match(r"^\s+$", line): + pass + elif line.startswith(" ") or line.startswith("\t"): + option, value = re.split(r"\s", line.lstrip())[:2] + if option in allow_options: + if option in ["quality", "audio_quality"]: + value = int(value) + if option in ["abs_path", "overwrite"]: + value = {"true": True, "false": False}[value.lower()] + options_list[-1][option] = value + else: + print("Warning: 局部作用域无效的选项 {}".format(option)) + else: + options = {} + options["uri"] = line.strip() + options_list.append(options) + return options_list + + +def parse_args() -> Tuple[List[object], object]: + """解析参数,返回选项列表 + + Returns: + List[object]: 选项列表,每个选项包含一个目标地址 + """ + parser = get_parser() + args = parser.parse_args() + + global_options = {option: getattr(args, option) for option in OPTIONS} + + options_list = [] + + if isinstance(args.uri, str): + if re.match(r"https?://", args.uri): + options = dict(global_options) + options.update( + { + "uri": args.uri, + } + ) + options_list = [options] + elif re.match(r"file://", args.uri): + filepath = args.uri[7:] + # TODO: 支持相对路径 + with open(filepath, "r", encoding="utf-8") as f: + text_options = f.read() + options_list = parse_text_options(text_options) + for i, options in enumerate(options_list): + options_list[i] = dict(global_options) + options_list[i].update(options) + else: + print("Error: 不规范的 uri 参数") + sys.exit(1) + else: + text_options = args.uri.read() + options_list = parse_text_options(text_options) + for i, options in enumerate(options_list): + options_list[i] = dict(global_options) + options_list[i].update(options) + global_options["yes"] = True # 由于此时无法输入,因此直接默认确认 + + return options_list, global_options diff --git a/bilili/bootstrap/common.py b/bilili/bootstrap/common.py new file mode 100644 index 0000000..4c974c6 --- /dev/null +++ b/bilili/bootstrap/common.py @@ -0,0 +1,48 @@ +import re + +from typing import List + + +def parse_episodes(episodes_str: str, total: int) -> List[int]: + """ 将选集字符串转为列表 """ + + def reslove_negetive(value): + return value if value > 0 else value + total + 1 + + # 解析字符串为列表 + print("全 {} 话".format(total)) + if re.match(r"([\-\d\^\$]+(~[\-\d\^\$]+)?)(,[\-\d\^\$]+(~[\-\d\^\$]+)?)*", episodes_str): + episodes_str = episodes_str.replace("^", "1") + episodes_str = episodes_str.replace("$", "-1") + episode_list = [] + for episode_item in episodes_str.split(","): + if "~" in episode_item: + start, end = episode_item.split("~") + start, end = int(start), int(end) + start, end = reslove_negetive(start), reslove_negetive(end) + assert end >= start, "终点值({})应不小于起点值({})".format(end, start) + episode_list.extend(list(range(start, end + 1))) + else: + episode_item = int(episode_item) + episode_item = reslove_negetive(episode_item) + episode_list.append(episode_item) + else: + episode_list = [] + + episode_list = sorted(list(set(episode_list))) + + # 筛选满足条件的剧集 + out_of_range = [] + episodes = [] + for episode in episode_list: + if episode in range(1, total + 1): + if episode not in episodes: + episodes.append(episode) + else: + out_of_range.append(episode) + if out_of_range: + print("warn: 剧集 {} 不存在".format(",".join(list(map(str, out_of_range))))) + + print("已选择第 {} 话".format(",".join(list(map(str, episodes))))) + assert episodes, "没有选中任何剧集" + return episodes diff --git a/bilili/bootstrap/downloader.py b/bilili/bootstrap/downloader.py new file mode 100644 index 0000000..4ae0541 --- /dev/null +++ b/bilili/bootstrap/downloader.py @@ -0,0 +1,232 @@ +import sys +import time + +from bilili.utils.base import size_format +from bilili.utils.thread import ThreadPool, Flag +from bilili.utils.console import Console, Font, Line, String, ProgressBar, LineList, DynamicSymbol, ColorString +from bilili.tools import spider, global_status +from bilili.handlers.downloader import RemoteFile +from bilili.handlers.merger import MergingFile + + +class Downloader: + def __init__(self, containers, overwrite=False, debug=False, yes=False, num_threads=16, use_mirrors=False): + self.overwrite = overwrite + self.yes = yes + self.debug = debug + self.num_threads = num_threads + self.use_mirrors = use_mirrors + self.check_and_display(containers) + self.download_pool, self.merge_pool, self.merge_wait_flag = self.init_tasks(containers) + self.ui = self.init_ui() + + def check_and_display(self, containers): + # 状态检查与校正 + for i, container in enumerate(containers): + container_downloaded = not container.check_needs_download(self.overwrite) + symbol = "✓" if container_downloaded else "✖" + if container_downloaded: + container._.merged = True + print("{} {}".format(symbol, str(container))) + for media in container.medias: + media_downloaded = not media.check_needs_download(self.overwrite) or container_downloaded + symbol = "✓" if media_downloaded else "✖" + if not container_downloaded: + print(" {} {}".format(symbol, media.name)) + for block in media.blocks: + block_downloaded = not block.check_needs_download(self.overwrite) or media_downloaded + symbol = "✓" if block_downloaded else "✖" + block._.downloaded = block_downloaded + if not media_downloaded and self.debug: + print(" {} {}".format(symbol, block.name)) + + # 询问是否下载,通过参数 -y 可以跳过 + if not self.yes: + answer = None + while answer is None: + result = input("以上标 ✖ 为需要进行下载的视频,是否立刻进行下载?[Y/n]") + if result == "" or result[0].lower() == "y": + answer = True + elif result[0].lower() == "n": + answer = False + else: + answer = None + if not answer: + sys.exit(0) + + def init_tasks(self, containers): + # 部署下载与合并任务 + merge_wait_flag = Flag(False) # 合并线程池不能因为没有任务就结束 + # 因此要设定一个 flag,待最后合并结束后改变其值 + merge_pool = ThreadPool(3, wait=merge_wait_flag, daemon=True) + download_pool = ThreadPool( + self.num_threads, + daemon=True, + thread_globals_creator={ + "thread_spider": spider.clone # 为每个线程创建一个全新的 Session,因为 requests.Session 不是线程安全的 + # https://github.com/psf/requests/issues/1871 + }, + ) + for container in containers: + merging_file = MergingFile( + container.type, + [media.path for media in container.medias], + container.path, + ) + for media in container.medias: + + block_merging_file = MergingFile(None, [block.path for block in media.blocks], media.path) + for block in media.blocks: + + mirrors = block.mirrors if self.use_mirrors else [] + remote_file = RemoteFile(block.url, block.path, mirrors=mirrors, range=block.range) + + # 为下载挂载各种钩子,以修改状态,注意外部变量应当作为默认参数传入 + @remote_file.on("before_download") + def before_download(file, status=block._): + status.downloading = True + + @remote_file.on("updated") + def updated(file, status=block._): + status.size = file.size + + @remote_file.on("downloaded") + def downloaded( + file, status=block._, merging_file=merging_file, block_merging_file=block_merging_file + ): + status.downloaded = True + + if status.parent.downloaded: + # 当前 media 的最后一个 block 所在线程进行合并(直接执行,不放线程池) + status.downloaded = False + block_merging_file.merge() + status.downloaded = True + + # 如果该线程同时也是当前 container 的最后一个 block,就部署合并任务(放到线程池) + if status.parent.parent.downloaded and not status.parent.parent.merged: + # 为合并挂载各种钩子 + @merging_file.on("before_merge") + def before_merge(file, status=status.parent.parent): + status.merging = True + + @merging_file.on("merged") + def merged(file, status=status.parent.parent): + status.merging = False + status.merged = True + + merge_pool.add_task(merging_file.merge, args=()) + + status.downloading = False + + # 下载过的不应继续部署任务 + if block._.downloaded: + continue + download_pool.add_task(remote_file.download, args=()) + return download_pool, merge_pool, merge_wait_flag + + def init_ui(self, debug=False): + console = Console(debug=debug) + console.add_component(Line(center=Font(char_a="𝓪", char_A="𝓐"), fillchar=" ")) + console.add_component(Line(left=ColorString(fore="cyan"), fillchar=" ")) + console.add_component(LineList(Line(left=String(), right=String(), fillchar="-"))) + console.add_component( + Line( + left=ColorString( + fore="green", + back="white", + subcomponent=ProgressBar(symbols=" ▏▎▍▌▋▊▉█", width=65), + ), + right=String(), + fillchar=" ", + ) + ) + console.add_component(Line(left=ColorString(fore="blue"), fillchar=" ")) + console.add_component(LineList(Line(left=String(), right=DynamicSymbol(symbols="🌑🌒🌓🌔🌕🌖🌗🌘"), fillchar=" "))) + console.add_component( + Line( + left=ColorString( + fore="yellow", + back="white", + subcomponent=ProgressBar(symbols=" ▏▎▍▌▋▊▉█", width=65), + ), + right=String(), + fillchar=" ", + ) + ) + return console + + def run(self, containers): + # 启动线程池 + self.merge_pool.run() + self.download_pool.run() + + # 准备监控 + size, t = global_status.size, time.time() + while True: + now_size, now_t = global_status.size, time.time() + delta_size, delta_t = ( + max(now_size - size, 0), + (now_t - t) if now_t - t > 1e-6 else 1e-6, + ) + speed = delta_size / delta_t + size, t = now_size, now_t + + # 数据传入,界面渲染 + self.ui.refresh( + # fmt: off + [ + { + "center": " 🍻 bilili ", + }, + { + "left": "🌠 Downloading videos: " + } if global_status.downloading else None, + [ + { + "left": "{} ".format(str(container)), + "right": " {}/{}".format( + size_format(container._.size), size_format(container._.total_size), + ), + } if container._.downloading else None + for container in containers + ] if global_status.downloading else None, + { + "left": global_status.size / global_status.total_size, + "right": " {}/{} {}/s ⚡".format( + size_format(global_status.size), + size_format(global_status.total_size), + size_format(speed), + ), + } if global_status.downloading else None, + { + "left": "🍰 Merging videos: " + } if global_status.merging else None, + [ + { + "left": "{} ".format(str(container)), + "right": True + } if container._.merging else None + for container in containers + ] if global_status.merging else None, + { + "left": sum([container._.merged for container in containers]) / len(containers), + "right": " {}/{} 🚀".format( + sum([container._.merged for container in containers]), len(containers), + ), + } if global_status.merging else None, + ] + ) + + # 检查是否已经全部完成 + if global_status.downloaded and global_status.merged: + self.merge_wait_flag.value = True + self.download_pool.join() + self.merge_pool.join() + break + try: + # 将刷新率稳定在 2fps + refresh_rate = 2 + time.sleep(max(1 / refresh_rate - (time.time() - now_t), 0.01)) + except (SystemExit, KeyboardInterrupt): + raise + print("已全部下载完成!") diff --git a/bilili/bootstrap/parser.py b/bilili/bootstrap/parser.py new file mode 100644 index 0000000..a3339db --- /dev/null +++ b/bilili/bootstrap/parser.py @@ -0,0 +1,152 @@ +import sys +import os + +from bilili.utils.attrdict import AttrDict +from bilili.utils.base import repair_filename, touch_dir, touch_file, size_format +from bilili.utils.playlist import Dpl, M3u +from bilili.utils.subtitle import Subtitle +from bilili.tools import spider, ass, regex +from bilili.video import BililiContainer +from bilili.bootstrap.common import parse_episodes +from bilili.api.subtitle import get_subtitle +from bilili.api.danmaku import get_danmaku +from bilili.api.exceptions import ( + ArgumentsError, + CannotDownloadError, + UnknownTypeError, + UnsupportTypeError, + IsPreviewError, +) + + +def parse_containers(options): + options = options >> AttrDict() + # 匹配资源的 id 以及其对应所属类型 + # fmt: off + resource_id = { + "avid": "", + "bvid": "", + "episode_id": "", + "season_id": "", + } >> AttrDict() + + # fmt: off + if (avid_match := regex.acg_video.av.origin.match(options.uri)) or \ + (avid_match := regex.acg_video.av.short.match(options.uri)): + from bilili.api.acg_video import get_video_info + avid = avid_match.group("avid") + if episode_id := get_video_info(avid=avid)["episode_id"]: + resource_id.episode_id = episode_id + else: + resource_id.avid = avid + elif (bvid_match := regex.acg_video.bv.origin.match(options.uri)) or \ + (bvid_match := regex.acg_video.bv.short.match(options.uri)): + from bilili.api.acg_video import get_video_info + bvid = bvid_match.group("bvid") + if episode_id := get_video_info(bvid=bvid)["episode_id"]: + resource_id.episode_id = episode_id + else: + resource_id.bvid = bvid + elif media_id_match := regex.bangumi.md.origin.match(options.uri): + from bilili.api.bangumi import get_season_id + media_id = media_id_match.group("media_id") + resource_id.season_id = get_season_id(media_id=media_id) + elif (episode_id_match := regex.bangumi.ep.origin.match(options.uri)) or \ + (episode_id_match := regex.bangumi.ep.short.match(options.uri)): + episode_id = episode_id_match.group("episode_id") + resource_id.episode_id = episode_id + elif (season_id_match := regex.bangumi.ss.origin.match(options.uri)) or \ + (season_id_match := regex.bangumi.ss.short.match(options.uri)): + season_id = season_id_match.group("season_id") + resource_id.season_id = season_id + else: + print("视频地址有误!") + sys.exit(1) + + if resource_id.avid or resource_id.bvid: + from bilili.parser.acg_video import get_title, get_list, get_playurl + bili_type = "acg_video" + elif resource_id.season_id or resource_id.episode_id: + from bilili.parser.bangumi import get_title, get_list, get_playurl + bili_type = "bangumi" + + # 对爬取器进行配置 + spider.set_cookies({ + "SESSDATA": options.sess_data + }) + if options.disable_proxy: + spider.trust_env = False + + # 获取标题 + title = get_title(resource_id) + print(title) + + # 创建所需目录结构 + base_dir = touch_dir(os.path.join(options.dir, repair_filename(title + " - bilibili"))) + video_dir = touch_dir(os.path.join(base_dir, "Videos")) + + # 获取需要的信息 + containers = [BililiContainer(video_dir=video_dir, type=options.type, **video) for video in get_list(resource_id)] + + # 解析并过滤不需要的选集 + episodes = parse_episodes(options.episodes, len(containers)) + containers, containers_need_filter = [], containers + for container in containers_need_filter: + if container.id not in episodes: + container._.downloaded = True + container._.merged = True + else: + containers.append(container) + + # 初始化播放列表 + if options.playlist_type == "dpl": + playlist = Dpl(os.path.join(base_dir, "Playlist.dpl"), path_type="AP" if options.abs_path else "RP") + elif options.playlist_type == "m3u": + playlist = M3u(os.path.join(base_dir, "Playlist.m3u"), path_type="AP" if options.abs_path else "RP") + else: + playlist = None + + # 解析片段信息及视频 url + for i, container in enumerate(containers): + print( + "{:02}/{:02} parsing segments info...".format(i + 1, len(containers)), end="\r", + ) + + # 解析视频 url + try: + for playinfo in get_playurl(container, options.quality, options.audio_quality): + container.append_media( + block_size=options.block_size * 1024 * 1024, + **playinfo + ) + except CannotDownloadError as e: + print('[warn] {} 无法下载,原因:{}'.format(container.name, e.message)) + except IsPreviewError: + print('[warn] {} 是预览视频'.format(container.name)) + + # 写入播放列表 + if playlist is not None: + playlist.write_path(container.path) + + # 下载弹幕 + if bili_type == "acg_video": + for sub_info in get_subtitle(avid=resource_id.avid, bvid=resource_id.bvid, cid=container.meta['cid']): + sub_path = '{}_{}.srt'.format(os.path.splitext(container.path)[0], sub_info['lang']) + subtitle = Subtitle(sub_path) + for sub_line in sub_info['lines']: + subtitle.write_line(sub_line["content"], sub_line["from"], sub_line["to"]) + + # 生成弹幕 + if options.danmaku != "no": + with open(os.path.splitext(container.path)[0] + ".xml", 'w', encoding='utf-8') as f: + f.write(get_danmaku(container.meta['cid'])) + + # 转换弹幕为 ASS + if options.danmaku == "ass": + ass.convert_danmaku_from_xml( + os.path.splitext(container.path)[0] + ".xml", container.height, container.width, + ) + if playlist is not None: + playlist.flush() + + return containers From 463a2709bdcb5e1fd84f5057662ead7a8dfb9c94 Mon Sep 17 00:00:00 2001 From: SigureMo Date: Sat, 14 Nov 2020 12:48:51 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20refactor=20downloader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bilili/__main__.py | 4 +- bilili/bootstrap/downloader.py | 6 +- bilili/downloader/__init__.py | 0 bilili/downloader/downloader.py | 13 +++ bilili/downloader/file.py | 105 ++++++++++++++++++++++++ bilili/downloader/pool.py | 77 +++++++++++++++++ bilili/downloader/processor/__init__.py | 0 bilili/downloader/processor/base.py | 89 ++++++++++++++++++++ bilili/downloader/processor/download.py | 62 ++++++++++++++ bilili/downloader/processor/merge.py | 27 ++++++ bilili/utils/base.py | 1 + bilili/video.py | 7 +- 12 files changed, 379 insertions(+), 12 deletions(-) create mode 100644 bilili/downloader/__init__.py create mode 100644 bilili/downloader/downloader.py create mode 100644 bilili/downloader/file.py create mode 100644 bilili/downloader/pool.py create mode 100644 bilili/downloader/processor/__init__.py create mode 100644 bilili/downloader/processor/base.py create mode 100644 bilili/downloader/processor/download.py create mode 100644 bilili/downloader/processor/merge.py diff --git a/bilili/__main__.py b/bilili/__main__.py index 50ebc0d..6c7a687 100644 --- a/bilili/__main__.py +++ b/bilili/__main__.py @@ -3,7 +3,7 @@ from bilili.utils.attrdict import AttrDict from bilili.bootstrap.cli import parse_args from bilili.bootstrap.parser import parse_containers -from bilili.bootstrap.downloader import Downloader +from bilili.bootstrap.downloader import BiliDownloader if __name__ == "__main__": if (sys.version_info.major, sys.version_info.minor) < (3, 8): @@ -17,7 +17,7 @@ containers.extend(parse_containers(options)) if containers: - downloader = Downloader( + downloader = BiliDownloader( containers, overwrite=global_options.overwrite, debug=global_options.debug, diff --git a/bilili/bootstrap/downloader.py b/bilili/bootstrap/downloader.py index 4ae0541..c420694 100644 --- a/bilili/bootstrap/downloader.py +++ b/bilili/bootstrap/downloader.py @@ -4,12 +4,10 @@ from bilili.utils.base import size_format from bilili.utils.thread import ThreadPool, Flag from bilili.utils.console import Console, Font, Line, String, ProgressBar, LineList, DynamicSymbol, ColorString -from bilili.tools import spider, global_status -from bilili.handlers.downloader import RemoteFile -from bilili.handlers.merger import MergingFile +from bilili.tools import spider -class Downloader: +class BiliDownloader: def __init__(self, containers, overwrite=False, debug=False, yes=False, num_threads=16, use_mirrors=False): self.overwrite = overwrite self.yes = yes diff --git a/bilili/downloader/__init__.py b/bilili/downloader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bilili/downloader/downloader.py b/bilili/downloader/downloader.py new file mode 100644 index 0000000..5bdcf71 --- /dev/null +++ b/bilili/downloader/downloader.py @@ -0,0 +1,13 @@ +class Downloader: + def __init__(self, files, overwrite=False, num_threads=16, use_mirrors=False): + self.files = files + self.overwrite = overwrite + self.num_threads = num_threads + self.use_mirrors = use_mirrors + + def boot(self): + for leaf in self.files.leaves: + leaf.process() + + def check_status(self): + if self.overwrite diff --git a/bilili/downloader/file.py b/bilili/downloader/file.py new file mode 100644 index 0000000..274b9b3 --- /dev/null +++ b/bilili/downloader/file.py @@ -0,0 +1,105 @@ +import os + +from bilili.downloader.processor.base import Processor, noop + + +class Tree: + def __init__(self, parent=None, children=[]): + self.parent = None + self.children = [] + if parent is not None: + self.set_parent(parent) + if children: + self.add_children(children) + + def add_child(self, child): + self.children.append(child) + child.parent = self + + def set_parent(self, parent): + parent.add_child(self) + + def add_children(self, children): + for child in children: + self.add_child(child) + + @property + def is_leaf(self): + return not self.children + + @property + def is_root(self): + return self.parent is None + + @property + def leaves(self): + if self.is_leaf: + return [self] + leaves = [] + for child in self.children: + leaves.extend(child.leaves) + return leaves + + +class CombinedFile(Tree): + def __init__(self, path, processor, pool): + super().__init__(parent=None, children=[]) + self.path = path + self.processor = processor + self.pool = pool + + # status + self.__total_size = 0 + self.__size = 0 + + def process(self): + self.processor.then(PostProcessor()) + self.processor.to(self.pool) + + @property + def total_size(self): + if self.is_leaf: + return self.__total_size + else: + return sum([child.total_size for child in self.children]) + + @total_size.setter + def total_size(self, value): + if self.is_leaf: + self.__total_size = value + else: + print("[WARNING] 无法设定非叶子结点的 total_size") + + @property + def size(self): + if self.is_leaf: + if self.downloaded: + return self.total_size + return self.__size + else: + return sum([child.size for child in self.children]) + + @size.setter + def size(self, value): + if self.is_leaf: + self.__size = value + else: + print("[WARNING] 无法设定非叶子结点的 size") + + def check_status(self, overwrite=False): + if overwrite: + if os.path.exists(self.path): + os.remove(self.path) + if os.path.exists(self.path + ".dl"): + os.remove(self.path + ".dl") + return True + if os.path.exists(self.path): + return False + return True + + +class PostProcessor(Processor): + def run(self, *args, **kwargs): + assert self.file is not None + if all([child.done for child in self.file.parent.children]): + self.file.parent.process() diff --git a/bilili/downloader/pool.py b/bilili/downloader/pool.py new file mode 100644 index 0000000..6673aed --- /dev/null +++ b/bilili/downloader/pool.py @@ -0,0 +1,77 @@ +import time +import queue +import threading + + +class Flag: + def __init__(self, value=False): + self.value = value + + def __bool__(self): + return self.value + + def set(self): + self.value = True + + def clear(self): + self.value = False + + +class ThreadPool: + """线程池类 + 快速创建多个相同任务的线程池 + """ + + def __init__(self, num, daemon=False, thread_globals_creator={}): + self.num = num + self.daemon = daemon + self._taskQ = queue.Queue() + self.threads = [] + self.__wait_flag = Flag(True) + self.thread_globals_creator = thread_globals_creator + + def clear_flag(self): + self.__wait_flag.clear() + + def set_flag(self): + self.__wait_flag.set() + + def add_task(self, task): + """ 添加任务 """ + self._taskQ.put(task) + + def _run_task(self, **thread_globals): + """ 启动任务线程 """ + while True: + if not self._taskQ.empty(): + task = self._taskQ.get(block=True, timeout=1) + task(**thread_globals) + self._taskQ.task_done() + elif not self.__wait_flag: + time.sleep(1) + else: + break + + def run(self): + """ 启动线程池 """ + for _ in range(self.num): + thread_globals = {} + for key, creator in self.thread_globals_creator.items(): + thread_globals[key] = creator() + th = threading.Thread(target=self._run_task, kwargs={"thread_globals": thread_globals}) + th.setDaemon(self.daemon) + self.threads.append(th) + th.start() + + def join(self): + """ 等待所有任务结束 """ + for th in self.threads: + th.join() + + +class RawExecutor: + def __init__(self): + pass + + def add_task(self, task): + task() diff --git a/bilili/downloader/processor/__init__.py b/bilili/downloader/processor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bilili/downloader/processor/base.py b/bilili/downloader/processor/base.py new file mode 100644 index 0000000..22df5ff --- /dev/null +++ b/bilili/downloader/processor/base.py @@ -0,0 +1,89 @@ +from typing import Enum + +noop = lambda *args, **kwargs: None + + +class Status(Enum): + TODO = 0 + IN_PROGRESS = 1 + DONE = 2 + + +class Task: + """ 任务对象 """ + + def __init__(self, func, args=(), kwargs={}): + """ 接受函数与参数以初始化对象 """ + + self.func = func + self.args = args + self.kwargs = kwargs + + def __call__(self, **extra_params): + """ 执行函数 """ + + result = self.func(*self.args, **self.kwargs, **extra_params) + return result + + +class Processor(Task): + def __init__(self, args=(), kwargs={}): + super().__init__(self.__run, args, kwargs) + self.__status = Status.TODO + self.next = noop + self.file = None + + def bind(self, file): + self.file = file + + def run(self, *args, **kwargs): + raise NotImplementedError + + def __run(self, *args, **kwargs): + self.in_progress = True + self.run(*args, **kwargs) + self.done = True + self.next.bind(self.file) + self.next(**kwargs) + + def to(self, pool): + pool.add_task(self) + + def then(self, process): + self.next = process + + @property + def todo(self): + return self.__status == Status.TODO + + @todo.setter + def todo(self, value): + if value: + self.__status = Status.TODO + else: + print("[WARNING] 无法设置为 False") + + @property + def in_progress(self): + return self.__status == Status.IN_PROGRESS + + @in_progress.setter + def in_progress(self, value): + if value: + self.__status = Status.IN_PROGRESS + else: + print("[WARNING] 无法设置为 False") + + @property + def done(self): + return self.__status == Status.DONE + + @done.setter + def done(self, value): + if value: + self.__status = Status.DONE + if self.file is not None: + for child in self.file.children: + child.processor.done = True + else: + print("[WARNING] 无法设置为 False") diff --git a/bilili/downloader/processor/download.py b/bilili/downloader/processor/download.py new file mode 100644 index 0000000..fdc67bb --- /dev/null +++ b/bilili/downloader/processor/download.py @@ -0,0 +1,62 @@ +import os +import random +import requests + +from bilili.downloader.processor.base import Processor + + +class DownloadProcessor(Processor): + + """ 下载处理器 """ + + def get_local_size(self, path, tmp_path): + """ 通过 os.path.getsize 获取本地文件大小 """ + try: + if os.path.exists(tmp_path): + size = os.path.getsize(tmp_path) + elif os.path.exists(path): + size = os.path.getsize(path) + else: + size = 0 + except FileNotFoundError: + size = 0 + return size + + def run(self, url, path, mirrors=[], range=(0, ""), stream=True, chunk_size=1024, thread_globals={}): + spider = thread_globals.get("spider") + path = path + name = os.path.split(path)[-1] + tmp_path = path + ".dl" + size = self.get_local_size(path, tmp_path) + + if not os.path.exists(path): + downloaded = False + while not downloaded: + # 设置 headers + headers = dict(spider.headers) + headers["Range"] = "bytes={}-{}".format(size + range[0], range[1]) + choiced_url = random.choice([url] + mirrors) if mirrors else url + + try: + # 尝试建立连接 + res = spider.get(choiced_url, stream=stream, headers=headers, timeout=(5, 10)) + # 下载到临时路径 + with open(tmp_path, "ab") as f: + if stream: + for chunk in res.iter_content(chunk_size=chunk_size): + if not chunk: + break + f.write(chunk) + size += len(chunk) + # 更新绑定文件对应的 size + self.file.size = size + else: + f.write(res.content) + downloaded = True + except requests.exceptions.RequestException: + print("[WARNING] file {}, request timeout, trying again...".format(name)) + + # 从临时文件迁移,并删除临时文件 + if os.path.exists(path): + os.remove(path) + os.rename(tmp_path, path) diff --git a/bilili/downloader/processor/merge.py b/bilili/downloader/processor/merge.py new file mode 100644 index 0000000..53b564b --- /dev/null +++ b/bilili/downloader/processor/merge.py @@ -0,0 +1,27 @@ +import os + +from bilili.downloader.processor.base import Processor +from bilili.utils.ffmpeg import FFmpeg + + +ffmpeg = FFmpeg() + + +class MergeProcessor(Processor): + def merge(self, type, src_path_list=[], dst_path=""): + if type == "mp4" or type is None: + with open(dst_path, "wb") as fw: + for src_path in src_path_list: + with open(src_path, "rb") as fr: + fw.write(fr.read()) + elif type == "flv": + ffmpeg.join_videos(src_path_list, dst_path) + elif type == "dash": + if len(src_path_list) == 2: + ffmpeg.join_video_audio(src_path_list[0], src_path_list[1], dst_path) + else: + ffmpeg.convert(src_path_list[0], dst_path) + else: + print("Unknown type {}".format(type)) + for src_path in src_path_list: + os.remove(src_path) diff --git a/bilili/utils/base.py b/bilili/utils/base.py index 0fdb5f7..1ca48a3 100644 --- a/bilili/utils/base.py +++ b/bilili/utils/base.py @@ -97,6 +97,7 @@ def to_full_width_chr(matchobj): filename = regex_spaces.sub(' ', filename) filename = regex_non_printable.sub('', filename) filename = filename.strip() + # TODO: 不应当使用随机,应当保证唯一 filename = filename if filename else 'file_{:04}'.format(random.randint(0, 9999)) return filename diff --git a/bilili/video.py b/bilili/video.py index 36a2170..406650a 100644 --- a/bilili/video.py +++ b/bilili/video.py @@ -11,12 +11,7 @@ class BililiContainer: - """bilibili 媒体容器类 - 即 B 站上的单个视频,其中可能包含多个媒体单元 - * 包含多个 flv 片段 - * 包含 m4s 的视频与音频流 - * 包含完整的一个 mp4 - """ + """ bilibili 媒体容器类 """ def __init__(self, id, name, meta, type="dash", video_dir=""):