├── strings.py ├── constents.py ├── services ├── exceptions.py ├── logger.py ├── appkey.py ├── user_info.py ├── util.py ├── ass_render.py ├── uploader.py ├── live_service.py ├── login.py ├── live.py ├── downloader.py ├── bili_uploader.py └── danmu_converter.py ├── app.py ├── logger.py ├── requirements.txt ├── Dockerfile ├── config.py ├── README.md └── .gitignore /strings.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /constents.py: -------------------------------------------------------------------------------- 1 | APP_NAME = "BiliBili 直播助手" 2 | APP_VERSION = "0.0.1" 3 | 4 | -------------------------------------------------------------------------------- /services/exceptions.py: -------------------------------------------------------------------------------- 1 | class NotAuthorizedException(Exception): 2 | pass 3 | 4 | class DownloadPathException(Exception): 5 | pass -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import services.logger 2 | import services.login 3 | import services.live 4 | import asyncio 5 | 6 | asyncio.get_event_loop().run_forever() 7 | -------------------------------------------------------------------------------- /logger.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | 4 | logger.add("logs/{time}.log", rotation="5MB", encoding="utf-8", enqueue=True, compression="zip", retention="10 days") 5 | -------------------------------------------------------------------------------- /services/logger.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | import sys 3 | 4 | logger.remove() 5 | 6 | logger.add(sys.stderr, colorize=True, format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}", level="INFO") 7 | logger.add("logs/{time}.log", format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}", level="DEBUG") 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==22.1.0 2 | aiohttp==3.8.3 3 | aiosignal==1.2.0 4 | async-timeout==4.0.2 5 | attrs==22.1.0 6 | Brotli==1.0.9 7 | certifi==2022.9.24 8 | charset-normalizer==2.1.1 9 | ffmpeg-python==0.2.0 10 | frozenlist==1.3.1 11 | future==0.18.2 12 | idna==3.4 13 | logger==1.4 14 | loguru==0.6.0 15 | multidict==6.0.2 16 | Pillow==9.2.0 17 | pyasn1==0.4.8 18 | pydantic==1.10.2 19 | qrcode==7.3.1 20 | requests==2.28.1 21 | rsa==4.9 22 | typing_extensions==4.3.0 23 | urllib3==1.26.12 24 | websockets==10.3 25 | yarl==1.8.1 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | USER root 3 | WORKDIR /usr/src/app 4 | COPY requirements.txt *.py /usr/src/app/ 5 | COPY services /usr/src/app/services 6 | RUN pip install -U pip & pip install -r requirements.txt 7 | RUN wget https://www.johnvansickle.com/ffmpeg/old-releases/ffmpeg-4.4.1-amd64-static.tar.xz \ 8 | && tar xvf ffmpeg-4.4.1-amd64-static.tar.xz \ 9 | && mv ffmpeg-4.4.1-amd64-static/ffmpeg /usr/local/bin/ffmpeg \ 10 | && mv ffmpeg-4.4.1-amd64-static/ffprobe /usr/local/bin/ffprobe \ 11 | && rm -rf ffmpeg-4.4.1-amd64-static ffmpeg-4.4.1-amd64-static.tar.xz 12 | CMD ["python", "app.py"] 13 | 14 | -------------------------------------------------------------------------------- /services/appkey.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import urllib.parse 3 | 4 | 5 | def appsign(params): 6 | '为请求参数进行 api 签名' 7 | params.update({'appkey': appkey}) 8 | params = dict(sorted(params.items())) # 重排序参数 key 9 | query = urllib.parse.urlencode(params) # 序列化参数 10 | sign = hashlib.md5((query+appsec).encode()).hexdigest() # 计算 api 签名 11 | params.update({'sign':sign}) 12 | return params 13 | 14 | 15 | appkey = '1d8b6e7d45233436' 16 | appsec = '560c52ccd288fed045859ed18bffd973' 17 | params = { 18 | 'id':114514, 19 | 'str':'1919810', 20 | 'test':'いいよ,こいよ', 21 | } 22 | 23 | 24 | # signed_params = appsign(params, appkey, appsec) 25 | # query = urllib.parse.urlencode(signed_params) 26 | # print(signed_params) 27 | # print(query) -------------------------------------------------------------------------------- /services/user_info.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import aiohttp 3 | from config import get_config, save_config, Config 4 | from pydantic import BaseModel, validator 5 | 6 | default_headers = { 7 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36', 8 | 'Referer': 'https://live.bilibili.com/' 9 | } 10 | 11 | 12 | class UserInfo(BaseModel): 13 | class Data(BaseModel): 14 | class Card(BaseModel): 15 | class LevelInfo(BaseModel): 16 | current_level: int 17 | current_min: int 18 | current_exp: int 19 | next_exp: int 20 | 21 | mid: int 22 | name: str 23 | sex: str 24 | face: str 25 | fans: int 26 | attention: int 27 | level_info: LevelInfo 28 | 29 | archive_count: int 30 | card: Card 31 | 32 | code: int 33 | message: str 34 | ttl: int 35 | data: Data 36 | 37 | 38 | async def get_user_info_by_mid(mid: int) -> UserInfo: 39 | config = get_config() 40 | cookies = { 41 | 'SESSDATA': config.SESSDATA, 42 | 'bili_jct': config.bili_jct, 43 | 'DedeUserID': config.DedeUserID, 44 | 'DedeUserID__ckMd5': config.DedeUserID__ckMd5, 45 | } 46 | session = aiohttp.ClientSession(cookies=cookies, headers=default_headers) 47 | params = { 48 | 'mid': mid 49 | } 50 | async with session.get('https://api.bilibili.com/x/web-interface/card', params=params) as response: 51 | user_info = UserInfo.parse_obj(await response.json()) 52 | await session.close() 53 | return user_info 54 | 55 | -------------------------------------------------------------------------------- /services/util.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | import asyncio 4 | 5 | from pathlib import Path 6 | import random 7 | 8 | 9 | class Danmu(BaseModel): 10 | appear_time: float 11 | danmu_type: int 12 | font_size: int 13 | color: int 14 | send_time: int 15 | pool: int = 0 16 | mid_hash: str 17 | d_mid: int 18 | content: str 19 | level: int = 11 20 | 21 | def __str__(self): 22 | if '<' in self.content or '>' in self.content or '&' in self.content: 23 | return f'' 24 | else: 25 | return f'{self.content}' 26 | 27 | @classmethod 28 | def generate_danmu_xml(self, danmus: list['Danmu']): 29 | danmu_xml = ''' 30 | 31 | chat.bilibili.com 32 | 404122228 33 | 0 34 | 100 35 | 0 36 | 0 37 | k-v''' 38 | for danmu in danmus: 39 | danmu_xml += str(danmu) 40 | danmu_xml += '' 41 | return danmu_xml 42 | 43 | 44 | async def concat_videos(input_files: list[Path], output_file: Path): 45 | # 首先,我们使用 ffmpeg 的 `concat` 功能来生成一个临时文件,该文件包含了需要拼接的视频文件的列表 46 | temp_file_name = f'temp{str(random.randint(0, 10000))}.txt' 47 | with open(temp_file_name, "w") as f: 48 | for file in input_files: 49 | f.write(f"file '{file.absolute()}'\n") 50 | 51 | # 然后,我们调用 ffmpeg 命令行工具,使用该临时文件来拼接视频文件 52 | await asyncio.create_subprocess_exec( 53 | "ffmpeg", "-f", "concat", "-safe", "0", "-i", Path(temp_file_name).absolute(), "-c", "copy", output_file.absolute(), 54 | ) 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | import os 3 | from typing import Optional 4 | from enum import IntEnum 5 | 6 | 7 | class Config(BaseModel): 8 | class LiveConfig(BaseModel): 9 | class DownloadConfig(BaseModel): 10 | class DownloadType(IntEnum): 11 | DEFAULT = 1 12 | CUSTOM = 2 13 | 14 | download_type: DownloadType = DownloadType.DEFAULT 15 | custom_downloader: Optional[str] = None 16 | download_format: str = '%title-%Y年%m月%d日-%H点%M分场' 17 | 18 | download: DownloadConfig = DownloadConfig() 19 | 20 | class MonitorLiveRoom(BaseModel): 21 | class Quality(IntEnum): 22 | FLUENT = 80 23 | STANDARD = 150 24 | HIGH = 400 25 | SUPER = 10000 26 | 27 | class AutoUpload(BaseModel): 28 | enabled: bool = False 29 | title: str = '【直播录制】%title-%Y年%m月%d日-%H点%M分场' 30 | desc: str = '直播录制' 31 | source: str = 'https://live.bilibili.com/' 32 | tags: list[str] = ['直播录制'] 33 | tid: int = 27 34 | cover_path: str = 'AUTO' 35 | 36 | short_id: int = -1 37 | auto_download: bool = False 38 | auto_download_path: Optional[str] 39 | auto_download_quality: Quality = Quality.SUPER 40 | auto_upload: AutoUpload = AutoUpload() 41 | transcode: bool = False 42 | mid: int = 0 43 | SESSDATA: Optional[str] 44 | bili_jct: Optional[str] 45 | DedeUserID: Optional[str] 46 | DedeUserID__ckMd5: Optional[str] 47 | refresh_token: Optional[str] 48 | live_config: LiveConfig = LiveConfig() 49 | access_token: Optional[str] 50 | monitor_live_rooms: list[MonitorLiveRoom] = [ 51 | MonitorLiveRoom(auto_download_path=None) 52 | ] 53 | 54 | 55 | if not os.path.exists('config'): 56 | os.mkdir('config') 57 | 58 | 59 | def get_config() -> Config: 60 | if not os.path.exists('config/config.json'): 61 | save_config(Config()) 62 | return Config() 63 | config = Config.parse_file('config/config.json') 64 | return config 65 | 66 | 67 | def save_config(config: Config): 68 | with open('config/config.json', 'w') as f: 69 | f.write(config.json(indent=4, ensure_ascii=False)) 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BiliRecorder 2 | 3 | 4 |
5 | python 6 | minecraft 7 | bilibili 8 | 9 | docker 10 | 11 |
12 | 13 | --- 14 | 15 | ## 介绍 16 | 全自动监听、录制、投稿B站直播,并为录制文件添加ass弹幕。 17 | 18 | ## 实现功能 19 | - [x] 监听直播间 20 | - [x] 自动录制直播 21 | - [x] 自动投稿 22 | - [x] 自动添加ass弹幕 23 | - [x] 自动添加直播间封面 24 | - [x] 将弹幕渲染入视频 25 | 26 | ## 使用方法 27 | ### 1. docker安装 28 | ```bash 29 | docker run -d --name bili-recorder \ 30 | -v /path/to/config:/usr/src/app/config \ 31 | -v /path/to/logs:/usr/src/app/logs \ 32 | -v /path/to/output:/app/output 33 | summerkirakira/bili-recorder 34 | ``` 35 | 36 | ### 2. 源码安装 37 | ⚠️**弹幕转换等多项功能需要 [ffmpeg](https://ffmpeg.org) 的支持,请确保安装正确**⚠️ 38 | ```bash 39 | git clone https://github.com/summerkirakira/biliRecorder && cd biliRecorder # 下载源码 40 | pip install -r requirements.txt # 安装依赖 41 | python app.py # 运行 42 | ``` 43 | 44 | ## 配置文件 45 | 配置文件在`config/config.json`中 (如要复制以下内容请**删除**注释!) 46 | ```yaml 47 | { 48 | "mid": 0, // biliRecorder所使用账户的用户id 0为匿名 49 | "SESSDATA": null, // biliRecorder所使用账户的SESSDATA,为null时为匿名 50 | "bili_jct": null, // biliRecorder所使用账户的bili_jct,为null时为匿名 51 | "DedeUserID": null, // biliRecorder所使用账户的DedeUserID,为null时为匿名 52 | "DedeUserID__ckMd5": null, // biliRecorder所使用账户的DedeUserID__ckMd5,为null时为匿名 53 | "cookies": null, 54 | "refresh_token": null, // biliRecorder所使用账户的refresh_token,为null时为匿名 55 | "live_config": { 56 | "download_format": "%title-%Y年%m%月%d%日-%H点%M分场", // 直播录制文件名格式,支持strftime 57 | "download": { 58 | "download_type": 1, 59 | "custom_downloader": null 60 | } 61 | }, 62 | "access_token": null, // biliRecorder所使用账户的access_token,为null时为匿名 63 | "monitor_live_rooms": [ 64 | { 65 | "short_id": 83171, // 直播间号 66 | "auto_download": true, 67 | "auto_download_path": "/path/to/download", // 直播录制文件保存路径 68 | "auto_download_quality": 10000, // 直播录制画质 69 | "auto_upload": { 70 | "enabled": false, // 是否自动投稿 71 | "title": "【直播录制】%title-%Y年%m%月%d%日-%H点%M分场", // 直播投稿标题,支持strftime 72 | "desc": "直播录制", // 直播投稿简介 73 | "source": "https://live.bilibili.com/", //转载来源 74 | "tags": [ 75 | "直播录制" // 直播投稿标签 76 | ], 77 | "tid": 27, // 直播投稿分区,默认为生活区 78 | "cover_path": "AUTO" // 封面路径,AUTO为自动获取直播间封面 79 | } 80 | } 81 | ] 82 | } 83 | ``` 84 | 如果无需自动投稿录播,使用匿名账户即可,可以正常使用所有功能。账户信息可通过 [biliup-rs](https://github.com/ForgQi/biliup-rs) 获取。 -------------------------------------------------------------------------------- /services/ass_render.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | from pathlib import Path 4 | import shutil 5 | from loguru import logger 6 | import ffmpeg 7 | 8 | 9 | @logger.catch 10 | async def render_ass(video_path: Path): 11 | if not video_path.exists(): 12 | raise FileNotFoundError(f'视频文件不存在:{video_path}') 13 | ass_path = video_path.with_suffix('.zh-CN.ass') 14 | if not ass_path.exists(): 15 | raise FileNotFoundError(f'弹幕文件不存在:{ass_path}') 16 | await fix_video(video_path) 17 | p = subprocess.Popen(f'ffmpeg -i "{video_path.absolute()}" -vf ass="{ass_path.absolute()}" -vcodec libx264 -acodec copy "{video_path.with_suffix(".danmaku.flv").absolute()}"', 18 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) 19 | stdout, stderr = p.communicate() 20 | if p.returncode != 0: 21 | raise RuntimeError(f'渲染弹幕出错:{stderr.decode("utf-8")}') 22 | return stdout, stderr 23 | 24 | 25 | @logger.catch 26 | async def fix_video(video_path: Path, transcode=False): 27 | if not video_path.exists(): 28 | raise FileNotFoundError(f'视频文件不存在:{video_path}') 29 | # p = subprocess.Popen(f'ffmpeg -y -i "{video_path.absolute()}" -codec copy "{video_path.with_suffix(".temp.flv").absolute()}"', 30 | # stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) 31 | # stdout, stderr = p.communicate() 32 | # if p.returncode != 0: 33 | # raise RuntimeError(f'修复视频文件出错:{stderr.decode("utf-8")}') 34 | # else: 35 | # os.remove(video_path) 36 | # shutil.copy(video_path.with_suffix('.temp.flv').absolute(), video_path.absolute()) 37 | # os.remove(video_path.with_suffix('.temp.flv').absolute()) 38 | # 39 | # return stdout, stderr 40 | input_video = ffmpeg.input(video_path.absolute()) 41 | out = ffmpeg.output(input_video, filename=video_path.with_suffix('.temp.flv').absolute(), vcodec='copy', 42 | acodec='copy') 43 | try: 44 | out.run() 45 | except ffmpeg.Error as e: 46 | raise RuntimeError(f'修复视频文件出错:{e.stderr.decode("utf-8")}') 47 | os.remove(video_path) 48 | shutil.copy(video_path.with_suffix('.temp.flv').absolute(), video_path.absolute()) 49 | os.remove(video_path.with_suffix('.temp.flv').absolute()) 50 | if transcode: 51 | out = ffmpeg.output(input_video, filename=video_path.with_suffix('.temp.flv').absolute(), vcodec='h264', acodec='aac') 52 | try: 53 | out.run() 54 | os.remove(video_path) 55 | shutil.copy(video_path.with_suffix('.temp.flv').absolute(), video_path.absolute()) 56 | os.remove(video_path.with_suffix('.temp.flv').absolute()) 57 | except ffmpeg.Error as e: 58 | raise RuntimeError(f'修复视频文件出错:{e.stderr.decode("utf-8")}') 59 | 60 | 61 | if __name__ == '__main__': 62 | import asyncio 63 | loop = asyncio.get_event_loop().run_until_complete(fix_video(Path(input('请输入视频文件路径:')), transcode=True)) 64 | 65 | -------------------------------------------------------------------------------- /services/uploader.py: -------------------------------------------------------------------------------- 1 | from services.bili_uploader import BiliBili, Data 2 | from config import get_config, save_config, Config 3 | from services.exceptions import NotAuthorizedException 4 | from abc import abstractmethod 5 | 6 | from threading import Thread 7 | 8 | 9 | class Uploader(Thread): 10 | 11 | def __init__(self): 12 | super().__init__() 13 | self.config = get_config() 14 | if self.config is None: 15 | raise NotAuthorizedException('未登录') 16 | self.video = Data() 17 | self.file_list: list[str] = [] 18 | 19 | @abstractmethod 20 | def run(self): 21 | pass 22 | 23 | 24 | class BiliBiliLiveUploader(Uploader): 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self.cover_path = None 29 | 30 | def run(self): 31 | lines = 'AUTO' 32 | tasks = 3 33 | with BiliBili(self.video) as bili: 34 | if self.cover_path is None: 35 | raise Exception('未设置封面') 36 | bili.login("bili.cookies", { 37 | 'cookies': { 38 | 'SESSDATA': self.config.SESSDATA, 39 | 'bili_jct': self.config.bili_jct, 40 | 'DedeUserID': self.config.DedeUserID, 41 | 'DedeUserID__ckMd5': self.config.DedeUserID__ckMd5 42 | }, 43 | 'access_token': self.config.access_token, 44 | }) 45 | self.video.cover = bili.cover_up(self.cover_path).replace('http:', '') 46 | for file in self.file_list: 47 | video_part = bili.upload_file(file['path'], lines=lines, tasks=tasks, title=file['title']) # 上传视频,默认线路AUTO自动选择,线程数量3。 48 | self.video.append(video_part) # 添加已经上传的视频 49 | 50 | bili.submit() # 提交视频 51 | 52 | def set_title(self, title: str): 53 | self.video.title = title 54 | 55 | def set_desc(self, desc: str): 56 | self.video.desc = desc 57 | 58 | def set_cover(self, cover: str): 59 | self.cover_path = cover 60 | 61 | def set_tags(self, tags: list[str]): 62 | self.video.set_tag(tags) 63 | 64 | def set_files(self, files: list[dict]): 65 | self.file_list = files 66 | 67 | def set_tid(self, tid: int): 68 | self.video.tid = tid 69 | 70 | def set_source(self, resource: str): 71 | self.video.source = resource 72 | 73 | 74 | # bilibili_uploader = BiliBiliVtbLiveUploader() 75 | # 76 | # bilibili_uploader.set_title('【录播】VirtualReal夏日合唱Super') 77 | # 78 | # bilibili_uploader.set_desc('') 79 | # 80 | # bilibili_uploader.set_tid(27) 81 | 82 | # bilibili_uploader.set_desc('七海Nana7mi的个人空间: https://space.bilibili.com/434334701/') 83 | 84 | # bilibili_uploader.set_tags(['虚拟UP主', 'VirtualReal']) 85 | # 86 | # bilibili_uploader.set_source("https://live.bilibili.com/21470454") 87 | # 88 | # bilibili_uploader.set_cover('/Users/forever/Downloads/111.jpeg') 89 | # 90 | # # bilibili_uploader.set_files(['/Users/forever/PycharmProjects/biliRecorder/七海Nana7mi/就!半小时-2022年09月23日-19点45分场.有弹幕.flv', '/Users/forever/PycharmProjects/biliRecorder/七海Nana7mi/就!半小时-2022年09月23日-19点45分场.flv'][:-1]) 91 | # bilibili_uploader.set_files( 92 | # [ 93 | # { 94 | # 'path': '/Users/forever/PycharmProjects/biliRecorder/VirtuaReal/【夏日合唱Super】- Day1-2022年09月24日-13点00分场.danmaku.flv', 95 | # 'title': '带弹幕', 96 | # }, 97 | # { 98 | # 'path': '/Users/forever/PycharmProjects/biliRecorder/VirtuaReal/【夏日合唱Super】- Day1-2022年09月24日-13点00分场.flv', 99 | # 'title': '无弹幕', 100 | # } 101 | # ] 102 | # ) 103 | # bilibili_uploader.run() 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | **/.Ds_Store 162 | **/*.flv 163 | **/*.ass 164 | config.json 165 | bili.cookies 166 | **/*.jpeg 167 | **/*.jpg 168 | test/ -------------------------------------------------------------------------------- /services/live_service.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | from pydantic import BaseModel, validator 4 | from enum import IntEnum 5 | from config import get_config 6 | import aiohttp 7 | 8 | config = get_config() 9 | 10 | 11 | class RoomInfo(BaseModel): 12 | class Data(BaseModel): 13 | class LiveStatus(IntEnum): 14 | NOT_LIVE = 0 15 | LIVE = 1 16 | VIDEO = 2 17 | 18 | uid: int 19 | room_id: int 20 | short_id: int 21 | attention: int 22 | online: int 23 | is_portrait: bool 24 | description: str 25 | live_status: LiveStatus 26 | area_id: int 27 | area_name: str 28 | parent_area_id: int 29 | parent_area_name: str 30 | background: str 31 | title: str 32 | user_cover: str 33 | keyframe: str 34 | live_time: str 35 | tags: str 36 | is_strict_room: bool 37 | data: Data 38 | code: int 39 | message: str 40 | 41 | 42 | class VideoStreamInfo(BaseModel): 43 | class Code(IntEnum): 44 | SUCCESS = 0 45 | INVALID_ARGUMENT = -400 46 | NO_ROOM = 19002003 47 | 48 | class Data(BaseModel): 49 | class QualityDescription(BaseModel): 50 | qn: int 51 | desc: str 52 | 53 | class Durl(BaseModel): 54 | order: int 55 | length: int 56 | url: str 57 | 58 | @validator('url') 59 | def format_url(cls, v): 60 | return v.replace("\\u0026", "&") 61 | 62 | current_quality: int 63 | accept_quality: list[str] 64 | current_qn: int 65 | quality_description: list[QualityDescription] 66 | durl: list[Durl] 67 | 68 | code: Code 69 | message: str 70 | ttl: int 71 | data: Data 72 | 73 | 74 | class LiveService: 75 | default_headers = { 76 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', 77 | 'Referer': 'https://live.bilibili.com/', 78 | 'Origin': 'https://live.bilibili.com', 79 | 'Sec-Fetch-Site': 'same-site', 80 | 'Sec-Fetch-Mode': 'cors', 81 | 'Sec-Fetch-Dest': 'empty', 82 | 'Accept-Encoding': 'gzip, deflate, br', 83 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 84 | 'Accept': 'application/json, text/plain, */*', 85 | 'Connection': 'keep-alive', 86 | 'Cache-Control': 'no-cache', 87 | 'Pragma': 'no-cache', 88 | } 89 | 90 | def __init__(self): 91 | self.cookies = { 92 | 'bili_jct': config.bili_jct, 93 | 'DedeUserID': config.DedeUserID, 94 | 'DedeUserID__ckMd5': config.DedeUserID__ckMd5, 95 | 'SESSDATA': config.SESSDATA, 96 | } 97 | self.session = None 98 | 99 | async def create_session(self): 100 | # 创建会话 101 | if self.session is None: 102 | self.session = aiohttp.ClientSession(headers=self.default_headers) 103 | 104 | async def get_room_info(self, room_id: int) -> RoomInfo: 105 | # 获取房间信息 106 | if self.session is None: 107 | self.session = aiohttp.ClientSession(headers=self.default_headers) 108 | url = 'https://api.live.bilibili.com/room/v1/Room/get_info' 109 | params = { 110 | 'room_id': room_id 111 | } 112 | async with self.session.get(url, params=params) as response: 113 | return RoomInfo.parse_obj(await response.json()) 114 | 115 | async def get_video_stream_info(self, room_id: int, qn: int) -> VideoStreamInfo: 116 | # 获取视频流信息 117 | url = 'https://api.live.bilibili.com/room/v1/Room/playUrl' 118 | params = { 119 | 'cid': room_id, 120 | 'qn': qn, 121 | 'platform': 'web' 122 | } 123 | async with self.session.get(url, params=params) as response: 124 | return VideoStreamInfo.parse_obj(await response.json()) 125 | 126 | async def get_video_stream_url(self, room_id: int) -> str: 127 | # 获取视频流链接 128 | await self.create_session() 129 | video_stream_info = await self.get_video_stream_info(room_id, 10000) 130 | if video_stream_info.code != VideoStreamInfo.Code.SUCCESS: 131 | raise ValueError(f'获取视频流信息失败: {video_stream_info.message}') 132 | return video_stream_info.data.durl[0].url 133 | 134 | class DownloadStatus(BaseModel): 135 | class Status(IntEnum): 136 | DOWNLOADING = 0 137 | FINISHED = 1 138 | FAILED = 2 139 | 140 | status: Status 141 | progress: int = 0 142 | start_time: int = 0 143 | 144 | class MessageKeyResponse(BaseModel): 145 | 146 | class Data(BaseModel): 147 | class Host(BaseModel): 148 | host: str 149 | port: int 150 | wss_port: int 151 | ws_port: int 152 | token: str 153 | host_list: list[Host] 154 | code: int 155 | message: str 156 | data: Data 157 | current_command_count: int = 1 -------------------------------------------------------------------------------- /services/login.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import time 3 | from abc import abstractmethod 4 | from enum import IntEnum, Enum 5 | from pydantic import BaseModel 6 | from loguru import logger 7 | from requests import Response 8 | from typing import Optional, Union 9 | from config import get_config, save_config 10 | import qrcode 11 | 12 | config = get_config() 13 | 14 | 15 | class BLogin: 16 | def __init__(self): 17 | self.default_headers = { 18 | "User-Agent": "Mozilla/5.0 BiliDroid/5.15.0 (bbcallen@gmail.com)", 19 | "Accept": "*/*", 20 | "Accept-Language": "en-US,en;q=0.5", 21 | "Accept-Encoding": "gzip, deflate, br" 22 | } 23 | self.session = requests.Session() 24 | self.session.headers.update(self.default_headers) 25 | self.SESSDATA: Optional[str] = None 26 | self.bili_jct: Optional[str] = None 27 | self.DedeUserID: Optional[str] = None 28 | self.DedeUserID__ckMd5: Optional[str] = None 29 | self.sid: Optional[str] = None 30 | self.cookies: Optional[dict] = None 31 | self.refresh_token = None 32 | self.mid = None 33 | 34 | class LoginType(Enum): 35 | QR = 1 36 | PASSWORD = 2 37 | 38 | class LoginStatus(Enum): 39 | SUCCESS = 1 40 | FAILED = 2 41 | UNDEFINED = 3 42 | 43 | @abstractmethod 44 | def login(self): 45 | # Login to Bilibili 46 | pass 47 | 48 | def update_login_config(self): 49 | config = get_config() 50 | config.mid = self.mid 51 | config.SESSDATA = self.SESSDATA 52 | config.bili_jct = self.bili_jct 53 | config.DedeUserID = self.DedeUserID 54 | config.DedeUserID__ckMd5 = self.DedeUserID__ckMd5 55 | config.cookies = self.cookies 56 | config.refresh_token = self.refresh_token 57 | save_config(config) 58 | 59 | 60 | class QRLogin(BLogin): 61 | 62 | class QRRequestResponse(BaseModel): 63 | # 向服务器请求登录二维码的响应 64 | class Data(BaseModel): 65 | url: str 66 | qrcode_key: str 67 | 68 | code: int 69 | message: str 70 | ttl: int 71 | data: Data 72 | 73 | class QRRequestStatusResponse(BaseModel): 74 | # 二维码请求状态 75 | class Code(IntEnum): 76 | SUCCESS = 0 77 | QR_EXPIRED = 86038 78 | QR_NOT_SCANNED = 86101 79 | QR_SCANNED = 86090 80 | UNDEFINED = 2 81 | 82 | class Data(BaseModel): 83 | code: 'QRLogin.QRRequestStatusResponse.Code' 84 | message: str 85 | refresh_token: str 86 | 87 | data: Data 88 | 89 | class NavUserInfo(BaseModel): 90 | class Data(BaseModel): 91 | isLogin: bool 92 | mid: int 93 | face: str 94 | uname: str 95 | data: Data 96 | 97 | def __init__(self): 98 | super().__init__() 99 | self.login_status = self.LoginStatus.UNDEFINED 100 | self.login_type = self.LoginType.QR 101 | self.qr_request_url = "http://passport.bilibili.com/x/passport-login/web/qrcode/generate" 102 | self.qr_check_url = "http://passport.bilibili.com/x/passport-login/web/qrcode/poll" 103 | self.QRRequestStatusResponse.Data.update_forward_refs() 104 | 105 | @logger.catch 106 | def login(self): 107 | # 向服务器请求登录二维码 108 | try: 109 | qr_request_response: QRLogin.QRRequestResponse = self.QRRequestResponse( 110 | **self.session.get(self.qr_request_url).json() 111 | ) 112 | logger.success(f"请在bili客户端中点击链接登录或使用二维码登录:{qr_request_response.data.url}") 113 | qrcode.make(qr_request_response.data.url).show() 114 | except Exception as e: 115 | logger.error(f"获取验证码失败: {e}") 116 | self.login_status = self.LoginStatus.FAILED 117 | return 118 | max_retry = 180 119 | while True: 120 | # 检查二维码状态 121 | login_status: Response = self.session.get(self.qr_check_url, headers=self.default_headers, params={ 122 | "qrcode_key": qr_request_response.data.qrcode_key 123 | }) 124 | login_status_response: QRLogin.QRRequestStatusResponse = self.QRRequestStatusResponse( 125 | **login_status.json() 126 | ) 127 | if login_status_response.data.code == self.QRRequestStatusResponse.Code.SUCCESS: 128 | self.cookies = login_status.cookies.get_dict() 129 | self.refresh_token = login_status_response.data.refresh_token 130 | for key, value in self.session.cookies.get_dict().items(): 131 | if key == "SESSDATA": 132 | self.SESSDATA = value 133 | elif key == "bili_jct": 134 | self.bili_jct = value 135 | elif key == "DedeUserID": 136 | self.DedeUserID = value 137 | elif key == "DedeUserID__ckMd5": 138 | self.DedeUserID__ckMd5 = value 139 | elif key == "sid": 140 | self.sid = value 141 | self.login_status = self.LoginStatus.SUCCESS 142 | break 143 | elif login_status_response.data.code == self.QRRequestStatusResponse.Code.QR_EXPIRED: 144 | logger.error("二维码已过期") 145 | self.login_status = self.LoginStatus.FAILED 146 | break 147 | elif login_status_response.data.code == self.QRRequestStatusResponse.Code.QR_NOT_SCANNED: 148 | pass 149 | elif login_status_response.data.code == self.QRRequestStatusResponse.Code.QR_SCANNED: 150 | logger.info("二维码已扫描") 151 | max_retry -= 1 152 | if max_retry == 0: 153 | logger.error("超过最大重试次数") 154 | self.login_status = self.LoginStatus.FAILED 155 | break 156 | 157 | time.sleep(1) 158 | 159 | user_info: QRLogin.NavUserInfo = self.NavUserInfo(**self.session.get("https://api.bilibili.com/x/web-interface/nav").json()) 160 | if user_info.data.isLogin: 161 | self.mid = user_info.data.mid 162 | if self.login_status == self.LoginStatus.SUCCESS: 163 | logger.success("登录成功") 164 | self.update_login_config() 165 | else: 166 | logger.error("登录失败") 167 | self.login_status = self.LoginStatus.FAILED 168 | 169 | def get_nav_user_info(self) -> NavUserInfo: 170 | # 获取导航栏用户信息 171 | nav_user_info: Response = self.session.get("https://api.bilibili.com/x/web-interface/nav", headers=self.default_headers) 172 | return self.NavUserInfo(**nav_user_info.json()) 173 | 174 | 175 | def login(): 176 | qr_login = QRLogin() 177 | qr_login.login() 178 | 179 | -------------------------------------------------------------------------------- /services/live.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | from pydantic import BaseModel 4 | from enum import IntEnum 5 | from config import get_config, Config 6 | from typing import Optional 7 | import asyncio 8 | import aiohttp 9 | import json 10 | from services.downloader import LiveDefaultDownloader, LiveFfmpegDownloader 11 | import websockets 12 | import zlib 13 | from loguru import logger 14 | import time 15 | from services.util import Danmu 16 | from services.exceptions import DownloadPathException 17 | from services.live_service import RoomInfo, LiveService 18 | 19 | config = get_config() 20 | 21 | 22 | class MonitorRoom: 23 | class MessageStreamCommand(IntEnum): 24 | HEARTBEAT = 2 25 | HEARTBEAT_REPLY = 3 26 | COMMAND = 5 27 | AUTHENTICATION = 7 28 | AUTHENTICATION_REPLY = 8 29 | 30 | class DanmuMessage(BaseModel): 31 | cmd: str 32 | info: list[dict] 33 | 34 | def __init__(self, room_config: Config.MonitorLiveRoom): 35 | self.room_id = room_config.short_id 36 | self.live = False 37 | self.down_video = False 38 | self.room_config = room_config 39 | self.room_info: Optional[RoomInfo] = None 40 | self.download_status: Optional[LiveService.DownloadStatus] = None 41 | self.downloader: Optional[LiveFfmpegDownloader] = None 42 | self.message_stream_data = None 43 | self.session = None 44 | self.message_ws = None 45 | self.danmus: list[Danmu] = [] 46 | self.default_headers = { 47 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', 48 | 'Origin': 'https://live.bilibili.com', 49 | 'Connection': 'Upgrade' 50 | } 51 | 52 | async def update_room_info(self): 53 | while True: 54 | try: 55 | self.room_info = await live_service.get_room_info(self.room_id) 56 | self.room_id = self.room_info.data.room_id 57 | self.live = self.room_info.data.live_status == RoomInfo.Data.LiveStatus.LIVE 58 | if self.live and self.download_status is None and self.room_config.auto_download: 59 | if self.message_stream_data is None: 60 | asyncio.get_running_loop().create_task(self.init_message_ws()) 61 | url = await live_service.get_video_stream_url(self.room_id) 62 | asyncio.get_running_loop().create_task(self.download_live_video(url)) 63 | if self.room_config.auto_upload.enabled and self.room_config.auto_upload.cover_path == 'AUTO': 64 | await self.download_live_image(self.room_info.data.user_cover) 65 | self.download_status = LiveService.DownloadStatus( 66 | status=LiveService.DownloadStatus.Status.DOWNLOADING) 67 | if self.room_info.data.live_status != RoomInfo.Data.LiveStatus.LIVE and self.download_status is not None: 68 | self.live = False 69 | await self.stop_download() 70 | except Exception as e: 71 | logger.debug(f'更新房间信息失败: {e}') 72 | await asyncio.sleep(10) 73 | 74 | async def download_live_image(self, url: str): 75 | # 下载直播封面 76 | if self.session is None: 77 | self.session = aiohttp.ClientSession(headers=self.default_headers) 78 | async with self.session.get(url) as response: 79 | with open(f'{self.room_config.short_id}.jpg', 'wb') as f: 80 | f.write(await response.read()) 81 | 82 | async def download_live_video(self, url: str): 83 | """ 84 | 下载直播视频 85 | """ 86 | self.download_status = LiveService.DownloadStatus(status=LiveService.DownloadStatus.Status.DOWNLOADING) 87 | try: 88 | self.downloader = LiveFfmpegDownloader(url, self.room_config, self.room_info) 89 | except DownloadPathException as e: 90 | logger.error(f'请在配置文件中指定下载路径!: {e}') 91 | exit(1) 92 | self.downloader.download() 93 | while True: 94 | if self.download_status is None: 95 | self.download_status = None 96 | self.downloader = None 97 | return 98 | logger.info( 99 | f'正在录制直播间: {self.room_info.data.title}({self.room_info.data.room_id if self.room_info.data.short_id == 0 else self.room_info.data.short_id})') 100 | await asyncio.sleep(10) 101 | 102 | async def get_live_message_stream_key(self, room_id: int) -> str: 103 | # 获取直播弹幕流密钥 104 | url = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo' 105 | params = { 106 | 'id': room_id 107 | } 108 | async with self.session.get(url, params=params) as response: 109 | self.message_stream_data = LiveService.MessageKeyResponse.parse_obj(await response.json()) 110 | return self.message_stream_data.data.token 111 | 112 | @logger.catch 113 | async def init_message_ws(self): 114 | # 初始化弹幕流连接 115 | self.session = aiohttp.ClientSession(cookies=live_service.cookies, headers=self.default_headers) 116 | key = await self.get_live_message_stream_key(self.room_id) 117 | logger.debug('弹幕流连接成功') 118 | message: bytes = json.dumps({ 119 | 'uid': get_config().mid, 120 | 'roomid': self.room_id, 121 | 'protover': 2, 122 | 'platform': 'web', 123 | 'type': 2, 124 | 'key': key 125 | }, 126 | separators=(',', ':') 127 | ).encode('utf-8') 128 | self.message_ws = await websockets.connect('wss://{}:{}/sub'.format( 129 | self.message_stream_data.data.host_list[0].host, 130 | self.message_stream_data.data.host_list[0].wss_port 131 | )) 132 | await self.send_ws_message(MonitorRoom.MessageStreamCommand.AUTHENTICATION, message) 133 | await self.send_heartbeat() 134 | await self.receive_message() 135 | 136 | def generate_message_stream(self, command: MessageStreamCommand, payload: bytes) -> bytes: 137 | # 生成websocket消息 138 | header = bytearray() 139 | header.extend(struct.pack('>I', 16 + len(payload))) 140 | header.extend(struct.pack('>H', 16)) 141 | if command == MonitorRoom.MessageStreamCommand.HEARTBEAT or command == MonitorRoom.MessageStreamCommand.AUTHENTICATION: 142 | header.extend(struct.pack('>H', 1)) 143 | else: 144 | header.extend(struct.pack('>H', 0)) 145 | header.extend(struct.pack('>I', command)) 146 | header.extend(struct.pack('>I', self.message_stream_data.current_command_count)) 147 | return bytes(header) + payload 148 | 149 | async def send_ws_message(self, command: 'MonitorRoom.MessageStreamCommand', message: bytes): 150 | message = self.generate_message_stream(command, message) 151 | logger.debug(f'发送消息: {message}') 152 | try: 153 | await self.message_ws.send(message) 154 | except Exception as e: 155 | logger.error(f'发送消息失败: {e}') 156 | await self.close_session() 157 | await self.init_message_ws() 158 | self.message_stream_data.current_command_count += 1 159 | 160 | async def send_heartbeat(self): 161 | # 发送心跳 162 | await self.send_ws_message(MonitorRoom.MessageStreamCommand.HEARTBEAT, b'') 163 | 164 | async def receive_message(self): 165 | # 接收消息 166 | while self.download_status is not None and self.download_status.status == LiveService.DownloadStatus.Status.DOWNLOADING: 167 | try: 168 | message = await self.message_ws.recv() 169 | # logger.debug(f'接收消息: {message}') 170 | try: 171 | await self.handle_message(message) 172 | except Exception as e: 173 | logger.debug(f'处理消息失败: {e}') 174 | except Exception as e: 175 | logger.error(f'接收消息出错: {e}') 176 | break 177 | await self.close_session() 178 | 179 | async def close_session(self): 180 | await self.session.close() 181 | self.session = None 182 | 183 | async def send_heartbeat_loop(self): 184 | while self.download_status is not None: 185 | await self.send_heartbeat() 186 | await asyncio.sleep(25) 187 | 188 | async def handle_message(self, message: bytes): 189 | # 处理消息 190 | header = message[:16] 191 | payload = message[16:] 192 | length, header_length, version, operation, sequence_id = struct.unpack('>IHHII', header) 193 | if operation == MonitorRoom.MessageStreamCommand.HEARTBEAT_REPLY: 194 | logger.debug('收到心跳回复') 195 | elif operation == MonitorRoom.MessageStreamCommand.AUTHENTICATION_REPLY: 196 | logger.debug('收到认证回复') 197 | asyncio.get_running_loop().create_task(self.send_heartbeat_loop()) 198 | elif operation == MonitorRoom.MessageStreamCommand.COMMAND: 199 | # logger.debug('收到命令') 200 | if version == 2: 201 | decompressed_message = zlib.decompress(payload) 202 | commands = await self.extract_commands(decompressed_message) 203 | else: 204 | commands = [json.loads(payload.decode('utf-8'))] 205 | for command in commands: 206 | if command['cmd'] == 'DANMU_MSG': 207 | if len(self.danmus) % 20 == 0: 208 | logger.info(f'在直播间{self.room_id}收到{len(self.danmus)}条弹幕') 209 | await self.process_danmu(command['info']) 210 | elif command['cmd'] == 'SEND_GIFT': 211 | logger.debug( 212 | f'收到礼物: {command["data"]["uname"]} 赠送 {command["data"]["num"]} 个 {command["data"]["giftName"]}') 213 | elif command['cmd'] == 'WELCOME': 214 | logger.debug(f'欢迎 {command["data"]["uname"]} 进入直播间') 215 | elif command['cmd'] == 'WELCOME_GUARD': 216 | logger.debug(f'欢迎 {command["data"]["username"]} 进入直播间') 217 | elif command['cmd'] == 'SYS_MSG': 218 | logger.debug(f'系统消息: {command["msg"]}') 219 | elif command['cmd'] == 'PREPARING': 220 | logger.debug('直播间准备中') 221 | elif command['cmd'] == 'LIVE': 222 | logger.debug('直播开始') 223 | elif command['cmd'] == 'PREPARING': 224 | logger.debug('直播结束') 225 | elif command['cmd'] == 'ROOM_BLOCK_MSG': 226 | logger.debug(f'直播间被封禁: {command["msg"]}') 227 | elif command['cmd'] == 'ROOM_SILENT_ON': 228 | logger.debug(f'直播间已开启全员禁言') 229 | elif command['cmd'] == 'ROOM_SILENT_OFF': 230 | logger.debug(f'直播间已关闭全员禁言') 231 | elif command['cmd'] == 'ROOM_REAL_TIME_MESSAGE_UPDATE': 232 | logger.debug(f'直播间人气值更新: {command["data"]["fans"]}') 233 | elif command['cmd'] == 'ROOM_RANK': 234 | logger.debug(f'直播间排行榜更新: {command["data"]}') 235 | elif command['cmd'] == 'ROOM_CHANGE': 236 | logger.debug(f'直播间信息更新: {command["data"]}') 237 | elif command['cmd'] == 'ROOM_ADMINS': 238 | logger.debug(f'直播间管理员更新: {command["data"]}') 239 | elif command['cmd'] == 'ROOM_ADMINS_SET': 240 | logger.debug(f'直播间管理员设置: {command["data"]}') 241 | elif command['cmd'] == 'ROOM_ADMINS_UNSET': 242 | logger.debug(f'直播间管理员取消: {command["data"]}') 243 | elif command['cmd'] == 'ROOM_BLOCK_MSG': 244 | logger.debug(f'直播间被封禁: {command["msg"]}') 245 | elif command['cmd'] == 'ROOM_LOCK': 246 | logger.debug(f'直播间已开启上锁') 247 | elif command['cmd'] == 'ROOM_UNLOCK': 248 | logger.debug(f'直播间已关闭上锁') 249 | elif command['cmd'] == 'ROOM_ADMIN_ENTER': 250 | logger.debug(f'管理员 {command["data"]["username"]} 进入直播间') 251 | elif command['cmd'] == 'NOTICE_MSG': 252 | logger.debug(f'通知消息: {command["msg"]}') 253 | elif command['cmd'] == 'ACTIVITY_BANNER_UPDATE_V2': 254 | logger.debug(f'活动信息更新: {command["data"]}') 255 | elif command['cmd'] == 'ANCHOR_LOT_CHECKSTATUS': 256 | logger.debug(f'主播开启抽奖: {command["data"]}') 257 | elif command['cmd'] == 'ANCHOR_LOT_START': 258 | logger.debug(f'主播开始抽奖: {command["data"]}') 259 | elif command['cmd'] == 'ANCHOR_LOT_END': 260 | logger.debug(f'主播结束抽奖: {command["data"]}') 261 | elif command['cmd'] == 'ANCHOR_LOT_AWARD': 262 | logger.debug(f'主播抽奖结果: {command["data"]}') 263 | 264 | @classmethod 265 | async def extract_commands(cls, message: bytes) -> list: 266 | # 提取命令 267 | commands = [] 268 | while len(message) > 0: 269 | header = message[:16] 270 | total_length, header_length, version, operation, sequence_id = struct.unpack('>IHHII', header) 271 | payload = message[16:total_length] 272 | message = message[total_length:] 273 | commands.append(json.loads(payload.decode('utf-8'))) 274 | return commands 275 | 276 | async def process_danmu(self, danmu): 277 | # 处理弹幕 278 | danmu_info = { 279 | 'danmu_type': danmu[0][1], 280 | 'font_size': danmu[0][2], 281 | 'color': danmu[0][3], 282 | 'send_time': danmu[0][4], 283 | 'mid_hash': danmu[2][0], 284 | 'd_mid': 123456789, 285 | 'content': danmu[1], 286 | } 287 | if self.download_status is not None and self.download_status == LiveService.DownloadStatus.Status.DOWNLOADING: 288 | # 录制中 289 | danmu_info['appear_time'] = time.time() - self.downloader.get_download_status().start_time 290 | else: 291 | # 未录制 292 | danmu_info['appear_time'] = -1 293 | self.danmus.append(Danmu.parse_obj(danmu_info)) 294 | 295 | async def stop_download(self): 296 | # 停止录制 297 | logger.info( 298 | f'录制结束: 录制时长 {round(time.time() - self.downloader.get_download_status().start_time)} 秒, 弹幕数量 {len(self.danmus)} 条') 299 | self.download_status = LiveService.DownloadStatus(status=LiveService.DownloadStatus.Status.FINISHED) 300 | self.downloader.damu_list = self.danmus 301 | self.downloader.cancel() 302 | self.download_status = None 303 | self.message_stream_data = None 304 | self.danmus = [] 305 | 306 | async def test_slice_video(self): 307 | self.downloader.download_process.kill() 308 | await asyncio.sleep(10) 309 | self.downloader.download_process.kill() 310 | await asyncio.sleep(10) 311 | self.downloader.download_process.kill() 312 | await asyncio.sleep(10) 313 | self.downloader.download_process.kill() 314 | await asyncio.sleep(10) 315 | self.downloader.download_process.kill() 316 | await asyncio.sleep(10) 317 | self.downloader.download_process.kill() 318 | await asyncio.sleep(10) 319 | self.downloader.download_process.kill() 320 | await asyncio.sleep(10) 321 | await self.stop_download() 322 | 323 | 324 | async def test(monitor_room: MonitorRoom): 325 | # 测试 326 | await asyncio.sleep(20) 327 | print('停止录制') 328 | await monitor_room.test_slice_video() 329 | 330 | 331 | live_service = LiveService() 332 | 333 | 334 | def start_monitor(): 335 | # 开始监控 336 | for room_config in config.monitor_live_rooms: 337 | if room_config.short_id == -1: 338 | continue 339 | monitor_room = MonitorRoom(room_config) 340 | try: 341 | asyncio.get_running_loop().create_task(monitor_room.update_room_info()) 342 | except RuntimeError: 343 | asyncio.get_event_loop().create_task(monitor_room.update_room_info()) 344 | # asyncio.get_event_loop().create_task(test(monitor_room)) 345 | logger.info( 346 | f'正在监听直播间: {", ".join([str(room_config.short_id) for room_config in config.monitor_live_rooms if room_config.short_id != -1])}') 347 | 348 | 349 | start_monitor() 350 | -------------------------------------------------------------------------------- /services/downloader.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import traceback 3 | from config import get_config, save_config, Config 4 | from typing import Optional 5 | from abc import abstractmethod 6 | import time 7 | from loguru import logger 8 | from pydantic import BaseModel 9 | from enum import IntEnum 10 | from pathlib import Path 11 | import aiofiles 12 | from services.user_info import get_user_info_by_mid, UserInfo 13 | from services.util import Danmu, concat_videos 14 | from services.danmu_converter import get_video_width_height, generate_ass 15 | from services.ass_render import fix_video 16 | from services.exceptions import DownloadPathException 17 | from services.uploader import BiliBiliLiveUploader 18 | from services.live_service import LiveService 19 | import asyncio 20 | 21 | 22 | class Downloader: 23 | running_downloaders: list['Downloader'] = [] 24 | 25 | def __str__(self): 26 | return f'{self.__class__.__name__}({self.url}, {self.path})' 27 | 28 | class DownloadStatus(BaseModel): 29 | 30 | class Status(IntEnum): 31 | # 下载状态 32 | SUCCESS = 1 33 | FAILED = 2 34 | UNDEFINED = 3 35 | CANCELED = 4 36 | DOWNLOADING = 5 37 | 38 | current_downloaded_size: int = 0 39 | total_size: int = 0 40 | target_path: str = '' 41 | file_name: str = '' 42 | status: Status = Status.UNDEFINED 43 | start_time: int = 0 44 | 45 | def __init__(self, url, room_config: Config.MonitorLiveRoom, mid): 46 | if room_config.auto_download_path is None: 47 | raise DownloadPathException('path 不能为空') 48 | self.url = url 49 | self.room_config = room_config 50 | self.path = Path(room_config.auto_download_path) 51 | self.session = None 52 | self.mid = mid 53 | config = get_config() 54 | self.cookies = { 55 | 'SESSDATA': config.SESSDATA, 56 | 'bili_jct': config.bili_jct, 57 | 'DedeUserID': config.DedeUserID, 58 | 'DedeUserID__ckMd5': config.DedeUserID__ckMd5, 59 | } 60 | self.default_headers = { 61 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36', 62 | 'Referer': 'https://live.bilibili.com/' 63 | } 64 | self.download_status = self.DownloadStatus() 65 | self.user_info: Optional[UserInfo] = None 66 | self.running_downloaders.append(self) 67 | self.damu_list: list[Danmu] = [] 68 | 69 | @abstractmethod 70 | async def _download(self): 71 | if not self.session: 72 | await self.create_session() 73 | 74 | def download(self): 75 | loop = asyncio.get_running_loop() 76 | loop.create_task(self._download()) 77 | self.download_status.start_time = time.time() 78 | 79 | def cancel(self): 80 | self.download_status.status = self.DownloadStatus.Status.CANCELED 81 | 82 | async def create_session(self): 83 | self.session = aiohttp.ClientSession(cookies=self.cookies, headers=self.default_headers) 84 | return self.session 85 | 86 | def get_download_status(self) -> DownloadStatus: 87 | return self.download_status 88 | 89 | 90 | class LiveDefaultDownloader(Downloader): 91 | 92 | def __str__(self): 93 | return f'[{self.__class__.__name__}] {self.path}, 房间号:{self.room_info.data.room_id})' 94 | 95 | def __init__(self, url: str, room_config: Config.MonitorLiveRoom, room_info): 96 | super().__init__(url, room_config, room_info.data.room_id) 97 | self.download_status.target_path = room_config.auto_download_path 98 | self.room_info = room_info 99 | self.live_service = LiveService() 100 | self.start_time = time.localtime() 101 | 102 | @logger.catch 103 | async def _download(self): 104 | await super()._download() 105 | self.user_info = await get_user_info_by_mid(self.room_info.data.uid) 106 | config = get_config() 107 | if not (self.path / self.user_info.data.card.name).exists(): 108 | (self.path / self.user_info.data.card.name).mkdir() 109 | file_name = (config.live_config.download_format 110 | .replace('%title', self.room_info.data.title) 111 | .replace('/', '_') 112 | .replace('\\', '_') 113 | .replace(':', '_') 114 | .replace('*', '_') 115 | .replace('?', '_') 116 | ) 117 | self.start_time = time.localtime() 118 | file_name = time.strftime(file_name, time.localtime()) + '.flv' 119 | self.download_status.target_path = str(self.path / self.user_info.data.card.name / file_name) 120 | async with aiofiles.open(self.path / self.user_info.data.card.name / file_name, 'wb') as f: 121 | while self.download_status.status != self.DownloadStatus.Status.CANCELED: 122 | try: 123 | async with self.session.get(self.url) as response: 124 | async for chunk in response.content.iter_chunked(1024): 125 | if self.download_status.status == self.DownloadStatus.Status.CANCELED: 126 | break 127 | self.download_status.current_downloaded_size += len(chunk) 128 | self.download_status.total_size = self.download_status.current_downloaded_size 129 | self.download_status.status = self.DownloadStatus.Status.DOWNLOADING 130 | await f.write(chunk) 131 | except Exception as e: 132 | if self.download_status.status == self.DownloadStatus.Status.CANCELED: 133 | self.download_status.status = self.DownloadStatus.Status.UNDEFINED 134 | return 135 | logger.error(f'下载出错,正在重试') 136 | logger.exception(e) 137 | logger.error(f'重新获取推流地址中...') 138 | while True: 139 | try: 140 | self.url = await self.live_service.get_video_stream_url(self.room_info.data.room_id) 141 | break 142 | except Exception as e: 143 | logger.error(f'获取推流地址出错,正在重试') 144 | logger.exception(e) 145 | await asyncio.sleep(1) 146 | logger.opt(colors=True).info(f'下载完成 直播间:{self.room_info.data.title}已关闭') 147 | logger.info('正在保存视频...') 148 | await fix_video(Path(self.download_status.target_path), transcode=self.room_config.transcode) 149 | logger.info('保存成功') 150 | if self.room_config.auto_upload.enabled: 151 | await self.upload() 152 | 153 | async def upload(self): 154 | if self.room_config.auto_upload.title is None: 155 | logger.error('上传失败,标题不能为空') 156 | return 157 | if self.room_config.auto_upload.desc is None: 158 | logger.error('上传失败,描述不能为空') 159 | return 160 | if self.room_config.auto_upload.tags is None: 161 | logger.error('上传失败,标签不能为空') 162 | return 163 | if self.room_config.auto_upload.tid is None: 164 | logger.error('上传失败,分区不能为空') 165 | return 166 | if self.room_config.auto_upload.source is None: 167 | logger.error('上传失败,来源不能为空') 168 | return 169 | 170 | bill_uploader = BiliBiliLiveUploader() 171 | 172 | bill_uploader.set_title( 173 | time.strftime( 174 | self.room_config.auto_upload.title.replace('%title', self.room_info.data.title), 175 | self.start_time 176 | ) 177 | ) 178 | ass_name = Path(self.download_status.target_path).with_suffix('.zh-CN.ass').name 179 | file_name = Path(self.download_status.target_path).name 180 | bill_uploader.set_desc( 181 | time.strftime( 182 | self.room_config.auto_upload.desc 183 | .replace('%title', self.room_info.data.title) 184 | .replace('%ass_name', ass_name) 185 | .replace('%file_name', file_name) 186 | .replace('%room_id', str(self.room_info.data.room_id)) 187 | .replace('%uid', str(self.room_info.data.uid)) 188 | .replace('%uname', self.user_info.data.card.name), 189 | time.localtime() 190 | ) 191 | ) 192 | bill_uploader.set_tags(self.room_config.auto_upload.tags) 193 | bill_uploader.set_tid(self.room_config.auto_upload.tid) 194 | bill_uploader.set_source(self.room_config.auto_upload.source) 195 | if self.room_config.auto_upload.cover_path == 'AUTO': 196 | bill_uploader.set_cover(f'{self.room_config.short_id}.jpg') 197 | else: 198 | bill_uploader.set_cover(self.room_config.auto_upload.cover) 199 | bill_uploader.set_files([ 200 | { 201 | 'path': self.download_status.target_path, 202 | 'title': self.room_config.auto_upload.title, 203 | } 204 | ]) 205 | bill_uploader.start() 206 | 207 | logger.info('正在上传视频...') 208 | 209 | async def save_danmus(self, damus: list[Danmu]): 210 | current_time = time.time() * 1000 211 | valid_danmus = [damu for damu in damus if (current_time >= damu.send_time >= self.download_status.start_time)] 212 | for damu in valid_danmus: 213 | damu.appear_time = (damu.send_time - self.download_status.start_time * 1000) / 1000 214 | video_file = Path(self.download_status.target_path) 215 | video_width, video_height = get_video_width_height(video_file) 216 | ass_file = video_file.with_suffix('.zh-CN.ass') 217 | generate_ass(Danmu.generate_danmu_xml(valid_danmus), str(ass_file), video_width, video_height) 218 | 219 | 220 | class LiveFfmpegDownloader(Downloader): 221 | 222 | def __str__(self): 223 | return f'[{self.__class__.__name__}] {self.path}, 房间号:{self.room_info.data.room_id})' 224 | 225 | def __init__(self, url: str, room_config: Config.MonitorLiveRoom, room_info): 226 | super().__init__(url, room_config, room_info.data.room_id) 227 | self.download_status.target_path = room_config.auto_download_path 228 | self.room_info = room_info 229 | self.live_service = LiveService() 230 | self.start_time = time.localtime() 231 | self.download_file_list: list[Path] = [] 232 | self.download_process = None 233 | 234 | @logger.catch 235 | async def _download(self): 236 | await super()._download() 237 | self.user_info = await get_user_info_by_mid(self.room_info.data.uid) 238 | config = get_config() 239 | if not (self.path / self.user_info.data.card.name).exists(): 240 | (self.path / self.user_info.data.card.name).mkdir() 241 | file_name = (config.live_config.download_format 242 | .replace('%title', self.room_info.data.title) 243 | .replace('/', '_') 244 | .replace('\\', '_') 245 | .replace(':', '_') 246 | .replace('*', '_') 247 | .replace('?', '_') 248 | ) 249 | self.start_time = time.localtime() 250 | file_name = time.strftime(file_name, time.localtime()) + '.flv' 251 | self.download_status.target_path = str(self.path / self.user_info.data.card.name / file_name) 252 | self.download_status.status = self.DownloadStatus.Status.DOWNLOADING 253 | while self.download_status.status != self.DownloadStatus.Status.CANCELED: 254 | sliced_file_name = self.path / self.user_info.data.card.name / (file_name + f'.{len(self.download_file_list)}') 255 | self.download_file_list.append(sliced_file_name) 256 | try: 257 | self.download_status.total_size = self.download_status.current_downloaded_size 258 | # self.download_status.status = self.DownloadStatus.Status.DOWNLOADING 259 | self.download_process = await asyncio.create_subprocess_exec( 260 | "ffmpeg", 261 | "-user_agent", f"User-Agent: {self.default_headers['User-Agent']}", 262 | "-headers", f"Referer: {self.default_headers['Referer']}", 263 | "-i", self.url, 264 | "-f", "flv", 265 | "-c", "copy", sliced_file_name.absolute(), 266 | stdout=asyncio.subprocess.PIPE, 267 | stderr=asyncio.subprocess.PIPE 268 | ) 269 | stdout, stderr = await self.download_process.communicate() 270 | if self.download_process.returncode != 0 and self.download_status.status != self.DownloadStatus.Status.CANCELED: 271 | raise Exception("下载出错,正在重试: " + stderr.decode("utf-8")) 272 | 273 | except Exception as e: 274 | if self.download_status.status == self.DownloadStatus.Status.CANCELED: 275 | self.download_status.status = self.DownloadStatus.Status.UNDEFINED 276 | return 277 | if "HTTP error 404 Not Found" not in str(e): 278 | self.download_file_list.append(sliced_file_name) 279 | logger.debug(f'下载出错,正在重试: {traceback.format_exc()}') 280 | logger.error(f'重新获取推流地址中...') 281 | while True: 282 | try: 283 | self.url = await self.live_service.get_video_stream_url(self.room_info.data.room_id) 284 | break 285 | except Exception as e: 286 | logger.error(f'获取推流地址出错,正在重试') 287 | logger.exception(e) 288 | await asyncio.sleep(1) 289 | logger.opt(colors=True).info(f'下载完成 直播间:{self.room_info.data.title}已关闭') 290 | logger.info('正在保存视频...') 291 | if len(self.download_file_list) == 1: 292 | self.download_file_list[0].rename(self.download_status.target_path) 293 | else: 294 | await concat_videos(self.download_file_list, Path(self.download_status.target_path)) 295 | logger.info('保存成功') 296 | logger.info('正在保存弹幕...') 297 | await self.save_danmus(self.damu_list) 298 | logger.info('保存成功') 299 | if len(self.download_file_list) > 1: 300 | for file in self.download_file_list: 301 | if file.exists(): 302 | file.unlink() 303 | if self.room_config.auto_upload.enabled: 304 | await self.upload() 305 | 306 | async def upload(self): 307 | if self.room_config.auto_upload.title is None: 308 | logger.error('上传失败,标题不能为空') 309 | return 310 | if self.room_config.auto_upload.desc is None: 311 | logger.error('上传失败,描述不能为空') 312 | return 313 | if self.room_config.auto_upload.tags is None: 314 | logger.error('上传失败,标签不能为空') 315 | return 316 | if self.room_config.auto_upload.tid is None: 317 | logger.error('上传失败,分区不能为空') 318 | return 319 | if self.room_config.auto_upload.source is None: 320 | logger.error('上传失败,来源不能为空') 321 | return 322 | 323 | bill_uploader = BiliBiliLiveUploader() 324 | 325 | bill_uploader.set_title( 326 | time.strftime( 327 | self.room_config.auto_upload.title.replace('%title', self.room_info.data.title), 328 | self.start_time 329 | ) 330 | ) 331 | ass_name = Path(self.download_status.target_path).with_suffix('.zh-CN.ass').name 332 | file_name = Path(self.download_status.target_path).name 333 | bill_uploader.set_desc( 334 | time.strftime( 335 | self.room_config.auto_upload.desc 336 | .replace('%title', self.room_info.data.title) 337 | .replace('%ass_name', ass_name) 338 | .replace('%file_name', file_name) 339 | .replace('%room_id', str(self.room_info.data.room_id)) 340 | .replace('%uid', str(self.room_info.data.uid)) 341 | .replace('%uname', self.user_info.data.card.name), 342 | time.localtime() 343 | ) 344 | ) 345 | bill_uploader.set_tags(self.room_config.auto_upload.tags) 346 | bill_uploader.set_tid(self.room_config.auto_upload.tid) 347 | bill_uploader.set_source(self.room_config.auto_upload.source) 348 | if self.room_config.auto_upload.cover_path == 'AUTO': 349 | bill_uploader.set_cover(f'{self.room_config.short_id}.jpg') 350 | else: 351 | bill_uploader.set_cover(self.room_config.auto_upload.cover) 352 | bill_uploader.set_files([ 353 | { 354 | 'path': self.download_status.target_path, 355 | 'title': self.room_config.auto_upload.title, 356 | } 357 | ]) 358 | bill_uploader.start() 359 | 360 | logger.info('正在上传视频...') 361 | 362 | async def save_danmus(self, damus: list[Danmu]): 363 | current_time = time.time() * 1000 364 | valid_danmus = [damu for damu in damus if (current_time >= damu.send_time >= self.download_status.start_time)] 365 | for damu in valid_danmus: 366 | damu.appear_time = (damu.send_time - self.download_status.start_time * 1000) / 1000 367 | video_file = Path(self.download_status.target_path) 368 | if not video_file.exists(): 369 | while True: 370 | video_file = Path(self.download_status.target_path) 371 | if video_file.exists(): 372 | break 373 | video_width, video_height = get_video_width_height(video_file) 374 | ass_file = video_file.with_suffix('.zh-CN.ass') 375 | generate_ass(Danmu.generate_danmu_xml(valid_danmus), str(ass_file), video_width, video_height) 376 | 377 | def cancel(self): 378 | self.download_status.status = self.DownloadStatus.Status.CANCELED 379 | self.download_process.kill() 380 | 381 | -------------------------------------------------------------------------------- /services/bili_uploader.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import hashlib 4 | import json 5 | import math 6 | import os 7 | import sys 8 | import time 9 | import urllib.parse 10 | from dataclasses import asdict, dataclass, field, InitVar 11 | from json import JSONDecodeError 12 | from os.path import splitext, basename 13 | from typing import Union, Any 14 | from urllib import parse 15 | from urllib.parse import quote 16 | from functools import reduce 17 | import aiohttp 18 | import requests.utils 19 | import rsa 20 | import xml.etree.ElementTree as ET 21 | from requests.adapters import HTTPAdapter, Retry 22 | import subprocess 23 | 24 | from loguru import logger 25 | 26 | from pathlib import Path 27 | import shutil 28 | 29 | 30 | class UploadBase: 31 | def __init__(self, principal, data, persistence_path=None, postprocessor=None): 32 | self.principal = principal 33 | self.persistence_path = persistence_path 34 | self.data = data 35 | self.post_processor = postprocessor 36 | 37 | # @property 38 | @staticmethod 39 | def file_list(index): 40 | file_list = [] 41 | for file_name in os.listdir('.'): 42 | if index in file_name and os.path.isfile(file_name): 43 | file_list.append(file_name) 44 | file_list = sorted(file_list) 45 | return file_list 46 | 47 | @staticmethod 48 | def remove_filelist(file_list): 49 | for r in file_list: 50 | os.remove(r) 51 | logger.info('删除-' + r) 52 | 53 | def filter_file(self, index): 54 | file_list = UploadBase.file_list(index) 55 | if len(file_list) == 0: 56 | return False 57 | for r in file_list: 58 | file_size = os.path.getsize(r) / 1024 / 1024 59 | threshold = self.data.get('threshold') if self.data.get('threshold') else 2 60 | if file_size <= threshold: 61 | os.remove(r) 62 | logger.info('过滤删除-' + r) 63 | file_list = UploadBase.file_list(index) 64 | if len(file_list) == 0: 65 | logger.info('视频过滤后无文件可传') 66 | return False 67 | for f in file_list: 68 | if f.endswith('.part'): 69 | shutil.move(f, os.path.splitext(f)[0]) 70 | logger.info('%s存在已更名' % f) 71 | return True 72 | 73 | def upload(self, file_list): 74 | raise NotImplementedError() 75 | 76 | def start(self): 77 | if self.filter_file(self.principal): 78 | logger.info('准备上传' + self.data["format_title"]) 79 | needed2process = self.upload(UploadBase.file_list(self.principal)) 80 | if needed2process: 81 | self.postprocessor(needed2process) 82 | 83 | def postprocessor(self, data): 84 | # data = file_list 85 | if self.post_processor is None: 86 | return self.remove_filelist(data) 87 | for post_processor in self.post_processor: 88 | if post_processor == 'rm': 89 | self.remove_filelist(data) 90 | continue 91 | if post_processor.get('mv'): 92 | for file in data: 93 | path = Path(file) 94 | dest = Path(post_processor['mv']) 95 | if not dest.is_dir(): 96 | dest.mkdir(parents=True, exist_ok=True) 97 | #path.rename(dest / path.name) 98 | shutil.move(path, dest / path.name) 99 | logger.info(f"move to {(dest / path.name).absolute()}") 100 | if post_processor.get('run'): 101 | process = subprocess.run( 102 | post_processor['run'], shell=True, input=reduce(lambda x, y: x + str(Path(y).absolute()) + '\n', data, ''), 103 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) 104 | if process.returncode != 0: 105 | logger.error(process.stdout) 106 | raise Exception("PostProcessorRunTimeError") 107 | logger.info(process.stdout.rstrip()) 108 | 109 | 110 | class BiliWeb(UploadBase): 111 | def __init__( 112 | self, principal, data, user, submit_api=None, copyright=2, postprocessor=None, dtime=None, 113 | dynamic='', lines='AUTO', threads=3, tid=122, tags=None, cover_path=None, description='' 114 | ): 115 | super().__init__(principal, data, persistence_path='bili.cookie', postprocessor=postprocessor) 116 | if tags is None: 117 | tags = [] 118 | self.user = user 119 | self.lines = lines 120 | self.submit_api = submit_api 121 | self.threads = threads 122 | self.tid = tid 123 | self.tags = tags 124 | self.cover_path = cover_path 125 | self.desc = description 126 | self.dynamic = dynamic 127 | self.copyright = copyright 128 | self.dtime = dtime 129 | 130 | def upload(self, file_list): 131 | video = Data() 132 | video.dynamic = self.dynamic 133 | with BiliBili(video) as bili: 134 | bili.app_key = self.user.get('app_key') 135 | bili.appsec = self.user.get('appsec') 136 | bili.login(self.persistence_path, self.user) 137 | for file in file_list: 138 | video_part = bili.upload_file(file, self.lines, self.threads) # 上传视频 139 | video_part['title'] = video_part['title'][:80] 140 | video.append(video_part) # 添加已经上传的视频 141 | video.title = self.data["format_title"][:80] # 稿件标题限制80字 142 | video.desc = self.desc 143 | video.copyright = self.copyright 144 | if self.copyright == 2: 145 | video.source = self.data["url"] # 添加转载地址说明 146 | # 设置视频分区,默认为174 生活,其他分区 147 | video.tid = self.tid 148 | video.set_tag(self.tags) 149 | if self.dtime: 150 | video.delay_time(int(time.time()) + self.dtime) 151 | if self.cover_path: 152 | video.cover = bili.cover_up(self.cover_path).replace('http:', '') 153 | ret = bili.submit(self.submit_api) # 提交视频 154 | logger.info(f"上传成功: {ret}") 155 | return file_list 156 | 157 | 158 | class BiliBili: 159 | def __init__(self, video: 'Data'): 160 | self.app_key = None 161 | self.appsec = None 162 | if self.app_key is None or self.appsec is None: 163 | self.app_key = 'ae57252b0c09105d' 164 | self.appsec = 'c75875c596a69eb55bd119e74b07cfe3' 165 | self.__session = requests.Session() 166 | self.video = video 167 | self.__session.mount('https://', HTTPAdapter(max_retries=Retry(total=5, method_whitelist=False))) 168 | self.__session.headers.update({ 169 | "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/63.0.3239.108", 170 | "Referer": "https://www.bilibili.com/", 'Connection': 'keep-alive' 171 | }) 172 | self.cookies = None 173 | self.access_token = None 174 | self.refresh_token = None 175 | self.account = None 176 | self.__bili_jct = None 177 | self._auto_os = None 178 | self.persistence_path = 'engine/bili.cookie' 179 | 180 | def check_tag(self, tag): 181 | r = self.__session.get("https://member.bilibili.com/x/vupre/web/topic/tag/check?tag=" + tag).json() 182 | if r["code"] == 0: 183 | return True 184 | else: 185 | return False 186 | 187 | def get_qrcode(self): 188 | params = { 189 | "appkey": "4409e2ce8ffd12b8", 190 | "local_id": "0", 191 | "ts": int(time.time()), 192 | } 193 | params["sign"] = hashlib.md5( 194 | f"{urllib.parse.urlencode(params)}59b43e04ad6965f34319062b478f83dd".encode()).hexdigest() 195 | response = self.__session.post("http://passport.bilibili.com/x/passport-tv-login/qrcode/auth_code", data=params, 196 | timeout=5) 197 | r = response.json() 198 | if r and r["code"] == 0: 199 | return r 200 | 201 | async def login_by_qrcode(self, value): 202 | params = { 203 | "appkey": "4409e2ce8ffd12b8", 204 | "auth_code": value["data"]["auth_code"], 205 | "local_id": "0", 206 | "ts": int(time.time()), 207 | } 208 | params["sign"] = hashlib.md5( 209 | f"{urllib.parse.urlencode(params)}59b43e04ad6965f34319062b478f83dd".encode()).hexdigest() 210 | for i in range(0, 120): 211 | await asyncio.sleep(1) 212 | response = self.__session.post("http://passport.bilibili.com/x/passport-tv-login/qrcode/poll", data=params, 213 | timeout=5) 214 | r = response.json() 215 | if r and r["code"] == 0: 216 | return r 217 | raise "Qrcode timeout" 218 | 219 | def tid_archive(self, cookies): 220 | requests.utils.add_dict_to_cookiejar(self.__session.cookies, cookies) 221 | response = self.__session.get("https://member.bilibili.com/x/vupre/web/archive/pre") 222 | return response.json() 223 | 224 | def login(self, persistence_path, user): 225 | self.persistence_path = persistence_path 226 | if os.path.isfile(persistence_path): 227 | logger.info('使用持久化内容上传') 228 | self.load() 229 | if not self.cookies and user.get('cookies'): 230 | self.cookies = user['cookies'] 231 | if not self.access_token and user.get('access_token'): 232 | self.access_token = user['access_token'] 233 | if not self.account and user.get('account'): 234 | self.account = user['account'] 235 | if self.cookies: 236 | try: 237 | self.login_by_cookies(self.cookies) 238 | except: 239 | logger.exception('login error') 240 | else: 241 | raise Exception('请在config文件中填入密钥') 242 | self.store() 243 | 244 | def load(self): 245 | try: 246 | with open(self.persistence_path) as f: 247 | self.cookies = json.load(f) 248 | self.access_token = self.cookies['access_token'] 249 | except (JSONDecodeError, KeyError): 250 | logger.exception('加载cookie出错') 251 | 252 | def store(self): 253 | with open(self.persistence_path, "w") as f: 254 | json.dump({**self.cookies, 255 | 'access_token': self.access_token, 256 | 'refresh_token': self.refresh_token 257 | }, f) 258 | 259 | def send_sms(self, phone_number, country_code): 260 | params = { 261 | "actionKey": "appkey", 262 | "appkey": "783bbb7264451d82", 263 | "build": 6510400, 264 | "channel": "bili", 265 | "cid": country_code, 266 | "device": "phone", 267 | "mobi_app": "android", 268 | "platform": "android", 269 | "tel": phone_number, 270 | "ts": int(time.time()), 271 | } 272 | sign = hashlib.md5(f"{urllib.parse.urlencode(params)}2653583c8873dea268ab9386918b1d65".encode()).hexdigest() 273 | payload = f"{urllib.parse.urlencode(params)}&sign={sign}" 274 | response = self.__session.post("https://passport.bilibili.com/x/passport-login/sms/send", data=payload, 275 | timeout=5) 276 | return response.json() 277 | 278 | def login_by_sms(self, code, params): 279 | params["code"] = code 280 | params["sign"] = hashlib.md5( 281 | f"{urllib.parse.urlencode(params)}59b43e04ad6965f34319062b478f83dd".encode()).hexdigest() 282 | response = self.__session.post("https://passport.bilibili.com/x/passport-login/login/sms", data=params, 283 | timeout=5) 284 | r = response.json() 285 | if r and r["code"] == 0: 286 | return r 287 | 288 | def login_by_cookies(self, cookie): 289 | logger.info('使用cookies上传') 290 | requests.utils.add_dict_to_cookiejar(self.__session.cookies, cookie) 291 | if 'bili_jct' in cookie: 292 | self.__bili_jct = cookie["bili_jct"] 293 | data = self.__session.get("https://api.bilibili.com/x/web-interface/nav", timeout=5).json() 294 | if data["code"] != 0: 295 | raise Exception(data) 296 | 297 | def sign(self, param): 298 | return hashlib.md5(f"{param}{self.appsec}".encode()).hexdigest() 299 | 300 | def get_key(self): 301 | url = "https://passport.bilibili.com/x/passport-login/web/key" 302 | payload = { 303 | 'appkey': f'{self.app_key}', 304 | 'sign': self.sign(f"appkey={self.app_key}"), 305 | } 306 | response = self.__session.get(url, data=payload, timeout=5) 307 | r = response.json() 308 | if r and r["code"] == 0: 309 | return r['data']['hash'], rsa.PublicKey.load_pkcs1_openssl_pem(r['data']['key'].encode()) 310 | 311 | def probe(self): 312 | ret = self.__session.get('https://member.bilibili.com/preupload?r=probe', timeout=5).json() 313 | logger.info(f"线路:{ret['lines']}") 314 | data, auto_os = None, None 315 | min_cost = 0 316 | if ret['probe'].get('get'): 317 | method = 'get' 318 | else: 319 | method = 'post' 320 | data = bytes(int(1024 * 0.1 * 1024)) 321 | for line in ret['lines']: 322 | start = time.perf_counter() 323 | test = self.__session.request(method, f"https:{line['probe_url']}", data=data, timeout=30) 324 | cost = time.perf_counter() - start 325 | logger.info(line['query'], cost) 326 | if test.status_code != 200: 327 | return 328 | if not min_cost or min_cost > cost: 329 | auto_os = line 330 | min_cost = cost 331 | auto_os['cost'] = min_cost 332 | return auto_os 333 | 334 | def upload_file(self, filepath: str, lines='AUTO', tasks=3, title=None): 335 | """上传本地视频文件,返回视频信息dict 336 | b站目前支持4种上传线路upos, kodo, gcs, bos 337 | gcs: {"os":"gcs","query":"bucket=bvcupcdngcsus&probe_version=20200810", 338 | "probe_url":"//storage.googleapis.com/bvcupcdngcsus/OK"}, 339 | bos: {"os":"bos","query":"bucket=bvcupcdnboshb&probe_version=20200810", 340 | "probe_url":"??"} 341 | """ 342 | if not self._auto_os: 343 | if lines == 'kodo': 344 | self._auto_os = {"os": "kodo", "query": "bucket=bvcupcdnkodobm&probe_version=20200810", 345 | "probe_url": "//up-na0.qbox.me/crossdomain.xml"} 346 | elif lines == 'bda2': 347 | self._auto_os = {"os": "upos", "query": "upcdn=bda2&probe_version=20200810", 348 | "probe_url": "//upos-sz-upcdnbda2.bilivideo.com/OK"} 349 | elif lines == 'ws': 350 | self._auto_os = {"os": "upos", "query": "upcdn=ws&probe_version=20200810", 351 | "probe_url": "//upos-sz-upcdnws.bilivideo.com/OK"} 352 | elif lines == 'qn': 353 | self._auto_os = {"os": "upos", "query": "upcdn=qn&probe_version=20200810", 354 | "probe_url": "//upos-sz-upcdnqn.bilivideo.com/OK"} 355 | elif lines == 'cos': 356 | self._auto_os = {"os": "cos", "query": "", 357 | "probe_url": ""} 358 | elif lines == 'cos-internal': 359 | self._auto_os = {"os": "cos-internal", "query": "", 360 | "probe_url": ""} 361 | else: 362 | self._auto_os = self.probe() 363 | logger.info(f"线路选择 => {self._auto_os['os']}: {self._auto_os['query']}. time: {self._auto_os.get('cost')}") 364 | if self._auto_os['os'] == 'upos': 365 | upload = self.upos 366 | elif self._auto_os['os'] == 'cos': 367 | upload = self.cos 368 | elif self._auto_os['os'] == 'cos-internal': 369 | upload = lambda *args, **kwargs: self.cos(*args, **kwargs, internal=True) 370 | elif self._auto_os['os'] == 'kodo': 371 | upload = self.kodo 372 | else: 373 | logger.error(f"NoSearch:{self._auto_os['os']}") 374 | raise NotImplementedError(self._auto_os['os']) 375 | logger.info(f"os: {self._auto_os['os']}") 376 | total_size = os.path.getsize(filepath) 377 | if title is None: 378 | title = os.path.basename(filepath) 379 | with open(filepath, 'rb') as f: 380 | query = { 381 | 'r': self._auto_os['os'] if self._auto_os['os'] != 'cos-internal' else 'cos', 382 | 'profile': 'ugcupos/bup' if 'upos' == self._auto_os['os'] else "ugcupos/bupfetch", 383 | 'ssl': 0, 384 | 'version': '2.8.12', 385 | 'build': 2081200, 386 | 'name': title, 387 | 'size': total_size, 388 | } 389 | ret = self.__session.get( 390 | f"https://member.bilibili.com/preupload?{self._auto_os['query']}", params=query, 391 | timeout=5) 392 | return asyncio.run(upload(f, total_size, ret.json(), tasks=tasks, title=title)) 393 | 394 | async def cos(self, file, total_size, ret, chunk_size=10485760, tasks=3, internal=False, title=None): 395 | if title is None: 396 | filename = file.name 397 | else: 398 | filename = title 399 | url = ret["url"] 400 | if internal: 401 | url = url.replace("cos.accelerate", "cos-internal.ap-shanghai") 402 | biz_id = ret["biz_id"] 403 | post_headers = { 404 | "Authorization": ret["post_auth"], 405 | } 406 | put_headers = { 407 | "Authorization": ret["put_auth"], 408 | } 409 | 410 | initiate_multipart_upload_result = ET.fromstring(self.__session.post(f'{url}?uploads&output=json', timeout=5, 411 | headers=post_headers).content) 412 | upload_id = initiate_multipart_upload_result.find('UploadId').text 413 | # 开始上传 414 | parts = [] # 分块信息 415 | chunks = math.ceil(total_size / chunk_size) # 获取分块数量 416 | 417 | async def upload_chunk(session, chunks_data, params): 418 | async with session.put(url, params=params, raise_for_status=True, 419 | data=chunks_data, headers=put_headers) as r: 420 | end = time.perf_counter() - start 421 | parts.append({"Part": {"PartNumber": params['chunk'] + 1, "ETag": r.headers['Etag']}}) 422 | sys.stdout.write(f"\r{params['end'] / 1000 / 1000 / end:.2f}MB/s " 423 | f"=> {params['partNumber'] / chunks:.1%}") 424 | 425 | start = time.perf_counter() 426 | await self._upload({ 427 | 'uploadId': upload_id, 428 | 'chunks': chunks, 429 | 'total': total_size 430 | }, file, chunk_size, upload_chunk, tasks=tasks) 431 | cost = time.perf_counter() - start 432 | fetch_headers = { 433 | "X-Upos-Fetch-Source": ret["fetch_headers"]["X-Upos-Fetch-Source"], 434 | "X-Upos-Auth": ret["fetch_headers"]["X-Upos-Auth"], 435 | "Fetch-Header-Authorization": ret["fetch_headers"]["Fetch-Header-Authorization"] 436 | } 437 | parts = sorted(parts, key=lambda x: x['Part']['PartNumber']) 438 | complete_multipart_upload = ET.Element('CompleteMultipartUpload') 439 | for part in parts: 440 | part_et = ET.SubElement(complete_multipart_upload, 'Part') 441 | part_number = ET.SubElement(part_et, 'PartNumber') 442 | part_number.text = str(part['Part']['PartNumber']) 443 | e_tag = ET.SubElement(part_et, 'ETag') 444 | e_tag.text = part['Part']['ETag'] 445 | xml = ET.tostring(complete_multipart_upload) 446 | ii = 0 447 | while ii <= 3: 448 | try: 449 | res = self.__session.post(url, params={'uploadId': upload_id}, data=xml, headers=post_headers, 450 | timeout=15) 451 | if res.status_code == 200: 452 | break 453 | raise IOError(res.text) 454 | except IOError: 455 | ii += 1 456 | logger.info("请求合并分片出现问题,尝试重连,次数:" + str(ii)) 457 | time.sleep(15) 458 | ii = 0 459 | while ii <= 3: 460 | try: 461 | res = self.__session.post("https:" + ret["fetch_url"], headers=fetch_headers, timeout=15).json() 462 | if res.get('OK') == 1: 463 | logger.info(f'{filename} uploaded >> {total_size / 1000 / 1000 / cost:.2f}MB/s. {res}') 464 | return {"title": splitext(filename)[0], "filename": ret["bili_filename"], "desc": ""} 465 | raise IOError(res) 466 | except IOError: 467 | ii += 1 468 | logger.info("上传出现问题,尝试重连,次数:" + str(ii)) 469 | time.sleep(15) 470 | 471 | async def kodo(self, file, total_size, ret, chunk_size=4194304, tasks=3, title=None): 472 | if title is None: 473 | filename = file.name 474 | else: 475 | filename = title 476 | bili_filename = ret['bili_filename'] 477 | key = ret['key'] 478 | endpoint = f"https:{ret['endpoint']}" 479 | token = ret['uptoken'] 480 | fetch_url = ret['fetch_url'] 481 | fetch_headers = ret['fetch_headers'] 482 | url = f'{endpoint}/mkblk' 483 | headers = { 484 | 'Authorization': f"UpToken {token}", 485 | } 486 | # 开始上传 487 | parts = [] # 分块信息 488 | chunks = math.ceil(total_size / chunk_size) # 获取分块数量 489 | 490 | async def upload_chunk(session, chunks_data, params): 491 | async with session.post(f'{url}/{len(chunks_data)}', 492 | data=chunks_data, headers=headers) as response: 493 | end = time.perf_counter() - start 494 | ctx = await response.json() 495 | parts.append({"index": params['chunk'], "ctx": ctx['ctx']}) 496 | sys.stdout.write(f"\r{params['end'] / 1000 / 1000 / end:.2f}MB/s " 497 | f"=> {params['partNumber'] / chunks:.1%}") 498 | 499 | start = time.perf_counter() 500 | await self._upload({}, file, chunk_size, upload_chunk, tasks=tasks) 501 | cost = time.perf_counter() - start 502 | 503 | logger.info(f'{filename} uploaded >> {total_size / 1000 / 1000 / cost:.2f}MB/s') 504 | parts.sort(key=lambda x: x['index']) 505 | self.__session.post(f"{endpoint}/mkfile/{total_size}/key/{base64.urlsafe_b64encode(key.encode()).decode()}", 506 | data=','.join(map(lambda x: x['ctx'], parts)), headers=headers, timeout=10) 507 | r = self.__session.post(f"https:{fetch_url}", headers=fetch_headers, timeout=5).json() 508 | if r["OK"] != 1: 509 | raise Exception(r) 510 | return {"title": splitext(filename)[0], "filename": bili_filename, "desc": ""} 511 | 512 | async def upos(self, file, total_size, ret, tasks=3, title=None): 513 | if title is None: 514 | filename = file.name 515 | else: 516 | filename = title 517 | chunk_size = ret['chunk_size'] 518 | auth = ret["auth"] 519 | endpoint = ret["endpoint"] 520 | biz_id = ret["biz_id"] 521 | upos_uri = ret["upos_uri"] 522 | url = f"https:{endpoint}/{upos_uri.replace('upos://', '')}" # 视频上传路径 523 | headers = { 524 | "X-Upos-Auth": auth 525 | } 526 | # 向上传地址申请上传,得到上传id等信息 527 | upload_id = self.__session.post(f'{url}?uploads&output=json', timeout=5, 528 | headers=headers).json()["upload_id"] 529 | # 开始上传 530 | parts = [] # 分块信息 531 | chunks = math.ceil(total_size / chunk_size) # 获取分块数量 532 | 533 | async def upload_chunk(session, chunks_data, params): 534 | async with session.put(url, params=params, raise_for_status=True, 535 | data=chunks_data, headers=headers): 536 | end = time.perf_counter() - start 537 | parts.append({"partNumber": params['chunk'] + 1, "eTag": "etag"}) 538 | sys.stdout.write(f"\r{params['end'] / 1000 / 1000 / end:.2f}MB/s " 539 | f"=> {params['partNumber'] / chunks:.1%}") 540 | 541 | start = time.perf_counter() 542 | await self._upload({ 543 | 'uploadId': upload_id, 544 | 'chunks': chunks, 545 | 'total': total_size 546 | }, file, chunk_size, upload_chunk, tasks=tasks) 547 | cost = time.perf_counter() - start 548 | p = { 549 | 'name': filename, 550 | 'uploadId': upload_id, 551 | 'biz_id': biz_id, 552 | 'output': 'json', 553 | 'profile': 'ugcupos/bup' 554 | } 555 | ii = 0 556 | while ii <= 3: 557 | try: 558 | r = self.__session.post(url, params=p, json={"parts": parts}, headers=headers, timeout=15).json() 559 | if r.get('OK') == 1: 560 | logger.info(f'{filename} uploaded >> {total_size / 1000 / 1000 / cost:.2f}MB/s. {r}') 561 | return {"title": splitext(filename)[0], "filename": splitext(basename(upos_uri))[0], "desc": ""} 562 | raise IOError(r) 563 | except IOError: 564 | ii += 1 565 | logger.info("上传出现问题,尝试重连,次数:" + str(ii)) 566 | time.sleep(15) 567 | 568 | @staticmethod 569 | async def _upload(params, file, chunk_size, afunc, tasks=3): 570 | params['chunk'] = -1 571 | 572 | async def upload_chunk(): 573 | while True: 574 | chunks_data = file.read(chunk_size) 575 | if not chunks_data: 576 | return 577 | params['chunk'] += 1 578 | params['size'] = len(chunks_data) 579 | params['partNumber'] = params['chunk'] + 1 580 | params['start'] = params['chunk'] * chunk_size 581 | params['end'] = params['start'] + params['size'] 582 | clone = params.copy() 583 | for i in range(10): 584 | try: 585 | await afunc(session, chunks_data, clone) 586 | break 587 | except (asyncio.TimeoutError, aiohttp.ClientError) as e: 588 | logger.error(f"retry chunk{clone['chunk']} >> {i + 1}. {e}") 589 | 590 | async with aiohttp.ClientSession() as session: 591 | await asyncio.gather(*[upload_chunk() for _ in range(tasks)]) 592 | 593 | def submit(self, submit_api=None): 594 | if not self.video.title: 595 | self.video.title = self.video.videos[0]["title"] 596 | self.__session.get('https://member.bilibili.com/x/geetest/pre/add', timeout=5) 597 | 598 | if submit_api is None: 599 | total_info = self.__session.get('http://api.bilibili.com/x/space/myinfo', timeout=15).json() 600 | if total_info.get('data') is None: 601 | logger.error(total_info) 602 | total_info = total_info.get('data') 603 | if total_info['level'] > 3 and total_info['follower'] > 1000: 604 | user_weight = 2 605 | else: 606 | user_weight = 1 607 | logger.info(f'用户权重: {user_weight}') 608 | submit_api = 'web' if user_weight == 2 else 'client' 609 | ret = None 610 | if submit_api == 'web': 611 | ret = self.submit_web() 612 | if ret["code"] == 21138: 613 | logger.info(f'改用客户端接口提交{ret}') 614 | submit_api = 'client' 615 | if submit_api == 'client': 616 | ret = self.submit_client() 617 | if not ret: 618 | raise Exception(f'不存在的选项:{submit_api}') 619 | if ret["code"] == 0: 620 | return ret 621 | else: 622 | raise Exception(ret) 623 | 624 | def submit_web(self): 625 | logger.info('使用网页端api提交') 626 | return self.__session.post(f'https://member.bilibili.com/x/vu/web/add?csrf={self.__bili_jct}', timeout=5, 627 | json=asdict(self.video)).json() 628 | 629 | def submit_client(self): 630 | logger.info('使用客户端api端提交') 631 | if not self.access_token: 632 | if self.account is None: 633 | raise RuntimeError("Access token is required, but account and access_token does not exist!") 634 | self.store() 635 | while True: 636 | ret = self.__session.post(f'http://member.bilibili.com/x/vu/client/add?access_key={self.access_token}', 637 | timeout=5, json=asdict(self.video)).json() 638 | if ret['code'] == -101: 639 | logger.info(f'刷新token{ret}') 640 | raise RuntimeError("Access token is invalid, please login again!") 641 | self.store() 642 | continue 643 | return ret 644 | 645 | def cover_up(self, img: str): 646 | """ 647 | :param img: img path or stream 648 | :return: img URL 649 | """ 650 | from PIL import Image 651 | from io import BytesIO 652 | 653 | with Image.open(img) as im: 654 | # 宽和高,需要16:10 655 | xsize, ysize = im.size 656 | if xsize / ysize > 1.6: 657 | delta = xsize - ysize * 1.6 658 | region = im.crop((delta / 2, 0, xsize - delta / 2, ysize)) 659 | else: 660 | delta = ysize - xsize * 10 / 16 661 | region = im.crop((0, delta / 2, xsize, ysize - delta / 2)) 662 | buffered = BytesIO() 663 | region.save(buffered, format=im.format) 664 | r = self.__session.post( 665 | url='https://member.bilibili.com/x/vu/web/cover/up', 666 | data={ 667 | 'cover': b'data:image/jpeg;base64,' + (base64.b64encode(buffered.getvalue())), 668 | 'csrf': self.__bili_jct 669 | }, timeout=30 670 | ) 671 | buffered.close() 672 | res = r.json() 673 | if res.get('data') is None: 674 | raise Exception(res) 675 | return res['data']['url'] 676 | 677 | def get_tags(self, upvideo, typeid="", desc="", cover="", groupid=1, vfea=""): 678 | """ 679 | 上传视频后获得推荐标签 680 | :param vfea: 681 | :param groupid: 682 | :param typeid: 683 | :param desc: 684 | :param cover: 685 | :param upvideo: 686 | :return: 返回官方推荐的tag 687 | """ 688 | url = f'https://member.bilibili.com/x/web/archive/tags?' \ 689 | f'typeid={typeid}&title={quote(upvideo["title"])}&filename=filename&desc={desc}&cover={cover}' \ 690 | f'&groupid={groupid}&vfea={vfea}' 691 | return self.__session.get(url=url, timeout=5).json() 692 | 693 | def __enter__(self): 694 | return self 695 | 696 | def __exit__(self, e_t, e_v, t_b): 697 | self.close() 698 | 699 | def close(self): 700 | """Closes all adapters and as such the session""" 701 | self.__session.close() 702 | 703 | 704 | @dataclass 705 | class Data: 706 | """ 707 | cover: 封面图片,可由recovers方法得到视频的帧截图 708 | """ 709 | copyright: int = 2 710 | source: str = '' 711 | tid: int = 21 712 | cover: str = '' 713 | title: str = '' 714 | desc_format_id: int = 0 715 | desc: str = '' 716 | dynamic: str = '' 717 | subtitle: dict = field(init=False) 718 | tag: Union[list, str] = '' 719 | videos: list = field(default_factory=list) 720 | dtime: Any = None 721 | open_subtitle: InitVar[bool] = False 722 | 723 | # interactive: int = 0 724 | # no_reprint: int 1 725 | # open_elec: int 1 726 | 727 | def __post_init__(self, open_subtitle): 728 | self.subtitle = {"open": int(open_subtitle), "lan": ""} 729 | if self.dtime and self.dtime - int(time.time()) <= 14400: 730 | self.dtime = None 731 | if isinstance(self.tag, list): 732 | self.tag = ','.join(self.tag) 733 | 734 | def delay_time(self, dtime: int): 735 | """设置延时发布时间,距离提交大于2小时,格式为10位时间戳""" 736 | if dtime - int(time.time()) > 7200: 737 | self.dtime = dtime 738 | 739 | def set_tag(self, tag: list): 740 | """设置标签,tag为数组""" 741 | self.tag = ','.join(tag) 742 | 743 | def append(self, video): 744 | self.videos.append(video) 745 | -------------------------------------------------------------------------------- /services/danmu_converter.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | import argparse 4 | import calendar 5 | import gettext 6 | import io 7 | import json 8 | import logging 9 | import math 10 | import os 11 | import random 12 | import re 13 | import sys 14 | import time 15 | import xml.dom.minidom 16 | import ffmpeg 17 | from pathlib import Path 18 | from loguru import logger 19 | 20 | if sys.version_info < (3,): 21 | raise RuntimeError('at least Python 3.0 is required') 22 | 23 | gettext.install('danmaku2ass', os.path.join(os.path.dirname(os.path.abspath(os.path.realpath(sys.argv[0] or 'locale'))), 'locale')) 24 | 25 | 26 | def _(message: str) -> str: 27 | return message 28 | 29 | 30 | def SeekZero(function): 31 | def decorated_function(file_): 32 | file_.seek(0) 33 | try: 34 | return function(file_) 35 | finally: 36 | file_.seek(0) 37 | return decorated_function 38 | 39 | 40 | def EOFAsNone(function): 41 | def decorated_function(*args, **kwargs): 42 | try: 43 | return function(*args, **kwargs) 44 | except EOFError: 45 | return None 46 | return decorated_function 47 | 48 | 49 | @SeekZero 50 | @EOFAsNone 51 | def ProbeCommentFormat(f): 52 | tmp = f.read(1) 53 | if tmp == '[': 54 | return 'Acfun' 55 | # It is unwise to wrap a JSON object in an array! 56 | # See this: http://haacked.com/archive/2008/11/20/anatomy-of-a-subtle-json-vulnerability.aspx/ 57 | # Do never follow what Acfun developers did! 58 | elif tmp == '{': 59 | tmp = f.read(14) 60 | if tmp == '"status_code":': 61 | return 'Tudou' 62 | elif tmp.strip().startswith('"result'): 63 | return 'Tudou2' 64 | elif tmp == '<': 65 | tmp = f.read(1) 66 | if tmp == '?': 67 | tmp = f.read(38) 68 | if tmp == 'xml version="1.0" encoding="UTF-8"?>\n<': 77 | return 'Bilibili' # Komica, with the same file format as Bilibili 78 | elif tmp == 'xml version="1.0" encoding="UTF-8"?>\n<': 79 | tmp = f.read(20) 80 | if tmp == '!-- BoonSutazioData=': 81 | return 'Niconico' # Niconico videos downloaded with NicoFox 82 | else: 83 | return 'MioMio' 84 | elif tmp == 'p': 85 | return 'Niconico' # Himawari Douga, with the same file format as Niconico Douga 86 | 87 | 88 | # 89 | # ReadComments**** protocol 90 | # 91 | # Input: 92 | # f: Input file 93 | # fontsize: Default font size 94 | # 95 | # Output: 96 | # yield a tuple: 97 | # (timeline, timestamp, no, comment, pos, color, size, height, width) 98 | # timeline: The position when the comment is replayed 99 | # timestamp: The UNIX timestamp when the comment is submitted 100 | # no: A sequence of 1, 2, 3, ..., used for sorting 101 | # comment: The content of the comment 102 | # pos: 0 for regular moving comment, 103 | # 1 for bottom centered comment, 104 | # 2 for top centered comment, 105 | # 3 for reversed moving comment 106 | # color: Font color represented in 0xRRGGBB, 107 | # e.g. 0xffffff for white 108 | # size: Font size 109 | # height: The estimated height in pixels 110 | # i.e. (comment.count('\n')+1)*size 111 | # width: The estimated width in pixels 112 | # i.e. CalculateLength(comment)*size 113 | # 114 | # After implementing ReadComments****, make sure to update ProbeCommentFormat 115 | # and CommentFormatMap. 116 | # 117 | 118 | 119 | def ReadCommentsNiconico(f, fontsize): 120 | NiconicoColorMap = {'red': 0xff0000, 'pink': 0xff8080, 'orange': 0xffcc00, 'yellow': 0xffff00, 'green': 0x00ff00, 'cyan': 0x00ffff, 'blue': 0x0000ff, 'purple': 0xc000ff, 'black': 0x000000, 'niconicowhite': 0xcccc99, 'white2': 0xcccc99, 'truered': 0xcc0033, 'red2': 0xcc0033, 'passionorange': 0xff6600, 'orange2': 0xff6600, 'madyellow': 0x999900, 'yellow2': 0x999900, 'elementalgreen': 0x00cc66, 'green2': 0x00cc66, 'marineblue': 0x33ffcc, 'blue2': 0x33ffcc, 'nobleviolet': 0x6633cc, 'purple2': 0x6633cc} 121 | dom = xml.dom.minidom.parse(f) 122 | comment_element = dom.getElementsByTagName('chat') 123 | for comment in comment_element: 124 | try: 125 | c = str(comment.childNodes[0].wholeText) 126 | if c.startswith('/'): 127 | continue # ignore advanced comments 128 | pos = 0 129 | color = 0xffffff 130 | size = fontsize 131 | for mailstyle in str(comment.getAttribute('mail')).split(): 132 | if mailstyle == 'ue': 133 | pos = 1 134 | elif mailstyle == 'shita': 135 | pos = 2 136 | elif mailstyle == 'big': 137 | size = fontsize * 1.44 138 | elif mailstyle == 'small': 139 | size = fontsize * 0.64 140 | elif mailstyle in NiconicoColorMap: 141 | color = NiconicoColorMap[mailstyle] 142 | yield (max(int(comment.getAttribute('vpos')), 0) * 0.01, int(comment.getAttribute('date')), int(comment.getAttribute('no')), c, pos, color, size, (c.count('\n') + 1) * size, CalculateLength(c) * size) 143 | except (AssertionError, AttributeError, IndexError, TypeError, ValueError): 144 | logging.warning(_('Invalid comment: %s') % comment.toxml()) 145 | continue 146 | 147 | 148 | def ReadCommentsAcfun(f, fontsize): 149 | #comment_element = json.load(f) 150 | # after load acfun comment json file as python list, flatten the list 151 | #comment_element = [c for sublist in comment_element for c in sublist] 152 | comment_elements = json.load(f) 153 | comment_element = comment_elements[2] 154 | for i, comment in enumerate(comment_element): 155 | try: 156 | p = str(comment['c']).split(',') 157 | assert len(p) >= 6 158 | assert p[2] in ('1', '2', '4', '5', '7') 159 | size = int(p[3]) * fontsize / 25.0 160 | if p[2] != '7': 161 | c = str(comment['m']).replace('\\r', '\n').replace('\r', '\n') 162 | yield (float(p[0]), int(p[5]), i, c, {'1': 0, '2': 0, '4': 2, '5': 1}[p[2]], int(p[1]), size, (c.count('\n') + 1) * size, CalculateLength(c) * size) 163 | else: 164 | c = dict(json.loads(comment['m'])) 165 | yield (float(p[0]), int(p[5]), i, c, 'acfunpos', int(p[1]), size, 0, 0) 166 | except (AssertionError, AttributeError, IndexError, TypeError, ValueError): 167 | logging.warning(_('Invalid comment: %r') % comment) 168 | continue 169 | 170 | 171 | def ReadCommentsBilibili(f, fontsize): 172 | dom = xml.dom.minidom.parse(f) 173 | comment_element = dom.getElementsByTagName('d') 174 | for i, comment in enumerate(comment_element): 175 | try: 176 | p = str(comment.getAttribute('p')).split(',') 177 | assert len(p) >= 5 178 | assert p[1] in ('1', '4', '5', '6', '7', '8') 179 | if comment.childNodes.length > 0: 180 | if p[1] in ('1', '4', '5', '6'): 181 | c = str(comment.childNodes[0].wholeText).replace('/n', '\n') 182 | size = int(p[2]) * fontsize / 25.0 183 | yield (float(p[0]), int(p[4]), i, c, {'1': 0, '4': 2, '5': 1, '6': 3}[p[1]], int(p[3]), size, (c.count('\n') + 1) * size, CalculateLength(c) * size) 184 | elif p[1] == '7': # positioned comment 185 | c = str(comment.childNodes[0].wholeText) 186 | yield (float(p[0]), int(p[4]), i, c, 'bilipos', int(p[3]), int(p[2]), 0, 0) 187 | elif p[1] == '8': 188 | pass # ignore scripted comment 189 | except (AssertionError, AttributeError, IndexError, TypeError, ValueError): 190 | logging.warning(_('Invalid comment: %s') % comment.toxml()) 191 | continue 192 | 193 | 194 | def ReadCommentsBilibili2(f, fontsize): 195 | dom = xml.dom.minidom.parse(f) 196 | comment_element = dom.getElementsByTagName('d') 197 | for i, comment in enumerate(comment_element): 198 | try: 199 | p = str(comment.getAttribute('p')).split(',') 200 | assert len(p) >= 7 201 | assert p[3] in ('1', '4', '5', '6', '7', '8') 202 | if comment.childNodes.length > 0: 203 | time = float(p[2]) / 1000.0 204 | if p[3] in ('1', '4', '5', '6'): 205 | c = str(comment.childNodes[0].wholeText).replace('/n', '\n') 206 | size = int(p[4]) * fontsize / 25.0 207 | yield (time, int(p[6]), i, c, {'1': 0, '4': 2, '5': 1, '6': 3}[p[3]], int(p[5]), size, (c.count('\n') + 1) * size, CalculateLength(c) * size) 208 | elif p[3] == '7': # positioned comment 209 | c = str(comment.childNodes[0].wholeText) 210 | yield (time, int(p[6]), i, c, 'bilipos', int(p[5]), int(p[4]), 0, 0) 211 | elif p[3] == '8': 212 | pass # ignore scripted comment 213 | except (AssertionError, AttributeError, IndexError, TypeError, ValueError): 214 | logging.warning(_('Invalid comment: %s') % comment.toxml()) 215 | continue 216 | 217 | 218 | def ReadCommentsTudou(f, fontsize): 219 | comment_element = json.load(f) 220 | for i, comment in enumerate(comment_element['comment_list']): 221 | try: 222 | assert comment['pos'] in (3, 4, 6) 223 | c = str(comment['data']) 224 | assert comment['size'] in (0, 1, 2) 225 | size = {0: 0.64, 1: 1, 2: 1.44}[comment['size']] * fontsize 226 | yield (int(comment['replay_time'] * 0.001), int(comment['commit_time']), i, c, {3: 0, 4: 2, 6: 1}[comment['pos']], int(comment['color']), size, (c.count('\n') + 1) * size, CalculateLength(c) * size) 227 | except (AssertionError, AttributeError, IndexError, TypeError, ValueError): 228 | logging.warning(_('Invalid comment: %r') % comment) 229 | continue 230 | 231 | 232 | def ReadCommentsTudou2(f, fontsize): 233 | comment_element = json.load(f) 234 | for i, comment in enumerate(comment_element['result']): 235 | try: 236 | c = str(comment['content']) 237 | prop = json.loads(str(comment['propertis']) or '{}') 238 | size = int(prop.get('size', 1)) 239 | assert size in (0, 1, 2) 240 | size = {0: 0.64, 1: 1, 2: 1.44}[size] * fontsize 241 | pos = int(prop.get('pos', 3)) 242 | assert pos in (0, 3, 4, 6) 243 | yield ( 244 | int(comment['playat'] * 0.001), int(comment['createtime'] * 0.001), i, c, 245 | {0: 0, 3: 0, 4: 2, 6: 1}[pos], 246 | int(prop.get('color', 0xffffff)), size, (c.count('\n') + 1) * size, CalculateLength(c) * size) 247 | except (AssertionError, AttributeError, IndexError, TypeError, ValueError): 248 | logging.warning(_('Invalid comment: %r') % comment) 249 | continue 250 | 251 | 252 | def ReadCommentsMioMio(f, fontsize): 253 | NiconicoColorMap = {'red': 0xff0000, 'pink': 0xff8080, 'orange': 0xffc000, 'yellow': 0xffff00, 'green': 0x00ff00, 'cyan': 0x00ffff, 'blue': 0x0000ff, 'purple': 0xc000ff, 'black': 0x000000} 254 | dom = xml.dom.minidom.parse(f) 255 | comment_element = dom.getElementsByTagName('data') 256 | for i, comment in enumerate(comment_element): 257 | try: 258 | message = comment.getElementsByTagName('message')[0] 259 | c = str(message.childNodes[0].wholeText) 260 | pos = 0 261 | size = int(message.getAttribute('fontsize')) * fontsize / 25.0 262 | yield (float(comment.getElementsByTagName('playTime')[0].childNodes[0].wholeText), int(calendar.timegm(time.strptime(comment.getElementsByTagName('times')[0].childNodes[0].wholeText, '%Y-%m-%d %H:%M:%S'))) - 28800, i, c, {'1': 0, '4': 2, '5': 1}[message.getAttribute('mode')], int(message.getAttribute('color')), size, (c.count('\n') + 1) * size, CalculateLength(c) * size) 263 | except (AssertionError, AttributeError, IndexError, TypeError, ValueError): 264 | logging.warning(_('Invalid comment: %s') % comment.toxml()) 265 | continue 266 | 267 | 268 | CommentFormatMap = {'Niconico': ReadCommentsNiconico, 'Acfun': ReadCommentsAcfun, 'Bilibili': ReadCommentsBilibili, 'Bilibili2': ReadCommentsBilibili2, 'Tudou': ReadCommentsTudou, 'Tudou2': ReadCommentsTudou2, 'MioMio': ReadCommentsMioMio} 269 | 270 | 271 | def WriteCommentBilibiliPositioned(f, c, width, height, styleid): 272 | # BiliPlayerSize = (512, 384) # Bilibili player version 2010 273 | # BiliPlayerSize = (540, 384) # Bilibili player version 2012 274 | BiliPlayerSize = (672, 438) # Bilibili player version 2014 275 | ZoomFactor = GetZoomFactor(BiliPlayerSize, (width, height)) 276 | 277 | def GetPosition(InputPos, isHeight): 278 | isHeight = int(isHeight) # True -> 1 279 | if isinstance(InputPos, int): 280 | return ZoomFactor[0] * InputPos + ZoomFactor[isHeight + 1] 281 | elif isinstance(InputPos, float): 282 | if InputPos > 1: 283 | return ZoomFactor[0] * InputPos + ZoomFactor[isHeight + 1] 284 | else: 285 | return BiliPlayerSize[isHeight] * ZoomFactor[0] * InputPos + ZoomFactor[isHeight + 1] 286 | else: 287 | try: 288 | InputPos = int(InputPos) 289 | except ValueError: 290 | InputPos = float(InputPos) 291 | return GetPosition(InputPos, isHeight) 292 | 293 | try: 294 | comment_args = safe_list(json.loads(c[3])) 295 | text = ASSEscape(str(comment_args[4]).replace('/n', '\n')) 296 | from_x = comment_args.get(0, 0) 297 | from_y = comment_args.get(1, 0) 298 | to_x = comment_args.get(7, from_x) 299 | to_y = comment_args.get(8, from_y) 300 | from_x = GetPosition(from_x, False) 301 | from_y = GetPosition(from_y, True) 302 | to_x = GetPosition(to_x, False) 303 | to_y = GetPosition(to_y, True) 304 | alpha = safe_list(str(comment_args.get(2, '1')).split('-')) 305 | from_alpha = float(alpha.get(0, 1)) 306 | to_alpha = float(alpha.get(1, from_alpha)) 307 | from_alpha = 255 - round(from_alpha * 255) 308 | to_alpha = 255 - round(to_alpha * 255) 309 | rotate_z = int(comment_args.get(5, 0)) 310 | rotate_y = int(comment_args.get(6, 0)) 311 | lifetime = float(comment_args.get(3, 4500)) 312 | duration = int(comment_args.get(9, lifetime * 1000)) 313 | delay = int(comment_args.get(10, 0)) 314 | fontface = comment_args.get(12) 315 | isborder = comment_args.get(11, 'true') 316 | from_rotarg = ConvertFlashRotation(rotate_y, rotate_z, from_x, from_y, width, height) 317 | to_rotarg = ConvertFlashRotation(rotate_y, rotate_z, to_x, to_y, width, height) 318 | styles = ['\\org(%d, %d)' % (width / 2, height / 2)] 319 | if from_rotarg[0:2] == to_rotarg[0:2]: 320 | styles.append('\\pos(%.0f, %.0f)' % (from_rotarg[0:2])) 321 | else: 322 | styles.append('\\move(%.0f, %.0f, %.0f, %.0f, %.0f, %.0f)' % (from_rotarg[0:2] + to_rotarg[0:2] + (delay, delay + duration))) 323 | styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (from_rotarg[2:7])) 324 | if (from_x, from_y) != (to_x, to_y): 325 | styles.append('\\t(%d, %d, ' % (delay, delay + duration)) 326 | styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (to_rotarg[2:7])) 327 | styles.append(')') 328 | if fontface: 329 | styles.append('\\fn%s' % ASSEscape(fontface)) 330 | styles.append('\\fs%.0f' % (c[6] * ZoomFactor[0])) 331 | if c[5] != 0xffffff: 332 | styles.append('\\c&H%s&' % ConvertColor(c[5])) 333 | if c[5] == 0x000000: 334 | styles.append('\\3c&HFFFFFF&') 335 | if from_alpha == to_alpha: 336 | styles.append('\\alpha&H%02X' % from_alpha) 337 | elif (from_alpha, to_alpha) == (255, 0): 338 | styles.append('\\fad(%.0f,0)' % (lifetime * 1000)) 339 | elif (from_alpha, to_alpha) == (0, 255): 340 | styles.append('\\fad(0, %.0f)' % (lifetime * 1000)) 341 | else: 342 | styles.append('\\fade(%(from_alpha)d, %(to_alpha)d, %(to_alpha)d, 0, %(end_time).0f, %(end_time).0f, %(end_time).0f)' % {'from_alpha': from_alpha, 'to_alpha': to_alpha, 'end_time': lifetime * 1000}) 343 | if isborder == 'false': 344 | styles.append('\\bord0') 345 | f.write('Dialogue: -1,%(start)s,%(end)s,%(styleid)s,,0,0,0,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(c[0]), 'end': ConvertTimestamp(c[0] + lifetime), 'styles': ''.join(styles), 'text': text, 'styleid': styleid}) 346 | except (IndexError, ValueError) as e: 347 | try: 348 | logging.warning(_('Invalid comment: %r') % c[3]) 349 | except IndexError: 350 | logging.warning(_('Invalid comment: %r') % c) 351 | 352 | 353 | def WriteCommentAcfunPositioned(f, c, width, height, styleid): 354 | AcfunPlayerSize = (560, 400) 355 | ZoomFactor = GetZoomFactor(AcfunPlayerSize, (width, height)) 356 | 357 | def GetPosition(InputPos, isHeight): 358 | isHeight = int(isHeight) # True -> 1 359 | return AcfunPlayerSize[isHeight] * ZoomFactor[0] * InputPos * 0.001 + ZoomFactor[isHeight + 1] 360 | 361 | def GetTransformStyles(x=None, y=None, scale_x=None, scale_y=None, rotate_z=None, rotate_y=None, color=None, alpha=None): 362 | styles = [] 363 | out_x, out_y = x, y 364 | if rotate_z is not None and rotate_y is not None: 365 | assert x is not None 366 | assert y is not None 367 | rotarg = ConvertFlashRotation(rotate_y, rotate_z, x, y, width, height) 368 | out_x, out_y = rotarg[0:2] 369 | if scale_x is None: 370 | scale_x = 1 371 | if scale_y is None: 372 | scale_y = 1 373 | styles.append('\\frx%.0f\\fry%.0f\\frz%.0f\\fscx%.0f\\fscy%.0f' % (rotarg[2:5] + (rotarg[5] * scale_x, rotarg[6] * scale_y))) 374 | else: 375 | if scale_x is not None: 376 | styles.append('\\fscx%.0f' % (scale_x * 100)) 377 | if scale_y is not None: 378 | styles.append('\\fscy%.0f' % (scale_y * 100)) 379 | if color is not None: 380 | styles.append('\\c&H%s&' % ConvertColor(color)) 381 | if color == 0x000000: 382 | styles.append('\\3c&HFFFFFF&') 383 | if alpha is not None: 384 | alpha = 255 - round(alpha * 255) 385 | styles.append('\\alpha&H%02X' % alpha) 386 | return out_x, out_y, styles 387 | 388 | def FlushCommentLine(f, text, styles, start_time, end_time, styleid): 389 | if end_time > start_time: 390 | f.write('Dialogue: -1,%(start)s,%(end)s,%(styleid)s,,0,0,0,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(start_time), 'end': ConvertTimestamp(end_time), 'styles': ''.join(styles), 'text': text, 'styleid': styleid}) 391 | 392 | try: 393 | comment_args = c[3] 394 | text = ASSEscape(str(comment_args['n']).replace('\r', '\n')) 395 | common_styles = ['\org(%d, %d)' % (width / 2, height / 2)] 396 | anchor = {0: 7, 1: 8, 2: 9, 3: 4, 4: 5, 5: 6, 6: 1, 7: 2, 8: 3}.get(comment_args.get('c', 0), 7) 397 | if anchor != 7: 398 | common_styles.append('\\an%s' % anchor) 399 | font = comment_args.get('w') 400 | if font: 401 | font = dict(font) 402 | fontface = font.get('f') 403 | if fontface: 404 | common_styles.append('\\fn%s' % ASSEscape(str(fontface))) 405 | fontbold = bool(font.get('b')) 406 | if fontbold: 407 | common_styles.append('\\b1') 408 | common_styles.append('\\fs%.0f' % (c[6] * ZoomFactor[0])) 409 | isborder = bool(comment_args.get('b', True)) 410 | if not isborder: 411 | common_styles.append('\\bord0') 412 | to_pos = dict(comment_args.get('p', {'x': 0, 'y': 0})) 413 | to_x = round(GetPosition(int(to_pos.get('x', 0)), False)) 414 | to_y = round(GetPosition(int(to_pos.get('y', 0)), True)) 415 | to_scale_x = float(comment_args.get('e', 1.0)) 416 | to_scale_y = float(comment_args.get('f', 1.0)) 417 | to_rotate_z = float(comment_args.get('r', 0.0)) 418 | to_rotate_y = float(comment_args.get('k', 0.0)) 419 | to_color = c[5] 420 | to_alpha = float(comment_args.get('a', 1.0)) 421 | from_time = float(comment_args.get('t', 0.0)) 422 | action_time = float(comment_args.get('l', 3.0)) 423 | actions = list(comment_args.get('z', [])) 424 | to_out_x, to_out_y, transform_styles = GetTransformStyles(to_x, to_y, to_scale_x, to_scale_y, to_rotate_z, to_rotate_y, to_color, to_alpha) 425 | FlushCommentLine(f, text, common_styles + ['\\pos(%.0f, %.0f)' % (to_out_x, to_out_y)] + transform_styles, c[0] + from_time, c[0] + from_time + action_time, styleid) 426 | action_styles = transform_styles 427 | for action in actions: 428 | action = dict(action) 429 | from_x, from_y = to_x, to_y 430 | from_out_x, from_out_y = to_out_x, to_out_y 431 | from_scale_x, from_scale_y = to_scale_x, to_scale_y 432 | from_rotate_z, from_rotate_y = to_rotate_z, to_rotate_y 433 | from_color, from_alpha = to_color, to_alpha 434 | transform_styles, action_styles = action_styles, [] 435 | from_time += action_time 436 | action_time = float(action.get('l', 0.0)) 437 | if 'x' in action: 438 | to_x = round(GetPosition(int(action['x']), False)) 439 | if 'y' in action: 440 | to_y = round(GetPosition(int(action['y']), True)) 441 | if 'f' in action: 442 | to_scale_x = float(action['f']) 443 | if 'g' in action: 444 | to_scale_y = float(action['g']) 445 | if 'c' in action: 446 | to_color = int(action['c']) 447 | if 't' in action: 448 | to_alpha = float(action['t']) 449 | if 'd' in action: 450 | to_rotate_z = float(action['d']) 451 | if 'e' in action: 452 | to_rotate_y = float(action['e']) 453 | to_out_x, to_out_y, action_styles = GetTransformStyles(to_x, to_y, from_scale_x, from_scale_y, to_rotate_z, to_rotate_y, from_color, from_alpha) 454 | if (from_out_x, from_out_y) == (to_out_x, to_out_y): 455 | pos_style = '\\pos(%.0f, %.0f)' % (to_out_x, to_out_y) 456 | else: 457 | pos_style = '\\move(%.0f, %.0f, %.0f, %.0f)' % (from_out_x, from_out_y, to_out_x, to_out_y) 458 | styles = common_styles + transform_styles 459 | styles.append(pos_style) 460 | if action_styles: 461 | styles.append('\\t(%s)' % (''.join(action_styles))) 462 | FlushCommentLine(f, text, styles, c[0] + from_time, c[0] + from_time + action_time, styleid) 463 | except (IndexError, ValueError) as e: 464 | logging.warning(_('Invalid comment: %r') % c[3]) 465 | 466 | 467 | # Result: (f, dx, dy) 468 | # To convert: NewX = f*x+dx, NewY = f*y+dy 469 | def GetZoomFactor(SourceSize, TargetSize): 470 | try: 471 | if (SourceSize, TargetSize) == GetZoomFactor.Cached_Size: 472 | return GetZoomFactor.Cached_Result 473 | except AttributeError: 474 | pass 475 | GetZoomFactor.Cached_Size = (SourceSize, TargetSize) 476 | try: 477 | SourceAspect = SourceSize[0] / SourceSize[1] 478 | TargetAspect = TargetSize[0] / TargetSize[1] 479 | if TargetAspect < SourceAspect: # narrower 480 | ScaleFactor = TargetSize[0] / SourceSize[0] 481 | GetZoomFactor.Cached_Result = (ScaleFactor, 0, (TargetSize[1] - TargetSize[0] / SourceAspect) / 2) 482 | elif TargetAspect > SourceAspect: # wider 483 | ScaleFactor = TargetSize[1] / SourceSize[1] 484 | GetZoomFactor.Cached_Result = (ScaleFactor, (TargetSize[0] - TargetSize[1] * SourceAspect) / 2, 0) 485 | else: 486 | GetZoomFactor.Cached_Result = (TargetSize[0] / SourceSize[0], 0, 0) 487 | return GetZoomFactor.Cached_Result 488 | except ZeroDivisionError: 489 | GetZoomFactor.Cached_Result = (1, 0, 0) 490 | return GetZoomFactor.Cached_Result 491 | 492 | 493 | # Calculation is based on https://github.com/jabbany/CommentCoreLibrary/issues/5#issuecomment-40087282 494 | # and https://github.com/m13253/danmaku2ass/issues/7#issuecomment-41489422 495 | # ASS FOV = width*4/3.0 496 | # But Flash FOV = width/math.tan(100*math.pi/360.0)/2 will be used instead 497 | # Result: (transX, transY, rotX, rotY, rotZ, scaleX, scaleY) 498 | def ConvertFlashRotation(rotY, rotZ, X, Y, width, height): 499 | def WrapAngle(deg): 500 | return 180 - ((180 - deg) % 360) 501 | rotY = WrapAngle(rotY) 502 | rotZ = WrapAngle(rotZ) 503 | if rotY in (90, -90): 504 | rotY -= 1 505 | if rotY == 0 or rotZ == 0: 506 | outX = 0 507 | outY = -rotY # Positive value means clockwise in Flash 508 | outZ = -rotZ 509 | rotY *= math.pi / 180.0 510 | rotZ *= math.pi / 180.0 511 | else: 512 | rotY *= math.pi / 180.0 513 | rotZ *= math.pi / 180.0 514 | outY = math.atan2(-math.sin(rotY) * math.cos(rotZ), math.cos(rotY)) * 180 / math.pi 515 | outZ = math.atan2(-math.cos(rotY) * math.sin(rotZ), math.cos(rotZ)) * 180 / math.pi 516 | outX = math.asin(math.sin(rotY) * math.sin(rotZ)) * 180 / math.pi 517 | trX = (X * math.cos(rotZ) + Y * math.sin(rotZ)) / math.cos(rotY) + (1 - math.cos(rotZ) / math.cos(rotY)) * width / 2 - math.sin(rotZ) / math.cos(rotY) * height / 2 518 | trY = Y * math.cos(rotZ) - X * math.sin(rotZ) + math.sin(rotZ) * width / 2 + (1 - math.cos(rotZ)) * height / 2 519 | trZ = (trX - width / 2) * math.sin(rotY) 520 | FOV = width * math.tan(2 * math.pi / 9.0) / 2 521 | try: 522 | scaleXY = FOV / (FOV + trZ) 523 | except ZeroDivisionError: 524 | logging.error('Rotation makes object behind the camera: trZ == %.0f' % trZ) 525 | scaleXY = 1 526 | trX = (trX - width / 2) * scaleXY + width / 2 527 | trY = (trY - height / 2) * scaleXY + height / 2 528 | if scaleXY < 0: 529 | scaleXY = -scaleXY 530 | outX += 180 531 | outY += 180 532 | logging.error('Rotation makes object behind the camera: trZ == %.0f < %.0f' % (trZ, FOV)) 533 | return (trX, trY, WrapAngle(outX), WrapAngle(outY), WrapAngle(outZ), scaleXY * 100, scaleXY * 100) 534 | 535 | 536 | def ProcessComments(comments, f, width, height, bottomReserved, fontface, fontsize, alpha, duration_marquee, duration_still, filters_regex, reduced, progress_callback): 537 | styleid = 'Danmaku2ASS_%04x' % random.randint(0, 0xffff) 538 | WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid) 539 | rows = [[None] * (height - bottomReserved + 1) for i in range(4)] 540 | for idx, i in enumerate(comments): 541 | if progress_callback and idx % 1000 == 0: 542 | progress_callback(idx, len(comments)) 543 | if isinstance(i[4], int): 544 | skip = False 545 | for filter_regex in filters_regex: 546 | if filter_regex and filter_regex.search(i[3]): 547 | skip = True 548 | break 549 | if skip: 550 | continue 551 | row = 0 552 | rowmax = height - bottomReserved - i[7] 553 | while row <= rowmax: 554 | freerows = TestFreeRows(rows, i, row, width, height, bottomReserved, duration_marquee, duration_still) 555 | if freerows >= i[7]: 556 | MarkCommentRow(rows, i, row) 557 | WriteComment(f, i, row, width, height, bottomReserved, fontsize, duration_marquee, duration_still, styleid) 558 | break 559 | else: 560 | row += freerows or 1 561 | else: 562 | if not reduced: 563 | row = FindAlternativeRow(rows, i, height, bottomReserved) 564 | MarkCommentRow(rows, i, row) 565 | WriteComment(f, i, row, width, height, bottomReserved, fontsize, duration_marquee, duration_still, styleid) 566 | elif i[4] == 'bilipos': 567 | WriteCommentBilibiliPositioned(f, i, width, height, styleid) 568 | elif i[4] == 'acfunpos': 569 | WriteCommentAcfunPositioned(f, i, width, height, styleid) 570 | else: 571 | logging.warning(_('Invalid comment: %r') % i[3]) 572 | if progress_callback: 573 | progress_callback(len(comments), len(comments)) 574 | 575 | 576 | def TestFreeRows(rows, c, row, width, height, bottomReserved, duration_marquee, duration_still): 577 | res = 0 578 | rowmax = height - bottomReserved 579 | targetRow = None 580 | if c[4] in (1, 2): 581 | while row < rowmax and res < c[7]: 582 | if targetRow != rows[c[4]][row]: 583 | targetRow = rows[c[4]][row] 584 | if targetRow and targetRow[0] + duration_still > c[0]: 585 | break 586 | row += 1 587 | res += 1 588 | else: 589 | try: 590 | thresholdTime = c[0] - duration_marquee * (1 - width / (c[8] + width)) 591 | except ZeroDivisionError: 592 | thresholdTime = c[0] - duration_marquee 593 | while row < rowmax and res < c[7]: 594 | if targetRow != rows[c[4]][row]: 595 | targetRow = rows[c[4]][row] 596 | try: 597 | if targetRow and (targetRow[0] > thresholdTime or targetRow[0] + targetRow[8] * duration_marquee / (targetRow[8] + width) > c[0]): 598 | break 599 | except ZeroDivisionError: 600 | pass 601 | row += 1 602 | res += 1 603 | return res 604 | 605 | 606 | def FindAlternativeRow(rows, c, height, bottomReserved): 607 | res = 0 608 | for row in range(height - bottomReserved - math.ceil(c[7])): 609 | if not rows[c[4]][row]: 610 | return row 611 | elif rows[c[4]][row][0] < rows[c[4]][res][0]: 612 | res = row 613 | return res 614 | 615 | 616 | def MarkCommentRow(rows, c, row): 617 | try: 618 | for i in range(row, row + math.ceil(c[7])): 619 | rows[c[4]][i] = c 620 | except IndexError: 621 | pass 622 | 623 | 624 | def WriteASSHead(f, width, height, fontface, fontsize, alpha, styleid): 625 | f.write( 626 | '''[Script Info] 627 | ; Script generated by Danmaku2ASS 628 | ; https://github.com/m13253/danmaku2ass 629 | Script Updated By: Danmaku2ASS (https://github.com/m13253/danmaku2ass) 630 | ScriptType: v4.00+ 631 | PlayResX: %(width)d 632 | PlayResY: %(height)d 633 | Aspect Ratio: %(width)d:%(height)d 634 | Collisions: Normal 635 | WrapStyle: 2 636 | ScaledBorderAndShadow: yes 637 | YCbCr Matrix: TV.601 638 | 639 | [V4+ Styles] 640 | Format: Name, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 641 | Style: %(styleid)s, %(fontsize).0f, &H%(alpha)02XFFFFFF, &H%(alpha)02XFFFFFF, &H%(alpha)02X000000, &H%(alpha)02X000000, 0, 0, 0, 0, 100, 100, 0.00, 0.00, 1, %(outline).0f, 0, 7, 0, 0, 0, 0 642 | 643 | [Events] 644 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 645 | ''' % {'width': width, 'height': height, 'fontsize': fontsize, 'alpha': 255 - round(alpha * 255), 'outline': max(fontsize / 25.0, 1), 'styleid': styleid} 646 | ) 647 | 648 | 649 | def WriteComment(f, c, row, width, height, bottomReserved, fontsize, duration_marquee, duration_still, styleid): 650 | text = ASSEscape(c[3]) 651 | styles = [] 652 | if c[4] == 1: 653 | styles.append('\\an8\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width / 2, 'row': row}) 654 | duration = duration_still 655 | elif c[4] == 2: 656 | styles.append('\\an2\\pos(%(halfwidth)d, %(row)d)' % {'halfwidth': width / 2, 'row': ConvertType2(row, height, bottomReserved)}) 657 | duration = duration_still 658 | elif c[4] == 3: 659 | styles.append('\\move(%(neglen)d, %(row)d, %(width)d, %(row)d)' % {'width': width, 'row': row, 'neglen': -math.ceil(c[8])}) 660 | duration = duration_marquee 661 | else: 662 | styles.append('\\move(%(width)d, %(row)d, %(neglen)d, %(row)d)' % {'width': width, 'row': row, 'neglen': -math.ceil(c[8])}) 663 | duration = duration_marquee 664 | if not (-1 < c[6] - fontsize < 1): 665 | styles.append('\\fs%.0f' % c[6]) 666 | if c[5] != 0xffffff: 667 | styles.append('\\c&H%s&' % ConvertColor(c[5])) 668 | if c[5] == 0x000000: 669 | styles.append('\\3c&HFFFFFF&') 670 | f.write('Dialogue: 2,%(start)s,%(end)s,%(styleid)s,,0000,0000,0000,,{%(styles)s}%(text)s\n' % {'start': ConvertTimestamp(c[0]), 'end': ConvertTimestamp(c[0] + duration), 'styles': ''.join(styles), 'text': text, 'styleid': styleid}) 671 | 672 | 673 | def ASSEscape(s): 674 | def ReplaceLeadingSpace(s): 675 | sstrip = s.strip(' ') 676 | slen = len(s) 677 | if slen == len(sstrip): 678 | return s 679 | else: 680 | llen = slen - len(s.lstrip(' ')) 681 | rlen = slen - len(s.rstrip(' ')) 682 | return ''.join(('\u2007' * llen, sstrip, '\u2007' * rlen)) 683 | return '\\N'.join((ReplaceLeadingSpace(i) or ' ' for i in str(s).replace('\\', '\\\\').replace('{', '\\{').replace('}', '\\}').split('\n'))) 684 | 685 | 686 | def CalculateLength(s): 687 | return max(map(len, s.split('\n'))) # May not be accurate 688 | 689 | 690 | def ConvertTimestamp(timestamp): 691 | timestamp = round(timestamp * 100.0) 692 | hour, minute = divmod(timestamp, 360000) 693 | minute, second = divmod(minute, 6000) 694 | second, centsecond = divmod(second, 100) 695 | return '%d:%02d:%02d.%02d' % (int(hour), int(minute), int(second), int(centsecond)) 696 | 697 | 698 | def ConvertColor(RGB, width=1280, height=576): 699 | if RGB == 0x000000: 700 | return '000000' 701 | elif RGB == 0xffffff: 702 | return 'FFFFFF' 703 | R = (RGB >> 16) & 0xff 704 | G = (RGB >> 8) & 0xff 705 | B = RGB & 0xff 706 | if width < 1280 and height < 576: 707 | return '%02X%02X%02X' % (B, G, R) 708 | else: # VobSub always uses BT.601 colorspace, convert to BT.709 709 | ClipByte = lambda x: 255 if x > 255 else 0 if x < 0 else round(x) 710 | return '%02X%02X%02X' % ( 711 | ClipByte(R * 0.00956384088080656 + G * 0.03217254540203729 + B * 0.95826361371715607), 712 | ClipByte(R * -0.10493933142075390 + G * 1.17231478191855154 + B * -0.06737545049779757), 713 | ClipByte(R * 0.91348912373987645 + G * 0.07858536372532510 + B * 0.00792551253479842) 714 | ) 715 | 716 | 717 | def ConvertType2(row, height, bottomReserved): 718 | return height - bottomReserved - row 719 | 720 | 721 | def ConvertToFile(filename_or_file, *args, **kwargs): 722 | if isinstance(filename_or_file, bytes): 723 | filename_or_file = str(bytes(filename_or_file).decode('utf-8', 'replace')) 724 | if isinstance(filename_or_file, str): 725 | return open(filename_or_file, *args, **kwargs) 726 | else: 727 | return filename_or_file 728 | 729 | 730 | def FilterBadChars(f): 731 | s = f.read() 732 | s = re.sub('[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]', '\ufffd', s) 733 | return io.StringIO(s) 734 | 735 | 736 | class safe_list(list): 737 | 738 | def get(self, index, default=None): 739 | try: 740 | return self[index] 741 | except IndexError: 742 | return default 743 | 744 | 745 | def export(func): 746 | global __all__ 747 | try: 748 | __all__.append(func.__name__) 749 | except NameError: 750 | __all__ = [func.__name__] 751 | return func 752 | 753 | 754 | @export 755 | def Danmaku2ASS(input_files, input_format, output_file, stage_width, stage_height, reserve_blank=0, font_face=_('(FONT) sans-serif')[7:], font_size=25.0, text_opacity=1.0, duration_marquee=5.0, duration_still=5.0, comment_filter=None, comment_filters_file=None, is_reduce_comments=False, progress_callback=None): 756 | comment_filters = [comment_filter] 757 | if comment_filters_file: 758 | with open(comment_filters_file, 'r') as f: 759 | d = f.readlines() 760 | comment_filters.extend([i.strip() for i in d]) 761 | filters_regex = [] 762 | for comment_filter in comment_filters: 763 | try: 764 | if comment_filter: 765 | filters_regex.append(re.compile(comment_filter)) 766 | except: 767 | raise ValueError(_('Invalid regular expression: %s') % comment_filter) 768 | fo = None 769 | comments = ReadComments(input_files, input_format, font_size) 770 | try: 771 | if output_file: 772 | fo = ConvertToFile(output_file, 'w', encoding='utf-8-sig', errors='replace', newline='\r\n') 773 | else: 774 | fo = sys.stdout 775 | ProcessComments(comments, fo, stage_width, stage_height, reserve_blank, font_face, font_size, text_opacity, duration_marquee, duration_still, filters_regex, is_reduce_comments, progress_callback) 776 | finally: 777 | if output_file and fo != output_file: 778 | fo.close() 779 | 780 | 781 | @export 782 | def ReadComments(input_files, input_format, font_size=25.0, progress_callback=None): 783 | if isinstance(input_files, bytes): 784 | input_files = str(bytes(input_files).decode('utf-8', 'replace')) 785 | if isinstance(input_files, str): 786 | input_files = [input_files] 787 | else: 788 | input_files = list(input_files) 789 | comments = [] 790 | for idx, i in enumerate(input_files): 791 | if progress_callback: 792 | progress_callback(idx, len(input_files)) 793 | with ConvertToFile(i, 'r', encoding='utf-8', errors='replace') as f: 794 | s = f.read() 795 | str_io = io.StringIO(s) 796 | if input_format == 'autodetect': 797 | CommentProcessor = GetCommentProcessor(str_io) 798 | if not CommentProcessor: 799 | raise ValueError( 800 | _('Failed to detect comment file format: %s') % i 801 | ) 802 | else: 803 | CommentProcessor = CommentFormatMap.get(input_format) 804 | if not CommentProcessor: 805 | raise ValueError( 806 | _('Unknown comment file format: %s') % input_format 807 | ) 808 | comments.extend(CommentProcessor(FilterBadChars(str_io), font_size)) 809 | if progress_callback: 810 | progress_callback(len(input_files), len(input_files)) 811 | comments.sort() 812 | return comments 813 | 814 | 815 | @export 816 | def GetCommentProcessor(input_file): 817 | return ReadCommentsBilibili 818 | 819 | 820 | def main1(): 821 | logging.basicConfig(format='%(levelname)s: %(message)s') 822 | if len(sys.argv) == 1: 823 | sys.argv.append('--help') 824 | parser = argparse.ArgumentParser() 825 | parser.add_argument('-f', '--format', metavar=_('FORMAT'), help=_('Format of input file (autodetect|%s) [default: autodetect]') % '|'.join(i for i in CommentFormatMap), default='autodetect') 826 | parser.add_argument('-o', '--output', metavar=_('OUTPUT'), help=_('Output file')) 827 | parser.add_argument('-s', '--size', metavar=_('WIDTHxHEIGHT'), required=True, help=_('Stage size in pixels')) 828 | parser.add_argument('-fn', '--font', metavar=_('FONT'), help=_('Specify font face [default: %s]') % _('(FONT) sans-serif')[7:], default=_('(FONT) sans-serif')[7:]) 829 | parser.add_argument('-fs', '--fontsize', metavar=_('SIZE'), help=(_('Default font size [default: %s]') % 25), type=float, default=25.0) 830 | parser.add_argument('-a', '--alpha', metavar=_('ALPHA'), help=_('Text opacity'), type=float, default=1.0) 831 | parser.add_argument('-dm', '--duration-marquee', metavar=_('SECONDS'), help=_('Duration of scrolling comment display [default: %s]') % 5, type=float, default=5.0) 832 | parser.add_argument('-ds', '--duration-still', metavar=_('SECONDS'), help=_('Duration of still comment display [default: %s]') % 5, type=float, default=5.0) 833 | parser.add_argument('-fl', '--filter', help=_('Regular expression to filter comments')) 834 | parser.add_argument('-flf', '--filter-file', help=_('Regular expressions from file (one line one regex) to filter comments')) 835 | parser.add_argument('-p', '--protect', metavar=_('HEIGHT'), help=_('Reserve blank on the bottom of the stage'), type=int, default=0) 836 | parser.add_argument('-r', '--reduce', action='store_true', help=_('Reduce the amount of comments if stage is full')) 837 | parser.add_argument('file', metavar=_('FILE'), nargs='+', help=_('Comment file to be processed')) 838 | args = parser.parse_args() 839 | try: 840 | width, height = str(args.size).split('x', 1) 841 | width = int(width) 842 | height = int(height) 843 | except ValueError: 844 | raise ValueError(_('Invalid stage size: %r') % args.size) 845 | Danmaku2ASS(args.file, args.format, args.output, width, height, args.protect, args.font, args.fontsize, args.alpha, args.duration_marquee, args.duration_still, args.filter, args.filter_file, args.reduce) 846 | 847 | 848 | headers = { 849 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36" 850 | } 851 | 852 | format = 'autodetect' 853 | output_file = 'danmaku.ass' 854 | size = '1920x1080' 855 | font = 'sans-serif' 856 | fontsize = 50.0 857 | alpha = 0.9 858 | duration_marquee = 15.0 859 | duration_still = 6.0 860 | filter = None 861 | filter_file = None 862 | protect = 0 863 | reduce = True 864 | 865 | 866 | def get_danmaku_xml(cid: int) -> str: 867 | r = requests.get(f"http://comment.bilibili.com/{cid}.xml", headers=headers) 868 | return r.text.encode("ISO-8859-1").decode("utf-8") 869 | 870 | 871 | def generate_ass(xml: str, output: str, width: int, height: int): 872 | global fontsize 873 | with open(f"{output}.temp", 'w', encoding='utf-8') as f: 874 | f.write(xml) 875 | if width < 1920: 876 | fontsize = 25 877 | elif width < 3840: 878 | fontsize = 50 879 | else: 880 | fontsize = 80 881 | try: 882 | Danmaku2ASS(f"{output}.temp", format, output, width, height, protect, font, fontsize, alpha, duration_marquee, duration_still, filter, filter_file, reduce) 883 | os.replace(f"{output}.temp", str(Path(output).with_suffix('.xml'))) 884 | except Exception as e: 885 | # print(e) 886 | sys.exit(1) 887 | 888 | 889 | def get_video_width_height(path: Path) -> (int, int): 890 | try: 891 | media_info = ffmpeg.probe(str(path)) 892 | except ffmpeg.Error as e: 893 | logger.error(e.stderr) 894 | print(e.stderr, file=sys.stderr) 895 | media_info = { 896 | "streams": [{ 897 | "width": 1920, 898 | "height": 1080 899 | }] 900 | } 901 | for stream in media_info['streams']: 902 | if 'width' in stream: 903 | width = stream['width'] 904 | height = stream['height'] 905 | return width, height 906 | 907 | --------------------------------------------------------------------------------