release: 1.3.0

This commit is contained in:
acgnhik 2022-01-21 17:47:49 +08:00
parent e8d655ef2b
commit e36dedce74
57 changed files with 17428 additions and 9287 deletions

View File

@ -1,5 +1,19 @@
# 更新日志
## 1.3.0
### 功能
- flv 添加关键帧元数据为可选功能
- 支持保存直播间封面
- 断网超过设置的等待时间自动结束录制
- 断网后网络恢复且未下播自动重新开始录制
### 修复
- 修复录制异常 `IndexError: list index out of range`
- 修复关闭录制后没有更新元数据或转封装
## 1.2.4
- 修复回收空间时文件不存在异常

View File

@ -17,7 +17,7 @@
- 自动修复时间戳问题:跳变、反跳等。
- 直播流参数改变自动分割文件,避免出现花屏等问题。
- 流中断自动拼接且支持 **无缝** 拼接,不会因网络中断而使录播文件片段化。
- `flv` 文件注入关键帧等元数据,定位播放和拖进度条不会卡顿。
- `flv` 文件添加关键帧等元数据,使定位播放和拖进度条不会卡顿。
- 可选录制的画质
- 可自定义文件保存路径和文件名
- 支持按文件大小或时长分割文件
@ -33,14 +33,12 @@
## 安装
- 通过 pip 安装
- 通过 pip 或者 pipx 安装
`pip install blrec`
`pip install blrec` 或者 `pipx install blrec`
用到的一些库需要 C 编译器Windows 没 C 编译器会安装出错,
使用以下方式先安装已编译好的库然后再按照上面的安装。
`pip install -r windows-requirements.txt`
使用的一些库需要自己编译Windows 没安装 C / C++ 编译器会安装出错,
参考 [Python Can't install packages](https://stackoverflow.com/questions/64261546/python-cant-install-packages) 先安装好 [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)。
- 免安装绿色版
@ -53,9 +51,9 @@
## 更新
- 通过 pip 安装的用以下方式更新
- 通过 pip 或者 pipx 安装的用以下方式更新
`pip install blrec --upgrade`
`pip install blrec --upgrade` 或者 `pipx upgrade blrec`
- 免安装绿色版
@ -64,11 +62,21 @@
- 把旧版本的设置文件 `settings.toml` 复制并覆盖新版本的设置文件
- 运行新版本的 `run.bat`
## 卸载
- 通过 pip 或者 pipx 安装的用以下方式卸载
`pip uninstall blrec` 或者 `pipx uninstall blrec`
- 免安装绿色版
删除解压后的文件夹
## 使用方法
### 使用默认设置文件和保存位置
在命令行终端里敲入 `blrec` 并回车运行,然后在浏览器访问 `http://localhost:2233`
在命令行终端里执行 `blrec` ,然后浏览器访问 `http://localhost:2233`
设置文件为 `toml` 文件,默认位置在 `~/.blrec/settings.toml`。默认录播文件保存位置为当前工作目录 `.`
@ -148,12 +156,6 @@
---
## 赞助 & 支持
如果觉得这个工具好用,对你有所帮助,可以投喂支持亿下哦~
投喂赞助 ☞ <https://afdian.net/@acgnhiki>
## Thanks
[![JetBrains Logo (Main) logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg)](https://jb.gg/OpenSource)

View File

@ -57,18 +57,19 @@ install_requires =
[options.extras_require]
dev =
flake8 >= 4.0.1, < 5.0.0
mypy >= 0.910, < 1.000
flake8 >= 4.0.1
mypy >= 0.910
setuptools >= 59.4.0, < 60.0.0
wheel >= 0.37, < 0.38.0
build >= 0.7.0, < 0.8.0
setuptools >= 59.4.0
wheel >= 0.37
build >= 0.7.0
twine >= 3.7.1
# missing stub packages
types-requests >= 2.26.1, < 3.0.0
types-aiofiles >= 0.1.7, < 0.2.0
types-toml >= 0.10.1, < 0.11.0
types-setuptools >= 57.4.4, < 58.0.0
types-requests >= 2.26.1
types-aiofiles >= 0.1.7
types-toml >= 0.10.1
types-setuptools >= 57.4.4
speedups = aiohttp[speedups] >= 3.8.1, < 4.0.0

View File

@ -1,4 +1,4 @@
__prog__ = 'blrec'
__version__ = '1.2.4'
__version__ = '1.3.0'
__github__ = 'https://github.com/acgnhiki/blrec'

View File

@ -114,6 +114,14 @@ class Live:
def is_living(self) -> bool:
return self._room_info.live_status == LiveStatus.LIVE
async def check_connectivity(self) -> bool:
try:
await self._session.head('https://live.bilibili.com/', timeout=3)
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return False
else:
return True
async def update_info(self) -> None:
await asyncio.wait([self.update_user_info(), self.update_room_info()])

View File

@ -72,16 +72,18 @@ class LiveMonitor(
current_status = self._live.room_info.live_status
if current_status == self._previous_status:
return
if current_status == LiveStatus.LIVE:
logger.debug('Simulating live began event')
await self._handle_status_change(current_status)
logger.debug('Simulating live stream available event')
await self._handle_status_change(current_status)
if current_status == LiveStatus.LIVE:
logger.debug('Simulating stream reset event')
await self._handle_status_change(current_status)
else:
logger.debug('Simulating live ended event')
await self._handle_status_change(current_status)
if current_status == LiveStatus.LIVE:
logger.debug('Simulating live began event')
await self._handle_status_change(current_status)
logger.debug('Simulating live stream available event')
await self._handle_status_change(current_status)
else:
logger.debug('Simulating live ended event')
await self._handle_status_change(current_status)
async def on_danmaku_received(self, danmu: Danmaku) -> None:
danmu_cmd = danmu['cmd']

View File

@ -3,7 +3,10 @@ import logging
from datetime import datetime
from typing import Iterator, Optional
import aiohttp
import aiofiles
import humanize
from tenacity import retry, wait_fixed, stop_after_attempt
from .danmaku_receiver import DanmakuReceiver
from .danmaku_dumper import DanmakuDumper, DanmakuDumperEventListener
@ -17,6 +20,8 @@ from ..bili.danmaku_client import DanmakuClient
from ..bili.live_monitor import LiveMonitor, LiveEventListener
from ..bili.typing import QualityNumber
from ..utils.mixins import AsyncStoppableMixin
from ..path import cover_path
from ..logging.room_id import aio_task_with_room_id
__all__ = 'RecorderEventListener', 'Recorder'
@ -67,10 +72,12 @@ class Recorder(
*,
buffer_size: Optional[int] = None,
read_timeout: Optional[int] = None,
disconnection_timeout: Optional[int] = None,
danmu_uname: bool = False,
record_gift_send: bool = False,
record_guard_buy: bool = False,
record_super_chat: bool = False,
save_cover: bool = False,
save_raw_danmaku: bool = False,
filesize_limit: int = 0,
duration_limit: int = 0,
@ -80,6 +87,7 @@ class Recorder(
self._live = live
self._danmaku_client = danmaku_client
self._live_monitor = live_monitor
self.save_cover = save_cover
self.save_raw_danmaku = save_raw_danmaku
self._recording: bool = False
@ -90,6 +98,7 @@ class Recorder(
path_template=path_template,
buffer_size=buffer_size,
read_timeout=read_timeout,
disconnection_timeout=disconnection_timeout,
filesize_limit=filesize_limit,
duration_limit=duration_limit,
)
@ -142,6 +151,14 @@ class Recorder(
def read_timeout(self, value: int) -> None:
self._stream_recorder.read_timeout = value
@property
def disconnection_timeout(self) -> int:
return self._stream_recorder.disconnection_timeout
@disconnection_timeout.setter
def disconnection_timeout(self, value: int) -> None:
self._stream_recorder.disconnection_timeout = value
@property
def danmu_uname(self) -> bool:
return self._danmaku_dumper.danmu_uname
@ -279,6 +296,8 @@ class Recorder(
async def on_live_stream_reset(self, live: Live) -> None:
logger.warning('The live stream has been reset')
if not self._recording:
await self._start_recording(stream_available=True)
async def on_room_changed(self, room_info: RoomInfo) -> None:
self._print_changed_room_info(room_info)
@ -291,6 +310,8 @@ class Recorder(
async def on_video_file_completed(self, path: str) -> None:
await self._emit('video_file_completed', path)
if self.save_cover:
await self._save_cover_image(path)
async def on_danmaku_file_created(self, path: str) -> None:
await self._emit('danmaku_file_created', path)
@ -298,6 +319,10 @@ class Recorder(
async def on_danmaku_file_completed(self, path: str) -> None:
await self._emit('danmaku_file_completed', path)
async def on_stream_recording_stopped(self) -> None:
logger.debug('Stream recording stopped')
await self._stop_recording()
async def _start_recording(self, stream_available: bool = False) -> None:
if self._recording:
return
@ -341,6 +366,24 @@ class Recorder(
self._danmaku_dumper.clear_files()
self._stream_recorder.clear_files()
@retry(wait=wait_fixed(1), stop=stop_after_attempt(3))
@aio_task_with_room_id
async def _save_cover_image(self, video_path: str) -> None:
await self._live.update_info()
async with aiohttp.ClientSession(raise_for_status=True) as session:
try:
url = self._live.room_info.cover
async with session.get(url) as response:
ext = url.rsplit('.', 1)[-1]
path = cover_path(video_path, ext)
async with aiofiles.open(path, 'wb') as file:
await file.write(await response.read())
except Exception as e:
logger.error(f'Failed to save cover image: {repr(e)}')
raise
else:
logger.info(f'Saved cover image: {path}')
def _print_waiting_message(self) -> None:
logger.info('Waiting... until the live starts')

View File

@ -62,6 +62,9 @@ class StreamRecorderEventListener(EventListener):
async def on_video_file_completed(self, path: str) -> None:
...
async def on_stream_recording_stopped(self) -> None:
...
class StreamRecorder(
EventEmitter[StreamRecorderEventListener],
@ -77,6 +80,7 @@ class StreamRecorder(
quality_number: QualityNumber = 10000,
buffer_size: Optional[int] = None,
read_timeout: Optional[int] = None,
disconnection_timeout: Optional[int] = None,
filesize_limit: int = 0,
duration_limit: int = 0,
) -> None:
@ -94,6 +98,7 @@ class StreamRecorder(
self._real_quality_number: Optional[QualityNumber] = None
self.buffer_size = buffer_size or io.DEFAULT_BUFFER_SIZE # bytes
self.read_timeout = read_timeout or 3 # seconds
self.disconnection_timeout = disconnection_timeout or 600 # seconds
self._filesize_limit = filesize_limit or 0
self._duration_limit = duration_limit or 0
@ -256,6 +261,7 @@ class StreamRecorder(
self._stream_processor = None
self._progress_bar = None
self._calculator.freeze()
self._emit_event('stream_recording_stopped')
def _main_loop(self) -> None:
for attempt in Retrying(
@ -316,6 +322,17 @@ class StreamRecorder(
logger.warning(repr(e))
except requests.exceptions.ConnectionError as e:
logger.warning(repr(e))
logger.info(
f'Waiting {self.disconnection_timeout} seconds '
'for connection recovery... '
)
try:
self._wait_connection_recovered(self.disconnection_timeout)
except TimeoutError as e:
logger.error(repr(e))
self._stopped = True
else:
logger.debug('Connection recovered')
except FlvStreamCorruptedError as e:
logger.warning(repr(e))
url = self._get_live_stream_url()
@ -376,6 +393,17 @@ class StreamRecorder(
logger.debug(f'Retry {name} after {seconds} seconds')
time.sleep(seconds)
def _wait_connection_recovered(
self, timeout: Optional[int] = None, check_interval: int = 3
) -> None:
timebase = time.monotonic()
while not self._run_coroutine(self._live.check_connectivity()):
if timeout is not None and time.monotonic() - timebase > timeout:
raise TimeoutError(
f'Connection not recovered in {timeout} seconds'
)
time.sleep(check_interval)
def _make_pbar_postfix(self) -> str:
return '{room_id} - {user_name}: {room_title}'.format(
room_id=self._live.room_info.room_id,

View File

@ -3,6 +3,7 @@ import html
import asyncio
import logging
import unicodedata
from datetime import datetime, timezone, timedelta
from typing import AsyncIterator, Final, List, Any
from lxml import etree
@ -51,12 +52,12 @@ class DanmakuReader: # TODO rewrite
room_title=self._tree.xpath('/i/metadata/room_title')[0].text,
area=self._tree.xpath('/i/metadata/area')[0].text,
parent_area=self._tree.xpath('/i/metadata/parent_area')[0].text,
live_start_time=int(
live_start_time=int(datetime.fromisoformat(
self._tree.xpath('/i/metadata/live_start_time')[0].text
),
record_start_time=int(
).timestamp()),
record_start_time=int(datetime.fromisoformat(
self._tree.xpath('/i/metadata/record_start_time')[0].text
),
).timestamp()),
recorder=self._tree.xpath('/i/metadata/recorder')[0].text,
)
@ -128,6 +129,11 @@ class DanmakuWriter:
await self._file.close()
def _serialize_metadata(self, metadata: Metadata) -> str:
tz = timezone(timedelta(hours=8))
ts = metadata.live_start_time
live_start_time = datetime.fromtimestamp(ts, tz).isoformat()
ts = metadata.record_start_time
record_start_time = datetime.fromtimestamp(ts, tz).isoformat()
return f"""\
<metadata>
<user_name>{html.escape(metadata.user_name)}</user_name>
@ -135,8 +141,8 @@ class DanmakuWriter:
<room_title>{html.escape(metadata.room_title)}</room_title>
<area>{html.escape(metadata.area)}</area>
<parent_area>{html.escape(metadata.parent_area)}</parent_area>
<live_start_time>{metadata.live_start_time}</live_start_time>
<record_start_time>{metadata.record_start_time}</record_start_time>
<live_start_time>{live_start_time}</live_start_time>
<record_start_time>{record_start_time}</record_start_time>
<recorder>{metadata.recorder}</recorder>
</metadata>
"""

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,7 @@ MIT
MIT
The MIT License
Copyright (c) 2021 Google LLC.
Copyright (c) 2022 Google LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -73,32 +73,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@ant-design/icons-angular
MIT
@babel/runtime
MIT
MIT License
Copyright (c) 2014-present Sebastian McKenzie and other contributors
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@ctrl/tinycolor
MIT
Copyright (c) Scott Cooper <scttcper@gmail.com>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,7 @@
(function() {
__ant_icon_load({
name: 'holder',
theme: 'outline',
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M300 276.5a56 56 0 1056-97 56 56 0 00-56 97zm0 284a56 56 0 1056-97 56 56 0 00-56 97zM640 228a56 56 0 10112 0 56 56 0 00-112 0zm0 284a56 56 0 10112 0 56 56 0 00-112 0zM300 844.5a56 56 0 1056-97 56 56 0 00-56 97zM640 796a56 56 0 10112 0 56 56 0 00-112 0z" /></svg>'
});
})()

View File

@ -0,0 +1 @@
<svg viewBox="64 64 896 896" focusable="false"><path d="M300 276.5a56 56 0 1056-97 56 56 0 00-56 97zm0 284a56 56 0 1056-97 56 56 0 00-56 97zM640 228a56 56 0 10112 0 56 56 0 00-112 0zm0 284a56 56 0 10112 0 56 56 0 00-112 0zM300 844.5a56 56 0 1056-97 56 56 0 00-56 97zM640 796a56 56 0 10112 0 56 56 0 00-112 0z" /></svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@ -1 +0,0 @@
"use strict";(self.webpackChunkblrec=self.webpackChunkblrec||[]).push([[592],{4670:(g,a,o)=>{o.d(a,{g:()=>r});var e=o(7716),s=o(8583);function i(n,c){if(1&n&&(e.TgZ(0,"div",2),e.TgZ(1,"h2",3),e._uU(2),e.qZA(),e.qZA()),2&n){const t=e.oxw();e.xp6(2),e.Oqu(t.name)}}const p=["*"];let r=(()=>{class n{constructor(){this.name=""}}return n.\u0275fac=function(t){return new(t||n)},n.\u0275cmp=e.Xpm({type:n,selectors:[["app-page-section"]],inputs:{name:"name"},ngContentSelectors:p,decls:3,vars:1,consts:[["class","header",4,"ngIf"],[1,"card"],[1,"header"],[1,"title"]],template:function(t,l){1&t&&(e.F$t(),e.YNc(0,i,3,1,"div",0),e.TgZ(1,"div",1),e.Hsn(2),e.qZA()),2&t&&e.Q6J("ngIf",l.name)},directives:[s.O5],styles:["[_nghost-%COMP%]{display:flex;flex-direction:column}.header[_ngcontent-%COMP%] .title[_ngcontent-%COMP%]{color:#202124;font-size:108%;font-weight:400;letter-spacing:.25px;margin-bottom:1em;margin-top:1.5em}.card[_ngcontent-%COMP%]{flex:1;background-color:#fff;border-radius:4px;box-shadow:0 2px 2px #00000024,0 1px 5px #0000001f,0 3px 1px -2px #0003}"],changeDetection:0}),n})()}}]);

View File

@ -0,0 +1 @@
"use strict";(self.webpackChunkblrec=self.webpackChunkblrec||[]).push([[592],{4670:(g,a,o)=>{o.d(a,{g:()=>r});var e=o(5e3),s=o(9808);function i(n,c){if(1&n&&(e.TgZ(0,"div",2),e.TgZ(1,"h2",3),e._uU(2),e.qZA(),e.qZA()),2&n){const t=e.oxw();e.xp6(2),e.Oqu(t.name)}}const p=["*"];let r=(()=>{class n{constructor(){this.name=""}}return n.\u0275fac=function(t){return new(t||n)},n.\u0275cmp=e.Xpm({type:n,selectors:[["app-page-section"]],inputs:{name:"name"},ngContentSelectors:p,decls:3,vars:1,consts:[["class","header",4,"ngIf"],[1,"card"],[1,"header"],[1,"title"]],template:function(t,l){1&t&&(e.F$t(),e.YNc(0,i,3,1,"div",0),e.TgZ(1,"div",1),e.Hsn(2),e.qZA()),2&t&&e.Q6J("ngIf",l.name)},directives:[s.O5],styles:["[_nghost-%COMP%]{display:flex;flex-direction:column}.header[_ngcontent-%COMP%] .title[_ngcontent-%COMP%]{color:#202124;font-size:108%;font-weight:400;letter-spacing:.25px;margin-bottom:1em;margin-top:1.5em}.card[_ngcontent-%COMP%]{flex:1;background-color:#fff;border-radius:4px;box-shadow:0 2px 2px #00000024,0 1px 5px #0000001f,0 3px 1px -2px #0003}"],changeDetection:0}),n})()}}]);

View File

@ -6,10 +6,10 @@
<link rel="icon" type="image/x-icon" href="assets/images/logo.png">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#1976d2">
<style>body,html{width:100%;height:100%}*,:after,:before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{margin:0;color:#000000d9;font-size:14px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-variant:tabular-nums;line-height:1.5715;background-color:#fff;font-feature-settings:"tnum","tnum"}html{--antd-wave-shadow-color:#1890ff;--scroll-bar:0}</style><link rel="stylesheet" href="styles.2e25f4678bcb2c0682d5.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles.2e25f4678bcb2c0682d5.css"></noscript></head>
<style>html,body{width:100%;height:100%}*,*:before,*:after{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{margin:0;color:#000000d9;font-size:14px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-variant:tabular-nums;line-height:1.5715;background-color:#fff;font-feature-settings:"tnum","tnum"}html{--antd-wave-shadow-color:#1890ff;--scroll-bar:0}</style><link rel="stylesheet" href="styles.1f581691b230dc4d.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles.1f581691b230dc4d.css"></noscript></head>
<body>
<app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
<script src="runtime.4f3f03ac4847b0cd3a3a.js" defer></script><script src="polyfills.a427f031f0f7196ffda1.js" defer></script><script src="main.1cfa15ba47a0ebad32e6.js" defer></script>
<script src="runtime.c48b962c8225f379.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.7a4da5a70e652c3f.js" type="module"></script>
</body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"configVersion": 1,
"timestamp": 1635958155081,
"timestamp": 1642737806856,
"index": "/index.html",
"assetGroups": [
{
@ -11,18 +11,18 @@
"ignoreVary": true
},
"urls": [
"/124.04d3ada75a9a6f4119c0.js",
"/481.2bc447cad2b7421383e8.js",
"/659.1d4258dba20472847e0d.js",
"/945.29b73cfac30295070168.js",
"/954.2fa849ff06b4bc2543e7.js",
"/common.1cef351b0cc7ea051261.js",
"/103.9c8251484169c949.js",
"/146.92e3b29c4c754544.js",
"/622.dd6c6ac77555edc7.js",
"/66.97582e026891bf70.js",
"/853.5697121b2e654d67.js",
"/common.858f777e9296e6f2.js",
"/index.html",
"/main.1cfa15ba47a0ebad32e6.js",
"/main.7a4da5a70e652c3f.js",
"/manifest.webmanifest",
"/polyfills.a427f031f0f7196ffda1.js",
"/runtime.4f3f03ac4847b0cd3a3a.js",
"/styles.2e25f4678bcb2c0682d5.css"
"/polyfills.4b08448aee19bb22.js",
"/runtime.c48b962c8225f379.js",
"/styles.1f581691b230dc4d.css"
],
"patterns": []
},
@ -896,6 +896,8 @@
"/assets/outline/highlight.svg",
"/assets/outline/history.js",
"/assets/outline/history.svg",
"/assets/outline/holder.js",
"/assets/outline/holder.svg",
"/assets/outline/home.js",
"/assets/outline/home.svg",
"/assets/outline/hourglass.js",
@ -1631,11 +1633,11 @@
],
"dataGroups": [],
"hashTable": {
"/124.04d3ada75a9a6f4119c0.js": "3be768c03ab30a02a3b9758ee4f58d8cc50bcbf4",
"/481.2bc447cad2b7421383e8.js": "64382466eafa92b4cf91d0a26f9d884c3c93c73b",
"/659.1d4258dba20472847e0d.js": "eb0a200adbc8a60e97d96a5d9cd76054df561bdf",
"/945.29b73cfac30295070168.js": "5cd3bbe72718f68a49f84f2ce4d98e637f80c57d",
"/954.2fa849ff06b4bc2543e7.js": "38c071c377d3a6d98902e679340d6a609996b717",
"/103.9c8251484169c949.js": "b111521f577092144d65506fa72c121543fd4446",
"/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d",
"/622.dd6c6ac77555edc7.js": "5406594e418982532e138bf3c12ccbb1cfb09942",
"/66.97582e026891bf70.js": "11cfd8acd3399fef42f0cf77d64aafc62c7e6994",
"/853.5697121b2e654d67.js": "beb26b99743f6363c59c477b18be790b1e70d56e",
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
@ -2498,6 +2500,8 @@
"/assets/outline/highlight.svg": "10c38de2ab624a0553f16c4a1b243844406f8e67",
"/assets/outline/history.js": "90d398576fce8a67c0b9d62469373b1e58de3eba",
"/assets/outline/history.svg": "7c95f0dcb033b0b8fc07a78f3dba9bc3b2dfba47",
"/assets/outline/holder.js": "8d91cdf7d106e626dc8ede61e2f5edcc4a87baa9",
"/assets/outline/holder.svg": "08173dd8f6fcdf84b0d0322db6ed19ab4ecbc570",
"/assets/outline/home.js": "9305d1873e04b856ae96d9a71e1c6c4f76b5a7c4",
"/assets/outline/home.svg": "3bf3a951726be554ee5bd28215b3aace7bbcd269",
"/assets/outline/hourglass.js": "1a06d6e7f637bebadb642e3189ef0a0c0f538c8d",
@ -3227,13 +3231,13 @@
"/assets/twotone/wallet.svg": "11e915efff832b47aa4bd5885af72e55014f59e6",
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.1cef351b0cc7ea051261.js": "8e62b9aa49dde6486f74c6c94b97054743f1cc1b",
"/index.html": "b4dc046b52af23f23dc312e7d55853cba3e57471",
"/main.1cfa15ba47a0ebad32e6.js": "f1fb8a45535eab8b91e27bc1f0e62c88e5613e92",
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
"/index.html": "0ed90c4296321165a7358a99ae67f911cc8b8a0f",
"/main.7a4da5a70e652c3f.js": "6dd73cbf2d5aee6a29559f6357b1075aadd6fb99",
"/manifest.webmanifest": "0c4534b4c868d756691b1b4372cecb2efce47c6d",
"/polyfills.a427f031f0f7196ffda1.js": "3e4560be48cd30e30bcbf51ca131a37d8c224fdc",
"/runtime.4f3f03ac4847b0cd3a3a.js": "fa9236c1156c10fff78efd3b11f731219ec4a9f4",
"/styles.2e25f4678bcb2c0682d5.css": "690a2053c128c1bfea1edbc5552f2427a73d4baa"
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
"/runtime.c48b962c8225f379.js": "ae674dc840fe5aa957d08253f56e21c4b772b93b",
"/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150"
},
"navigationUrls": [
{

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
(()=>{"use strict";var e,v={},m={};function r(e){var n=m[e];if(void 0!==n)return n.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(n,t,o,f)=>{if(!t){var a=1/0;for(i=0;i<e.length;i++){for(var[t,o,f]=e[i],d=!0,l=0;l<t.length;l++)(!1&f||a>=f)&&Object.keys(r.O).every(p=>r.O[p](t[l]))?t.splice(l--,1):(d=!1,f<a&&(a=f));if(d){e.splice(i--,1);var c=o();void 0!==c&&(n=c)}}return n}f=f||0;for(var i=e.length;i>0&&e[i-1][2]>f;i--)e[i]=e[i-1];e[i]=[t,o,f]},r.n=e=>{var n=e&&e.__esModule?()=>e.default:()=>e;return r.d(n,{a:n}),n},r.d=(e,n)=>{for(var t in n)r.o(n,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:n[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((n,t)=>(r.f[t](e,n),n),[])),r.u=e=>(592===e?"common":e)+"."+{124:"04d3ada75a9a6f4119c0",481:"2bc447cad2b7421383e8",592:"1cef351b0cc7ea051261",659:"1d4258dba20472847e0d",945:"29b73cfac30295070168",954:"2fa849ff06b4bc2543e7"}[e]+".js",r.miniCssF=e=>"styles.2e25f4678bcb2c0682d5.css",r.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),(()=>{var e={},n="blrec:";r.l=(t,o,f,i)=>{if(e[t])e[t].push(o);else{var a,d;if(void 0!==f)for(var l=document.getElementsByTagName("script"),c=0;c<l.length;c++){var u=l[c];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==n+f){a=u;break}}a||(d=!0,(a=document.createElement("script")).charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",n+f),a.src=r.tu(t)),e[t]=[o];var s=(g,p)=>{a.onerror=a.onload=null,clearTimeout(b);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(p)),g)return g(p)},b=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),d&&document.head.appendChild(a)}}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tu=n=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(n))})(),r.p="",(()=>{var e={666:0};r.f.j=(o,f)=>{var i=r.o(e,o)?e[o]:void 0;if(0!==i)if(i)f.push(i[2]);else if(666!=o){var a=new Promise((u,s)=>i=e[o]=[u,s]);f.push(i[2]=a);var d=r.p+r.u(o),l=new Error;r.l(d,u=>{if(r.o(e,o)&&(0!==(i=e[o])&&(e[o]=void 0),i)){var s=u&&("load"===u.type?"missing":u.type),b=u&&u.target&&u.target.src;l.message="Loading chunk "+o+" failed.\n("+s+": "+b+")",l.name="ChunkLoadError",l.type=s,l.request=b,i[1](l)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var n=(o,f)=>{var l,c,[i,a,d]=f,u=0;for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(d)var s=d(r);for(o&&o(f);u<i.length;u++)r.o(e,c=i[u])&&e[c]&&e[c][0](),e[i[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(n.bind(null,0)),t.push=n.bind(null,t.push.bind(t))})()})();

View File

@ -0,0 +1 @@
(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,o,f)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,o,f]=e[n],c=!0,l=0;l<t.length;l++)(!1&f||a>=f)&&Object.keys(r.O).every(b=>r.O[b](t[l]))?t.splice(l--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var d=o();void 0!==d&&(i=d)}}return i}f=f||0;for(var n=e.length;n>0&&e[n-1][2]>f;n--)e[n]=e[n-1];e[n]=[t,o,f]},r.n=e=>{var i=e&&e.__esModule?()=>e.default:()=>e;return r.d(i,{a:i}),i},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{66:"97582e026891bf70",103:"9c8251484169c949",146:"92e3b29c4c754544",592:"858f777e9296e6f2",622:"dd6c6ac77555edc7",853:"5697121b2e654d67"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="blrec:";r.l=(t,o,f,n)=>{if(e[t])e[t].push(o);else{var a,c;if(void 0!==f)for(var l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var u=l[d];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==i+f){a=u;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",i+f),a.src=r.tu(t)),e[t]=[o];var s=(g,b)=>{a.onerror=a.onload=null,clearTimeout(p);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(b)),g)return g(b)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),c&&document.head.appendChild(a)}}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tu=i=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(i))})(),r.p="",(()=>{var e={666:0};r.f.j=(o,f)=>{var n=r.o(e,o)?e[o]:void 0;if(0!==n)if(n)f.push(n[2]);else if(666!=o){var a=new Promise((u,s)=>n=e[o]=[u,s]);f.push(n[2]=a);var c=r.p+r.u(o),l=new Error;r.l(c,u=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=u&&("load"===u.type?"missing":u.type),p=u&&u.target&&u.target.src;l.message="Loading chunk "+o+" failed.\n("+s+": "+p+")",l.name="ChunkLoadError",l.type=s,l.request=p,n[1](l)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var l,d,[n,a,c]=f,u=0;if(n.some(p=>0!==e[p])){for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(c)var s=c(r)}for(o&&o(f);u<n.length;u++)r.o(e,d=n[u])&&e[d]&&e[d][0](),e[n[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -59,7 +59,13 @@ class FlvReader:
return self._stream.read(tag.body_size)
def _seek_to_previous_tag(self) -> int:
self._stream.seek(-BACK_POINTER_SIZE, SEEK_CUR)
try:
self._stream.seek(-BACK_POINTER_SIZE, SEEK_CUR)
except OSError as e:
if e.errno == 22 and e.strerror == 'Invalid argument':
raise EOFError()
else:
raise
previous_tag_size = self._parser.parse_previous_tag_size()
return self._stream.seek(-(4 + previous_tag_size), SEEK_CUR)

View File

@ -333,6 +333,7 @@ class StreamProcessor:
)
logger.debug('Meta tags have been transfered')
self._update_last_out_tags()
def _transfer_first_data_tag(self, tag: FlvTag) -> None:
logger.debug(f'Transfer the first data tag: {tag}')
@ -518,9 +519,13 @@ class StreamProcessor:
def _enrich_metadata(
self, old_metadata_tag: ScriptTag, offset: int
) -> ScriptTag:
# ensure the duration and filesize property exists in the metadata and
# init them.
self._metadata.update({'duration': 0.0, 'filesize': 0.0})
# ensure nesessary properties exists in the metadata and init them.
metadata = parse_metadata(old_metadata_tag)
self._metadata.update({
'duration': 0.0,
'filesize': 0.0,
'framerate': metadata.get('framerate', metadata.get('fps', 0.0)),
})
# merge the metadata into the metadata tag
return enrich_metadata(old_metadata_tag, self._metadata, offset)
@ -552,7 +557,14 @@ class StreamProcessor:
last_tag = self._last_tags[0]
duration = last_tag.timestamp / 1000
filesize = float(last_tag.next_tag_offset)
updates = dict(duration=duration, filesize=filesize)
updates = {
'duration': duration,
'filesize': filesize,
}
if self._analyse_data:
updates.update({
'framerate': self._data_analyser.calc_frame_rate(),
})
self._metadata_tag = update_metadata(self._metadata_tag, updates)
self._out_file.seek(self._metadata_tag.offset)
self._out_writer.write_tag(self._metadata_tag)

View File

@ -2,6 +2,7 @@ from .helpers import (
file_exists,
create_file,
danmaku_path,
cover_path,
raw_danmaku_path,
extra_metadata_path,
escape_path,
@ -12,6 +13,7 @@ __all__ = (
'file_exists',
'create_file',
'danmaku_path',
'cover_path',
'raw_danmaku_path',
'extra_metadata_path',
'escape_path',

View File

@ -7,6 +7,7 @@ __all__ = (
'file_exists',
'create_file',
'danmaku_path',
'cover_path',
'raw_danmaku_path',
'extra_metadata_path',
'escape_path',
@ -27,6 +28,10 @@ def danmaku_path(video_path: str) -> str:
return str(PurePath(video_path).with_suffix('.xml'))
def cover_path(video_path: str, ext: str = 'jpg') -> str:
return str(PurePath(video_path).with_suffix('.' + ext))
def raw_danmaku_path(video_path: str) -> str:
return str(PurePath(video_path).with_suffix('.jsonl'))

View File

@ -10,7 +10,7 @@ from rx.scheduler.threadpoolscheduler import ThreadPoolScheduler
from .models import PostprocessorStatus, DeleteStrategy
from .typing import Progress
from .remuxer import remux_video, RemuxProgress, RemuxResult
from .helpers import discard_file, discard_files, get_extra_metadata
from .helpers import discard_file, get_extra_metadata
from .ffmpeg_metadata import make_metadata_file
from ..event.event_emitter import EventListener, EventEmitter
from ..bili.live import Live
@ -51,6 +51,7 @@ class Postprocessor(
recorder: Recorder,
*,
remux_to_mp4: bool = False,
inject_extra_metadata: bool = False,
delete_source: DeleteStrategy = DeleteStrategy.AUTO,
) -> None:
super().__init__()
@ -59,6 +60,7 @@ class Postprocessor(
self._recorder = recorder
self.remux_to_mp4 = remux_to_mp4
self.inject_extra_metadata = inject_extra_metadata
self.delete_source = delete_source
self._status = PostprocessorStatus.WAITING
@ -103,9 +105,7 @@ class Postprocessor(
async def _do_stop(self) -> None:
self._recorder.remove_listener(self)
if self._status != PostprocessorStatus.WAITING:
await self._queue.join()
await self._queue.join()
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
@ -133,9 +133,13 @@ class Postprocessor(
if self.remux_to_mp4:
self._status = PostprocessorStatus.REMUXING
result_path = await self._remux_flv_to_mp4(video_path)
else:
elif self.inject_extra_metadata:
self._status = PostprocessorStatus.INJECTING
result_path = await self._inject_extra_metadata(video_path)
else:
result_path = video_path
await discard_file(extra_metadata_path(video_path), 'DEBUG')
self._completed_files.append(result_path)
await self._emit(
@ -149,7 +153,6 @@ class Postprocessor(
async def _inject_extra_metadata(self, path: str) -> str:
metadata = await get_extra_metadata(path)
await self._inject_metadata(path, metadata, self._scheduler)
await discard_file(extra_metadata_path(path), 'DEBUG')
return path
async def _remux_flv_to_mp4(self, in_path: str) -> str:
@ -175,11 +178,9 @@ class Postprocessor(
logger.debug(f'ffmpeg output:\n{remux_result.output}')
await discard_file(metadata_path, 'DEBUG')
if self._should_delete_source_files(remux_result):
await discard_file(in_path)
await discard_files(
[metadata_path, extra_metadata_path(in_path)], 'DEBUG'
)
return result_path

View File

@ -149,9 +149,11 @@ class DanmakuSettings(DanmakuOptions):
class RecorderOptions(BaseModel):
quality_number: Optional[QualityNumber]
read_timeout: Optional[int] # seconds
disconnection_timeout: Optional[int] # seconds
buffer_size: Annotated[ # bytes
Optional[int], Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2)
]
save_cover: Optional[bool]
@validator('read_timeout')
def _validate_read_timeout(cls, value: Optional[int]) -> Optional[int]:
@ -160,22 +162,35 @@ class RecorderOptions(BaseModel):
cls._validate_with_collection(value, allowed_values)
return value
@validator('disconnection_timeout')
def _validate_disconnection_timeout(
cls, value: Optional[int]
) -> Optional[int]:
if value is not None:
allowed_values = frozenset(60 * i for i in (3, 5, 10, 15, 20, 30))
cls._validate_with_collection(value, allowed_values)
return value
class RecorderSettings(RecorderOptions):
quality_number: QualityNumber = 20000 # 4K, the highest quality.
read_timeout: int = 3
disconnection_timeout: int = 600
buffer_size: Annotated[
int, Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2)
] = 8192
save_cover: bool = False
class PostprocessingOptions(BaseModel):
remux_to_mp4: Optional[bool]
inject_extra_metadata: Optional[bool]
delete_source: Optional[DeleteStrategy]
class PostprocessingSettings(PostprocessingOptions):
remux_to_mp4: bool = False
inject_extra_metadata: bool = True
delete_source: DeleteStrategy = DeleteStrategy.AUTO

View File

@ -53,9 +53,12 @@ class TaskParam:
# RecorderSettings
quality_number: QualityNumber
read_timeout: int
disconnection_timeout: Optional[int]
buffer_size: int
save_cover: bool
# PostprocessingOptions
remux_to_mp4: bool
inject_extra_metadata: bool
delete_source: DeleteStrategy

View File

@ -46,12 +46,15 @@ class RecordTask:
record_gift_send: bool = False,
record_guard_buy: bool = False,
record_super_chat: bool = False,
save_cover: bool = False,
save_raw_danmaku: bool = False,
buffer_size: Optional[int] = None,
read_timeout: Optional[int] = None,
disconnection_timeout: Optional[int] = None,
filesize_limit: int = 0,
duration_limit: int = 0,
remux_to_mp4: bool = False,
inject_extra_metadata: bool = True,
delete_source: DeleteStrategy = DeleteStrategy.AUTO,
) -> None:
super().__init__()
@ -67,12 +70,15 @@ class RecordTask:
self._record_gift_send = record_gift_send
self._record_guard_buy = record_guard_buy
self._record_super_chat = record_super_chat
self._save_cover = save_cover
self._save_raw_danmaku = save_raw_danmaku
self._buffer_size = buffer_size
self._read_timeout = read_timeout
self._disconnection_timeout = disconnection_timeout
self._filesize_limit = filesize_limit
self._duration_limit = duration_limit
self._remux_to_mp4 = remux_to_mp4
self._inject_extra_metadata = inject_extra_metadata
self._delete_source = delete_source
self._ready = False
@ -249,6 +255,14 @@ class RecordTask:
def record_super_chat(self, value: bool) -> None:
self._recorder.record_super_chat = value
@property
def save_cover(self) -> bool:
return self._recorder.save_cover
@save_cover.setter
def save_cover(self, value: bool) -> None:
self._recorder.save_cover = value
@property
def save_raw_danmaku(self) -> bool:
return self._recorder.save_raw_danmaku
@ -285,6 +299,14 @@ class RecordTask:
def read_timeout(self, value: int) -> None:
self._recorder.read_timeout = value
@property
def disconnection_timeout(self) -> int:
return self._recorder.disconnection_timeout
@disconnection_timeout.setter
def disconnection_timeout(self, value: int) -> None:
self._recorder.disconnection_timeout = value
@property
def out_dir(self) -> str:
return self._recorder.out_dir
@ -325,6 +347,14 @@ class RecordTask:
def remux_to_mp4(self, value: bool) -> None:
self._postprocessor.remux_to_mp4 = value
@property
def inject_extra_metadata(self) -> bool:
return self._postprocessor.inject_extra_metadata
@inject_extra_metadata.setter
def inject_extra_metadata(self, value: bool) -> None:
self._postprocessor.inject_extra_metadata = value
@property
def delete_source(self) -> DeleteStrategy:
return self._postprocessor.delete_source
@ -435,10 +465,12 @@ class RecordTask:
self._path_template,
buffer_size=self._buffer_size,
read_timeout=self._read_timeout,
disconnection_timeout=self._disconnection_timeout,
danmu_uname=self._danmu_uname,
record_gift_send=self._record_gift_send,
record_guard_buy=self._record_guard_buy,
record_super_chat=self._record_super_chat,
save_cover=self._save_cover,
save_raw_danmaku=self._save_raw_danmaku,
filesize_limit=self._filesize_limit,
duration_limit=self._duration_limit,
@ -453,6 +485,7 @@ class RecordTask:
self._live,
self._recorder,
remux_to_mp4=self._remux_to_mp4,
inject_extra_metadata=self._inject_extra_metadata,
delete_source=self._delete_source,
)

View File

@ -231,13 +231,16 @@ class RecordTaskManager:
task = self._get_task(room_id)
task.quality_number = settings.quality_number
task.read_timeout = settings.read_timeout
task.disconnection_timeout = settings.disconnection_timeout
task.buffer_size = settings.buffer_size
task.save_cover = settings.save_cover
def apply_task_postprocessing_settings(
self, room_id: int, settings: PostprocessingSettings
) -> None:
task = self._get_task(room_id)
task.remux_to_mp4 = settings.remux_to_mp4
task.inject_extra_metadata = settings.inject_extra_metadata
task.delete_source = settings.delete_source
def _get_task(self, room_id: int) -> RecordTask:
@ -258,11 +261,14 @@ class RecordTaskManager:
record_gift_send=task.record_gift_send,
record_guard_buy=task.record_guard_buy,
record_super_chat=task.record_super_chat,
save_cover=task.save_cover,
save_raw_danmaku=task.save_raw_danmaku,
quality_number=task.quality_number,
read_timeout=task.read_timeout,
disconnection_timeout=task.disconnection_timeout,
buffer_size=task.buffer_size,
remux_to_mp4=task.remux_to_mp4,
inject_extra_metadata=task.inject_extra_metadata,
delete_source=task.delete_source,
)

3
webapp/.gitignore vendored
View File

@ -43,3 +43,6 @@ testem.log
# System Files
.DS_Store
Thumbs.db
# angular
.angular/

21355
webapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,33 +10,33 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^12.2.0",
"@angular/cdk": "~12.2.0",
"@angular/common": "~12.2.0",
"@angular/compiler": "^12.2.0",
"@angular/core": "^12.2.0",
"@angular/forms": "~12.2.0",
"@angular/platform-browser": "~12.2.0",
"@angular/platform-browser-dynamic": "^12.2.0",
"@angular/router": "^12.2.0",
"@angular/service-worker": "~12.2.0",
"@angular/animations": "^13.1.3",
"@angular/cdk": "~13.1.3",
"@angular/common": "~13.1.3",
"@angular/compiler": "^13.1.3",
"@angular/core": "^13.1.3",
"@angular/forms": "~13.1.3",
"@angular/platform-browser": "~13.1.3",
"@angular/platform-browser-dynamic": "^13.1.3",
"@angular/router": "^13.1.3",
"@angular/service-worker": "~13.1.3",
"filesize": "^6.4.0",
"lodash-es": "^4.17.21",
"ng-zorro-antd": "^12.0.1",
"ng-zorro-antd": "^13.0.1",
"ngx-logger": "^4.2.2",
"rxjs": "~6.6.0",
"tslib": "^2.3.0",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^12.2.0",
"@angular-eslint/builder": "12.0.0",
"@angular-eslint/eslint-plugin": "12.0.0",
"@angular-eslint/eslint-plugin-template": "12.0.0",
"@angular-eslint/schematics": "12.0.0",
"@angular-eslint/template-parser": "12.0.0",
"@angular/cli": "^12.2.0",
"@angular/compiler-cli": "^12.2.0",
"@angular-devkit/build-angular": "^13.1.4",
"@angular-eslint/builder": "13.0.1",
"@angular-eslint/eslint-plugin": "13.0.1",
"@angular-eslint/eslint-plugin-template": "13.0.1",
"@angular-eslint/schematics": "13.0.1",
"@angular-eslint/template-parser": "13.0.1",
"@angular/cli": "^13.1.4",
"@angular/compiler-cli": "^13.1.3",
"@types/jasmine": "~3.6.0",
"@types/lodash-es": "^4.17.4",
"@types/node": "^12.20.19",
@ -50,6 +50,6 @@
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.7.0",
"source-map-explorer": "^2.5.2",
"typescript": "~4.2.3"
"typescript": "~4.5.4"
}
}
}

View File

@ -1,10 +1,30 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="添加关键帧等元数据使定位播放和拖进度条不会卡顿"
>flv 添加元数据</nz-form-label
>
<nz-form-control
class="setting-control switch"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.injectExtraMetadata ? injectExtraMetadataControl : 'warning'
"
>
<nz-switch
formControlName="injectExtraMetadata"
[nzDisabled]="remuxToMp4Control.value"
></nz-switch>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="调用 ffmpeg 进行转换,需要安装 ffmpeg 。"
>FLV 转 MP4</nz-form-label
>flv 转封装为 mp4</nz-form-label
>
<nz-form-control
class="setting-control switch"

View File

@ -39,11 +39,16 @@ export class PostProcessingSettingsComponent implements OnInit, OnChanges {
private settingsSyncService: SettingsSyncService
) {
this.settingsForm = formBuilder.group({
injectExtraMetadata: [''],
remuxToMp4: [''],
deleteSource: [''],
});
}
get injectExtraMetadataControl() {
return this.settingsForm.get('injectExtraMetadata') as FormControl;
}
get remuxToMp4Control() {
return this.settingsForm.get('remuxToMp4') as FormControl;
}

View File

@ -17,6 +17,21 @@
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="录播文件完成时保存当前直播间的封面"
>保存封面</nz-form-label
>
<nz-form-control
class="setting-control switch"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="syncStatus.saveCover ? saveCoverControl : 'warning'"
>
<nz-switch formControlName="saveCover"></nz-switch>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
@ -39,6 +54,29 @@
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="断网超过等待时间就结束录制,如果网络恢复后仍未下播会自动重新开始录制。"
>断网等待时间</nz-form-label
>
<nz-form-control
class="setting-control select"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.disconnectionTimeout
? disconnectionTimeoutControl
: 'warning'
"
>
<nz-select
formControlName="disconnectionTimeout"
[nzOptions]="disconnectionTimeoutOptions"
>
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"

View File

@ -16,8 +16,9 @@ import type { Mutable } from '../../shared/utility-types';
import {
BUFFER_OPTIONS,
QUALITY_OPTIONS,
SYNC_FAILED_WARNING_TIP,
TIMEOUT_OPTIONS,
DISCONNECTION_TIMEOUT_OPTIONS,
SYNC_FAILED_WARNING_TIP,
} from '../shared/constants/form';
import { RecorderSettings } from '../shared/setting.model';
import {
@ -44,6 +45,9 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
readonly timeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
typeof TIMEOUT_OPTIONS
>;
readonly disconnectionTimeoutOptions = cloneDeep(
DISCONNECTION_TIMEOUT_OPTIONS
) as Mutable<typeof DISCONNECTION_TIMEOUT_OPTIONS>;
readonly bufferOptions = cloneDeep(BUFFER_OPTIONS) as Mutable<
typeof BUFFER_OPTIONS
>;
@ -56,7 +60,9 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
this.settingsForm = formBuilder.group({
qualityNumber: [''],
readTimeout: [''],
disconnectionTimeout: [''],
bufferSize: [''],
saveCover: [''],
});
}
@ -68,10 +74,18 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
return this.settingsForm.get('readTimeout') as FormControl;
}
get disconnectionTimeoutControl() {
return this.settingsForm.get('disconnectionTimeout') as FormControl;
}
get bufferSizeControl() {
return this.settingsForm.get('bufferSize') as FormControl;
}
get saveCoverControl() {
return this.settingsForm.get('saveCover') as FormControl;
}
ngOnChanges(): void {
this.syncStatus = mapValues(this.settings, () => true);
this.settingsForm.setValue(this.settings);

View File

@ -72,6 +72,15 @@ export const TIMEOUT_OPTIONS = [
{ label: '10 分钟', value: 600 },
] as const;
export const DISCONNECTION_TIMEOUT_OPTIONS = [
{ label: '3 分钟', value: 3 * 60 },
{ label: '5 分钟', value: 5 * 60 },
{ label: '10 分钟', value: 10 * 60 },
{ label: '15 分钟', value: 15 * 60 },
{ label: '20 分钟', value: 20 * 60 },
{ label: '30 分钟', value: 30 * 60 },
] as const;
export const BUFFER_OPTIONS = [
{ label: '4 KB', value: 1024 * 4 },
{ label: '8 KB', value: 1024 * 8 },

View File

@ -29,7 +29,9 @@ export type QualityNumber =
export interface RecorderSettings {
qualityNumber: QualityNumber;
readTimeout: number;
disconnectionTimeout: number;
bufferSize: number;
saveCover: boolean;
}
export type RecorderOptions = Nullable<RecorderSettings>;
@ -40,6 +42,7 @@ export enum DeleteStrategy {
}
export interface PostprocessingSettings {
injectExtraMetadata: boolean;
remuxToMp4: boolean;
deleteSource: DeleteStrategy;
}

View File

@ -56,7 +56,7 @@
<p
class="status-bar injecting"
nz-tooltip
nzTooltipTitle="正在更新 FLV 元数据:{{
nzTooltipTitle="正在添加元数据:{{
status.postprocessing_path ?? '' | filename
}}"
nzTooltipPlacement="top"
@ -80,7 +80,7 @@
<p
class="status-bar remuxing"
nz-tooltip
nzTooltipTitle="正在转换 FLV 为 MP4{{
nzTooltipTitle="正在转封装{{
status.postprocessing_path ?? '' | filename
}}"
nzTooltipPlacement="top"

View File

@ -137,6 +137,31 @@
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="录播文件完成时保存当前直播间的封面"
>保存封面</nz-form-label
>
<nz-form-control class="setting-control switch">
<nz-switch
name="saveCover"
[(ngModel)]="model.recorder.saveCover"
[disabled]="options.recorder.saveCover === null"
></nz-switch>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.recorder.saveCover !== null"
(nzCheckedChange)="
options.recorder.saveCover = $event
? globalSettings.recorder.saveCover
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
@ -169,6 +194,34 @@
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="断网超过等待时间就结束录制,如果网络恢复后仍未下播会自动重新开始录制。"
>断网等待时间</nz-form-label
>
<nz-form-control class="setting-control select">
<nz-select
name="disconnectionTimeout"
[(ngModel)]="model.recorder.disconnectionTimeout"
[disabled]="options.recorder.disconnectionTimeout === null"
[nzOptions]="disconnectionTimeoutOptions"
[nzOptionOverflowSize]="6"
>
</nz-select>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.recorder.bufferSize !== null"
(nzCheckedChange)="
options.recorder.bufferSize = $event
? globalSettings.recorder.bufferSize
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
@ -344,8 +397,36 @@
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="需要安装 FFmpeg"
>FLV 转 MP4</nz-form-label
nzTooltipTitle="添加关键帧等元数据使定位播放和拖进度条不会卡顿"
>flv 添加元数据</nz-form-label
>
<nz-form-control class="setting-control switch">
<nz-switch
name="injectExtraMetadata"
[(ngModel)]="model.postprocessing.injectExtraMetadata"
[disabled]="
options.postprocessing.injectExtraMetadata === null ||
!!options.postprocessing.remuxToMp4
"
></nz-switch>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.postprocessing.injectExtraMetadata !== null"
(nzCheckedChange)="
options.postprocessing.injectExtraMetadata = $event
? globalSettings.postprocessing.injectExtraMetadata
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="调用 ffmpeg 进行转换,需要安装 ffmpeg 。"
>flv 转封装为 mp4</nz-form-label
>
<nz-form-control class="setting-control switch">
<nz-switch
@ -382,7 +463,10 @@
<nz-select
name="deleteSource"
[(ngModel)]="model.postprocessing.deleteSource"
[disabled]="options.postprocessing.deleteSource === null"
[disabled]="
options.postprocessing.deleteSource === null ||
!options.postprocessing.remuxToMp4
"
[nzOptions]="deleteStrategies"
></nz-select>
</nz-form-control>

View File

@ -25,6 +25,7 @@ import {
DURATION_LIMIT_OPTIONS,
QUALITY_OPTIONS,
TIMEOUT_OPTIONS,
DISCONNECTION_TIMEOUT_OPTIONS,
BUFFER_OPTIONS,
DELETE_STRATEGIES,
SPLIT_FILE_TIP,
@ -68,6 +69,9 @@ export class TaskSettingsDialogComponent implements OnChanges {
readonly timeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
typeof TIMEOUT_OPTIONS
>;
readonly disconnectionTimeoutOptions = cloneDeep(
DISCONNECTION_TIMEOUT_OPTIONS
) as Mutable<typeof DISCONNECTION_TIMEOUT_OPTIONS>;
readonly bufferOptions = cloneDeep(BUFFER_OPTIONS) as Mutable<
typeof BUFFER_OPTIONS
>;

View File

@ -1,6 +0,0 @@
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp38-cp38-win_amd64.whl ; platform_system == 'Windows' and '64' in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.8.0'
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp39-cp39-win_amd64.whl ; platform_system == 'Windows' and '64' in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.9.0'
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp310-cp310-win_amd64.whl ; platform_system == 'Windows' and '64' in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.10.0'
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp38-cp38-win32.whl ; platform_system == 'Windows' and '64' not in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.8.0'
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp39-cp39-win32.whl ; platform_system == 'Windows' and '64' not in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.9.0'
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp310-cp310-win32.whl ; platform_system == 'Windows' and '64' not in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.10.0'