├── 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 |
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 |
--------------------------------------------------------------------------------