release: v1.2.0

This commit is contained in:
acgnhiki 2021-11-06 10:07:49 +08:00
parent 672c3f094e
commit 3e71c39e0d
88 changed files with 5116 additions and 5835 deletions

View File

@ -1,5 +1,12 @@
# 更新日志
## 1.2.0
- 改进文件处理方式,文件录制完成后就进行处理。
- 支持手动分割文件(单击任务卡片左下角剪刀图标)
- 添加任务详情页面(单击任务卡片的封面图进入)
- 修复 FastApi 文档页面访问不了
## 1.1.0
- 支持记录送物、上舰、醒目留言到弹幕文件

View File

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

View File

@ -11,9 +11,10 @@ from .disk_space import SpaceMonitor, SpaceReclaimer
from .bili.helpers import ensure_room_id
from .task import (
RecordTaskManager,
TaskParam,
TaskData,
FileDetail,
TaskParam,
VideoFileDetail,
DanmakuFileDetail,
)
from .exception import ExistsError, ExceptionHandler
from .event.event_submitters import SpaceEventSubmitter
@ -189,8 +190,21 @@ class Application:
def get_task_param(self, room_id: int) -> TaskParam:
return self._task_manager.get_task_param(room_id)
def get_task_file_details(self, room_id: int) -> Iterator[FileDetail]:
yield from self._task_manager.get_task_file_details(room_id)
def get_task_video_file_details(
self, room_id: int
) -> Iterator[VideoFileDetail]:
yield from self._task_manager.get_task_video_file_details(room_id)
def get_task_danmaku_file_details(
self, room_id: int
) -> Iterator[DanmakuFileDetail]:
yield from self._task_manager.get_task_danmaku_file_details(room_id)
def can_cut_stream(self, room_id: int) -> bool:
return self._task_manager.can_cut_stream(room_id)
def cut_stream(self, room_id: int) -> bool:
return self._task_manager.cut_stream(room_id)
async def update_task_info(self, room_id: int) -> None:
await self._task_manager.update_task_info(room_id)

View File

@ -52,14 +52,15 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
def __init__(
self,
session: ClientSession,
api: WebApi,
room_id: int,
*,
max_retries: int = 10,
) -> None:
super().__init__()
self.session = session
self.api = api
self._room_id = room_id
self._api = WebApi(session)
self._host_index: int = 0
self._retry_delay: int = 0
@ -150,7 +151,7 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
raise ValueError(f'Unexpected code: {code}')
async def _update_danmu_info(self) -> None:
self._danmu_info = await self._api.get_danmu_info(self._room_id)
self._danmu_info = await self.api.get_danmu_info(self._room_id)
logger.debug('Danmu info updated')
async def _disconnect(self) -> None:

View File

@ -71,6 +71,10 @@ class Live:
def session(self) -> aiohttp.ClientSession:
return self._session
@property
def api(self) -> WebApi:
return self._api
@property
def room_id(self) -> int:
return self._room_id

View File

@ -1,8 +1,11 @@
import re
from enum import IntEnum
from datetime import datetime
from typing import cast
import attr
from lxml import html
from lxml.html.clean import clean_html
from .typing import ResponseData
@ -46,6 +49,11 @@ class RoomInfo:
else:
raise ValueError(f'Failed to init live_start_time: {data}')
if (description := data['description']):
description = re.sub(r'<br\s*/?>', '\n', description)
tree = html.fromstring(description)
description = clean_html(tree).text_content().strip()
return RoomInfo(
uid=data['uid'],
room_id=int(data['room_id']),
@ -60,7 +68,7 @@ class RoomInfo:
title=data['title'],
cover=data.get('cover', None) or data.get('user_cover', None),
tags=data['tags'],
description=data['description'],
description=description,
)

View File

@ -2,7 +2,7 @@ import html
import asyncio
import logging
from contextlib import suppress
from typing import Iterator, List
from typing import Iterator, List, Optional
from blrec.core.models import GiftSendMsg, GuardBuyMsg, SuperChatMsg
@ -13,6 +13,7 @@ from .stream_recorder import StreamRecorder, StreamRecorderEventListener
from .statistics import StatisticsCalculator
from ..bili.live import Live
from ..exception import exception_callback
from ..event.event_emitter import EventListener, EventEmitter
from ..path import danmaku_path
from ..danmaku.models import (
Metadata, Danmu, GiftSendRecord, GuardBuyRecord, SuperChatRecord
@ -22,13 +23,25 @@ from ..utils.mixins import SwitchableMixin
from ..logging.room_id import aio_task_with_room_id
__all__ = 'DanmakuDumper',
__all__ = 'DanmakuDumper', 'DanmakuDumperEventListener'
logger = logging.getLogger(__name__)
class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
class DanmakuDumperEventListener(EventListener):
async def on_danmaku_file_created(self, path: str) -> None:
...
async def on_danmaku_file_completed(self, path: str) -> None:
...
class DanmakuDumper(
EventEmitter[DanmakuDumperEventListener],
StreamRecorderEventListener,
SwitchableMixin,
):
def __init__(
self,
live: Live,
@ -51,6 +64,7 @@ class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
self.record_guard_buy = record_guard_buy
self.record_super_chat = record_super_chat
self._path: Optional[str] = None
self._files: List[str] = []
self._calculator = StatisticsCalculator(interval=60)
@ -66,6 +80,10 @@ class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
def elapsed(self) -> float:
return self._calculator.elapsed
@property
def dumping_path(self) -> Optional[str]:
return self._path
def _do_enable(self) -> None:
self._stream_recorder.add_listener(self)
logger.debug('Enabled danmaku dumper')
@ -97,6 +115,7 @@ class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
async def on_video_file_completed(self, video_path: str) -> None:
await self._stop_dumping()
self._path = None
def _start_dumping(self) -> None:
self._create_dump_task()
@ -115,12 +134,14 @@ class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
@aio_task_with_room_id
async def _dump(self) -> None:
assert self._path is not None
logger.debug('Started dumping danmaku')
self._calculator.reset()
try:
async with DanmakuWriter(self._path) as writer:
logger.info(f"Danmaku file created: '{self._path}'")
await self._emit('danmaku_file_created', self._path)
await writer.write_metadata(self._make_metadata())
while True:
@ -150,6 +171,7 @@ class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
logger.warning('Unsupported message type:', repr(msg))
finally:
logger.info(f"Danmaku file completed: '{self._path}'")
await self._emit('danmaku_file_completed', self._path)
logger.debug('Stopped dumping danmaku')
self._calculator.freeze()

View File

@ -46,9 +46,6 @@ class DanmakuReceiver(DanmakuListener, StoppableMixin):
msg = DanmuMsg.from_danmu(danmu)
elif cmd == DanmakuCommand.SEND_GIFT.value:
msg = GiftSendMsg.from_danmu(danmu)
elif cmd == DanmakuCommand.SPECIAL_GIFT.value: # TODO
logger.warning('SPECIAL_GIFT has unsupported yet:', repr(danmu))
return
elif cmd == DanmakuCommand.GUARD_BUY.value:
msg = GuardBuyMsg.from_danmu(danmu)
elif cmd == DanmakuCommand.SUPER_CHAT_MESSAGE.value:

View File

@ -65,7 +65,7 @@ class GiftSendMsg:
@attr.s(auto_attribs=True, frozen=True, slots=True)
class GuardBuyMsg:
gift_name: str # TODO verify
gift_name: str
count: int
price: int
uid: int

View File

@ -1,6 +1,4 @@
from __future__ import annotations
import re
import html
import logging
from datetime import datetime
from typing import Iterator, Optional
@ -8,10 +6,10 @@ from typing import Iterator, Optional
import humanize
from .danmaku_receiver import DanmakuReceiver
from .danmaku_dumper import DanmakuDumper
from .danmaku_dumper import DanmakuDumper, DanmakuDumperEventListener
from .raw_danmaku_receiver import RawDanmakuReceiver
from .raw_danmaku_dumper import RawDanmakuDumper
from .stream_recorder import StreamRecorder
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
from ..event.event_emitter import EventListener, EventEmitter
from ..bili.live import Live
from ..bili.models import RoomInfo
@ -37,9 +35,27 @@ class RecorderEventListener(EventListener):
async def on_recording_cancelled(self, recorder: Recorder) -> None:
...
async def on_video_file_created(
self, path: str, record_start_time: int
) -> None:
...
async def on_video_file_completed(self, path: str) -> None:
...
async def on_danmaku_file_created(self, path: str) -> None:
...
async def on_danmaku_file_completed(self, path: str) -> None:
...
class Recorder(
EventEmitter[RecorderEventListener], LiveEventListener, AsyncStoppableMixin
EventEmitter[RecorderEventListener],
LiveEventListener,
AsyncStoppableMixin,
DanmakuDumperEventListener,
StreamRecorderEventListener,
):
def __init__(
self,
@ -212,6 +228,8 @@ class Recorder(
async def _do_start(self) -> None:
self._live_monitor.add_listener(self)
self._danmaku_dumper.add_listener(self)
self._stream_recorder.add_listener(self)
logger.debug('Started recorder')
self._print_live_info()
@ -223,14 +241,28 @@ class Recorder(
async def _do_stop(self) -> None:
await self._stop_recording()
self._live_monitor.remove_listener(self)
self._danmaku_dumper.remove_listener(self)
self._stream_recorder.remove_listener(self)
logger.debug('Stopped recorder')
def get_recording_files(self) -> Iterator[str]:
if self._stream_recorder.recording_path is not None:
yield self._stream_recorder.recording_path
if self._danmaku_dumper.dumping_path is not None:
yield self._danmaku_dumper.dumping_path
def get_video_files(self) -> Iterator[str]:
yield from self._stream_recorder.get_files()
def get_danmaku_files(self) -> Iterator[str]:
yield from self._danmaku_dumper.get_files()
def can_cut_stream(self) -> bool:
return self._stream_recorder.can_cut_stream()
def cut_stream(self) -> bool:
return self._stream_recorder.cut_stream()
async def on_live_began(self, live: Live) -> None:
logger.info('The live has began')
self._print_live_info()
@ -252,6 +284,20 @@ class Recorder(
self._print_changed_room_info(room_info)
self._stream_recorder.update_progress_bar_info()
async def on_video_file_created(
self, path: str, record_start_time: int
) -> None:
await self._emit('video_file_created', path, record_start_time)
async def on_video_file_completed(self, path: str) -> None:
await self._emit('video_file_completed', path)
async def on_danmaku_file_created(self, path: str) -> None:
await self._emit('danmaku_file_created', path)
async def on_danmaku_file_completed(self, path: str) -> None:
await self._emit('danmaku_file_completed', path)
async def _start_recording(self, stream_available: bool = False) -> None:
if self._recording:
return
@ -309,12 +355,6 @@ class Recorder(
else:
live_start_time = 'NULL'
desc = re.sub(
r'</?[a-zA-Z][a-zA-Z\d]*[^<>]*>',
'',
re.sub(r'<br\s*/?>', '\n', html.unescape(room_info.description))
).strip()
msg = f"""
================================== User Info ==================================
user name : {user_info.name}
@ -336,7 +376,7 @@ parent area id : {room_info.parent_area_id}
parent area name : {room_info.parent_area_name}
tags : {room_info.tags}
description :
{desc}
{room_info.description}
===============================================================================
"""
logger.info(msg)

View File

@ -175,6 +175,10 @@ class StreamRecorder(
if self._stream_processor is not None:
self._stream_processor.duration_limit = value
@property
def recording_path(self) -> Optional[str]:
return self._file_manager.curr_path
def has_file(self) -> bool:
return self._file_manager.has_file()
@ -184,6 +188,16 @@ class StreamRecorder(
def clear_files(self) -> None:
self._file_manager.clear_files()
def can_cut_stream(self) -> bool:
if self._stream_processor is None:
return False
return self._stream_processor.can_cut_stream()
def cut_stream(self) -> bool:
if self._stream_processor is None:
return False
return self._stream_processor.cut_stream()
def update_progress_bar_info(self) -> None:
if self._progress_bar is not None:
self._progress_bar.set_postfix_str(self._make_pbar_postfix())

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

@ -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.45ceee0fc92e0f47588f.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles.45ceee0fc92e0f47588f.css"></noscript></head>
<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>
<body>
<app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
<script src="runtime.984b6246826f9f569f66.js" defer></script><script src="polyfills.a427f031f0f7196ffda1.js" defer></script><script src="main.59cb7d8427d901ad6225.js" defer></script>
<script src="runtime.4f3f03ac4847b0cd3a3a.js" defer></script><script src="polyfills.a427f031f0f7196ffda1.js" defer></script><script src="main.1cfa15ba47a0ebad32e6.js" defer></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

View File

@ -1735,7 +1735,7 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
const SW_VERSION = '12.2.0';
const SW_VERSION = '12.2.12';
const DEBUG_LOG_BUFFER_SIZE = 100;
class DebugHandler {
constructor(driver, adapter) {
@ -2218,7 +2218,7 @@ ${msgIdle}`, { headers: this.adapter.newHeaders({ 'Content-Type': 'text/plain' }
});
}
handleClick(notification, action) {
var _a, _b;
var _a, _b, _c;
return __awaiter(this, void 0, void 0, function* () {
notification.close();
const options = {};
@ -2227,8 +2227,8 @@ ${msgIdle}`, { headers: this.adapter.newHeaders({ 'Content-Type': 'text/plain' }
NOTIFICATION_OPTION_NAMES.filter(name => name in notification)
.forEach(name => options[name] = notification[name]);
const notificationAction = action === '' || action === undefined ? 'default' : action;
const onActionClick = (_a = notification === null || notification === void 0 ? void 0 : notification.data) === null || _a === void 0 ? void 0 : _a.onActionClick[notificationAction];
const urlToOpen = new URL((_b = onActionClick === null || onActionClick === void 0 ? void 0 : onActionClick.url) !== null && _b !== void 0 ? _b : '', this.scope.registration.scope).href;
const onActionClick = (_b = (_a = notification === null || notification === void 0 ? void 0 : notification.data) === null || _a === void 0 ? void 0 : _a.onActionClick) === null || _b === void 0 ? void 0 : _b[notificationAction];
const urlToOpen = new URL((_c = onActionClick === null || onActionClick === void 0 ? void 0 : onActionClick.url) !== null && _c !== void 0 ? _c : '', this.scope.registration.scope).href;
switch (onActionClick === null || onActionClick === void 0 ? void 0 : onActionClick.operation) {
case 'openWindow':
yield this.scope.clients.openWindow(urlToOpen);
@ -2345,7 +2345,8 @@ ${msgIdle}`, { headers: this.adapter.newHeaders({ 'Content-Type': 'text/plain' }
yield this.notifyClientsAboutUnrecoverableState(appVersion, err.message);
}
if (err.isCritical) {
// Something went wrong with the activation of this version.
// Something went wrong with handling the request from this version.
this.debugger.log(err, `Driver.handleFetch(version: ${appVersion.manifestHash})`);
yield this.versionFailed(appVersion, err);
return this.safeFetch(event.request);
}
@ -2628,34 +2629,21 @@ ${msgIdle}`, { headers: this.adapter.newHeaders({ 'Content-Type': 'text/plain' }
return;
}
const brokenHash = broken[0];
const affectedClients = Array.from(this.clientVersionMap.entries())
.filter(([clientId, hash]) => hash === brokenHash)
.map(([clientId]) => clientId);
// The specified version is broken and new clients should not be served from it. However, it is
// deemed even riskier to switch the existing clients to a different version or to the network.
// Therefore, we keep clients on their current version (even if broken) and ensure that no new
// clients will be assigned to it.
// TODO: notify affected apps.
// The action taken depends on whether the broken manifest is the active (latest) or not.
// If so, the SW cannot accept new clients, but can continue to service old ones.
// - If the broken version is not the latest, no further action is necessary, since new clients
// will be assigned to the latest version anyway.
// - If the broken version is the latest, the SW cannot accept new clients (but can continue to
// service old ones).
if (this.latestHash === brokenHash) {
// The latest manifest is broken. This means that new clients are at the mercy of the
// network, but caches continue to be valid for previous versions. This is
// unfortunate but unavoidable.
// The latest manifest is broken. This means that new clients are at the mercy of the network,
// but caches continue to be valid for previous versions. This is unfortunate but unavoidable.
this.state = DriverReadyState.EXISTING_CLIENTS_ONLY;
this.stateMessage = `Degraded due to: ${errorToString(err)}`;
// Cancel the binding for the affected clients.
affectedClients.forEach(clientId => this.clientVersionMap.delete(clientId));
}
else {
// The latest version is viable, but this older version isn't. The only
// possible remedy is to stop serving the older version and go to the network.
// Put the affected clients on the latest version.
affectedClients.forEach(clientId => this.clientVersionMap.set(clientId, this.latestHash));
}
try {
yield this.sync();
}
catch (err2) {
// We are already in a bad state. No need to make things worse.
// Just log the error and move on.
this.debugger.log(err2, `Driver.versionFailed(${err.message || err})`);
}
});
}

View File

@ -1,6 +1,6 @@
{
"configVersion": 1,
"timestamp": 1633590494074,
"timestamp": 1635958155081,
"index": "/index.html",
"assetGroups": [
{
@ -11,18 +11,18 @@
"ignoreVary": true
},
"urls": [
"/198.20d927ba29c55516dd2e.js",
"/51.c3a1708dc40a5461eaf3.js",
"/659.902c1857789597c9f3d8.js",
"/80.5aa4b3e3c4fcada93334.js",
"/954.05cbcc74da25eb3ef2a9.js",
"/common.fa68e1b34f0baff6ccad.js",
"/124.04d3ada75a9a6f4119c0.js",
"/481.2bc447cad2b7421383e8.js",
"/659.1d4258dba20472847e0d.js",
"/945.29b73cfac30295070168.js",
"/954.2fa849ff06b4bc2543e7.js",
"/common.1cef351b0cc7ea051261.js",
"/index.html",
"/main.59cb7d8427d901ad6225.js",
"/main.1cfa15ba47a0ebad32e6.js",
"/manifest.webmanifest",
"/polyfills.a427f031f0f7196ffda1.js",
"/runtime.984b6246826f9f569f66.js",
"/styles.45ceee0fc92e0f47588f.css"
"/runtime.4f3f03ac4847b0cd3a3a.js",
"/styles.2e25f4678bcb2c0682d5.css"
],
"patterns": []
},
@ -1631,11 +1631,11 @@
],
"dataGroups": [],
"hashTable": {
"/198.20d927ba29c55516dd2e.js": "93bf87b6cc89e0a67c508c5c232d50d73c659057",
"/51.c3a1708dc40a5461eaf3.js": "ef412bf0029f9923b62af368c185815503143c54",
"/659.902c1857789597c9f3d8.js": "eb0a200adbc8a60e97d96a5d9cd76054df561bdf",
"/80.5aa4b3e3c4fcada93334.js": "05503d5710b6b971c8117535de3839e37a23c149",
"/954.05cbcc74da25eb3ef2a9.js": "38c071c377d3a6d98902e679340d6a609996b717",
"/124.04d3ada75a9a6f4119c0.js": "3be768c03ab30a02a3b9758ee4f58d8cc50bcbf4",
"/481.2bc447cad2b7421383e8.js": "64382466eafa92b4cf91d0a26f9d884c3c93c73b",
"/659.1d4258dba20472847e0d.js": "eb0a200adbc8a60e97d96a5d9cd76054df561bdf",
"/945.29b73cfac30295070168.js": "5cd3bbe72718f68a49f84f2ce4d98e637f80c57d",
"/954.2fa849ff06b4bc2543e7.js": "38c071c377d3a6d98902e679340d6a609996b717",
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
@ -3227,13 +3227,13 @@
"/assets/twotone/wallet.svg": "11e915efff832b47aa4bd5885af72e55014f59e6",
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.fa68e1b34f0baff6ccad.js": "8e62b9aa49dde6486f74c6c94b97054743f1cc1b",
"/index.html": "feb7701a39ae28cb67aa8ec434fe2b35131f47a3",
"/main.59cb7d8427d901ad6225.js": "b35082dc068c552c032bb62c2d6dfa53a6d6532f",
"/common.1cef351b0cc7ea051261.js": "8e62b9aa49dde6486f74c6c94b97054743f1cc1b",
"/index.html": "b4dc046b52af23f23dc312e7d55853cba3e57471",
"/main.1cfa15ba47a0ebad32e6.js": "f1fb8a45535eab8b91e27bc1f0e62c88e5613e92",
"/manifest.webmanifest": "0c4534b4c868d756691b1b4372cecb2efce47c6d",
"/polyfills.a427f031f0f7196ffda1.js": "3e4560be48cd30e30bcbf51ca131a37d8c224fdc",
"/runtime.984b6246826f9f569f66.js": "9ef7243554b6cd1d20e1cc9eac2c5ce0c714082a",
"/styles.45ceee0fc92e0f47588f.css": "ebb9976bf7ee0e6443a627303522dd7c4ac1cff0"
"/runtime.4f3f03ac4847b0cd3a3a.js": "fa9236c1156c10fff78efd3b11f731219ec4a9f4",
"/styles.2e25f4678bcb2c0682d5.css": "690a2053c128c1bfea1edbc5552f2427a73d4baa"
},
"navigationUrls": [
{
@ -3251,6 +3251,14 @@
{
"positive": false,
"regex": "^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$"
},
{
"positive": false,
"regex": "^\\/(?:.+\\/)?docs$"
},
{
"positive": false,
"regex": "^\\/(?:.+\\/)?redoc$"
}
],
"navigationRequestStrategy": "performance"

View File

@ -0,0 +1 @@
(()=>{"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

@ -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,i,o)=>{if(!t){var a=1/0;for(f=0;f<e.length;f++){for(var[t,i,o]=e[f],d=!0,l=0;l<t.length;l++)(!1&o||a>=o)&&Object.keys(r.O).every(p=>r.O[p](t[l]))?t.splice(l--,1):(d=!1,o<a&&(a=o));if(d){e.splice(f--,1);var c=i();void 0!==c&&(n=c)}}return n}o=o||0;for(var f=e.length;f>0&&e[f-1][2]>o;f--)e[f]=e[f-1];e[f]=[t,i,o]},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)+"."+{51:"c3a1708dc40a5461eaf3",80:"5aa4b3e3c4fcada93334",198:"20d927ba29c55516dd2e",592:"fa68e1b34f0baff6ccad",659:"902c1857789597c9f3d8",954:"05cbcc74da25eb3ef2a9"}[e]+".js",r.miniCssF=e=>"styles.45ceee0fc92e0f47588f.css",r.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),(()=>{var e={},n="blrec:";r.l=(t,i,o,f)=>{if(e[t])e[t].push(i);else{var a,d;if(void 0!==o)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+o){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+o),a.src=r.tu(t)),e[t]=[i];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=(i,o)=>{var f=r.o(e,i)?e[i]:void 0;if(0!==f)if(f)o.push(f[2]);else if(666!=i){var a=new Promise((u,s)=>f=e[i]=[u,s]);o.push(f[2]=a);var d=r.p+r.u(i),l=new Error;r.l(d,u=>{if(r.o(e,i)&&(0!==(f=e[i])&&(e[i]=void 0),f)){var s=u&&("load"===u.type?"missing":u.type),b=u&&u.target&&u.target.src;l.message="Loading chunk "+i+" failed.\n("+s+": "+b+")",l.name="ChunkLoadError",l.type=s,l.request=b,f[1](l)}},"chunk-"+i,i)}else e[i]=0},r.O.j=i=>0===e[i];var n=(i,o)=>{var l,c,[f,a,d]=o,u=0;for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(d)var s=d(r);for(i&&i(o);u<f.length;u++)r.o(e,c=f[u])&&e[c]&&e[c][0](),e[f[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

@ -14,7 +14,13 @@ self.addEventListener('install', event => {
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
self.registration.unregister().then(() => {
event.waitUntil(self.registration.unregister().then(() => {
console.log('NGSW Safety Worker - unregistered old service worker');
});
}));
event.waitUntil(caches.keys().then(cacheNames => {
const ngswCacheNames = cacheNames.filter(name => /^ngsw:/.test(name));
return Promise.all(ngswCacheNames.map(name => caches.delete(name)));
}));
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -14,7 +14,13 @@ self.addEventListener('install', event => {
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
self.registration.unregister().then(() => {
event.waitUntil(self.registration.unregister().then(() => {
console.log('NGSW Safety Worker - unregistered old service worker');
});
}));
event.waitUntil(caches.keys().then(cacheNames => {
const ngswCacheNames = cacheNames.filter(name => /^ngsw:/.test(name));
return Promise.all(ngswCacheNames.map(name => caches.delete(name)));
}));
});

View File

@ -1,6 +1,7 @@
from .exceptions import (
ExistsError,
NotFoundError,
ForbiddenError,
)
from .exception_center import ExceptionCenter
from .exception_handler import ExceptionHandler
@ -15,6 +16,7 @@ from .helpers import format_exception
__all__ = (
'ExistsError',
'NotFoundError',
'ForbiddenError',
'ExceptionCenter',
'ExceptionSubmitter',

View File

@ -6,3 +6,7 @@ class NotFoundError(ValueError):
class ExistsError(ValueError):
pass
class ForbiddenError(Exception):
pass

View File

@ -22,3 +22,7 @@ class FileSizeOverLimit(Exception):
class DurationOverLimit(Exception):
...
class CutStream(Exception):
...

View File

@ -40,7 +40,7 @@ def make_comment_for_joinpoints(join_points: Iterable[JoinPoint]) -> str:
)
def is_valid(path: str) -> bool:
def is_valid_flv_file(path: str) -> bool:
with open(path, mode='rb') as file:
reader = FlvReader(file)

View File

@ -0,0 +1,60 @@
from __future__ import annotations
import logging
from typing import Optional
from .models import FlvTag, VideoTag
from .exceptions import CutStream
from .common import is_video_nalu_keyframe
from .utils import format_timestamp
__all__ = 'StreamCutter',
logger = logging.getLogger(__name__)
class StreamCutter:
def __init__(self, min_duration: int = 5_000) -> None:
self._min_duration = min_duration # milliseconds
self._last_position: int = 0
self.reset()
@property
def last_keyframe_tag(self) -> Optional[VideoTag]:
return self._last_keyframe_tag
def is_cutting(self) -> bool:
return self._cutting
def can_cut_stream(self) -> bool:
return self._timestamp >= self._min_duration
def cut_stream(self) -> bool:
if self.can_cut_stream():
self._triggered = True
return True
return False
def reset(self) -> None:
self._cutting = False
self._triggered = False
self._timestamp: int = 0
self._last_keyframe_tag: Optional[VideoTag] = None
def check_tag(self, tag: FlvTag) -> None:
self._timestamp = tag.timestamp
if not self._triggered:
return
if not is_video_nalu_keyframe(tag):
return
self._last_keyframe_tag = tag
self._last_position += self._timestamp
self._cutting = True
logger.info(f'Cut stream at: {format_timestamp(self._last_position)}')
raise CutStream()

View File

@ -15,6 +15,7 @@ from rx.core import Observable
from .models import FlvHeader, FlvTag, ScriptTag, VideoTag, AudioTag
from .data_analyser import DataAnalyser
from .stream_cutter import StreamCutter
from .limit_checker import LimitChecker
from .parameters_checker import ParametersChecker
from .io import FlvReader, FlvWriter
@ -26,6 +27,7 @@ from .exceptions import (
VideoParametersChanged,
FileSizeOverLimit,
DurationOverLimit,
CutStream,
)
from .common import (
is_audio_tag, is_metadata_tag, is_video_tag, parse_metadata, rpeek_tags,
@ -61,6 +63,7 @@ class StreamProcessor:
) -> None:
self._file_manager = file_manager
self._parameters_checker = ParametersChecker()
self._stream_cutter = StreamCutter()
if not disable_limit:
self._limit_checker = LimitChecker(filesize_limit, duration_limit)
if analyse_data:
@ -138,6 +141,12 @@ class StreamProcessor:
self._stream_count += 1
self._process_stream(stream)
def can_cut_stream(self) -> bool:
return self._stream_cutter.can_cut_stream()
def cut_stream(self) -> bool:
return self._stream_cutter.cut_stream()
def finalize(self) -> None:
assert not self._finalized, \
'should not be called after the processing finalized'
@ -154,6 +163,7 @@ class StreamProcessor:
return self._stream_count > 0 and len(self._last_tags) > 0
def _new_file(self) -> None:
self._stream_cutter.reset()
if not self._disable_limit:
self._limit_checker.reset()
@ -208,7 +218,7 @@ class StreamProcessor:
self._process_subsequent_stream(first_data_tag)
except (
AudioParametersChanged, VideoParametersChanged,
FileSizeOverLimit, DurationOverLimit,
FileSizeOverLimit, DurationOverLimit, CutStream,
):
self._process_split_stream(flv_header)
@ -263,7 +273,13 @@ class StreamProcessor:
self._complete_file()
first_data_tag: FlvTag
if (
if self._stream_cutter.is_cutting():
assert self._stream_cutter.last_keyframe_tag is not None
last_keyframe_tag = self._stream_cutter.last_keyframe_tag
original_ts = last_keyframe_tag.timestamp - self._delta
first_data_tag = last_keyframe_tag.evolve(timestamp=original_ts)
elif (
not self._disable_limit and (
self._limit_checker.is_filesize_over_limit() or
self._limit_checker.is_duration_over_limit()
@ -280,7 +296,7 @@ class StreamProcessor:
self._process_initial_stream(flv_header, first_data_tag)
except (
AudioParametersChanged, VideoParametersChanged,
FileSizeOverLimit, DurationOverLimit,
FileSizeOverLimit, DurationOverLimit, CutStream,
):
self._process_split_stream(flv_header)
@ -461,9 +477,9 @@ class StreamProcessor:
self._last_ts = tag.timestamp
self._stream_cutter.check_tag(tag)
if not self._disable_limit:
self._limit_checker.check_tag(tag)
if self._analyse_data:
self._data_analyser.analyse_tag(tag)
@ -547,6 +563,7 @@ class StreamProcessor:
map(lambda p: p.to_metadata_value(), self._join_points)
)
assert self._file_manager.curr_path is not None
path = extra_metadata_path(self._file_manager.curr_path)
with open(path, 'wt', encoding='utf8') as file:
json.dump(metadata, file)
@ -591,7 +608,7 @@ class JoinPointData(TypedDict):
class OutputFileManager(Protocol):
@property
def curr_path(self) -> str:
def curr_path(self) -> Optional[str]:
...
@property
@ -610,11 +627,11 @@ class BaseOutputFileManager(ABC):
super().__init__()
self.buffer_size = buffer_size or io.DEFAULT_BUFFER_SIZE # bytes
self._paths: List[str] = []
self._curr_path = ''
self._curr_path: Optional[str] = None
self._curr_file: Optional[BinaryIO] = None
@property
def curr_path(self) -> str:
def curr_path(self) -> Optional[str]:
return self._curr_path
@property
@ -643,7 +660,7 @@ class BaseOutputFileManager(ABC):
def close_file(self) -> None:
assert self._curr_file is not None
self._curr_file.close()
self._curr_path = ''
self._curr_path = None
self._curr_file = None
@abstractmethod

View File

@ -1,10 +1,10 @@
from .postprocessor import Postprocessor, PostprocessorEventListener
from .models import ProcessStatus, DeleteStrategy
from .models import PostprocessorStatus, DeleteStrategy
__all__ = (
'Postprocessor',
'PostprocessorEventListener',
'ProcessStatus',
'PostprocessorStatus',
'DeleteStrategy',
)

View File

@ -2,7 +2,7 @@
from enum import Enum
class ProcessStatus(Enum):
class PostprocessorStatus(Enum):
WAITING = 'waiting'
REMUXING = 'remuxing'
INJECTING = 'injecting'

View File

@ -1,15 +1,13 @@
import asyncio
import logging
from pathlib import PurePath
from typing import (
Any, Awaitable, Dict, Iterable, Iterator, List, Optional, Sequence, Set,
Tuple, Union,
)
from contextlib import suppress
from typing import Any, Awaitable, Dict, Iterator, List, Optional, Union
from rx.core.typing import Scheduler
from rx.scheduler.threadpoolscheduler import ThreadPoolScheduler
from .models import ProcessStatus, DeleteStrategy
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
@ -17,18 +15,18 @@ from .ffmpeg_metadata import make_metadata_file
from ..event.event_emitter import EventListener, EventEmitter
from ..bili.live import Live
from ..core import Recorder, RecorderEventListener
from ..exception import exception_callback
from ..utils.mixins import SwitchableMixin, AsyncCooperationMix
from ..path import danmaku_path, extra_metadata_path
from ..exception import submit_exception
from ..utils.mixins import AsyncStoppableMixin, AsyncCooperationMix
from ..path import extra_metadata_path
from ..flv.metadata_injector import inject_metadata, InjectProgress
from ..flv.helpers import is_valid
from ..flv.helpers import is_valid_flv_file
from ..logging.room_id import aio_task_with_room_id
__all__ = (
'Postprocessor',
'PostprocessorEventListener',
'ProcessStatus',
'PostprocessorStatus',
'DeleteStrategy',
)
@ -44,7 +42,7 @@ class PostprocessorEventListener(EventListener):
class Postprocessor(
EventEmitter[PostprocessorEventListener],
RecorderEventListener,
SwitchableMixin,
AsyncStoppableMixin,
AsyncCooperationMix,
):
def __init__(
@ -63,13 +61,13 @@ class Postprocessor(
self.remux_to_mp4 = remux_to_mp4
self.delete_source = delete_source
self._status = ProcessStatus.WAITING
self._status = PostprocessorStatus.WAITING
self._postprocessing_path: Optional[str] = None
self._postprocessing_progress: Optional[Progress] = None
self._files: List[str] = []
self._completed_files: List[str] = []
@property
def status(self) -> ProcessStatus:
def status(self) -> PostprocessorStatus:
return self._status
@property
@ -80,124 +78,110 @@ class Postprocessor(
def postprocessing_progress(self) -> Optional[Progress]:
return self._postprocessing_progress
def get_final_files(self) -> Iterator[str]:
yield from iter(self._files)
def get_completed_files(self) -> Iterator[str]:
yield from iter(self._completed_files)
async def wait(self) -> None:
await self._wait_processing_task()
async def on_recording_started(self, recorder: Recorder) -> None:
# clear completed files of previous recording
self._completed_files.clear()
async def on_recording_finished(self, recorder: Recorder) -> None:
self._create_processing_task(recorder)
async def on_video_file_completed(self, path: str) -> None:
self._queue.put_nowait(path)
async def on_recording_cancelled(self, recorder: Recorder) -> None:
self._create_processing_task(recorder)
async def on_danmaku_file_completed(self, path: str) -> None:
self._completed_files.append(path)
def _do_enable(self) -> None:
async def _do_start(self) -> None:
self._recorder.add_listener(self)
logger.debug('Enabled postprocessor')
def _do_disable(self) -> None:
self._queue: asyncio.Queue[str] = asyncio.Queue()
self._scheduler = ThreadPoolScheduler()
self._task = asyncio.create_task(self._worker())
logger.debug('Started postprocessor')
async def _do_stop(self) -> None:
self._recorder.remove_listener(self)
logger.debug('Disabled postprocessor')
def _create_processing_task(self, recorder: Recorder) -> None:
raw_videos = list(recorder.get_video_files())
raw_danmaku = list(recorder.get_danmaku_files())
self._processing_task = asyncio.create_task(
self._process(raw_videos, raw_danmaku),
)
self._processing_task.add_done_callback(exception_callback)
if self._status != PostprocessorStatus.WAITING:
await self._queue.join()
async def _wait_processing_task(self) -> None:
if hasattr(self, '_processing_task'):
await self._processing_task
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
del self._queue
del self._scheduler
del self._task
logger.debug('Stopped postprocessor')
@aio_task_with_room_id
async def _process(
self, raw_videos: Sequence[str], raw_danmaku: Sequence[str]
) -> None:
raw_videos, raw_danmaku = await self._discard_invaild_files(
raw_videos, raw_danmaku
async def _worker(self) -> None:
while True:
self._status = PostprocessorStatus.WAITING
self._postprocessing_path = None
self._postprocessing_progress = None
video_path = await self._queue.get()
if not await self._is_vaild_flv_file(video_path):
self._queue.task_done()
continue
try:
if self.remux_to_mp4:
self._status = PostprocessorStatus.REMUXING
result_path = await self._remux_flv_to_mp4(video_path)
else:
self._status = PostprocessorStatus.INJECTING
result_path = await self._inject_extra_metadata(video_path)
self._completed_files.append(result_path)
await self._emit(
'file_completed', self._live.room_id, result_path
)
except Exception as exc:
submit_exception(exc)
finally:
self._queue.task_done()
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:
out_path = str(PurePath(in_path).with_suffix('.mp4'))
logger.info(f"Remuxing '{in_path}' to '{out_path}' ...")
metadata_path = await make_metadata_file(in_path)
remux_result = await self._remux_video(
in_path, out_path, metadata_path, self._scheduler
)
self._files.clear()
if self.remux_to_mp4:
self._status = ProcessStatus.REMUXING
final_videos = await self._remux_videos(raw_videos)
if remux_result.is_successful():
logger.info(f"Successfully remux '{in_path}' to '{out_path}'")
result_path = out_path
elif remux_result.is_warned():
logger.warning('Remuxing done, but ran into problems.')
result_path = out_path
elif remux_result.is_failed:
logger.error(f"Failed to remux '{in_path}' to '{out_path}'")
result_path = in_path
else:
self._status = ProcessStatus.INJECTING
await self._inject_extra_metadata(raw_videos)
final_videos = raw_danmaku
pass
self._files.extend(final_videos)
self._files.extend(raw_danmaku)
logger.debug(f'ffmpeg output:\n{remux_result.output}')
self._status = ProcessStatus.WAITING
self._postprocessing_path = None
self._postprocessing_progress = None
async def _discard_invaild_files(
self, raw_videos: Sequence[str], raw_danmaku: Sequence[str]
) -> Tuple[List[str], List[str]]:
loop = asyncio.get_running_loop()
valid_video_set: Set[str] = await loop.run_in_executor(
None, set, filter(is_valid, raw_videos)
)
invalid_video_set = set(raw_videos) - valid_video_set
if invalid_video_set:
logger.info('Discarding invalid files ...')
await discard_files(invalid_video_set)
await discard_files(map(danmaku_path, invalid_video_set))
return list(valid_video_set), list(map(danmaku_path, valid_video_set))
async def _inject_extra_metadata(self, video_paths: Iterable[str]) -> None:
scheduler = ThreadPoolScheduler()
for path in video_paths:
metadata = await get_extra_metadata(path)
await self._inject_metadata(path, metadata, scheduler)
await discard_file(extra_metadata_path(path), 'DEBUG')
await self._emit('file_completed', self._live.room_id, path)
async def _remux_videos(self, video_paths: Iterable[str]) -> List[str]:
results = []
scheduler = ThreadPoolScheduler()
for in_path in video_paths:
out_path = str(PurePath(in_path).with_suffix('.mp4'))
logger.info(f"Remuxing '{in_path}' to '{out_path}' ...")
metadata_path = await make_metadata_file(in_path)
remux_result = await self._remux_video(
in_path, out_path, metadata_path, scheduler
if self._should_delete_source_files(remux_result):
await discard_file(in_path)
await discard_files(
[metadata_path, extra_metadata_path(in_path)], 'DEBUG'
)
if remux_result.is_successful():
logger.info(f"Successfully remux '{in_path}' to '{out_path}'")
result_path = out_path
elif remux_result.is_warned():
logger.warning('Remuxing done, but ran into problems.')
result_path = out_path
elif remux_result.is_failed:
logger.error(f"Failed to remux '{in_path}' to '{out_path}'")
result_path = in_path
else:
pass
logger.debug(f'ffmpeg output:\n{remux_result.output}')
if self._should_delete_source_files(remux_result):
await discard_file(in_path)
await discard_files(
[metadata_path, extra_metadata_path(in_path)], 'DEBUG'
)
results.append(result_path)
await self._emit('file_completed', self._live.room_id, result_path)
return results
return result_path
def _inject_metadata(
self,
@ -254,6 +238,10 @@ class Postprocessor(
return future
async def _is_vaild_flv_file(self, video_path: str) -> bool:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, is_valid_flv_file, video_path)
def _should_delete_source_files(
self, remux_result: RemuxResult
) -> bool:

View File

@ -1,13 +1,21 @@
from .task_manager import RecordTaskManager
from .models import TaskStatus, TaskParam, FileDetail, RunningStatus, TaskData
from .models import (
TaskData,
TaskStatus,
TaskParam,
RunningStatus,
VideoFileDetail,
DanmakuFileDetail,
)
__all__ = (
'RecordTaskManager',
'TaskData',
'TaskStatus',
'TaskParam',
'TaskData',
'FileDetail',
'RunningStatus',
'VideoFileDetail',
'DanmakuFileDetail',
)

View File

@ -1,5 +1,4 @@
from __future__ import annotations
import os
from enum import Enum
from typing import Optional
@ -7,7 +6,7 @@ import attr
from ..bili.models import RoomInfo, UserInfo
from ..bili.typing import QualityNumber
from ..postprocess import DeleteStrategy
from ..postprocess import DeleteStrategy, PostprocessorStatus
from ..postprocess.typing import Progress
@ -30,6 +29,7 @@ class TaskStatus:
danmu_count: int # Number of Danmu in total
danmu_rate: float # Number of Danmu per minutes
real_quality_number: QualityNumber
postprocessor_status: PostprocessorStatus = PostprocessorStatus.WAITING
postprocessing_path: Optional[str] = None
postprocessing_progress: Optional[Progress] = None
@ -66,15 +66,31 @@ class TaskData:
task_status: TaskStatus
class VideoFileStatus(str, Enum):
RECORDING = 'recording'
REMUXING = 'remuxing'
INJECTING = 'injecting'
COMPLETED = 'completed'
MISSING = 'missing'
BROKEN = 'broken'
class DanmukuFileStatus(str, Enum):
RECORDING = 'recording'
COMPLETED = 'completed'
MISSING = 'missing'
BROKEN = 'broken'
@attr.s(auto_attribs=True, slots=True, frozen=True)
class FileDetail:
exists: bool
class VideoFileDetail:
path: str
size: int
status: VideoFileStatus
@classmethod
def from_path(cls, path: str) -> FileDetail:
try:
return cls(True, path, os.path.getsize(path))
except FileNotFoundError:
return cls(False, path, 0)
@attr.s(auto_attribs=True, slots=True, frozen=True)
class DanmakuFileDetail:
path: str
size: int
status: DanmukuFileStatus

View File

@ -1,15 +1,26 @@
import os
import logging
from typing import List, Optional
from pathlib import Path
from typing import Iterator, Optional
from .models import RunningStatus, TaskStatus
from .models import (
TaskStatus,
RunningStatus,
VideoFileStatus,
VideoFileDetail,
DanmukuFileStatus,
DanmakuFileDetail,
)
from ..bili.live import Live
from ..bili.models import RoomInfo, UserInfo
from ..bili.danmaku_client import DanmakuClient
from ..bili.live_monitor import LiveMonitor
from ..bili.typing import QualityNumber
from ..core import Recorder
from ..postprocess import Postprocessor, ProcessStatus, DeleteStrategy
from ..postprocess import Postprocessor, PostprocessorStatus, DeleteStrategy
from ..postprocess.remuxer import RemuxProgress
from ..flv.metadata_injector import InjectProgress
from ..event.event_submitters import (
LiveEventSubmitter, PostprocessorEventSubmitter
)
@ -86,9 +97,9 @@ class RecordTask:
return RunningStatus.STOPPED
elif self._recorder.recording:
return RunningStatus.RECORDING
elif self._postprocessor.status == ProcessStatus.REMUXING:
elif self._postprocessor.status == PostprocessorStatus.REMUXING:
return RunningStatus.REMUXING
elif self._postprocessor.status == ProcessStatus.INJECTING:
elif self._postprocessor.status == PostprocessorStatus.INJECTING:
return RunningStatus.INJECTING
else:
return RunningStatus.WAITING
@ -113,6 +124,7 @@ class RecordTask:
danmu_count=self._recorder.danmu_count,
danmu_rate=self._recorder.danmu_rate,
real_quality_number=self._recorder.real_quality_number,
postprocessor_status=self._postprocessor.status,
postprocessing_path=self._postprocessor.postprocessing_path,
postprocessing_progress=(
self._postprocessor.postprocessing_progress
@ -120,18 +132,74 @@ class RecordTask:
)
@property
def files(self) -> List[str]:
if self.running_status == RunningStatus.STOPPED:
return []
if (
self.running_status == RunningStatus.RECORDING or
not self._postprocessor.enabled
):
return [
*self._recorder.get_video_files(),
*self._recorder.get_danmaku_files(),
]
return [*self._postprocessor.get_final_files()]
def video_file_details(self) -> Iterator[VideoFileDetail]:
recording_paths = set(self._recorder.get_recording_files())
completed_paths = set(self._postprocessor.get_completed_files())
for path in self._recorder.get_video_files():
try:
size = os.path.getsize(path)
exists = True
except FileNotFoundError:
mp4_path = str(Path(path).with_suffix('.mp4'))
try:
size = os.path.getsize(mp4_path)
exists = True
path = mp4_path
except FileNotFoundError:
size = 0
exists = False
if not exists:
status = VideoFileStatus.MISSING
elif path in completed_paths:
status = VideoFileStatus.COMPLETED
elif path in recording_paths:
status = VideoFileStatus.RECORDING
elif path == self._postprocessor.postprocessing_path:
progress = self._postprocessor.postprocessing_progress
if isinstance(progress, RemuxProgress):
status = VideoFileStatus.REMUXING
elif isinstance(progress, InjectProgress):
status = VideoFileStatus.INJECTING
else:
# disabling recorder by force or stoping task by force
status = VideoFileStatus.BROKEN
yield VideoFileDetail(
path=path,
size=size,
status=status,
)
@property
def danmaku_file_details(self) -> Iterator[DanmakuFileDetail]:
recording_paths = set(self._recorder.get_recording_files())
completed_paths = set(self._postprocessor.get_completed_files())
for path in self._recorder.get_danmaku_files():
try:
size = os.path.getsize(path)
exists = True
except FileNotFoundError:
size = 0
exists = False
if not exists:
status = DanmukuFileStatus.MISSING
elif path in completed_paths:
status = DanmukuFileStatus.COMPLETED
elif path in recording_paths:
status = DanmukuFileStatus.RECORDING
else:
# disabling recorder by force or stoping task by force
status = DanmukuFileStatus.BROKEN
yield DanmakuFileDetail(
path=path,
size=size,
status=status,
)
@property
def user_agent(self) -> str:
@ -265,6 +333,12 @@ class RecordTask:
def delete_source(self, value: DeleteStrategy) -> None:
self._postprocessor.delete_source = value
def can_cut_stream(self) -> bool:
return self._recorder.can_cut_stream()
def cut_stream(self) -> bool:
return self._recorder.cut_stream()
@aio_task_with_room_id
async def setup(self) -> None:
await self._live.init()
@ -301,7 +375,7 @@ class RecordTask:
return
self._recorder_enabled = True
self._postprocessor.enable()
await self._postprocessor.start()
await self._recorder.start()
@aio_task_with_room_id
@ -311,12 +385,11 @@ class RecordTask:
self._recorder_enabled = False
if force:
self._postprocessor.disable()
await self._postprocessor.stop()
await self._recorder.stop()
else:
await self._recorder.stop()
await self._postprocessor.wait()
self._postprocessor.disable()
await self._postprocessor.stop()
async def update_info(self) -> None:
await self._live.update_info()
@ -329,6 +402,7 @@ class RecordTask:
await self._live.deinit()
await self._live.init()
self._danmaku_client.session = self._live.session
self._danmaku_client.api = self._live.api
if self._monitor_enabled:
await self._danmaku_client.start()
@ -343,7 +417,7 @@ class RecordTask:
def _setup_danmaku_client(self) -> None:
self._danmaku_client = DanmakuClient(
self._live.session, self._live.room_id
self._live.session, self._live.api, self._live.room_id
)
def _setup_live_monitor(self) -> None:
@ -384,7 +458,6 @@ class RecordTask:
async def _destroy(self) -> None:
self._destroy_postprocessor_event_submitter()
self._destroy_postprocessor()
self._destroy_recorder()
self._destroy_live_event_submitter()
self._destroy_live_monitor()

View File

@ -2,10 +2,9 @@ from __future__ import annotations
import asyncio
from typing import Dict, Iterator, TYPE_CHECKING
from blrec.setting.models import OutputSettings
from .task import RecordTask
from .models import TaskData, TaskParam, FileDetail
from .models import TaskData, TaskParam, VideoFileDetail, DanmakuFileDetail
from ..exception import NotFoundError
if TYPE_CHECKING:
from ..setting import SettingsManager
@ -15,6 +14,7 @@ from ..setting import (
RecorderSettings,
PostprocessingSettings,
TaskSettings,
OutputSettings,
)
@ -154,10 +154,25 @@ class RecordTaskManager:
task = self._get_task(room_id)
return self._make_task_param(task)
def get_task_file_details(self, room_id: int) -> Iterator[FileDetail]:
def get_task_video_file_details(
self, room_id: int
) -> Iterator[VideoFileDetail]:
task = self._get_task(room_id)
for path in task.files:
yield FileDetail.from_path(path)
yield from task.video_file_details
def get_task_danmaku_file_details(
self, room_id: int
) -> Iterator[DanmakuFileDetail]:
task = self._get_task(room_id)
yield from task.danmaku_file_details
def can_cut_stream(self, room_id: int) -> bool:
task = self._get_task(room_id)
return task.can_cut_stream()
def cut_stream(self, room_id: int) -> bool:
task = self._get_task(room_id)
return task.cut_stream()
async def update_task_info(self, room_id: int) -> None:
task = self._get_task(room_id)

View File

@ -17,7 +17,7 @@ from .routers import (
from .schemas import ResponseMessage
from ..setting import EnvSettings, Settings
from ..application import Application
from ..exception import NotFoundError, ExistsError
from ..exception import NotFoundError, ExistsError, ForbiddenError
logger = logging.getLogger(__name__)
@ -77,6 +77,19 @@ async def not_found_error_handler(
)
@api.exception_handler(ForbiddenError)
async def forbidden_error_handler(
request: Request, exc: ForbiddenError
) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content=dict(ResponseMessage(
code=status.HTTP_403_FORBIDDEN,
message=str(exc),
)),
)
@api.exception_handler(ExistsError)
async def exists_error_handler(
request: Request, exc: ExistsError

View File

@ -18,7 +18,7 @@ from ..responses import (
accepted_responses,
created_responses,
)
from ...exception import NotFoundError
from ...exception import NotFoundError, ForbiddenError
from ...application import Application
@ -54,11 +54,19 @@ async def get_task_param(room_id: int) -> Dict[str, Any]:
@router.get(
'/{room_id}/files',
'/{room_id}/videos',
responses={**not_found_responses},
)
async def get_task_file_details(room_id: int) -> List[Dict[str, Any]]:
return [attr.asdict(d) for d in app.get_task_file_details(room_id)]
async def get_task_video_file_details(room_id: int) -> List[Dict[str, Any]]:
return [attr.asdict(d) for d in app.get_task_video_file_details(room_id)]
@router.get(
'/{room_id}/danmakus',
responses={**not_found_responses},
)
async def get_task_danmaku_file_details(room_id: int) -> List[Dict[str, Any]]:
return [attr.asdict(d) for d in app.get_task_danmaku_file_details(room_id)]
@router.post(
@ -81,6 +89,36 @@ async def update_task_info(room_id: int) -> ResponseMessage:
return ResponseMessage(message='The task info has been updated')
@router.get(
'/{room_id}/cut',
response_model=ResponseMessage,
responses={**not_found_responses},
)
async def can_cut_stream(room_id: int) -> ResponseMessage:
if app.can_cut_stream(room_id):
return ResponseMessage(
message='The stream can been cut',
data={'result': True},
)
else:
return ResponseMessage(
message='The stream cannot been cut',
data={'result': False},
)
@router.post(
'/{room_id}/cut',
response_model=ResponseMessage,
status_code=status.HTTP_202_ACCEPTED,
responses={**not_found_responses, **forbidden_responses},
)
async def cut_stream(room_id: int) -> ResponseMessage:
if not app.cut_stream(room_id):
raise ForbiddenError('The stream cannot been cut')
return ResponseMessage(message='The stream cutting have been triggered')
@router.post(
'/start',
response_model=ResponseMessage,

View File

@ -26,5 +26,13 @@
]
}
}
],
"navigationUrls": [
"/**",
"!/**/*.*",
"!/**/*__*",
"!/**/*__*/**",
"!/**/docs",
"!/**/redoc"
]
}

9124
webapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
></nz-spin>
<ng-template #elseBlock>
<div class="sub-page">
<div class="sub-page" [ngStyle]="pageStyles">
<nz-page-header
class="page-header"
nzBackIcon
@ -14,7 +14,7 @@
[nzGhost]="false"
>
</nz-page-header>
<div *ngIf="content" class="page-content">
<div *ngIf="content" class="page-content" [ngStyle]="contentStyles">
<ng-container [ngTemplateOutlet]="content.templateRef"></ng-container>
</div>
</div>

View File

@ -1,10 +1,9 @@
@use '../../../shared/styles/layout';
@use '../../../shared/styles/common';
@use "../../../shared/styles/layout";
@use "../../../shared/styles/common";
:host {
@extend %inner-content;
padding-top: 0;
padding-bottom: 0;
}
.sub-page {

View File

@ -16,6 +16,8 @@ import { SubPageContentDirective } from '../../directives/sub-page-content.direc
export class SubPageComponent {
@Input() pageTitle = '';
@Input() loading = false;
@Input() pageStyles = {};
@Input() contentStyles = {};
@ContentChild(SubPageContentDirective)
content?: SubPageContentDirective;

View File

@ -0,0 +1,8 @@
import { FilestatusPipe } from './filestatus.pipe';
describe('FilestatusPipe', () => {
it('create an instance', () => {
const pipe = new FilestatusPipe();
expect(pipe).toBeTruthy();
});
});

View File

@ -0,0 +1,24 @@
import { Pipe, PipeTransform } from '@angular/core';
import {
VideoFileStatus,
DanmakuFileStatus,
} from 'src/app/tasks/shared/task.model';
const STATUS_MAPPING = new Map([
[VideoFileStatus.RECORDING, '录制中'],
[VideoFileStatus.INJECTING, '处理中'],
[VideoFileStatus.REMUXING, '处理中'],
[VideoFileStatus.COMPLETED, '已完成'],
[VideoFileStatus.MISSING, '不存在'],
[VideoFileStatus.BROKEN, '录制中断'],
]);
@Pipe({
name: 'filestatus',
})
export class FilestatusPipe implements PipeTransform {
transform(status: VideoFileStatus | DanmakuFileStatus): string {
return STATUS_MAPPING.get(status as VideoFileStatus) ?? '';
}
}

View File

@ -10,6 +10,6 @@ export class ProgressPipe implements PipeTransform {
if (!progress || progress.duration === 0) {
return 0;
}
return (progress.time / progress.duration) * 100;
return Math.round((progress.time / progress.duration) * 100);
}
}

View File

@ -14,6 +14,7 @@ import { FilenamePipe } from './pipes/filename.pipe';
import { PageSectionComponent } from './components/page-section/page-section.component';
import { SubPageComponent } from './components/sub-page/sub-page.component';
import { SubPageContentDirective } from './directives/sub-page-content.directive';
import { FilestatusPipe } from './pipes/filestatus.pipe';
@NgModule({
declarations: [
@ -27,6 +28,7 @@ import { SubPageContentDirective } from './directives/sub-page-content.directive
PageSectionComponent,
ProgressPipe,
FilenamePipe,
FilestatusPipe,
],
imports: [
CommonModule,
@ -45,6 +47,7 @@ import { SubPageContentDirective } from './directives/sub-page-content.directive
SubPageComponent,
SubPageContentDirective,
PageSectionComponent,
FilestatusPipe,
]
})
export class SharedModule { }

View File

@ -282,4 +282,21 @@ export class TaskManagerService {
)
);
}
cutStream(roomId: number) {
return this.taskService.cutStream(roomId).pipe(
tap(
() => {
this.message.success('文件切割已触发');
},
(error: HttpErrorResponse) => {
if (error.status == 403) {
this.message.warning('时长太短不能切割,请稍后再试。');
} else {
this.message.error(`切割文件出错: ${error.message}`);
}
}
)
);
}
}

View File

@ -9,8 +9,9 @@ import {
TaskData,
DataSelection,
TaskParam,
FileDetail,
AddTaskResult,
VideoFileDetail,
DanmakuFileDetail,
} from '../task.model';
const apiUrl = environment.apiUrl;
@ -33,9 +34,14 @@ export class TaskService {
return this.http.get<TaskData>(url);
}
getTaskFileDetails(roomId: number): Observable<FileDetail[]> {
const url = apiUrl + `/api/v1/tasks/${roomId}/files`;
return this.http.get<FileDetail[]>(url);
getVideoFileDetails(roomId: number): Observable<VideoFileDetail[]> {
const url = apiUrl + `/api/v1/tasks/${roomId}/videos`;
return this.http.get<VideoFileDetail[]>(url);
}
getDanmakuFileDetails(roomId: number): Observable<DanmakuFileDetail[]> {
const url = apiUrl + `/api/v1/tasks/${roomId}/danmakus`;
return this.http.get<DanmakuFileDetail[]>(url);
}
getTaskParam(roomId: number): Observable<TaskParam> {
@ -144,4 +150,9 @@ export class TaskService {
const url = apiUrl + `/api/v1/tasks/recorder/disable`;
return this.http.post<ResponseMessage>(url, { force, background });
}
cutStream(roomId: number) {
const url = apiUrl + `/api/v1/tasks/${roomId}/cut`;
return this.http.post<null>(url, null);
}
}

View File

@ -66,6 +66,12 @@ export enum RunningStatus {
INJECTING = 'injecting',
}
export enum PostprocessorStatus {
WAITING = 'waiting',
REMUXING = 'remuxing',
INJECTING = 'injecting',
}
export interface Progress {
time: number;
duration: number;
@ -81,6 +87,7 @@ export interface TaskStatus {
readonly danmu_count: number;
readonly danmu_rate: number;
readonly real_quality_number: QualityNumber;
readonly postprocessor_status: PostprocessorStatus;
readonly postprocessing_path: string | null;
readonly postprocessing_progress: Progress | null;
}
@ -104,10 +111,32 @@ export interface TaskParam {
readonly delete_source: DeleteStrategy;
}
export interface FileDetail {
readonly exists: boolean;
export enum VideoFileStatus {
RECORDING = 'recording',
REMUXING = 'remuxing',
INJECTING = 'injecting',
COMPLETED = 'completed',
MISSING = 'missing',
BROKEN = 'broken',
}
export enum DanmakuFileStatus {
RECORDING = 'recording',
COMPLETED = 'completed',
MISSING = 'missing',
BROKEN = 'broken',
}
export interface VideoFileDetail {
readonly path: string;
readonly size: number;
readonly status: VideoFileStatus;
}
export interface DanmakuFileDetail {
readonly path: string;
readonly size: number;
readonly status: DanmakuFileStatus;
}
export interface AddTaskResult extends ResponseMessage {

View File

@ -56,12 +56,12 @@
<p
class="status-bar injecting"
nz-tooltip
nzTooltipTitle="正在注入元数据:{{
nzTooltipTitle="正在更新 FLV 元数据:{{
status.postprocessing_path ?? '' | filename
}}"
nzTooltipPlacement="top"
>
注入元数据:{{ status.postprocessing_path ?? "" | filename }}
{{ status.postprocessing_path ?? "" | filename }}
</p>
<nz-progress
[nzType]="'line'"
@ -80,12 +80,12 @@
<p
class="status-bar remuxing"
nz-tooltip
nzTooltipTitle="正在转换格式{{
nzTooltipTitle="正在转换 FLV 为 MP4{{
status.postprocessing_path ?? '' | filename
}}"
nzTooltipPlacement="top"
>
转换格式:{{ status.postprocessing_path ?? "" | filename }}
{{ status.postprocessing_path ?? "" | filename }}
</p>
<nz-progress
[nzType]="'line'"

View File

@ -0,0 +1,38 @@
<app-sub-page
pageTitle="任务详情"
[loading]="loading"
[pageStyles]="{ 'max-width': 'unset' }"
[contentStyles]="{ 'row-gap': '1em' }"
>
<ng-template appSubPageContent>
<app-task-user-info-detail
*ngIf="taskData"
[loading]="loading"
[userInfo]="taskData.user_info"
></app-task-user-info-detail>
<app-task-room-info-detail
*ngIf="taskData"
[loading]="loading"
[roomInfo]="taskData.room_info"
></app-task-room-info-detail>
<app-task-recording-detail
*ngIf="taskData"
[loading]="loading"
[taskStatus]="taskData.task_status"
></app-task-recording-detail>
<app-task-postprocessing-detail
*ngIf="taskData?.task_status?.postprocessing_path"
[loading]="loading"
[taskStatus]="taskData.task_status"
></app-task-postprocessing-detail>
<app-task-file-detail
[loading]="loading"
[videoFileDetails]="videoFileDetails"
[danmakuFileDetails]="danmakuFileDetails"
></app-task-file-detail>
</ng-template>
</app-sub-page>

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskDetailComponent } from './task-detail.component';
describe('TaskDetailComponent', () => {
let component: TaskDetailComponent;
let fixture: ComponentFixture<TaskDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TaskDetailComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TaskDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,94 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
} from '@angular/core';
import { ActivatedRoute, ParamMap, Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { interval, of, Subscription, zip } from 'rxjs';
import { catchError, concatAll, switchMap } from 'rxjs/operators';
import { NzNotificationService } from 'ng-zorro-antd/notification';
import { retry } from 'src/app/shared/rx-operators';
import { TaskService } from '../shared/services/task.service';
import {
TaskData,
DanmakuFileDetail,
VideoFileDetail,
} from '../shared/task.model';
@Component({
selector: 'app-task-detail',
templateUrl: './task-detail.component.html',
styleUrls: ['./task-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskDetailComponent implements OnInit {
roomId!: number;
taskData!: TaskData;
videoFileDetails: VideoFileDetail[] = [];
danmakuFileDetails: DanmakuFileDetail[] = [];
loading: boolean = true;
private dataSubscription?: Subscription;
constructor(
private route: ActivatedRoute,
private router: Router,
private changeDetector: ChangeDetectorRef,
private notification: NzNotificationService,
private taskService: TaskService
) {}
ngOnInit(): void {
this.route.paramMap.subscribe((params: ParamMap) => {
this.roomId = parseInt(params.get('id')!);
this.syncData();
});
}
ngOnDestroy(): void {
this.desyncData();
}
private syncData(): void {
this.dataSubscription = of(of(0), interval(1000))
.pipe(
concatAll(),
switchMap(() =>
zip(
this.taskService.getTaskData(this.roomId),
this.taskService.getVideoFileDetails(this.roomId),
this.taskService.getDanmakuFileDetails(this.roomId)
)
),
catchError((error: HttpErrorResponse) => {
this.notification.error('获取任务数据出错', error.message);
throw error;
}),
retry(10, 3000)
)
.subscribe(
([taskData, videoFileDetails, danmakuFileDetails]) => {
this.loading = false;
this.taskData = taskData;
this.videoFileDetails = videoFileDetails;
this.danmakuFileDetails = danmakuFileDetails;
this.changeDetector.markForCheck();
},
(error: HttpErrorResponse) => {
this.notification.error(
'获取任务数据出错',
'网络连接异常, 请待网络正常后刷新。',
{ nzDuration: 0 }
);
}
);
}
private desyncData(): void {
this.dataSubscription?.unsubscribe();
}
}

View File

@ -0,0 +1,35 @@
<nz-card nzTitle="文件详情" [nzLoading]="loading">
<nz-table
#fileDetailsTable
[nzLoading]="loading"
[nzData]="fileDetails"
[nzPageSize]="8"
[nzHideOnSinglePage]="true"
>
<thead>
<tr>
<th
*ngFor="let column of columns"
[nzSortOrder]="column.sortOrder"
[nzSortFn]="column.sortFn"
[nzSortDirections]="column.sortDirections"
[nzFilters]="column.listOfFilter"
[nzFilterFn]="column.filterFn"
[nzFilterMultiple]="column.filterMultiple"
[nzShowFilter]="column.listOfFilter.length > 0"
>
{{ column.name }}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let data of fileDetailsTable.data; trackBy: trackByPath">
<td title="{{ data.path }}">{{ data.path | filename }}</td>
<td title="{{ data.size | number }}">{{ data.size | filesize }}</td>
<td title="{{ data.status }}" class="status {{ data.status }}">
{{ data.status | filestatus }}
</td>
</tr>
</tbody>
</nz-table>
</nz-card>

View File

@ -0,0 +1,22 @@
.status {
&.recording {
color: red;
}
&.injecting,
&.remuxing {
color: blue;
}
&.completed {
color: green;
}
&.missing {
color: grey;
}
&.broken {
color: orange;
}
}

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskFileDetailComponent } from './task-file-detail.component';
describe('TaskFileDetailComponent', () => {
let component: TaskFileDetailComponent;
let fixture: ComponentFixture<TaskFileDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TaskFileDetailComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TaskFileDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,119 @@
import {
Component,
ChangeDetectionStrategy,
Input,
OnChanges,
} from '@angular/core';
import {
NzTableSortFn,
NzTableSortOrder,
NzTableFilterFn,
NzTableFilterList,
} from 'ng-zorro-antd/table';
import {
DanmakuFileDetail,
VideoFileDetail,
VideoFileStatus,
} from '../../shared/task.model';
type FileDetail = VideoFileDetail | DanmakuFileDetail;
interface ColumnItem {
name: string;
sortFn: NzTableSortFn<FileDetail> | null;
sortOrder: NzTableSortOrder | null;
sortDirections: NzTableSortOrder[];
filterFn: NzTableFilterFn<FileDetail> | null;
listOfFilter: NzTableFilterList;
filterMultiple: boolean;
}
const OrderedStatuses = [
VideoFileStatus.RECORDING,
VideoFileStatus.INJECTING,
VideoFileStatus.REMUXING,
VideoFileStatus.COMPLETED,
VideoFileStatus.MISSING,
];
@Component({
selector: 'app-task-file-detail',
templateUrl: './task-file-detail.component.html',
styleUrls: ['./task-file-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskFileDetailComponent implements OnChanges {
@Input() loading: boolean = true;
@Input() videoFileDetails: VideoFileDetail[] = [];
@Input() danmakuFileDetails: DanmakuFileDetail[] = [];
readonly VideoFileStatus = VideoFileStatus;
fileDetails: FileDetail[] = [];
columns: ColumnItem[] = [
{
name: '文件',
sortOrder: 'ascend',
sortFn: (a: FileDetail, b: FileDetail) => a.path.localeCompare(b.path),
sortDirections: ['ascend', 'descend'],
filterMultiple: false,
listOfFilter: [
{ text: '视频', value: 'video' },
{ text: '弹幕', value: 'danmaku' },
],
filterFn: (value: string, item: FileDetail) => {
switch (value) {
case 'video':
return item.path.endsWith('.flv') || item.path.endsWith('.mp4');
case 'danmaku':
return item.path.endsWith('.xml');
default:
return false;
}
},
},
{
name: '大小',
sortOrder: null,
sortFn: (a: FileDetail, b: FileDetail) => a.size - b.size,
sortDirections: ['ascend', 'descend', null],
filterMultiple: true,
listOfFilter: [],
filterFn: null,
},
{
name: '状态',
sortOrder: null,
sortFn: (a: FileDetail, b: FileDetail) =>
OrderedStatuses.indexOf(a.status as VideoFileStatus) -
OrderedStatuses.indexOf(b.status as VideoFileStatus),
sortDirections: ['ascend', 'descend', null],
filterMultiple: true,
listOfFilter: [
{ text: '录制中', value: [VideoFileStatus.RECORDING] },
{
text: '处理中',
value: [VideoFileStatus.INJECTING, VideoFileStatus.REMUXING],
},
{ text: '已完成', value: [VideoFileStatus.COMPLETED] },
{ text: '不存在', value: [VideoFileStatus.MISSING] },
],
filterFn: (filterValues: VideoFileStatus[][], item: FileDetail) =>
filterValues.some((listOfStatus) =>
listOfStatus.some((status) => status === item.status)
),
},
];
constructor() {}
ngOnChanges() {
this.fileDetails = [...this.videoFileDetails, ...this.danmakuFileDetails];
}
trackByPath(index: number, data: FileDetail): string {
return data.path;
}
}

View File

@ -0,0 +1,13 @@
<nz-card [nzTitle]="title" [nzLoading]="loading">
<p [title]="taskStatus.postprocessing_path">
{{ taskStatus.postprocessing_path ?? "" | filename }}
</p>
<nz-progress
nzStatus="active"
[nzPercent]="
taskStatus.postprocessing_progress === null
? 0
: (taskStatus.postprocessing_progress | progress)
"
></nz-progress>
</nz-card>

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskPostprocessingDetailComponent } from './task-postprocessing-detail.component';
describe('TaskPostprocessingDetailComponent', () => {
let component: TaskPostprocessingDetailComponent;
let fixture: ComponentFixture<TaskPostprocessingDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TaskPostprocessingDetailComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TaskPostprocessingDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,34 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
} from '@angular/core';
import { PostprocessorStatus, TaskStatus } from '../../shared/task.model';
@Component({
selector: 'app-task-postprocessing-detail',
templateUrl: './task-postprocessing-detail.component.html',
styleUrls: ['./task-postprocessing-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskPostprocessingDetailComponent implements OnInit {
@Input() loading: boolean = true;
@Input() taskStatus!: TaskStatus;
constructor() {}
ngOnInit(): void {}
get title(): string {
switch (this.taskStatus.postprocessor_status) {
case PostprocessorStatus.INJECTING:
return '更新 FLV 元数据';
case PostprocessorStatus.REMUXING:
return '转换 FLV 为 MP4';
default:
return '文件处理';
}
}
}

View File

@ -0,0 +1,24 @@
<nz-card nzTitle="录制详情" [nzLoading]="loading">
<div class="statistics">
<nz-statistic
[nzTitle]="'录制用时'"
[nzValue]="taskStatus.elapsed | duration"
></nz-statistic>
<nz-statistic
[nzTitle]="'录制速度'"
[nzValue]="taskStatus.data_rate | speed"
></nz-statistic>
<nz-statistic
[nzTitle]="'已录数据'"
[nzValue]="taskStatus.data_count | filesize: { spacer: '' }"
></nz-statistic>
<nz-statistic
[nzTitle]="'弹幕数量'"
[nzValue]="(taskStatus.danmu_count | number: '1.0-2')!"
></nz-statistic>
<nz-statistic
[nzTitle]="'所录画质'"
[nzValue]="(taskStatus.real_quality_number | quality)!"
></nz-statistic>
</div>
</nz-card>

View File

@ -0,0 +1,27 @@
$grid-width: 200px;
.statistics {
--grid-width: #{$grid-width};
display: grid;
grid-template-columns: repeat(auto-fill, var(--grid-width));
gap: 1em;
justify-content: center;
margin: 0 auto;
@media screen and (max-width: 1024px) {
--grid-width: 180px;
}
@media screen and (max-width: 720px) {
--grid-width: 160px;
}
@media screen and (max-width: 680px) {
--grid-width: 140px;
}
@media screen and (max-width: 480px) {
--grid-width: 120px;
}
}

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskRecordingDetailComponent } from './task-recording-detail.component';
describe('TaskRecordingDetailComponent', () => {
let component: TaskRecordingDetailComponent;
let fixture: ComponentFixture<TaskRecordingDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TaskRecordingDetailComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TaskRecordingDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,23 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
} from '@angular/core';
import { TaskStatus } from '../../shared/task.model';
@Component({
selector: 'app-task-recording-detail',
templateUrl: './task-recording-detail.component.html',
styleUrls: ['./task-recording-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskRecordingDetailComponent implements OnInit {
@Input() loading: boolean = true;
@Input() taskStatus!: TaskStatus;
constructor() {}
ngOnInit(): void {}
}

View File

@ -0,0 +1,44 @@
<nz-card nzTitle="直播间信息" [nzLoading]="loading">
<nz-descriptions nzTitle="">
<nz-descriptions-item nzTitle="标题">{{
roomInfo.title
}}</nz-descriptions-item>
<nz-descriptions-item nzTitle="分区">
{{ roomInfo.parent_area_name }} - {{ roomInfo.area_name }}
</nz-descriptions-item>
<nz-descriptions-item nzTitle="房间号">
<span class="room-id-wrapper">
<span class="short-room-id" *ngIf="roomInfo.short_room_id"
>{{ roomInfo.short_room_id }}
</span>
<span class="real-room-id">
{{ roomInfo.room_id }}
</span>
</span>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="状态">
<ng-container [ngSwitch]="roomInfo.live_status">
<ng-container *ngSwitchCase="0">闲置</ng-container>
<ng-container *ngSwitchCase="1">直播中</ng-container>
<ng-container *ngSwitchCase="2">轮播中</ng-container>
</ng-container>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="开播时间">
<ng-container *ngIf="roomInfo.live_start_time !== 0">
{{ roomInfo.live_start_time * 1000 | date: "YYYY-MM-dd HH:mm:ss":"+8" }}
</ng-container>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="标签">
<div class="tags">
<nz-tag *ngFor="let tag of roomInfo.tags.split(',')">
{{ tag }}
</nz-tag>
</div>
</nz-descriptions-item>
<nz-descriptions-item nzTitle="简介">
<div class="introduction">
<p *ngFor="let line of roomInfo.description.split('\n')">{{ line }}</p>
</div>
</nz-descriptions-item>
</nz-descriptions>
</nz-card>

View File

@ -0,0 +1,23 @@
.room-id-wrapper {
display: flex;
flex-wrap: wrap;
.short-room-id::after {
display: inline-block;
width: 1em;
content: ",";
}
}
.tags {
display: flex;
flex-wrap: wrap;
row-gap: 0.5em;
}
.introduction {
p {
margin: 0;
padding: 0;
}
}

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskRoomInfoDetailComponent } from './task-room-info-detail.component';
describe('TaskRoomInfoDetailComponent', () => {
let component: TaskRoomInfoDetailComponent;
let fixture: ComponentFixture<TaskRoomInfoDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TaskRoomInfoDetailComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TaskRoomInfoDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,23 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
} from '@angular/core';
import { RoomInfo } from '../../shared/task.model';
@Component({
selector: 'app-task-room-info-detail',
templateUrl: './task-room-info-detail.component.html',
styleUrls: ['./task-room-info-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskRoomInfoDetailComponent implements OnInit {
@Input() loading: boolean = true;
@Input() roomInfo!: RoomInfo;
constructor() {}
ngOnInit(): void {}
}

View File

@ -0,0 +1,19 @@
<nz-card nzTitle="主播信息" [nzLoading]="loading">
<nz-descriptions nzTitle="">
<nz-descriptions-item nzTitle="昵称">{{
userInfo.name
}}</nz-descriptions-item>
<nz-descriptions-item nzTitle="性别">{{
userInfo.gender
}}</nz-descriptions-item>
<nz-descriptions-item nzTitle="UID">{{
userInfo.uid
}}</nz-descriptions-item>
<nz-descriptions-item nzTitle="等级">{{
userInfo.level
}}</nz-descriptions-item>
<nz-descriptions-item nzTitle="签名">
{{ userInfo.sign }}
</nz-descriptions-item>
</nz-descriptions>
</nz-card>

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskUserInfoDetailComponent } from './task-user-info-detail.component';
describe('TaskUserInfoDetailComponent', () => {
let component: TaskUserInfoDetailComponent;
let fixture: ComponentFixture<TaskUserInfoDetailComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ TaskUserInfoDetailComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TaskUserInfoDetailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,23 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
} from '@angular/core';
import { UserInfo } from '../../shared/task.model';
@Component({
selector: 'app-task-user-info-detail',
templateUrl: './task-user-info-detail.component.html',
styleUrls: ['./task-user-info-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskUserInfoDetailComponent implements OnInit {
@Input() loading: boolean = true;
@Input() userInfo!: UserInfo;
constructor() {}
ngOnInit(): void {}
}

View File

@ -1,5 +1,6 @@
<nz-card
[nzCover]="coverTemplate"
[nzHoverable]="true"
[nzActions]="[actionDelete, actionSetting, actionSwitch, actionMore]"
[nzBodyStyle]="{ padding: '0.5rem' }"
>
@ -17,24 +18,26 @@
</nz-card>
<ng-template #coverTemplate>
<div class="cover-wrapper">
<img
class="cover"
alt="直播间封面"
[src]="data.room_info.cover | dataurl | async"
/>
<a [routerLink]="[data.room_info.room_id, 'detail']">
<div class="cover-wrapper">
<img
class="cover"
alt="直播间封面"
[src]="data.room_info.cover | dataurl | async"
/>
<h2
class="title"
nz-tooltip
[nzTooltipTitle]="'直播间标题:' + data.room_info.title"
nzTooltipPlacement="bottomLeft"
>
{{ data.room_info.title }}
</h2>
<h2
class="title"
nz-tooltip
[nzTooltipTitle]="'直播间标题:' + data.room_info.title"
nzTooltipPlacement="bottomLeft"
>
{{ data.room_info.title }}
</h2>
<app-status-display [status]="data.task_status"></app-status-display>
</div>
<app-status-display [status]="data.task_status"></app-status-display>
</div>
</a>
</ng-template>
<ng-template #avatarTemplate>
@ -147,19 +150,19 @@
<ng-template #actionDelete>
<div
nz-tooltip
nzTooltipTitle="删除任务"
nz-popconfirm
nzPopconfirmTitle="确定要删除此任务?"
nzPopconfirmPlacement="topLeft"
(nzOnConfirm)="removeTask()"
nzTooltipTitle="切割文件"
[class.not-allowed]="
data.task_status.running_status !== RunningStatus.RECORDING
"
(click)="cutStream()"
>
<i nz-icon nzType="delete"></i>
<i nz-icon nzType="scissor" class="action-icon"></i>
</div>
</ng-template>
<ng-template #actionSetting>
<div nz-tooltip nzTooltipTitle="任务设置" (click)="openSettingsDialog()">
<i nz-icon nzType="setting"></i>
<i nz-icon nzType="setting" class="action-icon"></i>
</div>
<ng-container *ngIf="taskOptions && globalSettings">
<app-task-settings-dialog
@ -179,10 +182,10 @@
nzPlacement="topRight"
[nzDropdownMenu]="dropdownMenu"
>
<i nz-icon nzType="more"></i>
<i nz-icon nzType="more" class="action-icon"></i>
</div>
<div *ngIf="useDrawer" (click)="menuDrawerVisible = true">
<i nz-icon nzType="more"></i>
<i nz-icon nzType="more" class="action-icon"></i>
</div>
</ng-template>
@ -192,6 +195,7 @@
<ul nz-menu class="menu">
<li nz-menu-item (click)="startTask()">运行任务</li>
<li nz-menu-item (click)="stopTask()">停止任务</li>
<li nz-menu-item (click)="removeTask()">删除任务</li>
<li nz-menu-item (click)="stopTask(true)">强制停止任务</li>
<li nz-menu-item (click)="disableRecorder(true)">强制关闭录制</li>
<li nz-menu-item (click)="updateTaskInfo()">刷新数据</li>

View File

@ -1,6 +1,6 @@
@use '../../shared/styles/layout';
@use '../../shared/styles/text';
@use '../shared/styles/drawer';
@use "../../shared/styles/layout";
@use "../../shared/styles/text";
@use "../shared/styles/drawer";
:host {
&.stopped {
@ -86,7 +86,7 @@ nz-card-meta {
.short-room-id::after {
display: inline-block;
width: 1em;
content: ", ";
content: ",";
}
@media screen and (max-width: 320px) {
@ -112,3 +112,11 @@ nz-card-meta {
@extend %drawer-menu;
}
}
.action-icon {
font-size: 16px;
}
.not-allowed {
cursor: not-allowed;
}

View File

@ -213,4 +213,10 @@ export class TaskItemComponent implements OnChanges, OnDestroy {
}
);
}
cutStream(): void {
if (this.data.task_status.running_status === RunningStatus.RECORDING) {
this.taskManager.cutStream(this.roomId).subscribe();
}
}
}

View File

@ -1,11 +1,18 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { TaskDetailComponent } from './task-detail/task-detail.component';
import { TasksComponent } from './tasks.component';
const routes: Routes = [{ path: '', component: TasksComponent }];
const routes: Routes = [
{
path: ':id/detail',
component: TaskDetailComponent,
},
{ path: '', component: TasksComponent },
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
exports: [RouterModule],
})
export class TasksRoutingModule { }
export class TasksRoutingModule {}

View File

@ -27,6 +27,9 @@ import { NzAlertModule } from 'ng-zorro-antd/alert';
import { NzDrawerModule } from 'ng-zorro-antd/drawer';
import { NzSelectModule } from 'ng-zorro-antd/select';
import { NzProgressModule } from 'ng-zorro-antd/progress';
import { NzTableModule } from 'ng-zorro-antd/table';
import { NzStatisticModule } from 'ng-zorro-antd/statistic';
import { NzDescriptionsModule } from 'ng-zorro-antd/descriptions';
import { SharedModule } from '../shared/shared.module';
import { TasksRoutingModule } from './tasks-routing.module';
@ -38,6 +41,12 @@ import { FilterTasksPipe } from './shared/pipes/filter-tasks.pipe';
import { AddTaskDialogComponent } from './add-task-dialog/add-task-dialog.component';
import { TaskSettingsDialogComponent } from './task-settings-dialog/task-settings-dialog.component';
import { StatusDisplayComponent } from './status-display/status-display.component';
import { TaskDetailComponent } from './task-detail/task-detail.component';
import { TaskFileDetailComponent } from './task-detail/task-file-detail/task-file-detail.component';
import { TaskUserInfoDetailComponent } from './task-detail/task-user-info-detail/task-user-info-detail.component';
import { TaskRoomInfoDetailComponent } from './task-detail/task-room-info-detail/task-room-info-detail.component';
import { TaskPostprocessingDetailComponent } from './task-detail/task-postprocessing-detail/task-postprocessing-detail.component';
import { TaskRecordingDetailComponent } from './task-detail/task-recording-detail/task-recording-detail.component';
@NgModule({
declarations: [
@ -49,6 +58,12 @@ import { StatusDisplayComponent } from './status-display/status-display.componen
AddTaskDialogComponent,
TaskSettingsDialogComponent,
StatusDisplayComponent,
TaskDetailComponent,
TaskFileDetailComponent,
TaskUserInfoDetailComponent,
TaskRoomInfoDetailComponent,
TaskPostprocessingDetailComponent,
TaskRecordingDetailComponent,
],
imports: [
CommonModule,
@ -81,6 +96,9 @@ import { StatusDisplayComponent } from './status-display/status-display.componen
NzDrawerModule,
NzSelectModule,
NzProgressModule,
NzTableModule,
NzStatisticModule,
NzDescriptionsModule,
TasksRoutingModule,
SharedModule,