mirror of
https://github.com/Xtao-Labs/iShotaBot.git
synced 2024-11-28 18:50:41 +00:00
✨ 支持 download bilibili video
This commit is contained in:
parent
a8609fdd14
commit
bc55ef7a45
@ -23,3 +23,4 @@ splash_channel_username = username
|
|||||||
[api]
|
[api]
|
||||||
amap_key = ABCD
|
amap_key = ABCD
|
||||||
bili_cookie = ABCD
|
bili_cookie = ABCD
|
||||||
|
bili_auth_user = 777000,111000
|
||||||
|
@ -86,7 +86,7 @@ def cut_text(old_str, cut):
|
|||||||
next_str = next_str[1:]
|
next_str = next_str[1:]
|
||||||
elif s == "\n":
|
elif s == "\n":
|
||||||
str_list.append(next_str[: i - 1])
|
str_list.append(next_str[: i - 1])
|
||||||
next_str = next_str[i - 1:]
|
next_str = next_str[i - 1 :]
|
||||||
si = 0
|
si = 0
|
||||||
i = 0
|
i = 0
|
||||||
continue
|
continue
|
||||||
@ -133,13 +133,19 @@ async def b23_extract(text):
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
async def video_info_get(cid):
|
def create_video(cid) -> Optional[Video]:
|
||||||
|
v = None
|
||||||
if cid[:2] == "av":
|
if cid[:2] == "av":
|
||||||
v = Video(aid=int(cid[2:]), credential=credential)
|
v = Video(aid=int(cid[2:]), credential=credential)
|
||||||
elif cid[:2] == "BV":
|
elif cid[:2] == "BV":
|
||||||
v = Video(bvid=cid, credential=credential)
|
v = Video(bvid=cid, credential=credential)
|
||||||
else:
|
return v
|
||||||
return
|
|
||||||
|
|
||||||
|
async def video_info_get(cid):
|
||||||
|
v = create_video(cid)
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
video_info = await v.get_info()
|
video_info = await v.get_info()
|
||||||
return video_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
|
f"resources{sep}font{sep}sarasa-mono-sc-semibold.ttf", 18
|
||||||
)
|
)
|
||||||
dynamic_cut_str = "\n".join(cut_text(dynamic, 58))
|
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")
|
dynamic_bg = Image.new("RGB", (560, dynamic_text_y + 24), "#F5F5F7")
|
||||||
draw = ImageDraw.Draw(dynamic_bg)
|
draw = ImageDraw.Draw(dynamic_bg)
|
||||||
draw.rectangle((0, 0, 580, dynamic_text_y + 24), "#E1E1E5")
|
draw.rectangle((0, 0, 580, dynamic_text_y + 24), "#E1E1E5")
|
||||||
|
262
defs/bilibili_download.py
Normal file
262
defs/bilibili_download.py
Normal file
@ -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()
|
@ -1,5 +1,5 @@
|
|||||||
from configparser import RawConfigParser
|
from configparser import RawConfigParser
|
||||||
from typing import Union
|
from typing import Union, List
|
||||||
from distutils.util import strtobool
|
from distutils.util import strtobool
|
||||||
|
|
||||||
# [pyrogram]
|
# [pyrogram]
|
||||||
@ -15,6 +15,7 @@ splash_channel: int = 0
|
|||||||
splash_channel_username: str = ""
|
splash_channel_username: str = ""
|
||||||
# [api]
|
# [api]
|
||||||
amap_key: str = ""
|
amap_key: str = ""
|
||||||
|
bili_auth_user_str: str = ""
|
||||||
config = RawConfigParser()
|
config = RawConfigParser()
|
||||||
config.read("config.ini")
|
config.read("config.ini")
|
||||||
api_id = config.getint("pyrogram", "api_id", fallback=api_id)
|
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
|
"post", "splash_channel_username", fallback=splash_channel_username
|
||||||
)
|
)
|
||||||
amap_key = config.get("api", "amap_key", fallback=amap_key)
|
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:
|
try:
|
||||||
ipv6 = bool(strtobool(ipv6))
|
ipv6 = bool(strtobool(ipv6))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
@ -20,6 +20,7 @@ from scheduler import scheduler
|
|||||||
filters.incoming
|
filters.incoming
|
||||||
& filters.text
|
& 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.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):
|
async def bili_resolve(_: Client, message: Message):
|
||||||
"""
|
"""
|
||||||
|
33
modules/bilibili_download.py
Normal file
33
modules/bilibili_download.py
Normal file
@ -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))
|
@ -1,6 +1,6 @@
|
|||||||
pyrogram==2.0.106
|
pyrogram==2.0.106
|
||||||
tgcrypto==1.2.5
|
tgcrypto==1.2.5
|
||||||
bilibili-api-python==15.5.3
|
bilibili-api-python==15.5.4
|
||||||
httpx
|
httpx
|
||||||
pillow
|
pillow
|
||||||
cashews
|
cashews
|
||||||
|
Loading…
Reference in New Issue
Block a user