mirror of
https://github.com/acgnhiki/blrec.git
synced 2025-03-13 17:00:15 +08:00
release: v1.2.0
This commit is contained in:
parent
672c3f094e
commit
3e71c39e0d
@ -1,5 +1,12 @@
|
||||
# 更新日志
|
||||
|
||||
## 1.2.0
|
||||
|
||||
- 改进文件处理方式,文件录制完成后就进行处理。
|
||||
- 支持手动分割文件(单击任务卡片左下角剪刀图标)
|
||||
- 添加任务详情页面(单击任务卡片的封面图进入)
|
||||
- 修复 FastApi 文档页面访问不了
|
||||
|
||||
## 1.1.0
|
||||
|
||||
- 支持记录送物、上舰、醒目留言到弹幕文件
|
||||
|
@ -1,4 +1,4 @@
|
||||
|
||||
__prog__ = 'blrec'
|
||||
__version__ = '1.1.0'
|
||||
__version__ = '1.2.0'
|
||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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())
|
||||
|
1
src/blrec/data/webapp/124.04d3ada75a9a6f4119c0.js
Normal file
1
src/blrec/data/webapp/124.04d3ada75a9a6f4119c0.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
src/blrec/data/webapp/481.2bc447cad2b7421383e8.js
Normal file
1
src/blrec/data/webapp/481.2bc447cad2b7421383e8.js
Normal file
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
1
src/blrec/data/webapp/945.29b73cfac30295070168.js
Normal file
1
src/blrec/data/webapp/945.29b73cfac30295070168.js
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
1
src/blrec/data/webapp/main.1cfa15ba47a0ebad32e6.js
Normal file
1
src/blrec/data/webapp/main.1cfa15ba47a0ebad32e6.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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"
|
||||
|
1
src/blrec/data/webapp/runtime.4f3f03ac4847b0cd3a3a.js
Normal file
1
src/blrec/data/webapp/runtime.4f3f03ac4847b0cd3a3a.js
Normal 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))})()})();
|
@ -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))})()})();
|
@ -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)));
|
||||
}));
|
||||
});
|
||||
|
2
src/blrec/data/webapp/styles.2e25f4678bcb2c0682d5.css
Normal file
2
src/blrec/data/webapp/styles.2e25f4678bcb2c0682d5.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
10
src/blrec/data/webapp/worker-basic.min.js
vendored
10
src/blrec/data/webapp/worker-basic.min.js
vendored
@ -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)));
|
||||
}));
|
||||
});
|
||||
|
@ -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',
|
||||
|
@ -6,3 +6,7 @@ class NotFoundError(ValueError):
|
||||
|
||||
class ExistsError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class ForbiddenError(Exception):
|
||||
pass
|
||||
|
@ -22,3 +22,7 @@ class FileSizeOverLimit(Exception):
|
||||
|
||||
class DurationOverLimit(Exception):
|
||||
...
|
||||
|
||||
|
||||
class CutStream(Exception):
|
||||
...
|
||||
|
@ -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)
|
||||
|
||||
|
60
src/blrec/flv/stream_cutter.py
Normal file
60
src/blrec/flv/stream_cutter.py
Normal 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()
|
@ -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
|
||||
|
@ -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',
|
||||
)
|
||||
|
@ -2,7 +2,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ProcessStatus(Enum):
|
||||
class PostprocessorStatus(Enum):
|
||||
WAITING = 'waiting'
|
||||
REMUXING = 'remuxing'
|
||||
INJECTING = 'injecting'
|
||||
|
@ -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:
|
||||
|
@ -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',
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -26,5 +26,13 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"navigationUrls": [
|
||||
"/**",
|
||||
"!/**/*.*",
|
||||
"!/**/*__*",
|
||||
"!/**/*__*/**",
|
||||
"!/**/docs",
|
||||
"!/**/redoc"
|
||||
]
|
||||
}
|
||||
|
9124
webapp/package-lock.json
generated
9124
webapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
8
webapp/src/app/shared/pipes/filestatus.pipe.spec.ts
Normal file
8
webapp/src/app/shared/pipes/filestatus.pipe.spec.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { FilestatusPipe } from './filestatus.pipe';
|
||||
|
||||
describe('FilestatusPipe', () => {
|
||||
it('create an instance', () => {
|
||||
const pipe = new FilestatusPipe();
|
||||
expect(pipe).toBeTruthy();
|
||||
});
|
||||
});
|
24
webapp/src/app/shared/pipes/filestatus.pipe.ts
Normal file
24
webapp/src/app/shared/pipes/filestatus.pipe.ts
Normal 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) ?? '???';
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 { }
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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'"
|
||||
|
38
webapp/src/app/tasks/task-detail/task-detail.component.html
Normal file
38
webapp/src/app/tasks/task-detail/task-detail.component.html
Normal 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>
|
@ -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();
|
||||
});
|
||||
});
|
94
webapp/src/app/tasks/task-detail/task-detail.component.ts
Normal file
94
webapp/src/app/tasks/task-detail/task-detail.component.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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>
|
@ -0,0 +1,22 @@
|
||||
.status {
|
||||
&.recording {
|
||||
color: red;
|
||||
}
|
||||
|
||||
&.injecting,
|
||||
&.remuxing {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
&.completed {
|
||||
color: green;
|
||||
}
|
||||
|
||||
&.missing {
|
||||
color: grey;
|
||||
}
|
||||
|
||||
&.broken {
|
||||
color: orange;
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
@ -0,0 +1,3 @@
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 '文件处理';
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 {}
|
||||
}
|
@ -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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 {}
|
||||
}
|
@ -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>
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 {}
|
||||
}
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {}
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user