From bc55ef7a452b1927636ef511735a748d3a87c822 Mon Sep 17 00:00:00 2001 From: xtaodada Date: Thu, 17 Aug 2023 22:16:53 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=20=E6=94=AF=E6=8C=81=20download=20?= =?UTF-8?q?bilibili=20video?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.gen.ini | 1 + defs/bilibili.py | 18 ++- defs/bilibili_download.py | 262 +++++++++++++++++++++++++++++++++++ defs/glover.py | 8 +- modules/bilibili.py | 1 + modules/bilibili_download.py | 33 +++++ requirements.txt | 2 +- 7 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 defs/bilibili_download.py create mode 100644 modules/bilibili_download.py diff --git a/config.gen.ini b/config.gen.ini index ab4a603..d07a9f0 100644 --- a/config.gen.ini +++ b/config.gen.ini @@ -23,3 +23,4 @@ splash_channel_username = username [api] amap_key = ABCD bili_cookie = ABCD +bili_auth_user = 777000,111000 diff --git a/defs/bilibili.py b/defs/bilibili.py index 20b9b0c..25ca5e1 100644 --- a/defs/bilibili.py +++ b/defs/bilibili.py @@ -86,7 +86,7 @@ def cut_text(old_str, cut): next_str = next_str[1:] elif s == "\n": str_list.append(next_str[: i - 1]) - next_str = next_str[i - 1:] + next_str = next_str[i - 1 :] si = 0 i = 0 continue @@ -133,13 +133,19 @@ async def b23_extract(text): return r -async def video_info_get(cid): +def create_video(cid) -> Optional[Video]: + v = None if cid[:2] == "av": v = Video(aid=int(cid[2:]), credential=credential) elif cid[:2] == "BV": v = Video(bvid=cid, credential=credential) - else: - return + return v + + +async def video_info_get(cid): + v = create_video(cid) + if not v: + return None video_info = await v.get_info() return video_info @@ -200,7 +206,9 @@ async def binfo_image_create(video_info: dict): f"resources{sep}font{sep}sarasa-mono-sc-semibold.ttf", 18 ) dynamic_cut_str = "\n".join(cut_text(dynamic, 58)) - _, _, _, dynamic_text_y = draw.multiline_textbbox((0, 0), dynamic_cut_str, dynamic_font) + _, _, _, dynamic_text_y = draw.multiline_textbbox( + (0, 0), dynamic_cut_str, dynamic_font + ) dynamic_bg = Image.new("RGB", (560, dynamic_text_y + 24), "#F5F5F7") draw = ImageDraw.Draw(dynamic_bg) draw.rectangle((0, 0, 580, dynamic_text_y + 24), "#E1E1E5") diff --git a/defs/bilibili_download.py b/defs/bilibili_download.py new file mode 100644 index 0000000..fe1ee68 --- /dev/null +++ b/defs/bilibili_download.py @@ -0,0 +1,262 @@ +import contextlib +import os +import time +from asyncio import create_subprocess_shell, subprocess, Lock +from typing import Tuple, Dict, Union + +import aiofiles +from bilibili_api import HEADERS +from bilibili_api.video import Video, VideoDownloadURLDataDetecter, VideoQuality +from httpx import AsyncClient, Response +from pyrogram.types import Message + +from init import bot, logger +from defs.request import cache_dir + +FFMPEG_PATH = "ffmpeg" +LOCK = Lock() +EDIT_TEMP_SECONDS = 10.0 +MESSAGE_MAP: Dict[int, float] = {} +UPLOAD_MESSAGE_MAP: Dict[int, int] = {} + + +class BilibiliDownloaderError(Exception): + """Bilibili 下载器错误""" + + MSG = "Bilibili 下载器错误" + + def __init__(self, msg: str = None): + self.MSG = msg or self.MSG + + +class FileTooBig(BilibiliDownloaderError): + """文件过大,超过2GB""" + + MSG = "文件过大,超过2GB" + + +class FileNoSize(BilibiliDownloaderError): + """文件大小未知""" + + MSG = "文件大小未知" + + +class FFmpegError(BilibiliDownloaderError): + """FFmpeg 转换失败""" + + MSG = "FFmpeg 转换失败" + + +def should_edit(m: Message) -> bool: + if m.id in MESSAGE_MAP: + last_time = MESSAGE_MAP[m.id] + if last_time + EDIT_TEMP_SECONDS < time.time(): + return True + else: + return True + return False + + +def format_bytes(size: Union[int, float]) -> str: + """格式化文件大小""" + power = 1024 + n = 0 + power_labels = {0: "", 1: "K", 2: "M", 3: "G", 4: "T"} + while size > power: + size /= power + n += 1 + if n > 4: + n = 4 + return f"{round(size, 2)} {power_labels[n]}B" + + +def format_seconds(seconds: Union[int, float]) -> str: + """格式化秒数""" + m, s = divmod(seconds, 60) + h, m = divmod(m, 60) + s = round(s, 2) + text = "" + if h > 0: + text += f" {h} 小时" + if m > 0: + text += f" {m} 分钟" + if s > 0: + text += f" {s} 秒" + return text.strip() + + +async def safe_edit(m: Message, text: str): + try: + await m.edit_text(text) + except Exception: + pass + + +async def fail_edit(m: Message, text: str): + try: + await m.edit_text(text) + except Exception: + with contextlib.suppress(Exception): + await m.reply(text, quote=True) + + +async def execute(command: str) -> Tuple[str, int]: + process = await create_subprocess_shell( + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE + ) + stdout, stderr = await process.communicate() + try: + result = str(stdout.decode().strip()) + str(stderr.decode().strip()) + except UnicodeDecodeError: + result = str(stdout.decode("gbk").strip()) + str(stderr.decode("gbk").strip()) + return result, process.returncode + + +def safe_remove(path: str): + if os.path.exists(path): + os.remove(path) + + +async def message_edit( + length: int, total_downloaded: int, temp_downloaded: int, m: Message, t: str +): + chunk_time = time.time() - MESSAGE_MAP[m.id] + speed = temp_downloaded / (chunk_time if chunk_time > 0 else 1) + text = ( + f"{t}进度\n\n" + f"{format_bytes(total_downloaded)} / {format_bytes(length)} " + f"({round(total_downloaded / length * 100.0, 2)}%)\n\n" + f"下载区间速度:{format_bytes(speed)}/s\n" + f"预计剩余时间:{format_seconds((length - total_downloaded) / speed)}" + ) + await safe_edit(m, text) + + +async def download_url(url: str, out: str, m: Message, start: str): + async with AsyncClient(headers=HEADERS, timeout=60) as sess: + async with sess.stream("GET", url) as resp: + logger.info(f"Downloading {url}") + resp: Response + length = resp.headers.get("content-length") + if not length: + raise FileNoSize + length = int(length) + if length > 1.9 * 1024 * 1024 * 1024: + raise FileTooBig + total_downloaded = 0 + temp_downloaded = 0 + MESSAGE_MAP[m.id] = time.time() - EDIT_TEMP_SECONDS + async with aiofiles.open(out, "wb") as f: + async for chunk in resp.aiter_bytes(1024): + if not chunk: + break + chunk_len = len(chunk) + total_downloaded += chunk_len + temp_downloaded += chunk_len + async with LOCK: + _should_edit = should_edit(m) + if _should_edit: + MESSAGE_MAP[m.id] = time.time() + if _should_edit: + bot.loop.create_task( + message_edit( + length, + total_downloaded, + temp_downloaded, + m, + f"{start}下载", + ) + ) + temp_downloaded = 0 + await f.write(chunk) + + +async def go_download(v: Video, p_num: int, m: Message): + video_path = cache_dir / f"{v.get_aid()}_{p_num}.mp4" + safe_remove(video_path) + flv_temp_path = cache_dir / f"{v.get_aid()}_{p_num}_temp.flv" + video_temp_path = cache_dir / f"{v.get_aid()}_{p_num}_video.m4s" + audio_temp_path = cache_dir / f"{v.get_aid()}_{p_num}_audio.m4s" + # 有 MP4 流 / FLV 流两种可能 + try: + # 获取视频下载链接 + download_url_data = await v.get_download_url(p_num) + # 解析视频下载信息 + detector = VideoDownloadURLDataDetecter(data=download_url_data) + streams = detector.detect_best_streams( + video_max_quality=VideoQuality._1080P_60, # noqa + ) + if not streams: + raise BilibiliDownloaderError("无法获取下载链接") + if detector.check_flv_stream(): + # FLV 流下载 + await download_url(streams[0].url, flv_temp_path, m, "视频 FLV ") + # 转换文件格式 + _, result = await execute(f"{FFMPEG_PATH} -i {flv_temp_path} {video_path}") + else: + if len(streams) < 2: + raise BilibiliDownloaderError("获取下载链接异常") + # MP4 流下载 + await download_url(streams[0].url, video_temp_path, m, "视频 m4s ") + await download_url(streams[1].url, audio_temp_path, m, "音频 m4s ") + # 混流 + _, result = await execute( + f"{FFMPEG_PATH} -i {video_temp_path} -i {audio_temp_path} -vcodec copy -acodec copy {video_path}" + ) + if result != 0: + raise FFmpegError + bot.loop.create_task(go_upload(v, p_num, m)) + except BilibiliDownloaderError as e: + await fail_edit(m, e.MSG) + except Exception as e: + logger.exception("Downloading video failed") + await fail_edit(m, f"下载失败:{e}") + finally: + # 删除临时文件 + safe_remove(flv_temp_path) + safe_remove(video_temp_path) + safe_remove(audio_temp_path) + + +async def go_upload_progress(current: int, total: int, m: Message): + if current == 0: + return + async with LOCK: + _should_edit = should_edit(m) + if _should_edit: + MESSAGE_MAP[m.id] = time.time() + if _should_edit: + t = UPLOAD_MESSAGE_MAP[m.id] if m.id in UPLOAD_MESSAGE_MAP else 0 + UPLOAD_MESSAGE_MAP[m.id] = current + chunk = current - t + chunk = chunk if chunk > 0 else 0 + await message_edit(total, current, chunk, m, "上传") + + +async def go_upload(v: Video, p_num: int, m: Message): + video_path = cache_dir / f"{v.get_aid()}_{p_num}.mp4" + if not video_path.exists(): + await fail_edit(m, "视频文件不存在") + return + try: + logger.info(f"Uploading {video_path}") + await bot.send_video( + chat_id=m.chat.id, + video=str(video_path), + supports_streaming=True, + progress=go_upload_progress, + progress_args=(m,), + ) + logger.info(f"Upload {video_path} success") + except Exception as e: + logger.exception("Uploading video failed") + await fail_edit(m, f"上传失败:{e}") + return + finally: + safe_remove(video_path) + if m.id in MESSAGE_MAP: + del MESSAGE_MAP[m.id] + if m.id in UPLOAD_MESSAGE_MAP: + del UPLOAD_MESSAGE_MAP[m.id] + with contextlib.suppress(Exception): + await m.delete() diff --git a/defs/glover.py b/defs/glover.py index 354d824..50be3b3 100644 --- a/defs/glover.py +++ b/defs/glover.py @@ -1,5 +1,5 @@ from configparser import RawConfigParser -from typing import Union +from typing import Union, List from distutils.util import strtobool # [pyrogram] @@ -15,6 +15,7 @@ splash_channel: int = 0 splash_channel_username: str = "" # [api] amap_key: str = "" +bili_auth_user_str: str = "" config = RawConfigParser() config.read("config.ini") api_id = config.getint("pyrogram", "api_id", fallback=api_id) @@ -30,6 +31,11 @@ splash_channel_username = config.get( "post", "splash_channel_username", fallback=splash_channel_username ) amap_key = config.get("api", "amap_key", fallback=amap_key) +bili_auth_user_str = config.get("api", "bili_auth_user", fallback=bili_auth_user_str) +try: + bili_auth_user: List[int] = list(map(int, bili_auth_user_str.split(","))) +except ValueError: + bili_auth_user: List[int] = [] try: ipv6 = bool(strtobool(ipv6)) except ValueError: diff --git a/modules/bilibili.py b/modules/bilibili.py index 61be32f..ff3cb7d 100644 --- a/modules/bilibili.py +++ b/modules/bilibili.py @@ -20,6 +20,7 @@ from scheduler import scheduler filters.incoming & filters.text & filters.regex(r"av(\d{1,12})|BV(1[A-Za-z0-9]{2}4.1.7[A-Za-z0-9]{2})|b23.tv") + & ~filters.command(["download"]) ) async def bili_resolve(_: Client, message: Message): """ diff --git a/modules/bilibili_download.py b/modules/bilibili_download.py new file mode 100644 index 0000000..b2af403 --- /dev/null +++ b/modules/bilibili_download.py @@ -0,0 +1,33 @@ +import re + +from pyrogram import filters, Client, ContinuePropagation +from pyrogram.types import Message + +from defs.bilibili import b23_extract, video_info_get, create_video +from defs.bilibili_download import go_download +from defs.glover import bili_auth_user +from init import bot + + +@bot.on_message( + filters.incoming + & filters.private + & filters.user(bili_auth_user) + & filters.command(["download"]) +) +async def bili_download_resolve(_: Client, message: Message): + if "b23.tv" in message.text: + message.text = await b23_extract(message.text) + p = re.compile(r"av(\d{1,12})|BV(1[A-Za-z0-9]{2}4.1.7[A-Za-z0-9]{2})") + video_number = p.search(message.text) + if video_number: + video_number = video_number[0] + else: + await message.reply("未找到视频 BV 号或 AV 号") + raise ContinuePropagation + p_ = re.compile(r"p=(\d{1,3})") + p_num = p_.search(message.text) + p_num = int(p_num[0][2:]) if p_num else 0 + video = create_video(video_number) + m = await message.reply("开始获取视频数据", quote=True) + bot.loop.create_task(go_download(video, p_num, m)) diff --git a/requirements.txt b/requirements.txt index 312126e..8e53876 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ pyrogram==2.0.106 tgcrypto==1.2.5 -bilibili-api-python==15.5.3 +bilibili-api-python==15.5.4 httpx pillow cashews