Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
159ab3b632 |
4
.github/workflows/portable.yml
vendored
4
.github/workflows/portable.yml
vendored
@@ -6,8 +6,8 @@ on:
|
||||
- v*.*.*
|
||||
|
||||
env:
|
||||
FFMPEG_ARCHIVE_URL: https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-lgpl-shared.zip
|
||||
FFMPEG_ARCHIVE_NAME: ffmpeg-master-latest-win64-lgpl-shared.zip
|
||||
FFMPEG_ARCHIVE_URL: https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n5.0-latest-win64-lgpl-shared-5.0.zip
|
||||
FFMPEG_ARCHIVE_NAME: ffmpeg-n5.0-latest-win64-lgpl-shared-5.0.zip
|
||||
PYTHON_ARCHIVE_URL: https://www.python.org/ftp/python/3.10.4/python-3.10.4-embed-amd64.zip
|
||||
|
||||
jobs:
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,19 +1,5 @@
|
||||
# 更新日志
|
||||
|
||||
## 1.9.1
|
||||
|
||||
- 修复禁用硬盘空间检测出现 cpu 占用异常
|
||||
|
||||
## 1.9.0
|
||||
|
||||
- 录制 HLS(fmp4) 原始数据
|
||||
- 可以禁用硬盘空间检测
|
||||
- 自定义自动文件分割的大小和时长
|
||||
|
||||
## 1.8.1
|
||||
|
||||
修 bug
|
||||
|
||||
## 1.8.0
|
||||
|
||||
- 重构直播流录制
|
||||
|
||||
14
FAQ.md
14
FAQ.md
@@ -1,19 +1,5 @@
|
||||
# 常见问题
|
||||
|
||||
## HLS 标准录制模式和原始录制模式有什么区别?
|
||||
|
||||
| | 标准录制模式 | 原始录制模式 |
|
||||
| --- | --- | --- |
|
||||
| ffmpeg | 需要 | 不需要 |
|
||||
| 资源占用 | 较多 | 较少 |
|
||||
| 稳定性 | 比较差 | 比较好 |
|
||||
| 录播文件 | 一个 flv 文件 | 很多片段文件 |
|
||||
| 录播信息 | 包含在 flv 文件里 | 单独保存为一个文件 (index.meta.json) |
|
||||
| 播放器支持 | 几乎全部播放器都支持 flv | 支持 m3u8 的播放器很少 (VLC、dandanplay) |
|
||||
| 自动分割文件 | 支持 | 不支持 |
|
||||
| 手动分割文件 | 支持 | 不支持 |
|
||||
| 自动转 mp4 | 支持 | 支持 |
|
||||
|
||||
## 如何终止程序?
|
||||
|
||||
`ctrl + c`
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
__prog__ = 'blrec'
|
||||
__version__ = '1.9.1'
|
||||
__version__ = '1.8.0'
|
||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
||||
|
||||
@@ -244,11 +244,11 @@ class Live:
|
||||
return 1
|
||||
if num == '08':
|
||||
return 2
|
||||
return 1000 + int(num)
|
||||
return int(num)
|
||||
elif re.search(r'cn-[a-z]+-[a-z]+', host):
|
||||
return 2000
|
||||
return 1000
|
||||
elif 'mcdn' in host:
|
||||
return 5000
|
||||
return 2000
|
||||
else:
|
||||
return 10000
|
||||
|
||||
|
||||
@@ -8,13 +8,12 @@ import aiofiles
|
||||
import aiohttp
|
||||
from tenacity import retry, stop_after_attempt, wait_fixed
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.exception import exception_callback
|
||||
from blrec.logging.room_id import aio_task_with_room_id
|
||||
from blrec.path import cover_path
|
||||
from blrec.utils.hash import sha1sum
|
||||
from blrec.utils.mixins import SwitchableMixin
|
||||
|
||||
from ..bili.live import Live
|
||||
from ..exception import exception_callback
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
from ..path import cover_path
|
||||
from ..utils.hash import sha1sum
|
||||
from ..utils.mixins import SwitchableMixin
|
||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||
|
||||
__all__ = ('CoverDownloader',)
|
||||
|
||||
@@ -7,23 +7,22 @@ from typing import Iterator, List, Optional
|
||||
|
||||
from tenacity import AsyncRetrying, retry_if_not_exception_type, stop_after_attempt
|
||||
|
||||
from blrec import __github__, __prog__, __version__
|
||||
from blrec.bili.live import Live
|
||||
from blrec.core.models import GiftSendMsg, GuardBuyMsg, SuperChatMsg
|
||||
from blrec.danmaku.io import DanmakuWriter
|
||||
from blrec.danmaku.models import (
|
||||
from .. import __github__, __prog__, __version__
|
||||
from ..bili.live import Live
|
||||
from ..core.models import GiftSendMsg, GuardBuyMsg, SuperChatMsg
|
||||
from ..danmaku.io import DanmakuWriter
|
||||
from ..danmaku.models import (
|
||||
Danmu,
|
||||
GiftSendRecord,
|
||||
GuardBuyRecord,
|
||||
Metadata,
|
||||
SuperChatRecord,
|
||||
)
|
||||
from blrec.event.event_emitter import EventEmitter, EventListener
|
||||
from blrec.exception import exception_callback, submit_exception
|
||||
from blrec.logging.room_id import aio_task_with_room_id
|
||||
from blrec.path import danmaku_path
|
||||
from blrec.utils.mixins import SwitchableMixin
|
||||
|
||||
from ..event.event_emitter import EventEmitter, EventListener
|
||||
from ..exception import exception_callback, submit_exception
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
from ..path import danmaku_path
|
||||
from ..utils.mixins import SwitchableMixin
|
||||
from .danmaku_receiver import DanmakuReceiver, DanmuMsg
|
||||
from .statistics import Statistics
|
||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||
@@ -94,13 +93,10 @@ class DanmakuDumper(
|
||||
|
||||
def _do_enable(self) -> None:
|
||||
self._stream_recorder.add_listener(self)
|
||||
self._statistics.reset()
|
||||
logger.debug('Enabled danmaku dumper')
|
||||
|
||||
def _do_disable(self) -> None:
|
||||
self._stream_recorder.remove_listener(self)
|
||||
asyncio.create_task(self._stop_dumping())
|
||||
self._statistics.freeze()
|
||||
logger.debug('Disabled danmaku dumper')
|
||||
|
||||
def set_live_start_time(self, time: int) -> None:
|
||||
@@ -126,7 +122,6 @@ class DanmakuDumper(
|
||||
self._stream_recording_interrupted: bool = False
|
||||
self._path = danmaku_path(video_path)
|
||||
self._files.append(self._path)
|
||||
await self._stop_dumping()
|
||||
self._start_dumping()
|
||||
|
||||
async def on_video_file_completed(self, video_path: str) -> None:
|
||||
@@ -152,9 +147,7 @@ class DanmakuDumper(
|
||||
self._create_dump_task()
|
||||
|
||||
async def _stop_dumping(self) -> None:
|
||||
if hasattr(self, '_dump_task'):
|
||||
await self._cancel_dump_task()
|
||||
del self._dump_task # type: ignore
|
||||
await self._cancel_dump_task()
|
||||
|
||||
def _create_dump_task(self) -> None:
|
||||
self._dump_task = asyncio.create_task(self._do_dump())
|
||||
@@ -169,6 +162,7 @@ class DanmakuDumper(
|
||||
async def _do_dump(self) -> None:
|
||||
assert self._path is not None
|
||||
logger.debug('Started dumping danmaku')
|
||||
self._statistics.reset()
|
||||
|
||||
try:
|
||||
async with DanmakuWriter(self._path) as writer:
|
||||
@@ -190,6 +184,7 @@ class DanmakuDumper(
|
||||
logger.info(f"Danmaku file completed: '{self._path}'")
|
||||
await self._emit('danmaku_file_completed', self._path)
|
||||
logger.debug('Stopped dumping danmaku')
|
||||
self._statistics.freeze()
|
||||
|
||||
async def _dumping_loop(self, writer: DanmakuWriter) -> None:
|
||||
while True:
|
||||
|
||||
@@ -2,10 +2,9 @@ import logging
|
||||
from asyncio import Queue, QueueFull
|
||||
from typing import Final
|
||||
|
||||
from blrec.bili.danmaku_client import DanmakuClient, DanmakuCommand, DanmakuListener
|
||||
from blrec.bili.typing import Danmaku
|
||||
from blrec.utils.mixins import StoppableMixin
|
||||
|
||||
from ..bili.danmaku_client import DanmakuClient, DanmakuCommand, DanmakuListener
|
||||
from ..bili.typing import Danmaku
|
||||
from ..utils.mixins import StoppableMixin
|
||||
from .models import DanmuMsg, GiftSendMsg, GuardBuyMsg, SuperChatMsg
|
||||
from .typing import DanmakuMsg
|
||||
|
||||
|
||||
@@ -3,13 +3,10 @@ from typing import Optional
|
||||
|
||||
from reactivex.scheduler import NewThreadScheduler
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.bili.typing import QualityNumber
|
||||
from blrec.flv import operators as flv_ops
|
||||
from blrec.flv.metadata_dumper import MetadataDumper
|
||||
from blrec.utils.mixins import SupportDebugMixin
|
||||
|
||||
from . import operators as core_ops
|
||||
from ..bili.live import Live
|
||||
from ..bili.typing import QualityNumber
|
||||
from ..flv import operators as flv_ops
|
||||
from ..utils.mixins import SupportDebugMixin
|
||||
from .stream_recorder_impl import StreamRecorderImpl
|
||||
|
||||
__all__ = ('FLVStreamRecorderImpl',)
|
||||
@@ -37,7 +34,6 @@ class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
|
||||
out_dir=out_dir,
|
||||
path_template=path_template,
|
||||
stream_format='flv',
|
||||
recording_mode='standard',
|
||||
quality_number=quality_number,
|
||||
buffer_size=buffer_size,
|
||||
read_timeout=read_timeout,
|
||||
@@ -47,89 +43,6 @@ class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
|
||||
)
|
||||
self._init_for_debug(live.room_id)
|
||||
|
||||
self._stream_fetcher = core_ops.StreamFetcher(
|
||||
live, self._session, read_timeout=read_timeout
|
||||
)
|
||||
|
||||
self._prober = flv_ops.Prober()
|
||||
self._dl_statistics = core_ops.StreamStatistics()
|
||||
|
||||
self._stream_parser = core_ops.StreamParser(self._stream_param_holder)
|
||||
self._analyser = flv_ops.Analyser()
|
||||
self._injector = flv_ops.Injector(self._metadata_provider)
|
||||
self._join_point_extractor = flv_ops.JoinPointExtractor()
|
||||
self._limiter = flv_ops.Limiter(filesize_limit, duration_limit)
|
||||
self._cutter = flv_ops.Cutter()
|
||||
self._dumper = flv_ops.Dumper(self._path_provider, buffer_size)
|
||||
self._metadata_dumper = MetadataDumper(
|
||||
self._dumper, self._analyser, self._join_point_extractor
|
||||
)
|
||||
|
||||
self._recording_monitor = core_ops.RecordingMonitor(
|
||||
live, lambda: self._analyser.duration
|
||||
)
|
||||
|
||||
self._prober.profiles.subscribe(self._on_profile_updated)
|
||||
self._dumper.file_opened.subscribe(self._on_video_file_opened)
|
||||
self._dumper.file_closed.subscribe(self._on_video_file_closed)
|
||||
self._recording_monitor.interrupted.subscribe(self._on_recording_interrupted)
|
||||
self._recording_monitor.recovered.subscribe(self._on_recording_recovered)
|
||||
|
||||
@property
|
||||
def read_timeout(self) -> int:
|
||||
return self._stream_fetcher.read_timeout
|
||||
|
||||
@read_timeout.setter
|
||||
def read_timeout(self, value: int) -> None:
|
||||
self._stream_fetcher.read_timeout = value
|
||||
|
||||
@property
|
||||
def buffer_size(self) -> int:
|
||||
return self._dumper.buffer_size
|
||||
|
||||
@buffer_size.setter
|
||||
def buffer_size(self, value: int) -> None:
|
||||
self._dumper.buffer_size = value
|
||||
|
||||
@property
|
||||
def recording_path(self) -> Optional[str]:
|
||||
return self._dumper.path
|
||||
|
||||
@property
|
||||
def filesize_limit(self) -> int:
|
||||
return self._limiter.filesize_limit
|
||||
|
||||
@filesize_limit.setter
|
||||
def filesize_limit(self, value: int) -> None:
|
||||
self._limiter.filesize_limit = value
|
||||
|
||||
@property
|
||||
def duration_limit(self) -> int:
|
||||
return self._limiter.duration_limit
|
||||
|
||||
@duration_limit.setter
|
||||
def duration_limit(self, value: int) -> None:
|
||||
self._limiter.duration_limit = value
|
||||
|
||||
@property
|
||||
def metadata(self) -> Optional[flv_ops.MetaData]:
|
||||
try:
|
||||
return self._analyser.make_metadata()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def can_cut_stream(self) -> bool:
|
||||
return self._cutter.can_cut_stream()
|
||||
|
||||
def cut_stream(self) -> bool:
|
||||
return self._cutter.cut_stream()
|
||||
|
||||
def _on_start(self) -> None:
|
||||
self._metadata_dumper.enable()
|
||||
|
||||
def _on_stop(self) -> None:
|
||||
self._metadata_dumper.disable()
|
||||
|
||||
def _run(self) -> None:
|
||||
self._subscription = (
|
||||
self._stream_param_holder.get_stream_params() # type: ignore
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from reactivex import operators as ops
|
||||
from reactivex.scheduler import NewThreadScheduler
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.bili.typing import QualityNumber
|
||||
from blrec.hls import operators as hls_ops
|
||||
from blrec.hls.metadata_dumper import MetadataDumper
|
||||
|
||||
from . import operators as core_ops
|
||||
from .stream_recorder_impl import StreamRecorderImpl
|
||||
|
||||
__all__ = ('HLSRawStreamRecorderImpl',)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HLSRawStreamRecorderImpl(StreamRecorderImpl):
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
out_dir: str,
|
||||
path_template: str,
|
||||
*,
|
||||
quality_number: QualityNumber = 10000,
|
||||
buffer_size: Optional[int] = None,
|
||||
read_timeout: Optional[int] = None,
|
||||
disconnection_timeout: Optional[int] = None,
|
||||
filesize_limit: int = 0,
|
||||
duration_limit: int = 0,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
live=live,
|
||||
out_dir=out_dir,
|
||||
path_template=path_template,
|
||||
stream_format='fmp4',
|
||||
recording_mode='raw',
|
||||
quality_number=quality_number,
|
||||
buffer_size=buffer_size,
|
||||
read_timeout=read_timeout,
|
||||
disconnection_timeout=disconnection_timeout,
|
||||
filesize_limit=filesize_limit,
|
||||
duration_limit=duration_limit,
|
||||
)
|
||||
|
||||
self._playlist_fetcher = hls_ops.PlaylistFetcher(self._live, self._session)
|
||||
self._playlist_dumper = hls_ops.PlaylistDumper(self._path_provider)
|
||||
self._segment_fetcher = hls_ops.SegmentFetcher(self._live, self._session)
|
||||
self._segment_dumper = hls_ops.SegmentDumper(self._playlist_dumper)
|
||||
self._ff_metadata_dumper = MetadataDumper(
|
||||
self._playlist_dumper, self._metadata_provider
|
||||
)
|
||||
|
||||
self._prober = hls_ops.Prober()
|
||||
self._dl_statistics = core_ops.SizedStatistics()
|
||||
|
||||
self._recording_monitor = core_ops.RecordingMonitor(
|
||||
live, lambda: self._playlist_dumper.duration
|
||||
)
|
||||
|
||||
self._prober.profiles.subscribe(self._on_profile_updated)
|
||||
self._playlist_dumper.file_opened.subscribe(self._on_video_file_opened)
|
||||
self._playlist_dumper.file_closed.subscribe(self._on_video_file_closed)
|
||||
self._recording_monitor.interrupted.subscribe(self._on_recording_interrupted)
|
||||
self._recording_monitor.recovered.subscribe(self._on_recording_recovered)
|
||||
|
||||
@property
|
||||
def recording_path(self) -> Optional[str]:
|
||||
return self._playlist_dumper.path
|
||||
|
||||
def _on_start(self) -> None:
|
||||
self._ff_metadata_dumper.enable()
|
||||
|
||||
def _on_stop(self) -> None:
|
||||
self._ff_metadata_dumper.disable()
|
||||
|
||||
def _run(self) -> None:
|
||||
self._subscription = (
|
||||
self._stream_param_holder.get_stream_params() # type: ignore
|
||||
.pipe(
|
||||
self._stream_url_resolver,
|
||||
ops.subscribe_on(
|
||||
NewThreadScheduler(self._thread_factory('PlaylistDownloader'))
|
||||
),
|
||||
self._playlist_fetcher,
|
||||
self._recording_monitor,
|
||||
self._connection_error_handler,
|
||||
self._request_exception_handler,
|
||||
self._playlist_dumper,
|
||||
ops.observe_on(
|
||||
NewThreadScheduler(self._thread_factory('SegmentDownloader'))
|
||||
),
|
||||
self._segment_fetcher,
|
||||
self._dl_statistics,
|
||||
self._prober,
|
||||
self._segment_dumper,
|
||||
self._rec_statistics,
|
||||
self._progress_bar,
|
||||
self._exception_handler,
|
||||
)
|
||||
.subscribe(on_completed=self._on_completed)
|
||||
)
|
||||
@@ -4,12 +4,9 @@ from typing import Optional
|
||||
from reactivex import operators as ops
|
||||
from reactivex.scheduler import NewThreadScheduler
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.bili.typing import QualityNumber
|
||||
from blrec.flv import operators as flv_ops
|
||||
from blrec.flv.metadata_dumper import MetadataDumper
|
||||
from blrec.hls import operators as hls_ops
|
||||
|
||||
from ..bili.live import Live
|
||||
from ..bili.typing import QualityNumber
|
||||
from ..flv import operators as flv_ops
|
||||
from . import operators as core_ops
|
||||
from .stream_recorder_impl import StreamRecorderImpl
|
||||
|
||||
@@ -38,7 +35,6 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
|
||||
out_dir=out_dir,
|
||||
path_template=path_template,
|
||||
stream_format='fmp4',
|
||||
recording_mode='standard',
|
||||
quality_number=quality_number,
|
||||
buffer_size=buffer_size,
|
||||
read_timeout=read_timeout,
|
||||
@@ -47,83 +43,10 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
|
||||
duration_limit=duration_limit,
|
||||
)
|
||||
|
||||
self._playlist_fetcher = hls_ops.PlaylistFetcher(self._live, self._session)
|
||||
self._playlist_resolver = hls_ops.PlaylistResolver()
|
||||
self._segment_fetcher = hls_ops.SegmentFetcher(self._live, self._session)
|
||||
self._segment_remuxer = hls_ops.SegmentRemuxer(live)
|
||||
|
||||
self._prober = hls_ops.Prober()
|
||||
self._dl_statistics = core_ops.SizedStatistics()
|
||||
|
||||
self._stream_parser = core_ops.StreamParser(
|
||||
self._stream_param_holder, ignore_eof=True, ignore_value_error=True
|
||||
)
|
||||
self._analyser = flv_ops.Analyser()
|
||||
self._injector = flv_ops.Injector(self._metadata_provider)
|
||||
self._join_point_extractor = flv_ops.JoinPointExtractor()
|
||||
self._limiter = flv_ops.Limiter(filesize_limit, duration_limit)
|
||||
self._cutter = flv_ops.Cutter()
|
||||
self._dumper = flv_ops.Dumper(self._path_provider, buffer_size)
|
||||
self._metadata_dumper = MetadataDumper(
|
||||
self._dumper, self._analyser, self._join_point_extractor
|
||||
)
|
||||
|
||||
self._recording_monitor = core_ops.RecordingMonitor(
|
||||
live, lambda: self._analyser.duration
|
||||
)
|
||||
|
||||
self._prober.profiles.subscribe(self._on_profile_updated)
|
||||
self._dumper.file_opened.subscribe(self._on_video_file_opened)
|
||||
self._dumper.file_closed.subscribe(self._on_video_file_closed)
|
||||
self._recording_monitor.interrupted.subscribe(self._on_recording_interrupted)
|
||||
self._recording_monitor.recovered.subscribe(self._on_recording_recovered)
|
||||
|
||||
@property
|
||||
def buffer_size(self) -> int:
|
||||
return self._dumper.buffer_size
|
||||
|
||||
@buffer_size.setter
|
||||
def buffer_size(self, value: int) -> None:
|
||||
self._dumper.buffer_size = value
|
||||
|
||||
@property
|
||||
def recording_path(self) -> Optional[str]:
|
||||
return self._dumper.path
|
||||
|
||||
@property
|
||||
def filesize_limit(self) -> int:
|
||||
return self._limiter.filesize_limit
|
||||
|
||||
@filesize_limit.setter
|
||||
def filesize_limit(self, value: int) -> None:
|
||||
self._limiter.filesize_limit = value
|
||||
|
||||
@property
|
||||
def duration_limit(self) -> int:
|
||||
return self._limiter.duration_limit
|
||||
|
||||
@duration_limit.setter
|
||||
def duration_limit(self, value: int) -> None:
|
||||
self._limiter.duration_limit = value
|
||||
|
||||
@property
|
||||
def metadata(self) -> Optional[flv_ops.MetaData]:
|
||||
try:
|
||||
return self._analyser.make_metadata()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def can_cut_stream(self) -> bool:
|
||||
return self._cutter.can_cut_stream()
|
||||
|
||||
def cut_stream(self) -> bool:
|
||||
return self._cutter.cut_stream()
|
||||
|
||||
def _on_start(self) -> None:
|
||||
self._metadata_dumper.enable()
|
||||
|
||||
def _on_stop(self) -> None:
|
||||
self._metadata_dumper.disable()
|
||||
self._playlist_fetcher = core_ops.PlaylistFetcher(self._live, self._session)
|
||||
self._playlist_resolver = core_ops.PlaylistResolver()
|
||||
self._segment_fetcher = core_ops.SegmentFetcher(self._live, self._session)
|
||||
self._segment_remuxer = core_ops.SegmentRemuxer(live)
|
||||
|
||||
def _run(self) -> None:
|
||||
self._subscription = (
|
||||
|
||||
@@ -4,9 +4,9 @@ from collections import OrderedDict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any, Dict, Union
|
||||
|
||||
from blrec import __github__, __prog__, __version__
|
||||
from blrec.bili.helpers import get_quality_name
|
||||
from blrec.bili.live import Live
|
||||
from .. import __github__, __prog__, __version__
|
||||
from ..bili.helpers import get_quality_name
|
||||
from ..bili.live import Live
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .stream_recorder_impl import StreamRecorderImpl
|
||||
@@ -54,13 +54,6 @@ class MetadataProvider:
|
||||
', bluray' if '_bluray' in self._stream_recorder.stream_url else '',
|
||||
)
|
||||
|
||||
if self._stream_recorder.recording_mode == 'standard':
|
||||
recording_mode_desc = '标准'
|
||||
elif self._stream_recorder.recording_mode == 'raw':
|
||||
recording_mode_desc = '原始'
|
||||
else:
|
||||
recording_mode_desc = '??'
|
||||
|
||||
return {
|
||||
'Title': self._live.room_info.title,
|
||||
'Artist': self._live.user_info.name,
|
||||
@@ -78,7 +71,6 @@ HLS流可用时间: {hls_stream_available_time}
|
||||
流主机: {self._stream_recorder.stream_host}
|
||||
流格式:{self._stream_recorder.stream_format}
|
||||
流画质:{stream_quality}
|
||||
录制模式: {recording_mode_desc}
|
||||
录制程序:{__prog__} v{__version__} {__github__}''',
|
||||
'description': OrderedDict(
|
||||
{
|
||||
@@ -95,7 +87,6 @@ HLS流可用时间: {hls_stream_available_time}
|
||||
'StreamHost': self._stream_recorder.stream_host,
|
||||
'StreamFormat': self._stream_recorder.stream_format,
|
||||
'StreamQuality': stream_quality,
|
||||
'RecordingMode': self._stream_recorder.recording_mode,
|
||||
'Recorder': f'{__prog__} v{__version__} {__github__}',
|
||||
}
|
||||
),
|
||||
|
||||
@@ -3,7 +3,8 @@ from typing import Literal
|
||||
|
||||
import attr
|
||||
|
||||
from blrec.bili.typing import Danmaku
|
||||
from ..bili.typing import Danmaku
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from .connection_error_handler import ConnectionErrorHandler
|
||||
from .exception_handler import ExceptionHandler
|
||||
from .hls_prober import HLSProber, StreamProfile
|
||||
from .playlist_fetcher import PlaylistFetcher
|
||||
from .playlist_resolver import PlaylistResolver
|
||||
from .progress_bar import ProgressBar
|
||||
from .recording_monitor import RecordingMonitor
|
||||
from .request_exception_handler import RequestExceptionHandler
|
||||
from .segment_fetcher import InitSectionData, SegmentData, SegmentFetcher
|
||||
from .segment_remuxer import SegmentRemuxer
|
||||
from .sized_statistics import SizedStatistics
|
||||
from .stream_fetcher import StreamFetcher
|
||||
from .stream_parser import StreamParser
|
||||
@@ -12,12 +17,20 @@ from .stream_url_resolver import StreamURLResolver
|
||||
__all__ = (
|
||||
'ConnectionErrorHandler',
|
||||
'ExceptionHandler',
|
||||
'HLSProber',
|
||||
'InitSectionData',
|
||||
'PlaylistFetcher',
|
||||
'PlaylistResolver',
|
||||
'ProgressBar',
|
||||
'RecordingMonitor',
|
||||
'RequestExceptionHandler',
|
||||
'SegmentData',
|
||||
'SegmentFetcher',
|
||||
'SegmentRemuxer',
|
||||
'SizedStatistics',
|
||||
'StreamFetcher',
|
||||
'StreamParser',
|
||||
'StreamProfile',
|
||||
'StreamStatistics',
|
||||
'StreamURLResolver',
|
||||
)
|
||||
|
||||
@@ -8,9 +8,9 @@ import aiohttp
|
||||
import requests
|
||||
from reactivex import Observable, abc
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.utils import operators as utils_ops
|
||||
from blrec.utils.mixins import AsyncCooperationMixin
|
||||
from ...bili.live import Live
|
||||
from ...utils import operators as utils_ops
|
||||
from ...utils.mixins import AsyncCooperationMixin
|
||||
|
||||
__all__ = ('ConnectionErrorHandler',)
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ from typing import Optional, TypeVar
|
||||
|
||||
from reactivex import Observable, abc
|
||||
|
||||
from blrec.bili.exceptions import LiveRoomEncrypted, LiveRoomHidden, LiveRoomLocked
|
||||
from blrec.utils import operators as utils_ops
|
||||
from blrec.utils.mixins import AsyncCooperationMixin
|
||||
from ...bili.exceptions import LiveRoomEncrypted, LiveRoomHidden, LiveRoomLocked
|
||||
from ...utils import operators as utils_ops
|
||||
from ...utils.mixins import AsyncCooperationMixin
|
||||
|
||||
__all__ = ('ExceptionHandler',)
|
||||
|
||||
|
||||
@@ -7,17 +7,16 @@ from typing import List, Optional, Union
|
||||
from reactivex import Observable, Subject, abc
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
|
||||
from blrec.utils.ffprobe import StreamProfile, ffprobe
|
||||
|
||||
from ...utils.ffprobe import StreamProfile, ffprobe
|
||||
from .segment_fetcher import InitSectionData, SegmentData
|
||||
|
||||
__all__ = ('Prober', 'StreamProfile')
|
||||
__all__ = ('HLSProber', 'StreamProfile')
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Prober:
|
||||
class HLSProber:
|
||||
def __init__(self) -> None:
|
||||
self._profiles: Subject[StreamProfile] = Subject()
|
||||
|
||||
@@ -12,8 +12,8 @@ from reactivex import Observable, abc
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.utils.mixins import SupportDebugMixin
|
||||
from ...bili.live import Live
|
||||
from ...utils.mixins import SupportDebugMixin
|
||||
|
||||
__all__ = ('PlaylistFetcher',)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Final, Optional
|
||||
from typing import Final, Optional, Set
|
||||
|
||||
import m3u8
|
||||
import urllib3
|
||||
@@ -18,16 +17,11 @@ logging.getLogger(urllib3.__name__).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
class PlaylistResolver:
|
||||
_MAX_LAST_SEG_URIS: Final[int] = 30
|
||||
|
||||
def __call__(self, source: Observable[m3u8.M3U8]) -> Observable[m3u8.Segment]:
|
||||
return self._solve(source)
|
||||
|
||||
def _name_of(self, uri: str) -> str:
|
||||
name, ext = os.path.splitext(uri)
|
||||
return name
|
||||
|
||||
def _sequence_number_of(self, uri: str) -> int:
|
||||
return int(self._name_of(uri))
|
||||
|
||||
def _solve(self, source: Observable[m3u8.M3U8]) -> Observable[m3u8.Segment]:
|
||||
def subscribe(
|
||||
observer: abc.ObserverBase[m3u8.Segment],
|
||||
@@ -36,36 +30,35 @@ class PlaylistResolver:
|
||||
disposed = False
|
||||
subscription = SerialDisposable()
|
||||
|
||||
last_sequence_number: Optional[int] = None
|
||||
last_seg_uris: OrderedSet[str] = OrderedSet()
|
||||
|
||||
def on_next(playlist: m3u8.M3U8) -> None:
|
||||
nonlocal last_sequence_number
|
||||
curr_seg_uris: Set[str] = set()
|
||||
|
||||
for seg in playlist.segments:
|
||||
if disposed:
|
||||
return
|
||||
curr_seg_uris.add(seg.uri)
|
||||
if seg.uri not in last_seg_uris:
|
||||
observer.on_next(seg)
|
||||
last_seg_uris.add(seg.uri)
|
||||
if len(last_seg_uris) > self._MAX_LAST_SEG_URIS:
|
||||
last_seg_uris.pop(0)
|
||||
|
||||
if last_seg_uris and not curr_seg_uris.intersection(last_seg_uris):
|
||||
logger.debug(
|
||||
'Segments broken!\n'
|
||||
f'Last segments uris: {last_seg_uris}\n'
|
||||
f'Current segments uris: {curr_seg_uris}'
|
||||
)
|
||||
|
||||
if playlist.is_endlist:
|
||||
logger.debug('Playlist ended')
|
||||
|
||||
for seg in playlist.segments:
|
||||
uri = seg.uri
|
||||
name = self._name_of(uri)
|
||||
num = int(name)
|
||||
if last_sequence_number is not None:
|
||||
if last_sequence_number >= num:
|
||||
continue
|
||||
if last_sequence_number + 1 != num:
|
||||
logger.warning(
|
||||
'Segments discontinuous: '
|
||||
f'last sequence number: {last_sequence_number}, '
|
||||
f'current sequence number: {num}'
|
||||
)
|
||||
seg.discontinuity = True
|
||||
observer.on_next(seg)
|
||||
last_sequence_number = num
|
||||
|
||||
def dispose() -> None:
|
||||
nonlocal disposed
|
||||
nonlocal last_sequence_number
|
||||
disposed = True
|
||||
last_sequence_number = None
|
||||
last_seg_uris.clear()
|
||||
|
||||
subscription.disposable = source.subscribe(
|
||||
on_next, observer.on_error, observer.on_completed, scheduler=scheduler
|
||||
@@ -7,8 +7,8 @@ from reactivex import Observable, abc
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
from tqdm import tqdm
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.flv.operators.typing import FLVStream, FLVStreamItem
|
||||
from ...bili.live import Live
|
||||
from ...flv.operators.typing import FLVStream, FLVStreamItem
|
||||
|
||||
__all__ = ('ProgressBar',)
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable, Final, Optional, TypeVar
|
||||
from typing import Final, Optional, TypeVar
|
||||
|
||||
from reactivex import Observable, Subject, abc
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.utils.mixins import AsyncCooperationMixin
|
||||
from ...bili.live import Live
|
||||
from ...flv import operators as flv_ops
|
||||
from ...utils.mixins import AsyncCooperationMixin
|
||||
|
||||
__all__ = ('RecordingMonitor',)
|
||||
|
||||
@@ -17,10 +18,10 @@ _T = TypeVar('_T')
|
||||
|
||||
|
||||
class RecordingMonitor(AsyncCooperationMixin):
|
||||
def __init__(self, live: Live, duration_provider: Callable[..., float]) -> None:
|
||||
def __init__(self, live: Live, analyser: flv_ops.Analyser) -> None:
|
||||
super().__init__()
|
||||
self._live = live
|
||||
self._duration_provider = duration_provider
|
||||
self._analyser = analyser
|
||||
self._interrupted: Subject[float] = Subject()
|
||||
self._recovered: Subject[int] = Subject()
|
||||
|
||||
@@ -58,8 +59,7 @@ class RecordingMonitor(AsyncCooperationMixin):
|
||||
if recording:
|
||||
failed_count += 1
|
||||
if failed_count == CRITERIA:
|
||||
duration = self._duration_provider()
|
||||
self._interrupted.on_next(duration)
|
||||
self._interrupted.on_next(self._analyser.duration)
|
||||
observer.on_error(exc)
|
||||
|
||||
return source.subscribe(
|
||||
|
||||
@@ -9,7 +9,7 @@ import requests
|
||||
import urllib3
|
||||
from reactivex import Observable, abc
|
||||
|
||||
from blrec.utils import operators as utils_ops
|
||||
from ...utils import operators as utils_ops
|
||||
|
||||
__all__ = ('RequestExceptionHandler',)
|
||||
|
||||
|
||||
@@ -12,10 +12,7 @@ from reactivex import Observable, abc
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.utils.hash import cksum
|
||||
|
||||
from ..exceptions import SegmentDataCorrupted
|
||||
from ...bili.live import Live
|
||||
|
||||
__all__ = ('SegmentFetcher', 'InitSectionData', 'SegmentData')
|
||||
|
||||
@@ -25,7 +22,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class InitSectionData:
|
||||
init_section: InitializationSection
|
||||
payload: bytes
|
||||
|
||||
def __len__(self) -> int:
|
||||
@@ -34,7 +30,6 @@ class InitSectionData:
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class SegmentData:
|
||||
segment: m3u8.Segment
|
||||
payload: bytes
|
||||
|
||||
def __len__(self) -> int:
|
||||
@@ -61,52 +56,30 @@ class SegmentFetcher:
|
||||
disposed = False
|
||||
subscription = SerialDisposable()
|
||||
|
||||
last_segment: Optional[m3u8.Segment] = None
|
||||
init_section: Optional[InitializationSection] = None
|
||||
|
||||
def on_next(seg: m3u8.Segment) -> None:
|
||||
nonlocal last_segment
|
||||
nonlocal init_section
|
||||
url: str = ''
|
||||
|
||||
try:
|
||||
if hasattr(seg, 'init_section') and (
|
||||
(
|
||||
last_segment is None
|
||||
or seg.init_section != last_segment.init_section
|
||||
or seg.discontinuity
|
||||
)
|
||||
if getattr(seg, 'init_section', None) and (
|
||||
not init_section or seg.init_section.uri != init_section.uri
|
||||
):
|
||||
url = seg.init_section.absolute_uri
|
||||
data = self._fetch_segment(url)
|
||||
observer.on_next(
|
||||
InitSectionData(init_section=seg.init_section, payload=data)
|
||||
)
|
||||
last_segment = seg
|
||||
|
||||
init_section = seg.init_section
|
||||
observer.on_next(InitSectionData(payload=data))
|
||||
url = seg.absolute_uri
|
||||
crc32 = seg.title.split('|')[-1]
|
||||
for _ in range(3):
|
||||
data = self._fetch_segment(url)
|
||||
crc32_of_data = cksum(data)
|
||||
if crc32_of_data == crc32:
|
||||
break
|
||||
logger.debug(
|
||||
'Segment data corrupted: '
|
||||
f'correct crc32: {crc32}, '
|
||||
f'crc32 of segment data: {crc32_of_data}, '
|
||||
f'segment url: {url}'
|
||||
)
|
||||
else:
|
||||
raise SegmentDataCorrupted(crc32, crc32_of_data)
|
||||
data = self._fetch_segment(url)
|
||||
observer.on_next(SegmentData(payload=data))
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to fetch segment {url}: {repr(e)}')
|
||||
else:
|
||||
observer.on_next(SegmentData(segment=seg, payload=data))
|
||||
|
||||
def dispose() -> None:
|
||||
nonlocal disposed
|
||||
nonlocal last_segment
|
||||
nonlocal init_section
|
||||
disposed = True
|
||||
last_segment = None
|
||||
init_section = None
|
||||
|
||||
subscription.disposable = source.subscribe(
|
||||
on_next, observer.on_error, observer.on_completed, scheduler=scheduler
|
||||
@@ -119,7 +92,12 @@ class SegmentFetcher:
|
||||
@retry(
|
||||
reraise=True,
|
||||
retry=retry_if_exception_type(
|
||||
(requests.exceptions.RequestException, urllib3.exceptions.HTTPError)
|
||||
(
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.HTTPError,
|
||||
urllib3.exceptions.TimeoutError,
|
||||
urllib3.exceptions.ProtocolError,
|
||||
)
|
||||
),
|
||||
wait=wait_exponential(multiplier=0.1, max=5),
|
||||
stop=stop_after_delay(60),
|
||||
@@ -8,9 +8,8 @@ import urllib3
|
||||
from reactivex import Observable, abc
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.utils.io import wait_for
|
||||
|
||||
from ...bili.live import Live
|
||||
from ...utils.io import wait_for
|
||||
from ..stream_remuxer import StreamRemuxer
|
||||
from .segment_fetcher import InitSectionData, SegmentData
|
||||
|
||||
@@ -7,8 +7,8 @@ from typing import Optional
|
||||
import requests
|
||||
from reactivex import Observable, abc
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.utils.mixins import AsyncCooperationMixin
|
||||
from ...bili.live import Live
|
||||
from ...utils.mixins import AsyncCooperationMixin
|
||||
|
||||
__all__ = ('StreamFetcher',)
|
||||
|
||||
|
||||
@@ -6,11 +6,10 @@ import logging
|
||||
from reactivex import Observable
|
||||
from reactivex import operators as ops
|
||||
|
||||
from blrec.flv import operators as flv_ops
|
||||
from blrec.flv.exceptions import FlvDataError
|
||||
from blrec.flv.operators.typing import FLVStream
|
||||
from blrec.utils import operators as utils_ops
|
||||
|
||||
from ...flv import operators as flv_ops
|
||||
from ...flv.exceptions import FlvDataError
|
||||
from ...flv.operators.typing import FLVStream
|
||||
from ...utils import operators as utils_ops
|
||||
from ..stream_param_holder import StreamParamHolder
|
||||
|
||||
__all__ = ('StreamParser',)
|
||||
|
||||
@@ -9,7 +9,7 @@ import urllib3
|
||||
from reactivex import Observable, abc
|
||||
from reactivex import operators as ops
|
||||
|
||||
from blrec.bili.exceptions import (
|
||||
from ...bili.exceptions import (
|
||||
LiveRoomEncrypted,
|
||||
LiveRoomHidden,
|
||||
LiveRoomLocked,
|
||||
@@ -19,10 +19,9 @@ from blrec.bili.exceptions import (
|
||||
NoStreamFormatAvailable,
|
||||
NoStreamQualityAvailable,
|
||||
)
|
||||
from blrec.bili.live import Live
|
||||
from blrec.utils import operators as utils_ops
|
||||
from blrec.utils.mixins import AsyncCooperationMixin
|
||||
|
||||
from ...bili.live import Live
|
||||
from ...utils import operators as utils_ops
|
||||
from ...utils.mixins import AsyncCooperationMixin
|
||||
from ..stream_param_holder import StreamParamHolder, StreamParams
|
||||
|
||||
__all__ = ('StreamURLResolver',)
|
||||
|
||||
@@ -4,9 +4,9 @@ import re
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.path import escape_path
|
||||
from blrec.utils.mixins import AsyncCooperationMixin
|
||||
from ..bili.live import Live
|
||||
from ..path import escape_path
|
||||
from ..utils.mixins import AsyncCooperationMixin
|
||||
|
||||
__all__ = ('PathProvider',)
|
||||
|
||||
|
||||
@@ -8,13 +8,12 @@ import aiofiles
|
||||
from aiofiles.threadpool.text import AsyncTextIOWrapper
|
||||
from tenacity import AsyncRetrying, retry_if_not_exception_type, stop_after_attempt
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.event.event_emitter import EventEmitter, EventListener
|
||||
from blrec.exception import exception_callback, submit_exception
|
||||
from blrec.logging.room_id import aio_task_with_room_id
|
||||
from blrec.path import raw_danmaku_path
|
||||
from blrec.utils.mixins import SwitchableMixin
|
||||
|
||||
from ..bili.live import Live
|
||||
from ..event.event_emitter import EventEmitter, EventListener
|
||||
from ..exception import exception_callback, submit_exception
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
from ..path import raw_danmaku_path
|
||||
from ..utils.mixins import SwitchableMixin
|
||||
from .raw_danmaku_receiver import RawDanmakuReceiver
|
||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||
|
||||
@@ -55,7 +54,6 @@ class RawDanmakuDumper(
|
||||
|
||||
def _do_disable(self) -> None:
|
||||
self._stream_recorder.remove_listener(self)
|
||||
asyncio.create_task(self._stop_dumping())
|
||||
logger.debug('Disabled raw danmaku dumper')
|
||||
|
||||
async def on_video_file_created(
|
||||
@@ -63,7 +61,6 @@ class RawDanmakuDumper(
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._path = raw_danmaku_path(video_path)
|
||||
await self._stop_dumping()
|
||||
self._start_dumping()
|
||||
|
||||
async def on_video_file_completed(self, video_path: str) -> None:
|
||||
@@ -74,9 +71,7 @@ class RawDanmakuDumper(
|
||||
self._create_dump_task()
|
||||
|
||||
async def _stop_dumping(self) -> None:
|
||||
if hasattr(self, '_dump_task'):
|
||||
await self._cancel_dump_task()
|
||||
del self._dump_task # type: ignore
|
||||
await self._cancel_dump_task()
|
||||
|
||||
def _create_dump_task(self) -> None:
|
||||
self._dump_task = asyncio.create_task(self._do_dump())
|
||||
|
||||
@@ -2,11 +2,13 @@ import logging
|
||||
from asyncio import Queue, QueueFull
|
||||
from typing import Final
|
||||
|
||||
from blrec.bili.danmaku_client import DanmakuClient, DanmakuListener
|
||||
from blrec.bili.typing import Danmaku
|
||||
from blrec.utils.mixins import StoppableMixin
|
||||
|
||||
__all__ = ('RawDanmakuReceiver',)
|
||||
from ..bili.danmaku_client import DanmakuClient, DanmakuListener
|
||||
from ..bili.typing import Danmaku
|
||||
from ..utils.mixins import StoppableMixin
|
||||
|
||||
|
||||
__all__ = 'RawDanmakuReceiver',
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -7,17 +7,15 @@ from typing import Iterator, Optional
|
||||
|
||||
import humanize
|
||||
|
||||
from blrec.bili.danmaku_client import DanmakuClient
|
||||
from blrec.bili.live import Live
|
||||
from blrec.bili.live_monitor import LiveEventListener, LiveMonitor
|
||||
from blrec.bili.models import RoomInfo
|
||||
from blrec.bili.typing import QualityNumber, StreamFormat
|
||||
from blrec.event.event_emitter import EventEmitter, EventListener
|
||||
from blrec.flv.operators import MetaData, StreamProfile
|
||||
from blrec.logging.room_id import aio_task_with_room_id
|
||||
from blrec.setting.typing import RecordingMode
|
||||
from blrec.utils.mixins import AsyncStoppableMixin
|
||||
|
||||
from ..bili.danmaku_client import DanmakuClient
|
||||
from ..bili.live import Live
|
||||
from ..bili.live_monitor import LiveEventListener, LiveMonitor
|
||||
from ..bili.models import RoomInfo
|
||||
from ..bili.typing import QualityNumber, StreamFormat
|
||||
from ..event.event_emitter import EventEmitter, EventListener
|
||||
from ..flv.operators import MetaData, StreamProfile
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
from ..utils.mixins import AsyncStoppableMixin
|
||||
from .cover_downloader import CoverDownloader, CoverSaveStrategy
|
||||
from .danmaku_dumper import DanmakuDumper, DanmakuDumperEventListener
|
||||
from .danmaku_receiver import DanmakuReceiver
|
||||
@@ -79,7 +77,6 @@ class Recorder(
|
||||
path_template: str,
|
||||
*,
|
||||
stream_format: StreamFormat = 'flv',
|
||||
recording_mode: RecordingMode = 'standard',
|
||||
quality_number: QualityNumber = 10000,
|
||||
fmp4_stream_timeout: int = 10,
|
||||
buffer_size: Optional[int] = None,
|
||||
@@ -111,7 +108,6 @@ class Recorder(
|
||||
out_dir=out_dir,
|
||||
path_template=path_template,
|
||||
stream_format=stream_format,
|
||||
recording_mode=recording_mode,
|
||||
quality_number=quality_number,
|
||||
fmp4_stream_timeout=fmp4_stream_timeout,
|
||||
buffer_size=buffer_size,
|
||||
@@ -160,14 +156,6 @@ class Recorder(
|
||||
def stream_format(self, value: StreamFormat) -> None:
|
||||
self._stream_recorder.stream_format = value
|
||||
|
||||
@property
|
||||
def recording_mode(self) -> RecordingMode:
|
||||
return self._stream_recorder.recording_mode
|
||||
|
||||
@recording_mode.setter
|
||||
def recording_mode(self, value: RecordingMode) -> None:
|
||||
self._stream_recorder.recording_mode = value
|
||||
|
||||
@property
|
||||
def quality_number(self) -> QualityNumber:
|
||||
return self._stream_recorder.quality_number
|
||||
@@ -377,7 +365,6 @@ class Recorder(
|
||||
|
||||
async def on_live_ended(self, live: Live) -> None:
|
||||
logger.info('The live has ended')
|
||||
await asyncio.sleep(3)
|
||||
self._stream_available = False
|
||||
self._stream_recorder.stream_available_time = None
|
||||
await self._stop_recording()
|
||||
|
||||
@@ -8,7 +8,7 @@ from reactivex import Observable, abc, create
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
from reactivex.scheduler.currentthreadscheduler import CurrentThreadScheduler
|
||||
|
||||
from blrec.bili.typing import ApiPlatform, QualityNumber, StreamFormat
|
||||
from ..bili.typing import ApiPlatform, QualityNumber, StreamFormat
|
||||
|
||||
__all__ = ('StreamParamHolder',)
|
||||
|
||||
|
||||
@@ -3,15 +3,12 @@ import logging
|
||||
import time
|
||||
from typing import Iterator, Optional
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.bili.typing import QualityNumber, StreamFormat
|
||||
from blrec.event.event_emitter import EventEmitter
|
||||
from blrec.flv.operators import MetaData, StreamProfile
|
||||
from blrec.setting.typing import RecordingMode
|
||||
from blrec.utils.mixins import AsyncStoppableMixin
|
||||
|
||||
from ..bili.live import Live
|
||||
from ..bili.typing import QualityNumber, StreamFormat
|
||||
from ..event.event_emitter import EventEmitter
|
||||
from ..flv.operators import MetaData, StreamProfile
|
||||
from ..utils.mixins import AsyncStoppableMixin
|
||||
from .flv_stream_recorder_impl import FLVStreamRecorderImpl
|
||||
from .hls_raw_stream_recorder_impl import HLSRawStreamRecorderImpl
|
||||
from .hls_stream_recorder_impl import HLSStreamRecorderImpl
|
||||
from .stream_recorder_impl import StreamRecorderEventListener
|
||||
|
||||
@@ -33,7 +30,6 @@ class StreamRecorder(
|
||||
path_template: str,
|
||||
*,
|
||||
stream_format: StreamFormat = 'flv',
|
||||
recording_mode: RecordingMode = 'standard',
|
||||
quality_number: QualityNumber = 10000,
|
||||
fmp4_stream_timeout: int = 10,
|
||||
buffer_size: Optional[int] = None,
|
||||
@@ -46,16 +42,12 @@ class StreamRecorder(
|
||||
|
||||
self._live = live
|
||||
self.stream_format = stream_format
|
||||
self.recording_mode = recording_mode
|
||||
self.fmp4_stream_timeout = fmp4_stream_timeout
|
||||
|
||||
if stream_format == 'flv':
|
||||
cls = FLVStreamRecorderImpl
|
||||
elif stream_format == 'fmp4':
|
||||
if recording_mode == 'standard':
|
||||
cls = HLSStreamRecorderImpl # type: ignore
|
||||
else:
|
||||
cls = HLSRawStreamRecorderImpl # type: ignore
|
||||
cls = HLSStreamRecorderImpl # type: ignore
|
||||
else:
|
||||
logger.warning(
|
||||
f'The specified stream format ({stream_format}) is '
|
||||
@@ -262,8 +254,8 @@ class StreamRecorder(
|
||||
async def on_video_file_completed(self, path: str) -> None:
|
||||
await self._emit('video_file_completed', path)
|
||||
|
||||
async def on_stream_recording_interrupted(self, duration: float) -> None:
|
||||
await self._emit('stream_recording_interrupted', duration)
|
||||
async def on_stream_recording_interrupted(self, timestamp: int) -> None:
|
||||
await self._emit('stream_recording_interrupted', timestamp)
|
||||
|
||||
async def on_stream_recording_recovered(self, timestamp: int) -> None:
|
||||
await self._emit('stream_recording_recovered', timestamp)
|
||||
@@ -292,10 +284,7 @@ class StreamRecorder(
|
||||
if stream_format == 'flv':
|
||||
cls = FLVStreamRecorderImpl
|
||||
elif stream_format == 'fmp4':
|
||||
if self.recording_mode == 'standard':
|
||||
cls = HLSStreamRecorderImpl # type: ignore
|
||||
else:
|
||||
cls = HLSRawStreamRecorderImpl # type: ignore
|
||||
cls = HLSStreamRecorderImpl # type: ignore
|
||||
else:
|
||||
logger.warning(
|
||||
f'The specified stream format ({stream_format}) is '
|
||||
|
||||
@@ -9,16 +9,15 @@ import urllib3
|
||||
from reactivex import abc
|
||||
from reactivex.typing import StartableFactory, StartableTarget
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.bili.typing import QualityNumber, StreamFormat
|
||||
from blrec.event.event_emitter import EventEmitter, EventListener
|
||||
from blrec.flv import operators as flv_ops
|
||||
from blrec.flv.operators import StreamProfile
|
||||
from blrec.flv.utils import format_timestamp
|
||||
from blrec.logging.room_id import aio_task_with_room_id
|
||||
from blrec.setting.typing import RecordingMode
|
||||
from blrec.utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin
|
||||
|
||||
from ..bili.live import Live
|
||||
from ..bili.typing import QualityNumber, StreamFormat
|
||||
from ..event.event_emitter import EventEmitter, EventListener
|
||||
from ..flv import operators as flv_ops
|
||||
from ..flv.metadata_dumper import MetadataDumper
|
||||
from ..flv.operators import StreamProfile
|
||||
from ..flv.utils import format_timestamp
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
from ..utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin
|
||||
from . import operators as core_ops
|
||||
from .metadata_provider import MetadataProvider
|
||||
from .path_provider import PathProvider
|
||||
@@ -61,7 +60,6 @@ class StreamRecorderImpl(
|
||||
path_template: str,
|
||||
*,
|
||||
stream_format: StreamFormat = 'flv',
|
||||
recording_mode: RecordingMode = 'standard',
|
||||
quality_number: QualityNumber = 10000,
|
||||
buffer_size: Optional[int] = None,
|
||||
read_timeout: Optional[int] = None,
|
||||
@@ -73,30 +71,50 @@ class StreamRecorderImpl(
|
||||
|
||||
self._live = live
|
||||
self._session = requests.Session()
|
||||
|
||||
self._recording_mode = recording_mode
|
||||
self._buffer_size = buffer_size
|
||||
self._read_timeout = read_timeout
|
||||
self._filesize_limit = filesize_limit
|
||||
self._duration_limit = duration_limit
|
||||
|
||||
self._stream_param_holder = StreamParamHolder(
|
||||
stream_format=stream_format, quality_number=quality_number
|
||||
)
|
||||
self._stream_url_resolver = core_ops.StreamURLResolver(
|
||||
live, self._stream_param_holder
|
||||
)
|
||||
self._stream_fetcher = core_ops.StreamFetcher(
|
||||
live, self._session, read_timeout=read_timeout
|
||||
)
|
||||
self._stream_parser = core_ops.StreamParser(
|
||||
self._stream_param_holder,
|
||||
ignore_eof=stream_format != 'flv',
|
||||
ignore_value_error=stream_format != 'flv',
|
||||
)
|
||||
self._progress_bar = core_ops.ProgressBar(live)
|
||||
self._analyser = flv_ops.Analyser()
|
||||
self._metadata_provider = MetadataProvider(live, self)
|
||||
self._injector = flv_ops.Injector(self._metadata_provider)
|
||||
self._join_point_extractor = flv_ops.JoinPointExtractor()
|
||||
self._limiter = flv_ops.Limiter(filesize_limit, duration_limit)
|
||||
self._cutter = flv_ops.Cutter()
|
||||
self._path_provider = PathProvider(live, out_dir, path_template)
|
||||
self._dumper = flv_ops.Dumper(self._path_provider, buffer_size)
|
||||
self._rec_statistics = core_ops.SizedStatistics()
|
||||
self._recording_monitor = core_ops.RecordingMonitor(live, self._analyser)
|
||||
|
||||
self._prober: Union[flv_ops.Prober, core_ops.HLSProber]
|
||||
self._dl_statistics: Union[core_ops.StreamStatistics, core_ops.SizedStatistics]
|
||||
if stream_format == 'flv':
|
||||
self._prober = flv_ops.Prober()
|
||||
self._dl_statistics = core_ops.StreamStatistics()
|
||||
else:
|
||||
self._prober = core_ops.HLSProber()
|
||||
self._dl_statistics = core_ops.SizedStatistics()
|
||||
|
||||
self._request_exception_handler = core_ops.RequestExceptionHandler()
|
||||
self._connection_error_handler = core_ops.ConnectionErrorHandler(
|
||||
live, disconnection_timeout=disconnection_timeout
|
||||
)
|
||||
self._exception_handler = core_ops.ExceptionHandler()
|
||||
self._metadata_dumper = MetadataDumper(
|
||||
self._dumper, self._analyser, self._join_point_extractor
|
||||
)
|
||||
self._metadata_dumper.enable()
|
||||
|
||||
self._subscription: abc.DisposableBase
|
||||
self._completed: bool = False
|
||||
@@ -108,6 +126,37 @@ class StreamRecorderImpl(
|
||||
self._stream_available_time: Optional[int] = None
|
||||
self._hls_stream_available_time: Optional[int] = None
|
||||
|
||||
def on_profile_updated(profile: StreamProfile) -> None:
|
||||
self._stream_profile = profile
|
||||
|
||||
self._prober.profiles.subscribe(on_profile_updated)
|
||||
|
||||
def on_file_opened(args: Tuple[str, int]) -> None:
|
||||
logger.info(f"Video file created: '{args[0]}'")
|
||||
self._files.append(args[0])
|
||||
self._record_start_time = args[1]
|
||||
self._emit_event('video_file_created', *args)
|
||||
|
||||
def on_file_closed(path: str) -> None:
|
||||
logger.info(f"Video file completed: '{path}'")
|
||||
self._emit_event('video_file_completed', path)
|
||||
|
||||
self._dumper.file_opened.subscribe(on_file_opened)
|
||||
self._dumper.file_closed.subscribe(on_file_closed)
|
||||
|
||||
def on_recording_interrupted(duration: float) -> None:
|
||||
duration_string = format_timestamp(int(duration * 1000))
|
||||
logger.info(f'Recording interrupted, current duration: {duration_string}')
|
||||
self._emit_event('stream_recording_interrupted', duration)
|
||||
|
||||
def on_recording_recovered(timestamp: int) -> None:
|
||||
datetime_string = datetime.fromtimestamp(timestamp).isoformat()
|
||||
logger.info(f'Recording recovered, current date time {(datetime_string)}')
|
||||
self._emit_event('stream_recording_recovered', timestamp)
|
||||
|
||||
self._recording_monitor.interrupted.subscribe(on_recording_interrupted)
|
||||
self._recording_monitor.recovered.subscribe(on_recording_recovered)
|
||||
|
||||
@property
|
||||
def stream_url(self) -> str:
|
||||
return self._stream_url_resolver.stream_url
|
||||
@@ -176,10 +225,6 @@ class StreamRecorderImpl(
|
||||
def stream_format(self) -> StreamFormat:
|
||||
return self._stream_param_holder.stream_format
|
||||
|
||||
@property
|
||||
def recording_mode(self) -> RecordingMode:
|
||||
return self._recording_mode
|
||||
|
||||
@property
|
||||
def quality_number(self) -> QualityNumber:
|
||||
return self._stream_param_holder.quality_number
|
||||
@@ -196,27 +241,27 @@ class StreamRecorderImpl(
|
||||
|
||||
@property
|
||||
def filesize_limit(self) -> int:
|
||||
return self._filesize_limit
|
||||
return self._limiter.filesize_limit
|
||||
|
||||
@filesize_limit.setter
|
||||
def filesize_limit(self, value: int) -> None:
|
||||
self._filesize_limit = value
|
||||
self._limiter.filesize_limit = value
|
||||
|
||||
@property
|
||||
def duration_limit(self) -> int:
|
||||
return self._duration_limit
|
||||
return self._limiter.duration_limit
|
||||
|
||||
@duration_limit.setter
|
||||
def duration_limit(self, value: int) -> None:
|
||||
self._duration_limit = value
|
||||
self._limiter.duration_limit = value
|
||||
|
||||
@property
|
||||
def read_timeout(self) -> int:
|
||||
return self._read_timeout
|
||||
return self._stream_fetcher.read_timeout
|
||||
|
||||
@read_timeout.setter
|
||||
def read_timeout(self, value: int) -> None:
|
||||
self._read_timeout = value
|
||||
self._stream_fetcher.read_timeout = value
|
||||
|
||||
@property
|
||||
def disconnection_timeout(self) -> int:
|
||||
@@ -228,19 +273,22 @@ class StreamRecorderImpl(
|
||||
|
||||
@property
|
||||
def buffer_size(self) -> int:
|
||||
return self._buffer_size
|
||||
return self._dumper.buffer_size
|
||||
|
||||
@buffer_size.setter
|
||||
def buffer_size(self, value: int) -> None:
|
||||
self._buffer_size = value
|
||||
self._dumper.buffer_size = value
|
||||
|
||||
@property
|
||||
def recording_path(self) -> Optional[str]:
|
||||
return ''
|
||||
return self._dumper.path
|
||||
|
||||
@property
|
||||
def metadata(self) -> Optional[flv_ops.MetaData]:
|
||||
return None
|
||||
try:
|
||||
return self._analyser.make_metadata()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def stream_profile(self) -> StreamProfile:
|
||||
@@ -256,10 +304,10 @@ class StreamRecorderImpl(
|
||||
self._files.clear()
|
||||
|
||||
def can_cut_stream(self) -> bool:
|
||||
return False
|
||||
return self._cutter.can_cut_stream()
|
||||
|
||||
def cut_stream(self) -> bool:
|
||||
return False
|
||||
return self._cutter.cut_stream()
|
||||
|
||||
def update_progress_bar_info(self) -> None:
|
||||
self._progress_bar.update_bar_info()
|
||||
@@ -272,7 +320,6 @@ class StreamRecorderImpl(
|
||||
|
||||
async def _do_start(self) -> None:
|
||||
logger.debug('Starting stream recorder...')
|
||||
self._on_start()
|
||||
self._reset()
|
||||
self._run()
|
||||
logger.debug('Started stream recorder')
|
||||
@@ -285,15 +332,8 @@ class StreamRecorderImpl(
|
||||
for thread in self._threads:
|
||||
await self._loop.run_in_executor(None, thread.join, 30)
|
||||
self._threads.clear()
|
||||
self._on_stop()
|
||||
logger.debug('Stopped stream recorder')
|
||||
|
||||
def _on_start(self) -> None:
|
||||
pass
|
||||
|
||||
def _on_stop(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _run(self) -> None:
|
||||
raise NotImplementedError()
|
||||
@@ -322,30 +362,6 @@ class StreamRecorderImpl(
|
||||
self._rec_statistics.freeze()
|
||||
self._emit_event('stream_recording_completed')
|
||||
|
||||
def _on_profile_updated(self, profile: StreamProfile) -> None:
|
||||
logger.debug(f'Stream profile: {profile}')
|
||||
self._stream_profile = profile
|
||||
|
||||
def _on_video_file_opened(self, args: Tuple[str, int]) -> None:
|
||||
logger.info(f"Video file created: '{args[0]}'")
|
||||
self._files.append(args[0])
|
||||
self._record_start_time = args[1]
|
||||
self._emit_event('video_file_created', *args)
|
||||
|
||||
def _on_video_file_closed(self, path: str) -> None:
|
||||
logger.info(f"Video file completed: '{path}'")
|
||||
self._emit_event('video_file_completed', path)
|
||||
|
||||
def _on_recording_interrupted(self, duration: float) -> None:
|
||||
duration_string = format_timestamp(int(duration * 1000))
|
||||
logger.info(f'Recording interrupted, current duration: {duration_string}')
|
||||
self._emit_event('stream_recording_interrupted', duration)
|
||||
|
||||
def _on_recording_recovered(self, timestamp: int) -> None:
|
||||
datetime_string = datetime.fromtimestamp(timestamp).isoformat()
|
||||
logger.info(f'Recording recovered, current date time {(datetime_string)}')
|
||||
self._emit_event('stream_recording_recovered', timestamp)
|
||||
|
||||
def _emit_event(self, name: str, *args: Any, **kwds: Any) -> None:
|
||||
self._run_coroutine(self._emit(name, *args, **kwds))
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import errno
|
||||
import io
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from contextlib import suppress
|
||||
from subprocess import PIPE, CalledProcessError, Popen
|
||||
from threading import Condition, Thread
|
||||
from typing import Optional, cast
|
||||
|
||||
from blrec.utils.io import wait_for
|
||||
from blrec.utils.mixins import StoppableMixin, SupportDebugMixin
|
||||
from ..utils.io import wait_for
|
||||
from ..utils.mixins import StoppableMixin, SupportDebugMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -150,7 +150,7 @@ class StreamRemuxer(StoppableMixin, SupportDebugMixin):
|
||||
self._check_error(line)
|
||||
|
||||
if not self._stopped and self._subprocess.returncode not in (0, 255):
|
||||
# 255: Exiting standardly, received signal 2.
|
||||
# 255: Exiting normally, received signal 2.
|
||||
raise CalledProcessError(self._subprocess.returncode, cmd=cmd)
|
||||
|
||||
def _check_error(self, line: str) -> None:
|
||||
File diff suppressed because one or more lines are too long
1
src/blrec/data/webapp/146.92e3b29c4c754544.js
Normal file
1
src/blrec/data/webapp/146.92e3b29c4c754544.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/183.ae1a1102b7d5cbdb.js
Normal file
1
src/blrec/data/webapp/183.ae1a1102b7d5cbdb.js
Normal file
File diff suppressed because one or more lines are too long
1
src/blrec/data/webapp/202.e15e5ae9f06639b8.js
Normal file
1
src/blrec/data/webapp/202.e15e5ae9f06639b8.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/66.9faa0b5a6adf9602.js
Normal file
1
src/blrec/data/webapp/66.9faa0b5a6adf9602.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
@@ -10,6 +10,6 @@
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
||||
<script src="runtime.68e08c4d681726f6.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.16a8fc7b1f8a870d.js" type="module"></script>
|
||||
<script src="runtime.0ce129f346263990.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.888c50197ddf8040.js" type="module"></script>
|
||||
|
||||
</body></html>
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"configVersion": 1,
|
||||
"timestamp": 1659243718041,
|
||||
"timestamp": 1655391613431,
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
@@ -12,17 +12,17 @@
|
||||
},
|
||||
"urls": [
|
||||
"/103.5b5d2a6e5a8a7479.js",
|
||||
"/146.5a8902910bda9e87.js",
|
||||
"/183.2c7c85597ba82f9e.js",
|
||||
"/146.92e3b29c4c754544.js",
|
||||
"/183.ae1a1102b7d5cbdb.js",
|
||||
"/202.e15e5ae9f06639b8.js",
|
||||
"/45.c90c3cea2bf1a66e.js",
|
||||
"/500.5d39ab52fb714a12.js",
|
||||
"/91.be3cbd4101dc7500.js",
|
||||
"/66.9faa0b5a6adf9602.js",
|
||||
"/common.858f777e9296e6f2.js",
|
||||
"/index.html",
|
||||
"/main.16a8fc7b1f8a870d.js",
|
||||
"/main.888c50197ddf8040.js",
|
||||
"/manifest.webmanifest",
|
||||
"/polyfills.4b08448aee19bb22.js",
|
||||
"/runtime.68e08c4d681726f6.js",
|
||||
"/runtime.0ce129f346263990.js",
|
||||
"/styles.2e152d608221c2ee.css"
|
||||
],
|
||||
"patterns": []
|
||||
@@ -1635,11 +1635,11 @@
|
||||
"dataGroups": [],
|
||||
"hashTable": {
|
||||
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
|
||||
"/146.5a8902910bda9e87.js": "d9c33c7073662699f00f46f3a384ae5b749fdef9",
|
||||
"/183.2c7c85597ba82f9e.js": "22a1524d6399d9bde85334a2eba15670f68ccd96",
|
||||
"/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d",
|
||||
"/183.ae1a1102b7d5cbdb.js": "6cb22d60b0a20214212e6050fbbf33926a4c1346",
|
||||
"/202.e15e5ae9f06639b8.js": "62335dc98644969539760565ff9c3c472d304287",
|
||||
"/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764",
|
||||
"/500.5d39ab52fb714a12.js": "646fbfd3af1124519171f1cd9fac4c214b5af60f",
|
||||
"/91.be3cbd4101dc7500.js": "f0fec71455c96f9a60c4fa671d2ccdba07e9a00a",
|
||||
"/66.9faa0b5a6adf9602.js": "c2f418ebb80f35402d9f24e5acaf8167c96f9eb3",
|
||||
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
|
||||
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
|
||||
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
||||
@@ -3234,11 +3234,11 @@
|
||||
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
|
||||
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
|
||||
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
|
||||
"/index.html": "3f28dbdfc92c1a0930448a8ff6d5d2ac49648987",
|
||||
"/main.16a8fc7b1f8a870d.js": "9c680888ae14907d6c20e60c026b49a2331768e9",
|
||||
"/index.html": "374ebd2a9b656c5ebcbc9f5a4402b345cd4c7c5c",
|
||||
"/main.888c50197ddf8040.js": "f506b85641a4598b002c21bc49c9a36e0c058326",
|
||||
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
|
||||
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
|
||||
"/runtime.68e08c4d681726f6.js": "04815a3dd35466f647f3707a295bc2c76c9f0375",
|
||||
"/runtime.0ce129f346263990.js": "98698b10b3f873a761f1e1c7fb5a9bcd2f3830ee",
|
||||
"/styles.2e152d608221c2ee.css": "9830389a46daa5b4511e0dd343aad23ca9f9690f"
|
||||
},
|
||||
"navigationUrls": [
|
||||
|
||||
@@ -1 +1 @@
|
||||
(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,o,f)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,o,f]=e[n],c=!0,l=0;l<t.length;l++)(!1&f||a>=f)&&Object.keys(r.O).every(b=>r.O[b](t[l]))?t.splice(l--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var d=o();void 0!==d&&(i=d)}}return i}f=f||0;for(var n=e.length;n>0&&e[n-1][2]>f;n--)e[n]=e[n-1];e[n]=[t,o,f]},r.n=e=>{var i=e&&e.__esModule?()=>e.default:()=>e;return r.d(i,{a:i}),i},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{45:"c90c3cea2bf1a66e",91:"be3cbd4101dc7500",103:"5b5d2a6e5a8a7479",146:"5a8902910bda9e87",183:"2c7c85597ba82f9e",500:"5d39ab52fb714a12",592:"858f777e9296e6f2"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="blrec:";r.l=(t,o,f,n)=>{if(e[t])e[t].push(o);else{var a,c;if(void 0!==f)for(var l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var u=l[d];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==i+f){a=u;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",i+f),a.src=r.tu(t)),e[t]=[o];var s=(g,b)=>{a.onerror=a.onload=null,clearTimeout(p);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(b)),g)return g(b)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),c&&document.head.appendChild(a)}}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tu=i=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(i))})(),r.p="",(()=>{var e={666:0};r.f.j=(o,f)=>{var n=r.o(e,o)?e[o]:void 0;if(0!==n)if(n)f.push(n[2]);else if(666!=o){var a=new Promise((u,s)=>n=e[o]=[u,s]);f.push(n[2]=a);var c=r.p+r.u(o),l=new Error;r.l(c,u=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=u&&("load"===u.type?"missing":u.type),p=u&&u.target&&u.target.src;l.message="Loading chunk "+o+" failed.\n("+s+": "+p+")",l.name="ChunkLoadError",l.type=s,l.request=p,n[1](l)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var l,d,[n,a,c]=f,u=0;if(n.some(p=>0!==e[p])){for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(c)var s=c(r)}for(o&&o(f);u<n.length;u++)r.o(e,d=n[u])&&e[d]&&e[d][0](),e[n[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();
|
||||
(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,o,f)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,o,f]=e[n],c=!0,l=0;l<t.length;l++)(!1&f||a>=f)&&Object.keys(r.O).every(p=>r.O[p](t[l]))?t.splice(l--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var d=o();void 0!==d&&(i=d)}}return i}f=f||0;for(var n=e.length;n>0&&e[n-1][2]>f;n--)e[n]=e[n-1];e[n]=[t,o,f]},r.n=e=>{var i=e&&e.__esModule?()=>e.default:()=>e;return r.d(i,{a:i}),i},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{45:"c90c3cea2bf1a66e",66:"9faa0b5a6adf9602",103:"5b5d2a6e5a8a7479",146:"92e3b29c4c754544",183:"ae1a1102b7d5cbdb",202:"e15e5ae9f06639b8",592:"858f777e9296e6f2"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="blrec:";r.l=(t,o,f,n)=>{if(e[t])e[t].push(o);else{var a,c;if(void 0!==f)for(var l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var u=l[d];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==i+f){a=u;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",i+f),a.src=r.tu(t)),e[t]=[o];var s=(g,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),c&&document.head.appendChild(a)}}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tu=i=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(i))})(),r.p="",(()=>{var e={666:0};r.f.j=(o,f)=>{var n=r.o(e,o)?e[o]:void 0;if(0!==n)if(n)f.push(n[2]);else if(666!=o){var a=new Promise((u,s)=>n=e[o]=[u,s]);f.push(n[2]=a);var c=r.p+r.u(o),l=new Error;r.l(c,u=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=u&&("load"===u.type?"missing":u.type),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,n[1](l)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var l,d,[n,a,c]=f,u=0;if(n.some(b=>0!==e[b])){for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(c)var s=c(r)}for(o&&o(f);u<n.length;u++)r.o(e,d=n[u])&&e[d]&&e[d][0](),e[n[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();
|
||||
@@ -35,25 +35,11 @@ class SpaceMonitor(
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.path = path
|
||||
self._check_interval = check_interval
|
||||
self.check_interval = check_interval
|
||||
self.space_threshold = space_threshold
|
||||
self._monitoring: bool = False
|
||||
|
||||
@property
|
||||
def check_interval(self) -> int:
|
||||
return self._check_interval
|
||||
|
||||
@check_interval.setter
|
||||
def check_interval(self, value: int) -> None:
|
||||
self._check_interval = value
|
||||
if value <= 0:
|
||||
self.disable()
|
||||
else:
|
||||
self.enable()
|
||||
|
||||
def _do_enable(self) -> None:
|
||||
if self._check_interval <= 0:
|
||||
return
|
||||
asyncio.create_task(self.start())
|
||||
logger.debug('Enabled space monitor')
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpaceReclaimer(SpaceEventListener, SwitchableMixin):
|
||||
_SUFFIX_SET = frozenset(
|
||||
('.flv', '.mp4', '.ts', '.m4s', '.m3u8' '.xml', '.jsonl', '.jpg')
|
||||
)
|
||||
_SUFFIX_SET = frozenset(('.flv', '.mp4', '.xml', '.jsonl', '.jpg'))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -77,17 +77,20 @@ def fix() -> Callable[[FLVStream], FLVStream]:
|
||||
|
||||
def update_delta(tag: FlvTag) -> None:
|
||||
nonlocal delta
|
||||
assert last_tag is not None
|
||||
assert last_video_tag is not None
|
||||
assert last_audio_tag is not None
|
||||
|
||||
if is_video_tag(tag) and last_video_tag is not None:
|
||||
if is_video_tag(tag):
|
||||
delta = (
|
||||
last_video_tag.timestamp - tag.timestamp + video_frame_interval
|
||||
)
|
||||
elif is_audio_tag(tag) and last_audio_tag is not None:
|
||||
elif is_audio_tag(tag):
|
||||
delta = (
|
||||
last_audio_tag.timestamp - tag.timestamp + sound_sample_interval
|
||||
)
|
||||
|
||||
if last_tag is not None and tag.timestamp + delta <= last_tag.timestamp:
|
||||
if tag.timestamp + delta <= last_tag.timestamp:
|
||||
if is_video_tag(tag):
|
||||
delta = (
|
||||
last_tag.timestamp - tag.timestamp + video_frame_interval
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
class SegmentDataCorrupted(ValueError):
|
||||
pass
|
||||
@@ -1,52 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from typing import Any, Callable, Dict, Tuple
|
||||
|
||||
from blrec.path.helpers import record_metadata_path
|
||||
from blrec.utils.mixins import SwitchableMixin
|
||||
|
||||
from . import operators as hls_ops
|
||||
|
||||
__all__ = ('MetadataDumper',)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetadataDumper(SwitchableMixin):
|
||||
def __init__(
|
||||
self,
|
||||
playlist_dumper: hls_ops.PlaylistDumper,
|
||||
metadata_provider: Callable[[Dict[str, Any]], Dict[str, Any]],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._playlist_dumper = playlist_dumper
|
||||
self._metadata_provider = metadata_provider
|
||||
self._metadata: Dict[str, Any] = {}
|
||||
|
||||
def _do_enable(self) -> None:
|
||||
self._file_opened_subscription = self._playlist_dumper.file_opened.subscribe(
|
||||
self._on_playlist_file_opened
|
||||
)
|
||||
logger.debug('Enabled metadata dumper')
|
||||
|
||||
def _do_disable(self) -> None:
|
||||
with suppress(Exception):
|
||||
self._file_opened_subscription.dispose()
|
||||
del self._file_opened_subscription
|
||||
self._metadata.clear()
|
||||
logger.debug('Disabled metadata dumper')
|
||||
|
||||
def _on_playlist_file_opened(self, args: Tuple[str, int]) -> None:
|
||||
playlist_path, _ = args
|
||||
metadata = self._metadata_provider({})
|
||||
self._dump_metadata(playlist_path, metadata)
|
||||
|
||||
def _dump_metadata(self, playlist_path: str, metadata: Dict[str, Any]) -> None:
|
||||
path = record_metadata_path(playlist_path)
|
||||
logger.debug(f"Dumping metadata to file: '{path}'")
|
||||
|
||||
with open(path, 'wt', encoding='utf8') as file:
|
||||
json.dump(metadata, file, ensure_ascii=False)
|
||||
@@ -1,20 +0,0 @@
|
||||
from .playlist_dumper import PlaylistDumper
|
||||
from .playlist_fetcher import PlaylistFetcher
|
||||
from .playlist_resolver import PlaylistResolver
|
||||
from .prober import Prober, StreamProfile
|
||||
from .segment_dumper import SegmentDumper
|
||||
from .segment_fetcher import InitSectionData, SegmentData, SegmentFetcher
|
||||
from .segment_remuxer import SegmentRemuxer
|
||||
|
||||
__all__ = (
|
||||
'InitSectionData',
|
||||
'PlaylistDumper',
|
||||
'PlaylistFetcher',
|
||||
'PlaylistResolver',
|
||||
'Prober',
|
||||
'SegmentData',
|
||||
'SegmentDumper',
|
||||
'SegmentFetcher',
|
||||
'SegmentRemuxer',
|
||||
'StreamProfile',
|
||||
)
|
||||
@@ -1,183 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from decimal import Decimal
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
import m3u8
|
||||
from reactivex import Observable, Subject, abc
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
|
||||
__all__ = ('PlaylistDumper',)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlaylistDumper:
|
||||
def __init__(self, path_provider: Callable[..., Tuple[str, int]]) -> None:
|
||||
self._path_provider = path_provider
|
||||
self._file_opened: Subject[Tuple[str, int]] = Subject()
|
||||
self._file_closed: Subject[str] = Subject()
|
||||
self._reset()
|
||||
|
||||
def _reset(self) -> None:
|
||||
self._path: str = ''
|
||||
self._file: Optional[io.TextIOWrapper] = None
|
||||
self._duration: Decimal = Decimal()
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return float(self._duration)
|
||||
|
||||
@property
|
||||
def file_opened(self) -> Observable[Tuple[str, int]]:
|
||||
return self._file_opened
|
||||
|
||||
@property
|
||||
def file_closed(self) -> Observable[str]:
|
||||
return self._file_closed
|
||||
|
||||
def __call__(self, source: Observable[m3u8.M3U8]) -> Observable[m3u8.Segment]:
|
||||
return self._dump(source)
|
||||
|
||||
def _open_file(self) -> None:
|
||||
path, timestamp = self._path_provider()
|
||||
root, ext = os.path.splitext(path)
|
||||
os.makedirs(root, exist_ok=True)
|
||||
self._path = os.path.join(root, 'index.m3u8')
|
||||
self._file = open(self._path, 'wt', encoding='utf8') # type: ignore
|
||||
logger.debug(f'Opened file: {self._path}')
|
||||
self._file_opened.on_next((self._path, timestamp))
|
||||
|
||||
def _close_file(self) -> None:
|
||||
if self._file is not None and not self._file.closed:
|
||||
self._file.write('#EXT-X-ENDLIST')
|
||||
self._file.close()
|
||||
logger.debug(f'Closed file: {self._path}')
|
||||
self._file_closed.on_next(self._path)
|
||||
|
||||
def _name_of(self, uri: str) -> str:
|
||||
name, ext = os.path.splitext(uri)
|
||||
return name
|
||||
|
||||
def _sequence_number_of(self, uri: str) -> int:
|
||||
return int(self._name_of(uri))
|
||||
|
||||
def _replace_uri(self, segment: m3u8.Segment) -> m3u8.Segment:
|
||||
copied_seg = deepcopy(segment)
|
||||
if init_section := getattr(copied_seg, 'init_section', None):
|
||||
init_section.uri = f'segments/{init_section.uri}'
|
||||
uri = segment.uri
|
||||
name = self._name_of(uri)
|
||||
copied_seg.uri = 'segments/%s/%s' % (name[:-3], uri)
|
||||
return copied_seg
|
||||
|
||||
def _replace_all_uri(self, playlist: m3u8.M3U8) -> m3u8.M3U8:
|
||||
copied_playlist = deepcopy(playlist)
|
||||
copied_playlist.segments = m3u8.SegmentList(
|
||||
self._replace_uri(s) for s in copied_playlist.segments
|
||||
)
|
||||
return copied_playlist
|
||||
|
||||
def _update_duration(self, segment: m3u8.Segment) -> None:
|
||||
self._duration += Decimal(str(segment.duration))
|
||||
|
||||
def _dump(self, source: Observable[m3u8.M3U8]) -> Observable[m3u8.Segment]:
|
||||
def subscribe(
|
||||
observer: abc.ObserverBase[m3u8.Segment],
|
||||
scheduler: Optional[abc.SchedulerBase] = None,
|
||||
) -> abc.DisposableBase:
|
||||
disposed = False
|
||||
subscription = SerialDisposable()
|
||||
|
||||
last_segment: Optional[m3u8.Segment] = None
|
||||
last_sequence_number: Optional[int] = None
|
||||
first_playlist_dumped: bool = False
|
||||
|
||||
self._close_file()
|
||||
self._reset()
|
||||
|
||||
def on_next(playlist: m3u8.M3U8) -> None:
|
||||
nonlocal last_sequence_number, last_segment, first_playlist_dumped
|
||||
|
||||
if playlist.is_endlist:
|
||||
logger.debug('Playlist ended')
|
||||
|
||||
try:
|
||||
if not first_playlist_dumped:
|
||||
self._close_file()
|
||||
self._reset()
|
||||
self._open_file()
|
||||
assert self._file is not None
|
||||
playlist.is_endlist = False
|
||||
self._file.write(self._replace_all_uri(playlist).dumps())
|
||||
self._file.flush()
|
||||
for seg in playlist.segments:
|
||||
observer.on_next(seg)
|
||||
self._update_duration(seg)
|
||||
last_segment = seg
|
||||
last_sequence_number = self._sequence_number_of(seg.uri)
|
||||
first_playlist_dumped = True
|
||||
logger.debug('The first playlist has been dumped')
|
||||
return
|
||||
|
||||
assert self._file is not None
|
||||
for seg in playlist.segments:
|
||||
num = self._sequence_number_of(seg.uri)
|
||||
discontinuity = False
|
||||
if last_sequence_number is not None:
|
||||
if last_sequence_number >= num:
|
||||
continue
|
||||
if last_sequence_number + 1 != num:
|
||||
logger.warning(
|
||||
'Segments discontinuous: '
|
||||
f'last sequence number: {last_sequence_number}, '
|
||||
f'current sequence number: {num}'
|
||||
)
|
||||
discontinuity = True
|
||||
new_seg = self._replace_uri(seg)
|
||||
new_seg.discontinuity = discontinuity
|
||||
new_last_seg = self._replace_uri(last_segment)
|
||||
self._file.write(new_seg.dumps(new_last_seg) + '\n')
|
||||
observer.on_next(seg)
|
||||
self._update_duration(seg)
|
||||
last_segment = seg
|
||||
last_sequence_number = num
|
||||
except Exception as e:
|
||||
self._close_file()
|
||||
self._reset()
|
||||
observer.on_error(e)
|
||||
|
||||
def on_completed() -> None:
|
||||
self._close_file()
|
||||
self._reset()
|
||||
observer.on_completed()
|
||||
|
||||
def on_error(e: Exception) -> None:
|
||||
self._close_file()
|
||||
self._reset()
|
||||
observer.on_error(e)
|
||||
|
||||
def dispose() -> None:
|
||||
nonlocal disposed
|
||||
nonlocal last_segment, last_sequence_number
|
||||
disposed = True
|
||||
last_segment = None
|
||||
last_sequence_number = None
|
||||
self._close_file()
|
||||
self._reset()
|
||||
|
||||
subscription.disposable = source.subscribe(
|
||||
on_next, on_error, on_completed, scheduler=scheduler
|
||||
)
|
||||
|
||||
return CompositeDisposable(subscription, Disposable(dispose))
|
||||
|
||||
return Observable(subscribe)
|
||||
@@ -1,79 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
from reactivex import Observable, Subject, abc
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
|
||||
from blrec.hls.operators.segment_fetcher import InitSectionData, SegmentData
|
||||
|
||||
from .playlist_dumper import PlaylistDumper
|
||||
|
||||
__all__ = ('SegmentDumper',)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SegmentDumper:
|
||||
def __init__(self, playlist_dumper: PlaylistDumper) -> None:
|
||||
self._playlist_dumper = playlist_dumper
|
||||
self._out_dir: str = ''
|
||||
|
||||
def on_next(args: Tuple[str, int]) -> None:
|
||||
path, timestamp = args
|
||||
self._out_dir = os.path.dirname(path)
|
||||
|
||||
self._playlist_dumper.file_opened.subscribe(on_next)
|
||||
self._file_opened: Subject[Tuple[str, int]] = Subject()
|
||||
self._file_closed: Subject[str] = Subject()
|
||||
|
||||
@property
|
||||
def file_opened(self) -> Observable[Tuple[str, int]]:
|
||||
return self._file_opened
|
||||
|
||||
@property
|
||||
def file_closed(self) -> Observable[str]:
|
||||
return self._file_closed
|
||||
|
||||
def __call__(
|
||||
self, source: Observable[Union[InitSectionData, SegmentData]]
|
||||
) -> Observable[Union[InitSectionData, SegmentData]]:
|
||||
return self._dump(source)
|
||||
|
||||
def _dump(
|
||||
self, source: Observable[Union[InitSectionData, SegmentData]]
|
||||
) -> Observable[Union[InitSectionData, SegmentData]]:
|
||||
def subscribe(
|
||||
observer: abc.ObserverBase[Union[InitSectionData, SegmentData]],
|
||||
scheduler: Optional[abc.SchedulerBase] = None,
|
||||
) -> abc.DisposableBase:
|
||||
subscription = SerialDisposable()
|
||||
|
||||
def on_next(item: Union[InitSectionData, SegmentData]) -> None:
|
||||
if isinstance(item, InitSectionData):
|
||||
uri = item.init_section.uri
|
||||
path = os.path.join(self._out_dir, 'segments', uri)
|
||||
else:
|
||||
uri = item.segment.uri
|
||||
name, ext = os.path.splitext(uri)
|
||||
path = os.path.join(self._out_dir, 'segments', name[:-3], uri)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
try:
|
||||
with open(path, 'wb') as file:
|
||||
file.write(item.payload)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to dump segmemt: {repr(e)}')
|
||||
observer.on_error(e)
|
||||
else:
|
||||
observer.on_next(item)
|
||||
|
||||
def dispose() -> None:
|
||||
pass
|
||||
|
||||
subscription.disposable = source.subscribe(
|
||||
on_next, observer.on_error, observer.on_completed, scheduler=scheduler
|
||||
)
|
||||
|
||||
return CompositeDisposable(subscription, Disposable(dispose))
|
||||
|
||||
return Observable(subscribe)
|
||||
@@ -1,21 +1,20 @@
|
||||
from .helpers import (
|
||||
cover_path,
|
||||
file_exists,
|
||||
create_file,
|
||||
danmaku_path,
|
||||
escape_path,
|
||||
extra_metadata_path,
|
||||
file_exists,
|
||||
cover_path,
|
||||
raw_danmaku_path,
|
||||
record_metadata_path,
|
||||
extra_metadata_path,
|
||||
escape_path,
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
'cover_path',
|
||||
'file_exists',
|
||||
'create_file',
|
||||
'danmaku_path',
|
||||
'escape_path',
|
||||
'extra_metadata_path',
|
||||
'file_exists',
|
||||
'cover_path',
|
||||
'raw_danmaku_path',
|
||||
'record_metadata_path',
|
||||
'extra_metadata_path',
|
||||
'escape_path',
|
||||
)
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import os
|
||||
import re
|
||||
import os
|
||||
from pathlib import PurePath
|
||||
|
||||
|
||||
__all__ = (
|
||||
'cover_path',
|
||||
'file_exists',
|
||||
'create_file',
|
||||
'danmaku_path',
|
||||
'escape_path',
|
||||
'extra_metadata_path',
|
||||
'ffmpeg_metadata_path',
|
||||
'record_metadata_path',
|
||||
'file_exists',
|
||||
'cover_path',
|
||||
'raw_danmaku_path',
|
||||
'extra_metadata_path',
|
||||
'escape_path',
|
||||
)
|
||||
|
||||
|
||||
@@ -41,13 +40,5 @@ def extra_metadata_path(video_path: str) -> str:
|
||||
return video_path + '.meta.json'
|
||||
|
||||
|
||||
def record_metadata_path(video_path: str) -> str:
|
||||
return str(PurePath(video_path).with_suffix('.meta.json'))
|
||||
|
||||
|
||||
def ffmpeg_metadata_path(video_path: str) -> str:
|
||||
return video_path + '.meta'
|
||||
|
||||
|
||||
def escape_path(path: str) -> str:
|
||||
return re.sub(r'[\\/:*?"<>|]', '', path)
|
||||
|
||||
@@ -1,38 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Iterable, List, Tuple, cast
|
||||
from typing import Iterable, cast
|
||||
|
||||
import aiofiles
|
||||
import m3u8
|
||||
|
||||
from blrec.flv.helpers import make_comment_for_joinpoints
|
||||
from blrec.flv.operators import JoinPoint
|
||||
from blrec.flv.utils import format_timestamp
|
||||
from blrec.path.helpers import ffmpeg_metadata_path
|
||||
|
||||
from .helpers import get_extra_metadata, get_metadata, get_record_metadata
|
||||
from ..flv.helpers import make_comment_for_joinpoints
|
||||
from ..flv.operators import JoinPoint
|
||||
from .helpers import get_extra_metadata, get_metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def make_metadata_file(video_path: str) -> str:
|
||||
path = ffmpeg_metadata_path(video_path)
|
||||
async with aiofiles.open(path, 'wb') as file:
|
||||
if video_path.endswith('.flv'):
|
||||
content = await _make_metadata_content_for_flv(video_path)
|
||||
elif video_path.endswith('.m3u8'):
|
||||
content = await _make_metadata_content_for_m3u8(video_path)
|
||||
else:
|
||||
raise NotImplementedError(video_path)
|
||||
await file.write(content.encode(encoding='utf8'))
|
||||
async def make_metadata_file(flv_path: str) -> str:
|
||||
path = flv_path + '.meta'
|
||||
async with aiofiles.open(path, 'wb') as f:
|
||||
content = await _make_metadata_content(flv_path)
|
||||
await f.write(content.encode(encoding='utf8'))
|
||||
return path
|
||||
|
||||
|
||||
async def _make_metadata_content_for_flv(flv_path: str) -> str:
|
||||
async def _make_metadata_content(flv_path: str) -> str:
|
||||
metadata = await get_metadata(flv_path)
|
||||
try:
|
||||
extra_metadata = await get_extra_metadata(flv_path)
|
||||
@@ -50,7 +37,7 @@ async def _make_metadata_content_for_flv(flv_path: str) -> str:
|
||||
cast(float, extra_metadata.get('duration') or metadata.get('duration'))
|
||||
* 1000
|
||||
)
|
||||
chapters = _make_chapters_for_flv(join_points, last_timestamp)
|
||||
chapters = _make_chapters(join_points, last_timestamp)
|
||||
|
||||
comment = '\\\n'.join(comment.splitlines())
|
||||
|
||||
@@ -68,9 +55,7 @@ Comment={comment}
|
||||
"""
|
||||
|
||||
|
||||
def _make_chapters_for_flv(
|
||||
join_points: Iterable[JoinPoint], last_timestamp: int
|
||||
) -> str:
|
||||
def _make_chapters(join_points: Iterable[JoinPoint], last_timestamp: int) -> str:
|
||||
join_points = filter(lambda p: not p.seamless, join_points)
|
||||
timestamps = list(map(lambda p: p.timestamp, join_points))
|
||||
if not timestamps:
|
||||
@@ -93,72 +78,3 @@ END={end}
|
||||
title=segment \\#{i}
|
||||
"""
|
||||
return result
|
||||
|
||||
|
||||
async def _make_metadata_content_for_m3u8(playlist_path: str) -> str:
|
||||
metadata = await get_record_metadata(playlist_path)
|
||||
comment = cast(str, metadata.get('Comment', ''))
|
||||
chapters = ''
|
||||
|
||||
timestamps, duration = await _get_discontinuities(playlist_path)
|
||||
if timestamps:
|
||||
comment += '\n\n' + _make_comment_for_discontinuities(timestamps)
|
||||
chapters = _make_chapters_for_m3u8(timestamps, duration)
|
||||
|
||||
comment = '\\\n'.join(comment.splitlines())
|
||||
|
||||
# ref: https://ffmpeg.org/ffmpeg-formats.html#Metadata-1
|
||||
return f"""\
|
||||
;FFMETADATA1
|
||||
Title={metadata['Title']}
|
||||
Artist={metadata['Artist']}
|
||||
Date={metadata['Date']}
|
||||
# Description may be truncated!
|
||||
Description={json.dumps(metadata['description'], ensure_ascii=False)}
|
||||
Comment={comment}
|
||||
|
||||
{chapters}
|
||||
"""
|
||||
|
||||
|
||||
def _make_chapters_for_m3u8(timestamps: Iterable[int], duration: float) -> str:
|
||||
timestamps = list(timestamps)
|
||||
if not timestamps:
|
||||
return ''
|
||||
|
||||
timestamps.insert(0, 0)
|
||||
timestamps.append(int(duration * 1000))
|
||||
|
||||
result = ''
|
||||
for i in range(1, len(timestamps)):
|
||||
start = timestamps[i - 1]
|
||||
end = timestamps[i]
|
||||
if end < start:
|
||||
logger.warning(f'Chapter end time {end} before start {start}')
|
||||
end = start
|
||||
result += f"""\
|
||||
[CHAPTER]
|
||||
TIMEBASE=1/1000
|
||||
START={start}
|
||||
END={end}
|
||||
title=segment \\#{i}
|
||||
"""
|
||||
return result
|
||||
|
||||
|
||||
def _make_comment_for_discontinuities(timestamps: Iterable[int]) -> str:
|
||||
return 'HLS片段不连续位置:\n' + '\n'.join(
|
||||
('时间戳:{}'.format(format_timestamp(ts)) for ts in timestamps)
|
||||
)
|
||||
|
||||
|
||||
async def _get_discontinuities(playlist_path: str) -> Tuple[List[int], float]:
|
||||
loop = asyncio.get_running_loop()
|
||||
playlist = await loop.run_in_executor(None, m3u8.load, playlist_path)
|
||||
duration = Decimal()
|
||||
timestamps: List[int] = []
|
||||
for seg in playlist.segments:
|
||||
if seg.discontinuity:
|
||||
timestamps.append(int(duration * 1000))
|
||||
duration += Decimal(str(seg.duration))
|
||||
return timestamps, float(duration)
|
||||
|
||||
@@ -1,86 +1,31 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import PurePath
|
||||
from typing import Any, Dict, Iterable, Literal
|
||||
|
||||
import aiofiles
|
||||
|
||||
from blrec.path.helpers import (
|
||||
cover_path,
|
||||
danmaku_path,
|
||||
raw_danmaku_path,
|
||||
record_metadata_path,
|
||||
)
|
||||
|
||||
from ..flv.helpers import get_extra_metadata as _get_extra_metadata
|
||||
from ..flv.helpers import get_metadata as _get_metadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from ..flv.helpers import get_extra_metadata as _get_extra_metadata
|
||||
|
||||
|
||||
async def discard_files(
|
||||
paths: Iterable[str], log_level: Literal['INFO', 'DEBUG'] = 'INFO'
|
||||
paths: Iterable[str],
|
||||
log_level: Literal['INFO', 'DEBUG'] = 'INFO',
|
||||
) -> None:
|
||||
for path in paths:
|
||||
await discard_file(path, log_level)
|
||||
|
||||
|
||||
async def discard_file(path: str, log_level: Literal['INFO', 'DEBUG'] = 'INFO') -> None:
|
||||
async def discard_file(
|
||||
path: str,
|
||||
log_level: Literal['INFO', 'DEBUG'] = 'INFO',
|
||||
) -> None:
|
||||
from ..disk_space import delete_file
|
||||
|
||||
await delete_file(path, log_level)
|
||||
|
||||
|
||||
async def discard_dir(path: str, log_level: Literal['INFO', 'DEBUG'] = 'INFO') -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await loop.run_in_executor(None, shutil.rmtree, path)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to delete {path!r}, due to: {repr(e)}')
|
||||
else:
|
||||
logger.log(logging.getLevelName(log_level), f'Deleted {path!r}')
|
||||
|
||||
|
||||
async def copy_files_related(video_path: str) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
dirname = os.path.dirname(video_path)
|
||||
|
||||
for src_path in [
|
||||
danmaku_path(video_path),
|
||||
raw_danmaku_path(video_path),
|
||||
cover_path(video_path, ext='jpg'),
|
||||
cover_path(video_path, ext='png'),
|
||||
]:
|
||||
if not os.path.isfile(src_path):
|
||||
continue
|
||||
root, ext = os.path.splitext(src_path)
|
||||
dst_path = PurePath(dirname).with_suffix(ext)
|
||||
try:
|
||||
await loop.run_in_executor(None, shutil.copy, src_path, dst_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to copy '{src_path}' to '{dst_path}': {repr(e)}")
|
||||
else:
|
||||
logger.info(f"Copied '{src_path}' to '{dst_path}'")
|
||||
|
||||
|
||||
async def get_metadata(flv_path: str) -> Dict[str, Any]:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, _get_metadata, flv_path)
|
||||
|
||||
|
||||
async def get_record_metadata(video_path: str) -> Dict[str, Any]:
|
||||
if video_path.endswith('.m3u8'):
|
||||
path = record_metadata_path(video_path)
|
||||
else:
|
||||
raise NotImplementedError(video_path)
|
||||
async with aiofiles.open(path, 'rb') as file:
|
||||
data = await file.read()
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
async def get_extra_metadata(flv_path: str) -> Dict[str, Any]:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, _get_extra_metadata, flv_path)
|
||||
|
||||
@@ -2,10 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from pathlib import PurePath
|
||||
from typing import Any, Awaitable, Dict, Final, Iterator, List, Optional, Tuple, Union
|
||||
from typing import Any, Awaitable, Dict, Final, Iterator, List, Optional, Union
|
||||
|
||||
from reactivex.scheduler import ThreadPoolScheduler
|
||||
|
||||
@@ -20,7 +19,7 @@ from ..logging.room_id import aio_task_with_room_id
|
||||
from ..path import extra_metadata_path
|
||||
from ..utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin, SupportDebugMixin
|
||||
from .ffmpeg_metadata import make_metadata_file
|
||||
from .helpers import copy_files_related, discard_dir, discard_file, get_extra_metadata
|
||||
from .helpers import discard_file, get_extra_metadata
|
||||
from .models import DeleteStrategy, PostprocessorStatus
|
||||
from .remux import RemuxingProgress, RemuxingResult, remux_video
|
||||
from .typing import Progress
|
||||
@@ -142,44 +141,22 @@ class Postprocessor(
|
||||
async with self._worker_semaphore:
|
||||
logger.debug(f'Postprocessing... {video_path}')
|
||||
|
||||
try:
|
||||
if video_path.endswith('.flv'):
|
||||
if not await self._is_vaild_flv_file(video_path):
|
||||
logger.warning(f'The flv file may be invalid: {video_path}')
|
||||
if self.remux_to_mp4:
|
||||
self._status = PostprocessorStatus.REMUXING
|
||||
(
|
||||
result_path,
|
||||
remuxing_result,
|
||||
) = await self._remux_video_to_mp4(video_path)
|
||||
if not self._debug:
|
||||
await discard_file(
|
||||
extra_metadata_path(video_path), 'DEBUG'
|
||||
)
|
||||
if self._should_delete_source_files(remuxing_result):
|
||||
await discard_file(video_path)
|
||||
elif self.inject_extra_metadata:
|
||||
self._status = PostprocessorStatus.INJECTING
|
||||
result_path = await self._inject_extra_metadata(video_path)
|
||||
else:
|
||||
result_path = video_path
|
||||
if not await self._is_vaild_flv_file(video_path):
|
||||
logger.warning(f'The flv file may be invalid: {video_path}')
|
||||
|
||||
elif video_path.endswith('.m3u8'):
|
||||
if self.remux_to_mp4:
|
||||
self._status = PostprocessorStatus.REMUXING
|
||||
(
|
||||
result_path,
|
||||
remuxing_result,
|
||||
) = await self._remux_video_to_mp4(video_path)
|
||||
await copy_files_related(video_path)
|
||||
if not self._debug:
|
||||
if self._should_delete_source_files(remuxing_result):
|
||||
await discard_dir(os.path.dirname(video_path))
|
||||
else:
|
||||
result_path = video_path
|
||||
try:
|
||||
if self.remux_to_mp4:
|
||||
self._status = PostprocessorStatus.REMUXING
|
||||
result_path = await self._remux_flv_to_mp4(video_path)
|
||||
elif self.inject_extra_metadata:
|
||||
self._status = PostprocessorStatus.INJECTING
|
||||
result_path = await self._inject_extra_metadata(video_path)
|
||||
else:
|
||||
result_path = video_path
|
||||
|
||||
if not self._debug:
|
||||
await discard_file(extra_metadata_path(video_path), 'DEBUG')
|
||||
|
||||
self._completed_files.append(result_path)
|
||||
await self._emit(
|
||||
'video_postprocessing_completed', self, result_path
|
||||
@@ -214,37 +191,33 @@ class Postprocessor(
|
||||
logger.info(f"Successfully injected metadata for '{path}'")
|
||||
return path
|
||||
|
||||
async def _remux_video_to_mp4(self, in_path: str) -> Tuple[str, RemuxingResult]:
|
||||
if in_path.endswith('.flv'):
|
||||
out_path = str(PurePath(in_path).with_suffix('.mp4'))
|
||||
metadata_path = await make_metadata_file(in_path)
|
||||
elif in_path.endswith('.m3u8'):
|
||||
out_path = str(PurePath(in_path).parent.with_suffix('.mp4'))
|
||||
metadata_path = await make_metadata_file(in_path)
|
||||
else:
|
||||
raise NotImplementedError(in_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)
|
||||
|
||||
if remux_result.is_failed():
|
||||
logger.error(f"Failed to remux '{in_path}' to '{out_path}'")
|
||||
result_path = in_path
|
||||
if remux_result.is_successful():
|
||||
logger.info(f"Successfully remuxed '{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_successful():
|
||||
logger.info(f"Successfully remuxed '{in_path}' to '{out_path}'")
|
||||
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 not self._debug and in_path.endswith('.flv'):
|
||||
if not self._debug:
|
||||
await discard_file(metadata_path, 'DEBUG')
|
||||
if self._should_delete_source_files(remux_result):
|
||||
await discard_file(in_path)
|
||||
|
||||
return result_path, remux_result
|
||||
return result_path
|
||||
|
||||
def _analyse_metadata(self, path: str) -> Awaitable[None]:
|
||||
future: asyncio.Future[None] = asyncio.Future()
|
||||
|
||||
@@ -19,12 +19,8 @@ class RemuxingProgress:
|
||||
total: int
|
||||
|
||||
|
||||
_ERROR_PATTERN = re.compile(r'\b(error|missing|invalid|corrupt)\b', re.IGNORECASE)
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class RemuxingResult:
|
||||
|
||||
return_code: int
|
||||
output: str
|
||||
|
||||
@@ -32,20 +28,13 @@ class RemuxingResult:
|
||||
return self.return_code == 0
|
||||
|
||||
def is_successful(self) -> bool:
|
||||
return (
|
||||
self.is_done()
|
||||
and not self.may_timestamps_incorrect()
|
||||
and not self.has_errors()
|
||||
)
|
||||
return self.is_done() and not self.may_timestamps_incorrect()
|
||||
|
||||
def is_warned(self) -> bool:
|
||||
return self.is_done() and self.may_timestamps_incorrect()
|
||||
|
||||
def is_failed(self) -> bool:
|
||||
return not self.is_done() or self.has_errors()
|
||||
|
||||
def has_errors(self) -> bool:
|
||||
return _ERROR_PATTERN.search(self.output) is not None
|
||||
return not self.is_done()
|
||||
|
||||
def may_timestamps_incorrect(self) -> bool:
|
||||
return 'Non-monotonous DTS in output stream' in self.output
|
||||
@@ -60,19 +49,8 @@ def remux_video(
|
||||
remove_filler_data: bool = False,
|
||||
) -> Observable[Union[RemuxingProgress, RemuxingResult]]:
|
||||
SIZE_PATTERN: Final = re.compile(r'size=\s*(?P<number>\d+)(?P<unit>[a-zA-Z]?B)')
|
||||
if in_path.endswith('.m3u8'):
|
||||
total = 0
|
||||
for root, dirs, files in os.walk(os.path.dirname(in_path)):
|
||||
for filename in files:
|
||||
if not (filename.endswith('.m4s') or filename.endswith('.ts')):
|
||||
continue
|
||||
total += os.path.getsize(os.path.join(root, filename))
|
||||
postfix = os.path.join(
|
||||
os.path.basename(os.path.dirname(in_path)), os.path.basename(in_path)
|
||||
)
|
||||
else:
|
||||
total = os.path.getsize(in_path)
|
||||
postfix = os.path.basename(in_path)
|
||||
filesize = os.path.getsize(in_path)
|
||||
filename = os.path.basename(in_path)
|
||||
|
||||
def parse_size(line: str) -> int:
|
||||
match = SIZE_PATTERN.search(line)
|
||||
@@ -97,11 +75,7 @@ def remux_video(
|
||||
|
||||
def should_output_line(line: str) -> bool:
|
||||
line = line.strip()
|
||||
return not (
|
||||
line.startswith('frame=')
|
||||
or line.startswith('Press [q]')
|
||||
or (line.startswith('[hls') and 'Opening' in line and 'for reading' in line)
|
||||
)
|
||||
return not (line.startswith('frame=') or line.startswith('Press [q]'))
|
||||
|
||||
def subscribe(
|
||||
observer: abc.ObserverBase[Union[RemuxingProgress, RemuxingResult]],
|
||||
@@ -118,11 +92,11 @@ def remux_video(
|
||||
|
||||
with tqdm(
|
||||
desc='Remuxing',
|
||||
total=total,
|
||||
total=filesize,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
postfix=postfix,
|
||||
postfix=filename,
|
||||
disable=not show_progress,
|
||||
) as pbar:
|
||||
cmd = f'ffmpeg -i "{in_path}"'
|
||||
@@ -156,15 +130,15 @@ def remux_video(
|
||||
if line.startswith('frame='):
|
||||
size = parse_size(line)
|
||||
pbar.update(size - pbar.n)
|
||||
progress = RemuxingProgress(size, total)
|
||||
progress = RemuxingProgress(size, filesize)
|
||||
observer.on_next(progress)
|
||||
|
||||
if should_output_line(line):
|
||||
out_lines.append(line)
|
||||
|
||||
if not disposed and process.returncode == 0:
|
||||
pbar.update(total)
|
||||
progress = RemuxingProgress(total, total)
|
||||
pbar.update(filesize)
|
||||
progress = RemuxingProgress(filesize, filesize)
|
||||
observer.on_next(progress)
|
||||
except Exception as e:
|
||||
observer.on_error(e)
|
||||
|
||||
@@ -11,17 +11,15 @@ from pydantic import BaseSettings, Field, PrivateAttr, validator
|
||||
from pydantic.networks import EmailStr, HttpUrl
|
||||
from typing_extensions import Annotated
|
||||
|
||||
from blrec.bili.typing import QualityNumber, StreamFormat
|
||||
from blrec.core.cover_downloader import CoverSaveStrategy
|
||||
from blrec.logging.typing import LOG_LEVEL
|
||||
from blrec.postprocess import DeleteStrategy
|
||||
from blrec.utils.string import camel_case
|
||||
|
||||
from ..bili.typing import QualityNumber, StreamFormat
|
||||
from ..core.cover_downloader import CoverSaveStrategy
|
||||
from ..logging.typing import LOG_LEVEL
|
||||
from ..postprocess import DeleteStrategy
|
||||
from ..utils.string import camel_case
|
||||
from .typing import (
|
||||
EmailMessageType,
|
||||
PushdeerMessageType,
|
||||
PushplusMessageType,
|
||||
RecordingMode,
|
||||
ServerchanMessageType,
|
||||
TelegramMessageType,
|
||||
)
|
||||
@@ -144,7 +142,6 @@ class DanmakuSettings(DanmakuOptions):
|
||||
|
||||
class RecorderOptions(BaseModel):
|
||||
stream_format: Optional[StreamFormat]
|
||||
recording_mode: Optional[RecordingMode]
|
||||
quality_number: Optional[QualityNumber]
|
||||
fmp4_stream_timeout: Optional[int]
|
||||
read_timeout: Optional[int] # seconds
|
||||
@@ -179,7 +176,6 @@ class RecorderOptions(BaseModel):
|
||||
|
||||
class RecorderSettings(RecorderOptions):
|
||||
stream_format: StreamFormat = 'flv'
|
||||
recording_mode: RecordingMode = 'standard'
|
||||
quality_number: QualityNumber = 20000 # 4K, the highest quality.
|
||||
fmp4_stream_timeout: int = 10
|
||||
read_timeout: int = 3
|
||||
@@ -242,22 +238,18 @@ class OutputOptions(BaseModel):
|
||||
|
||||
@validator('filesize_limit')
|
||||
def _validate_filesize_limit(cls, value: Optional[int]) -> Optional[int]:
|
||||
# file size in bytes, 0 indicates not limit。
|
||||
# allowed 1 ~ 20 GB, 0 indicates not limit.
|
||||
if value is not None:
|
||||
if not (0 <= value <= 1073731086581): # 1073731086581(999.99 GB)
|
||||
raise ValueError(
|
||||
'The filesize limit must be in the range of 0 to 1073731086581'
|
||||
)
|
||||
allowed_values = frozenset(1024**3 * i for i in range(0, 21))
|
||||
cls._validate_with_collection(value, allowed_values)
|
||||
return value
|
||||
|
||||
@validator('duration_limit')
|
||||
def _validate_duration_limit(cls, value: Optional[int]) -> Optional[int]:
|
||||
# duration in seconds, 0 indicates not limit。
|
||||
# allowed 1 ~ 24 hours, 0 indicates not limit.
|
||||
if value is not None:
|
||||
if not (0 <= value <= 359999): # 359999(99:59:59)
|
||||
raise ValueError(
|
||||
'The duration limit must be in the range of 0 to 359999'
|
||||
)
|
||||
allowed_values = frozenset(3600 * i for i in range(0, 25))
|
||||
cls._validate_with_collection(value, allowed_values)
|
||||
return value
|
||||
|
||||
|
||||
@@ -336,7 +328,7 @@ class SpaceSettings(BaseModel):
|
||||
|
||||
@validator('check_interval')
|
||||
def _validate_interval(cls, value: int) -> int:
|
||||
allowed_values = frozenset((0, 10, 30, *(60 * i for i in (1, 3, 5, 10))))
|
||||
allowed_values = frozenset((10, 30, *(60 * i for i in (1, 3, 5, 10))))
|
||||
cls._validate_with_collection(value, allowed_values)
|
||||
return value
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from typing import AbstractSet, Literal, Union
|
||||
|
||||
RecordingMode = Literal['standard', 'raw']
|
||||
|
||||
TextMessageType = Literal['text']
|
||||
HtmlMessageType = Literal['html']
|
||||
MarkdownMessageType = Literal['markdown']
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
import attr
|
||||
|
||||
from blrec.bili.models import RoomInfo, UserInfo
|
||||
from blrec.bili.typing import QualityNumber, StreamFormat
|
||||
from blrec.core.cover_downloader import CoverSaveStrategy
|
||||
from blrec.postprocess import DeleteStrategy, PostprocessorStatus
|
||||
from blrec.postprocess.typing import Progress
|
||||
from blrec.setting.typing import RecordingMode
|
||||
from ..bili.models import RoomInfo, UserInfo
|
||||
from ..bili.typing import StreamFormat, QualityNumber
|
||||
from ..core.cover_downloader import CoverSaveStrategy
|
||||
from ..postprocess import DeleteStrategy, PostprocessorStatus
|
||||
from ..postprocess.typing import Progress
|
||||
|
||||
|
||||
class RunningStatus(str, Enum):
|
||||
@@ -62,7 +60,6 @@ class TaskParam:
|
||||
save_raw_danmaku: bool
|
||||
# RecorderSettings
|
||||
stream_format: StreamFormat
|
||||
recording_mode: RecordingMode
|
||||
quality_number: QualityNumber
|
||||
fmp4_stream_timeout: int
|
||||
read_timeout: int
|
||||
|
||||
@@ -3,25 +3,23 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional
|
||||
|
||||
from blrec.bili.danmaku_client import DanmakuClient
|
||||
from blrec.bili.live import Live
|
||||
from blrec.bili.live_monitor import LiveMonitor
|
||||
from blrec.bili.models import RoomInfo, UserInfo
|
||||
from blrec.bili.typing import QualityNumber, StreamFormat
|
||||
from blrec.core import Recorder
|
||||
from blrec.core.cover_downloader import CoverSaveStrategy
|
||||
from blrec.event.event_submitters import (
|
||||
from ..bili.danmaku_client import DanmakuClient
|
||||
from ..bili.live import Live
|
||||
from ..bili.live_monitor import LiveMonitor
|
||||
from ..bili.models import RoomInfo, UserInfo
|
||||
from ..bili.typing import QualityNumber, StreamFormat
|
||||
from ..core import Recorder
|
||||
from ..core.cover_downloader import CoverSaveStrategy
|
||||
from ..event.event_submitters import (
|
||||
LiveEventSubmitter,
|
||||
PostprocessorEventSubmitter,
|
||||
RecorderEventSubmitter,
|
||||
)
|
||||
from blrec.flv.metadata_injection import InjectingProgress
|
||||
from blrec.flv.operators import MetaData, StreamProfile
|
||||
from blrec.logging.room_id import aio_task_with_room_id
|
||||
from blrec.postprocess import DeleteStrategy, Postprocessor, PostprocessorStatus
|
||||
from blrec.postprocess.remux import RemuxingProgress
|
||||
from blrec.setting.typing import RecordingMode
|
||||
|
||||
from ..flv.metadata_injection import InjectingProgress
|
||||
from ..flv.operators import MetaData, StreamProfile
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
from ..postprocess import DeleteStrategy, Postprocessor, PostprocessorStatus
|
||||
from ..postprocess.remux import RemuxingProgress
|
||||
from .models import (
|
||||
DanmakuFileDetail,
|
||||
DanmukuFileStatus,
|
||||
@@ -278,14 +276,6 @@ class RecordTask:
|
||||
def stream_format(self, value: StreamFormat) -> None:
|
||||
self._recorder.stream_format = value
|
||||
|
||||
@property
|
||||
def recording_mode(self) -> RecordingMode:
|
||||
return self._recorder.recording_mode
|
||||
|
||||
@recording_mode.setter
|
||||
def recording_mode(self, value: RecordingMode) -> None:
|
||||
self._recorder.recording_mode = value
|
||||
|
||||
@property
|
||||
def quality_number(self) -> QualityNumber:
|
||||
return self._recorder.quality_number
|
||||
|
||||
@@ -263,7 +263,6 @@ class RecordTaskManager:
|
||||
) -> None:
|
||||
task = self._get_task(room_id)
|
||||
task.stream_format = settings.stream_format
|
||||
task.recording_mode = settings.recording_mode
|
||||
task.quality_number = settings.quality_number
|
||||
task.fmp4_stream_timeout = settings.fmp4_stream_timeout
|
||||
task.read_timeout = settings.read_timeout
|
||||
@@ -307,7 +306,6 @@ class RecordTaskManager:
|
||||
cover_save_strategy=task.cover_save_strategy,
|
||||
save_raw_danmaku=task.save_raw_danmaku,
|
||||
stream_format=task.stream_format,
|
||||
recording_mode=task.recording_mode,
|
||||
quality_number=task.quality_number,
|
||||
fmp4_stream_timeout=task.fmp4_stream_timeout,
|
||||
read_timeout=task.read_timeout,
|
||||
|
||||
@@ -5,7 +5,7 @@ from subprocess import PIPE, Popen
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from reactivex import Observable, abc
|
||||
from reactivex.scheduler import CurrentThreadScheduler
|
||||
from reactivex.scheduler import NewThreadScheduler
|
||||
|
||||
__all__ = ('ffprobe', 'StreamProfile')
|
||||
|
||||
@@ -17,7 +17,7 @@ def ffprobe(data: bytes) -> Observable[StreamProfile]:
|
||||
observer: abc.ObserverBase[StreamProfile],
|
||||
scheduler: Optional[abc.SchedulerBase] = None,
|
||||
) -> abc.DisposableBase:
|
||||
_scheduler = scheduler or CurrentThreadScheduler()
|
||||
_scheduler = scheduler or NewThreadScheduler()
|
||||
|
||||
def action(scheduler: abc.SchedulerBase, state: Optional[Any] = None) -> None:
|
||||
args = [
|
||||
|
||||
@@ -33,7 +33,6 @@ export class DiskSpaceSettingsComponent implements OnInit, OnChanges {
|
||||
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
|
||||
|
||||
readonly intervalOptions = [
|
||||
{ label: '不检测', value: 0 },
|
||||
{ label: '10 秒', value: 10 },
|
||||
{ label: '30 秒', value: 30 },
|
||||
{ label: '1 分钟', value: 60 },
|
||||
|
||||
@@ -44,19 +44,9 @@
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
[nzTooltipTitle]="filesizeLimitTip"
|
||||
[nzTooltipTitle]="splitFileTip"
|
||||
>大小限制</nz-form-label
|
||||
>
|
||||
<ng-template #filesizeLimitTip>
|
||||
<p>
|
||||
自动分割文件以限制录播文件大小
|
||||
<br />
|
||||
格式:数字 + 单位(GB, MB, KB, B)
|
||||
<br />
|
||||
不自动分割文件设置为 <strong>0 B</strong>
|
||||
<br />
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control
|
||||
class="setting-control select"
|
||||
[nzWarningTip]="syncFailedWarningTip"
|
||||
@@ -64,26 +54,20 @@
|
||||
syncStatus.filesizeLimit ? filesizeLimitControl : 'warning'
|
||||
"
|
||||
>
|
||||
<app-input-filesize formControlName="filesizeLimit"></app-input-filesize>
|
||||
<nz-select
|
||||
formControlName="filesizeLimit"
|
||||
[nzOptions]="filesizeLimitOptions"
|
||||
>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
[nzTooltipTitle]="durationLimitTip"
|
||||
[nzTooltipTitle]="splitFileTip"
|
||||
>时长限制</nz-form-label
|
||||
>
|
||||
<ng-template #durationLimitTip>
|
||||
<p>
|
||||
自动分割文件以限制录播文件时长
|
||||
<br />
|
||||
格式:HH:MM:SS
|
||||
<br />
|
||||
不自动分割文件设置为 <strong>00:00:00</strong>
|
||||
<br />
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control
|
||||
class="setting-control select"
|
||||
[nzWarningTip]="syncFailedWarningTip"
|
||||
@@ -91,7 +75,11 @@
|
||||
syncStatus.durationLimit ? durationLimitControl : 'warning'
|
||||
"
|
||||
>
|
||||
<app-input-duration formControlName="durationLimit"></app-input-duration>
|
||||
<nz-select
|
||||
formControlName="durationLimit"
|
||||
[nzOptions]="durationLimitOptions"
|
||||
>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</form>
|
||||
|
||||
@@ -6,23 +6,25 @@ import {
|
||||
OnChanges,
|
||||
ChangeDetectorRef,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import mapValues from 'lodash-es/mapValues';
|
||||
|
||||
import type { Mutable } from '../../shared/utility-types';
|
||||
import { OutputSettings } from '../shared/setting.model';
|
||||
import { filterValueChanges } from '../shared/rx-operators';
|
||||
import {
|
||||
SettingsSyncService,
|
||||
SyncStatus,
|
||||
calcSyncStatus,
|
||||
} from '../shared/services/settings-sync.service';
|
||||
import { SYNC_FAILED_WARNING_TIP } from '../shared/constants/form';
|
||||
import {
|
||||
DURATION_LIMIT_OPTIONS,
|
||||
FILESIZE_LIMIT_OPTIONS,
|
||||
SPLIT_FILE_TIP,
|
||||
SYNC_FAILED_WARNING_TIP,
|
||||
} from '../shared/constants/form';
|
||||
|
||||
@Component({
|
||||
selector: 'app-output-settings',
|
||||
@@ -35,7 +37,14 @@ export class OutputSettingsComponent implements OnInit, OnChanges {
|
||||
syncStatus!: SyncStatus<OutputSettings>;
|
||||
|
||||
readonly settingsForm: FormGroup;
|
||||
readonly splitFileTip = SPLIT_FILE_TIP;
|
||||
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
|
||||
readonly filesizeLimitOptions = cloneDeep(FILESIZE_LIMIT_OPTIONS) as Mutable<
|
||||
typeof FILESIZE_LIMIT_OPTIONS
|
||||
>;
|
||||
readonly durationLimitOptions = cloneDeep(DURATION_LIMIT_OPTIONS) as Mutable<
|
||||
typeof DURATION_LIMIT_OPTIONS
|
||||
>;
|
||||
|
||||
constructor(
|
||||
formBuilder: FormBuilder,
|
||||
@@ -45,22 +54,8 @@ export class OutputSettingsComponent implements OnInit, OnChanges {
|
||||
this.settingsForm = formBuilder.group({
|
||||
outDir: [''],
|
||||
pathTemplate: [''],
|
||||
filesizeLimit: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0),
|
||||
Validators.max(1073731086581), // 1073731086581(999.99 GB)
|
||||
],
|
||||
],
|
||||
durationLimit: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0),
|
||||
Validators.max(359999), // 359999(99:59:59)
|
||||
],
|
||||
],
|
||||
filesizeLimit: [''],
|
||||
durationLimit: [''],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +70,6 @@ export class OutputSettingsComponent implements OnInit, OnChanges {
|
||||
get filesizeLimitControl() {
|
||||
return this.settingsForm.get('filesizeLimit') as FormControl;
|
||||
}
|
||||
|
||||
get durationLimitControl() {
|
||||
return this.settingsForm.get('durationLimit') as FormControl;
|
||||
}
|
||||
@@ -90,9 +84,7 @@ export class OutputSettingsComponent implements OnInit, OnChanges {
|
||||
.syncSettings(
|
||||
'output',
|
||||
this.settings,
|
||||
this.settingsForm.valueChanges.pipe(
|
||||
filterValueChanges<OutputSettings>(this.settingsForm)
|
||||
)
|
||||
this.settingsForm.valueChanges as Observable<OutputSettings>
|
||||
)
|
||||
.subscribe((detail) => {
|
||||
this.syncStatus = { ...this.syncStatus, ...calcSyncStatus(detail) };
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
nzTooltipTitle="调用 ffmpeg 进行转换,需要安装 ffmpeg 。"
|
||||
>转封装为 mp4</nz-form-label
|
||||
>flv 转封装为 mp4</nz-form-label
|
||||
>
|
||||
<nz-form-control
|
||||
class="setting-control switch"
|
||||
|
||||
@@ -38,10 +38,7 @@
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item
|
||||
class="setting-item"
|
||||
*ngIf="streamFormatControl.value === 'fmp4'"
|
||||
>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
@@ -74,38 +71,6 @@
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item
|
||||
class="setting-item"
|
||||
*ngIf="streamFormatControl.value === 'fmp4'"
|
||||
>
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
[nzTooltipTitle]="recordingModeTip"
|
||||
>录制模式</nz-form-label
|
||||
>
|
||||
<ng-template #recordingModeTip>
|
||||
<p>
|
||||
标准模式: 对下载的流数据进行解析处理,支持自动分割文件等功能。
|
||||
<br />
|
||||
原始模式: 直接下载流数据,没有进行解析处理,不支持自动分割文件等功能。
|
||||
<br />
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control
|
||||
class="setting-control select"
|
||||
[nzWarningTip]="syncFailedWarningTip"
|
||||
[nzValidateStatus]="
|
||||
syncStatus.recordingMode ? recordingModeControl : 'warning'
|
||||
"
|
||||
>
|
||||
<nz-select
|
||||
formControlName="recordingMode"
|
||||
[nzOptions]="recordingModeOptions"
|
||||
>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
@@ -174,10 +139,7 @@
|
||||
</nz-radio-group>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item
|
||||
class="setting-item"
|
||||
*ngIf="streamFormatControl.value === 'flv'"
|
||||
>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
@@ -222,14 +184,7 @@
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item
|
||||
class="setting-item"
|
||||
*ngIf="
|
||||
streamFormatControl.value === 'flv' ||
|
||||
(streamFormatControl.value === 'fmp4' &&
|
||||
recordingModeControl.value === 'standard')
|
||||
"
|
||||
>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { Mutable } from '../../shared/utility-types';
|
||||
import {
|
||||
BUFFER_OPTIONS,
|
||||
STREAM_FORMAT_OPTIONS,
|
||||
RECORDING_MODE_OPTIONS,
|
||||
QUALITY_OPTIONS,
|
||||
TIMEOUT_OPTIONS,
|
||||
DISCONNECTION_TIMEOUT_OPTIONS,
|
||||
@@ -45,9 +44,6 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
||||
readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable<
|
||||
typeof STREAM_FORMAT_OPTIONS
|
||||
>;
|
||||
readonly recordingModeOptions = cloneDeep(RECORDING_MODE_OPTIONS) as Mutable<
|
||||
typeof RECORDING_MODE_OPTIONS
|
||||
>;
|
||||
readonly fmp4StreamTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||
typeof TIMEOUT_OPTIONS
|
||||
>;
|
||||
@@ -74,7 +70,6 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
||||
) {
|
||||
this.settingsForm = formBuilder.group({
|
||||
streamFormat: [''],
|
||||
recordingMode: [''],
|
||||
qualityNumber: [''],
|
||||
fmp4StreamTimeout: [''],
|
||||
readTimeout: [''],
|
||||
@@ -89,10 +84,6 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
||||
return this.settingsForm.get('streamFormat') as FormControl;
|
||||
}
|
||||
|
||||
get recordingModeControl() {
|
||||
return this.settingsForm.get('recordingMode') as FormControl;
|
||||
}
|
||||
|
||||
get qualityNumberControl() {
|
||||
return this.settingsForm.get('qualityNumber') as FormControl;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CoverSaveStrategy, DeleteStrategy } from '../setting.model';
|
||||
|
||||
import range from 'lodash-es/range';
|
||||
|
||||
export const SPLIT_FILE_TIP = '会按照此限制自动分割文件';
|
||||
export const SYNC_FAILED_WARNING_TIP = '设置同步失败!';
|
||||
|
||||
export const PATH_TEMPLATE_PATTERN =
|
||||
@@ -27,6 +30,22 @@ export const PATH_TEMPLATE_VARIABLES: Readonly<PathTemplateVariable[]> = [
|
||||
{ name: 'second', desc: '文件创建日期时间之秒数' },
|
||||
] as const;
|
||||
|
||||
export const FILESIZE_LIMIT_OPTIONS = [
|
||||
{ label: '不限', value: 0 },
|
||||
...range(1, 21).map((i) => ({
|
||||
label: `${i} GB`,
|
||||
value: 1024 ** 3 * i,
|
||||
})),
|
||||
] as const;
|
||||
|
||||
export const DURATION_LIMIT_OPTIONS = [
|
||||
{ label: '不限', value: 0 },
|
||||
...range(1, 25).map((i) => ({
|
||||
label: `${i} 小时`,
|
||||
value: 3600 * i,
|
||||
})),
|
||||
] as const;
|
||||
|
||||
export const DELETE_STRATEGIES = [
|
||||
{ label: '自动', value: DeleteStrategy.AUTO },
|
||||
{ label: '谨慎', value: DeleteStrategy.SAFE },
|
||||
@@ -44,11 +63,6 @@ export const STREAM_FORMAT_OPTIONS = [
|
||||
{ label: 'HLS (fmp4)', value: 'fmp4' },
|
||||
] as const;
|
||||
|
||||
export const RECORDING_MODE_OPTIONS = [
|
||||
{ label: '标准', value: 'standard' },
|
||||
{ label: '原始', value: 'raw' },
|
||||
] as const;
|
||||
|
||||
export const QUALITY_OPTIONS = [
|
||||
{ label: '4K', value: 20000 },
|
||||
{ label: '原画', value: 10000 },
|
||||
|
||||
@@ -23,18 +23,16 @@ export function filterValueChanges<T extends object>(control: AbstractControl) {
|
||||
|
||||
export function trimString<T extends object>() {
|
||||
return pipe(
|
||||
map((object: T) => {
|
||||
if (isString(object)) {
|
||||
return object.trim() as unknown as T;
|
||||
}
|
||||
return transform(
|
||||
object,
|
||||
(result, value: any, prop) => {
|
||||
result[prop] = isString(value) ? value.trim() : value;
|
||||
},
|
||||
{} as T
|
||||
) as T;
|
||||
})
|
||||
map(
|
||||
(object: T) =>
|
||||
transform(
|
||||
object,
|
||||
(result, value: any, prop) => {
|
||||
result[prop] = isString(value) ? value.trim() : value;
|
||||
},
|
||||
{} as T
|
||||
) as T
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface DanmakuSettings {
|
||||
export type DanmakuOptions = Nullable<DanmakuSettings>;
|
||||
|
||||
export type StreamFormat = 'flv' | 'ts' | 'fmp4';
|
||||
export type RecordingMode = 'standard' | 'raw';
|
||||
|
||||
export type QualityNumber =
|
||||
| 20000 // 4K
|
||||
@@ -37,7 +36,6 @@ export enum CoverSaveStrategy {
|
||||
|
||||
export interface RecorderSettings {
|
||||
streamFormat: StreamFormat;
|
||||
recordingMode: RecordingMode;
|
||||
qualityNumber: QualityNumber;
|
||||
fmp4StreamTimeout: number;
|
||||
readTimeout: number;
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<form nz-form [formGroup]="formGroup">
|
||||
<nz-form-item>
|
||||
<nz-form-control [nzErrorTip]="errorTip">
|
||||
<input nz-input type="text" formControlName="duration" />
|
||||
<ng-template #errorTip let-control>
|
||||
<ng-container *ngIf="control.hasError('required')">
|
||||
请输入文件大小
|
||||
</ng-container>
|
||||
<ng-container *ngIf="control.hasError('pattern')">
|
||||
输入有错误
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</form>
|
||||
@@ -1,3 +0,0 @@
|
||||
nz-form-item {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { InputDurationComponent } from './input-duration.component';
|
||||
|
||||
describe('InputDurationComponent', () => {
|
||||
let component: InputDurationComponent;
|
||||
let fixture: ComponentFixture<InputDurationComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ InputDurationComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(InputDurationComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
forwardRef,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ControlValueAccessor,
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
NG_VALUE_ACCESSOR,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
|
||||
import { OnChangeType, OnTouchedType } from 'ng-zorro-antd/core/types';
|
||||
|
||||
import { formatDuration, parseDuration } from '../../../shared/utils';
|
||||
import { filterValueChanges } from 'src/app/settings/shared/rx-operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-input-duration',
|
||||
templateUrl: './input-duration.component.html',
|
||||
styleUrls: ['./input-duration.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => InputDurationComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class InputDurationComponent implements OnInit, ControlValueAccessor {
|
||||
readonly formGroup: FormGroup;
|
||||
private value: number = 0;
|
||||
|
||||
onChange: OnChangeType = () => {};
|
||||
onTouched: OnTouchedType = () => {};
|
||||
|
||||
constructor(formBuilder: FormBuilder) {
|
||||
this.formGroup = formBuilder.group({
|
||||
duration: [
|
||||
'',
|
||||
[Validators.required, Validators.pattern(/^\d{2}:[0~5]\d:[0~5]\d$/)],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
get durationControl() {
|
||||
return this.formGroup.get('duration') as FormControl;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.durationControl.valueChanges
|
||||
.pipe(filterValueChanges(this.durationControl))
|
||||
.subscribe((displayValue) => {
|
||||
this.onDisplayValueChange(displayValue);
|
||||
});
|
||||
}
|
||||
|
||||
writeValue(value: number): void {
|
||||
this.value = value;
|
||||
this.updateDisplayValue(value);
|
||||
}
|
||||
|
||||
registerOnChange(fn: OnChangeType): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: OnTouchedType): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
setDisabledState(disabled: boolean): void {
|
||||
if (disabled) {
|
||||
this.durationControl.disable();
|
||||
} else {
|
||||
this.durationControl.enable();
|
||||
}
|
||||
}
|
||||
|
||||
private onDisplayValueChange(displayValue: string): void {
|
||||
const value = parseDuration(displayValue);
|
||||
if (typeof value === 'number' && this.value !== value) {
|
||||
this.value = value;
|
||||
this.onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
private updateDisplayValue(value: number): void {
|
||||
const displayValue = formatDuration(value);
|
||||
this.durationControl.setValue(displayValue);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<form nz-form [formGroup]="formGroup">
|
||||
<nz-form-item>
|
||||
<nz-form-control [nzErrorTip]="errorTip">
|
||||
<input nz-input type="text" formControlName="filesize" />
|
||||
<ng-template #errorTip let-control>
|
||||
<ng-container *ngIf="control.hasError('required')">
|
||||
请输入文件大小
|
||||
</ng-container>
|
||||
<ng-container *ngIf="control.hasError('pattern')">
|
||||
输入有错误
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</form>
|
||||
@@ -1,3 +0,0 @@
|
||||
nz-form-item {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { InputFilesizeComponent } from './input-filesize.component';
|
||||
|
||||
describe('InputFilesizeComponent', () => {
|
||||
let component: InputFilesizeComponent;
|
||||
let fixture: ComponentFixture<InputFilesizeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ InputFilesizeComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(InputFilesizeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
forwardRef,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ControlValueAccessor,
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
NG_VALUE_ACCESSOR,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
|
||||
import { OnChangeType, OnTouchedType } from 'ng-zorro-antd/core/types';
|
||||
|
||||
import { formatFilesize, parseFilesize } from '../../../shared/utils';
|
||||
import { filterValueChanges } from 'src/app/settings/shared/rx-operators';
|
||||
|
||||
@Component({
|
||||
selector: 'app-input-filesize',
|
||||
templateUrl: './input-filesize.component.html',
|
||||
styleUrls: ['./input-filesize.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => InputFilesizeComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class InputFilesizeComponent implements OnInit, ControlValueAccessor {
|
||||
readonly formGroup: FormGroup;
|
||||
private value: number = 0;
|
||||
|
||||
onChange: OnChangeType = () => {};
|
||||
onTouched: OnTouchedType = () => {};
|
||||
|
||||
constructor(formBuilder: FormBuilder) {
|
||||
this.formGroup = formBuilder.group({
|
||||
filesize: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(/^\d{1,3}(?:\.\d{1,2})?\s?[GMK]?B$/),
|
||||
],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
get filesizeControl() {
|
||||
return this.formGroup.get('filesize') as FormControl;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.filesizeControl.valueChanges
|
||||
.pipe(filterValueChanges(this.filesizeControl))
|
||||
.subscribe((displayValue) => {
|
||||
this.onDisplayValueChange(displayValue);
|
||||
});
|
||||
}
|
||||
|
||||
writeValue(value: number): void {
|
||||
this.value = value;
|
||||
this.updateDisplayValue(value);
|
||||
}
|
||||
|
||||
registerOnChange(fn: OnChangeType): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: OnTouchedType): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
|
||||
setDisabledState(disabled: boolean): void {
|
||||
if (disabled) {
|
||||
this.filesizeControl.disable();
|
||||
} else {
|
||||
this.filesizeControl.enable();
|
||||
}
|
||||
}
|
||||
|
||||
private onDisplayValueChange(displayValue: string): void {
|
||||
const value = parseFilesize(displayValue);
|
||||
if (typeof value === 'number' && this.value !== value) {
|
||||
this.value = value;
|
||||
this.onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
private updateDisplayValue(value: number): void {
|
||||
const displayValue = formatFilesize(value);
|
||||
this.filesizeControl.setValue(displayValue);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,29 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { formatDuration } from '../utils';
|
||||
|
||||
@Pipe({
|
||||
name: 'duration',
|
||||
})
|
||||
export class DurationPipe implements PipeTransform {
|
||||
transform(totalSeconds: number): string {
|
||||
return formatDuration(totalSeconds, true);
|
||||
if (totalSeconds < 0) {
|
||||
throw RangeError(
|
||||
'the argument totalSeconds must be greater than or equal to 0'
|
||||
);
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds / 60) % 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
|
||||
let result = '';
|
||||
|
||||
if (hours > 0) {
|
||||
result += hours + ':';
|
||||
}
|
||||
result += minutes < 10 ? '0' + minutes : minutes;
|
||||
result += ':';
|
||||
result += seconds < 10 ? '0' + seconds : seconds;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
import { NzSpinModule } from 'ng-zorro-antd/spin';
|
||||
import { NzPageHeaderModule } from 'ng-zorro-antd/page-header';
|
||||
import { NzFormModule } from 'ng-zorro-antd/form';
|
||||
import { NzInputModule } from 'ng-zorro-antd/input';
|
||||
|
||||
import { DataurlPipe } from './pipes/dataurl.pipe';
|
||||
import { DurationPipe } from './pipes/duration.pipe';
|
||||
@@ -15,12 +11,10 @@ import { FilesizePipe } from './pipes/filesize.pipe';
|
||||
import { QualityPipe } from './pipes/quality.pipe';
|
||||
import { ProgressPipe } from './pipes/progress.pipe';
|
||||
import { FilenamePipe } from './pipes/filename.pipe';
|
||||
import { FilestatusPipe } from './pipes/filestatus.pipe';
|
||||
import { SubPageContentDirective } from './directives/sub-page-content.directive';
|
||||
import { PageSectionComponent } from './components/page-section/page-section.component';
|
||||
import { SubPageComponent } from './components/sub-page/sub-page.component';
|
||||
import { InputFilesizeComponent } from './components/input-filesize/input-filesize.component';
|
||||
import { InputDurationComponent } from './components/input-duration/input-duration.component';
|
||||
import { SubPageContentDirective } from './directives/sub-page-content.directive';
|
||||
import { FilestatusPipe } from './pipes/filestatus.pipe';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
@@ -29,24 +23,14 @@ import { InputDurationComponent } from './components/input-duration/input-durati
|
||||
DataratePipe,
|
||||
FilesizePipe,
|
||||
QualityPipe,
|
||||
SubPageComponent,
|
||||
SubPageContentDirective,
|
||||
PageSectionComponent,
|
||||
ProgressPipe,
|
||||
FilenamePipe,
|
||||
FilestatusPipe,
|
||||
SubPageContentDirective,
|
||||
SubPageComponent,
|
||||
PageSectionComponent,
|
||||
InputFilesizeComponent,
|
||||
InputDurationComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NzSpinModule,
|
||||
NzFormModule,
|
||||
NzPageHeaderModule,
|
||||
NzInputModule,
|
||||
],
|
||||
imports: [CommonModule, NzSpinModule, NzPageHeaderModule],
|
||||
exports: [
|
||||
DataurlPipe,
|
||||
DurationPipe,
|
||||
@@ -55,12 +39,11 @@ import { InputDurationComponent } from './components/input-duration/input-durati
|
||||
QualityPipe,
|
||||
ProgressPipe,
|
||||
FilenamePipe,
|
||||
FilestatusPipe,
|
||||
SubPageContentDirective,
|
||||
|
||||
SubPageComponent,
|
||||
SubPageContentDirective,
|
||||
PageSectionComponent,
|
||||
InputFilesizeComponent,
|
||||
InputDurationComponent,
|
||||
FilestatusPipe,
|
||||
],
|
||||
})
|
||||
export class SharedModule {}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { transform, isEqual, isObject } from 'lodash-es';
|
||||
import * as filesize from 'filesize';
|
||||
|
||||
// ref: https://gist.github.com/Yimiprod/7ee176597fef230d1451
|
||||
export function difference(object: object, base: object): object {
|
||||
@@ -86,70 +85,3 @@ export function toByteRateString(
|
||||
const digits = precision - Math.floor(Math.abs(Math.log10(num))) - 1;
|
||||
return num.toFixed(digits < 0 ? 0 : digits) + spacer + unit;
|
||||
}
|
||||
|
||||
export function formatDuration(
|
||||
totalSeconds: number,
|
||||
concise: boolean = false
|
||||
): string {
|
||||
if (!(totalSeconds > 0)) {
|
||||
totalSeconds = 0;
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds / 60) % 60);
|
||||
const seconds = Math.floor(totalSeconds % 60);
|
||||
|
||||
let result = '';
|
||||
|
||||
if (concise) {
|
||||
if (hours > 0) {
|
||||
result += hours + ':';
|
||||
}
|
||||
} else {
|
||||
result += hours < 10 ? '0' + hours : hours;
|
||||
result += ':';
|
||||
}
|
||||
result += minutes < 10 ? '0' + minutes : minutes;
|
||||
result += ':';
|
||||
result += seconds < 10 ? '0' + seconds : seconds;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseDuration(str: string): number | null {
|
||||
try {
|
||||
const [_, hours, minutes, seconds] = /(\d{1,2}):(\d{2}):(\d{2})/.exec(str)!;
|
||||
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse duration: ${str}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatFilesize(size: number): string {
|
||||
return filesize(size);
|
||||
}
|
||||
|
||||
export function parseFilesize(str: string): number | null {
|
||||
try {
|
||||
const [_, num, unit] = /^(\d+(?:\.\d+)?)\s*([TGMK]?B)$/.exec(str)!;
|
||||
switch (unit) {
|
||||
case 'B':
|
||||
return parseFloat(num);
|
||||
case 'KB':
|
||||
return 1024 ** 1 * parseFloat(num);
|
||||
case 'MB':
|
||||
return 1024 ** 2 * parseFloat(num);
|
||||
case 'GB':
|
||||
return 1024 ** 3 * parseFloat(num);
|
||||
case 'TB':
|
||||
return 1024 ** 4 * parseFloat(num);
|
||||
default:
|
||||
console.warn(`Unexpected unit: ${unit}`, str);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse filesize: ${str}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,16 +285,6 @@ export class TaskManagerService {
|
||||
);
|
||||
}
|
||||
|
||||
canCutStream(roomId: number) {
|
||||
return this.taskService.canCutStream(roomId).pipe(
|
||||
tap((ableToCutStream) => {
|
||||
if (!ableToCutStream) {
|
||||
this.message.warning(`[${roomId}] 不支持文件切割~`);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
cutStream(roomId: number) {
|
||||
return this.taskService.cutStream(roomId).pipe(
|
||||
tap(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { ResponseMessage } from '../../../shared/api.models';
|
||||
@@ -164,13 +163,6 @@ export class TaskService {
|
||||
return this.http.post<ResponseMessage>(url, { force, background });
|
||||
}
|
||||
|
||||
canCutStream(roomId: number) {
|
||||
const url = apiUrl + `/api/v1/tasks/${roomId}/cut`;
|
||||
return this.http
|
||||
.get<{ data: { result: boolean } }>(url)
|
||||
.pipe(map((response) => response.data.result));
|
||||
}
|
||||
|
||||
cutStream(roomId: number) {
|
||||
const url = apiUrl + `/api/v1/tasks/${roomId}/cut`;
|
||||
return this.http.post<null>(url, null);
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
DeleteStrategy,
|
||||
StreamFormat,
|
||||
QualityNumber,
|
||||
RecordingMode,
|
||||
} from '../../settings/shared/setting.model';
|
||||
|
||||
export interface TaskData {
|
||||
@@ -117,7 +116,6 @@ export interface TaskParam {
|
||||
readonly save_raw_danmaku: boolean;
|
||||
|
||||
readonly stream_format: StreamFormat;
|
||||
readonly recording_mode: RecordingMode;
|
||||
readonly quality_number: QualityNumber;
|
||||
readonly read_timeout: number;
|
||||
readonly disconnection_timeout: number;
|
||||
|
||||
@@ -226,13 +226,7 @@ export class TaskItemComponent implements OnChanges, OnDestroy {
|
||||
|
||||
cutStream(): void {
|
||||
if (this.data.task_status.running_status === RunningStatus.RECORDING) {
|
||||
this.taskManager
|
||||
.canCutStream(this.roomId)
|
||||
.subscribe((ableToCutStream) => {
|
||||
if (ableToCutStream) {
|
||||
this.taskManager.cutStream(this.roomId).subscribe();
|
||||
}
|
||||
});
|
||||
this.taskManager.cutStream(this.roomId).subscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,39 +52,21 @@
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item
|
||||
class="setting-item filesize-limit"
|
||||
*ngIf="
|
||||
(options.recorder.streamFormat || model.recorder.streamFormat) ===
|
||||
'flv' ||
|
||||
((options.recorder.streamFormat || model.recorder.streamFormat) ===
|
||||
'fmp4' &&
|
||||
(options.recorder.recordingMode ||
|
||||
model.recorder.recordingMode) === 'standard')
|
||||
"
|
||||
>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
[nzTooltipTitle]="filesizeLimitTip"
|
||||
[nzTooltipTitle]="splitFileTip"
|
||||
>大小限制</nz-form-label
|
||||
>
|
||||
<ng-template #filesizeLimitTip>
|
||||
<p>
|
||||
自动分割文件以限制录播文件大小
|
||||
<br />
|
||||
格式:数字 + 单位(GB, MB, KB, B)
|
||||
<br />
|
||||
不自动分割文件设置为 <strong>0 B</strong>
|
||||
<br />
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control class="setting-control input">
|
||||
<app-input-filesize
|
||||
<nz-form-control class="setting-control select">
|
||||
<nz-select
|
||||
name="filesizeLimit"
|
||||
[(ngModel)]="model.output.filesizeLimit"
|
||||
[disabled]="options.output.filesizeLimit === null"
|
||||
></app-input-filesize>
|
||||
[nzOptions]="filesizeLimitOptions"
|
||||
>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
<label
|
||||
nz-checkbox
|
||||
@@ -97,39 +79,21 @@
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item
|
||||
class="setting-item duration-limit"
|
||||
*ngIf="
|
||||
(options.recorder.streamFormat || model.recorder.streamFormat) ===
|
||||
'flv' ||
|
||||
((options.recorder.streamFormat || model.recorder.streamFormat) ===
|
||||
'fmp4' &&
|
||||
(options.recorder.recordingMode ||
|
||||
model.recorder.recordingMode) === 'standard')
|
||||
"
|
||||
>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
[nzTooltipTitle]="durationLimitTip"
|
||||
[nzTooltipTitle]="splitFileTip"
|
||||
>时长限制</nz-form-label
|
||||
>
|
||||
<ng-template #durationLimitTip>
|
||||
<p>
|
||||
自动分割文件以限制录播文件时长
|
||||
<br />
|
||||
格式:HH:MM:SS
|
||||
<br />
|
||||
不自动分割文件设置为 <strong>00:00:00</strong>
|
||||
<br />
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control class="setting-control input">
|
||||
<app-input-duration
|
||||
<nz-form-control class="setting-control select">
|
||||
<nz-select
|
||||
name="durationLimit"
|
||||
[(ngModel)]="model.output.durationLimit"
|
||||
[disabled]="options.output.durationLimit === null"
|
||||
></app-input-duration>
|
||||
[nzOptions]="durationLimitOptions"
|
||||
>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
<label
|
||||
nz-checkbox
|
||||
@@ -191,13 +155,7 @@
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item
|
||||
class="setting-item"
|
||||
*ngIf="
|
||||
(options.recorder.streamFormat || model.recorder.streamFormat) ===
|
||||
'fmp4'
|
||||
"
|
||||
>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
@@ -237,48 +195,6 @@
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item
|
||||
class="setting-item"
|
||||
*ngIf="
|
||||
(options.recorder.streamFormat || model.recorder.streamFormat) ===
|
||||
'fmp4'
|
||||
"
|
||||
>
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
[nzTooltipTitle]="recordingModeTip"
|
||||
>录制模式</nz-form-label
|
||||
>
|
||||
<ng-template #recordingModeTip>
|
||||
<p>
|
||||
标准模式: 对下载的流数据进行解析处理,支持自动分割文件等功能。
|
||||
<br />
|
||||
原始模式:
|
||||
直接下载流数据,没有进行解析处理,不支持自动分割文件等功能。
|
||||
<br />
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control class="setting-control select">
|
||||
<nz-select
|
||||
name="recordingMode"
|
||||
[(ngModel)]="model.recorder.recordingMode"
|
||||
[disabled]="options.recorder.recordingMode === null"
|
||||
[nzOptions]="recordingModeOptions"
|
||||
>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
<label
|
||||
nz-checkbox
|
||||
[nzChecked]="options.recorder.recordingMode !== null"
|
||||
(nzCheckedChange)="
|
||||
options.recorder.recordingMode = $event
|
||||
? globalSettings.recorder.recordingMode
|
||||
: null
|
||||
"
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
@@ -370,13 +286,7 @@
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item
|
||||
class="setting-item"
|
||||
*ngIf="
|
||||
(options.recorder.streamFormat || model.recorder.streamFormat) ===
|
||||
'flv'
|
||||
"
|
||||
>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
@@ -436,17 +346,7 @@
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item
|
||||
class="setting-item"
|
||||
*ngIf="
|
||||
(options.recorder.streamFormat || model.recorder.streamFormat) ===
|
||||
'flv' ||
|
||||
((options.recorder.streamFormat || model.recorder.streamFormat) ===
|
||||
'fmp4' &&
|
||||
(options.recorder.recordingMode ||
|
||||
model.recorder.recordingMode) === 'standard')
|
||||
"
|
||||
>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
@@ -644,17 +544,7 @@
|
||||
|
||||
<div ngModelGroup="postprocessing" class="form-group postprocessing">
|
||||
<h2>文件处理</h2>
|
||||
<nz-form-item
|
||||
class="setting-item"
|
||||
*ngIf="
|
||||
(options.recorder.streamFormat || model.recorder.streamFormat) ===
|
||||
'flv' ||
|
||||
((options.recorder.streamFormat || model.recorder.streamFormat) ===
|
||||
'fmp4' &&
|
||||
(options.recorder.recordingMode ||
|
||||
model.recorder.recordingMode) === 'standard')
|
||||
"
|
||||
>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
@@ -687,7 +577,7 @@
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
nzTooltipTitle="调用 ffmpeg 进行转换,需要安装 ffmpeg 。"
|
||||
>转封装为 mp4</nz-form-label
|
||||
>flv 转封装为 mp4</nz-form-label
|
||||
>
|
||||
<nz-form-control class="setting-control switch">
|
||||
<nz-switch
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use "../../settings/shared/styles/setting";
|
||||
@use '../../settings/shared/styles/setting';
|
||||
|
||||
nz-divider {
|
||||
margin: 0 !important;
|
||||
@@ -44,8 +44,7 @@ nz-divider {
|
||||
}
|
||||
}
|
||||
|
||||
&.input,
|
||||
&.textarea {
|
||||
&.input, &.textarea {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
.setting-label {
|
||||
@@ -81,19 +80,3 @@ nz-divider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filesize-limit,
|
||||
.duration-limit {
|
||||
.setting-control {
|
||||
&.input {
|
||||
max-width: 8em !important;
|
||||
width: 8em !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 319px) {
|
||||
&.input {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,16 @@ import {
|
||||
} from '../../settings/shared/setting.model';
|
||||
import {
|
||||
PATH_TEMPLATE_PATTERN,
|
||||
FILESIZE_LIMIT_OPTIONS,
|
||||
DURATION_LIMIT_OPTIONS,
|
||||
STREAM_FORMAT_OPTIONS,
|
||||
QUALITY_OPTIONS,
|
||||
TIMEOUT_OPTIONS,
|
||||
DISCONNECTION_TIMEOUT_OPTIONS,
|
||||
BUFFER_OPTIONS,
|
||||
DELETE_STRATEGIES,
|
||||
SPLIT_FILE_TIP,
|
||||
COVER_SAVE_STRATEGIES,
|
||||
RECORDING_MODE_OPTIONS,
|
||||
} from '../../settings/shared/constants/form';
|
||||
|
||||
type OptionsModel = NonNullable<TaskOptions>;
|
||||
@@ -55,13 +57,17 @@ export class TaskSettingsDialogComponent implements OnChanges {
|
||||
|
||||
readonly warningTip =
|
||||
'需要重启弹幕客户端才能生效,如果任务正在录制可能会丢失弹幕!';
|
||||
readonly splitFileTip = SPLIT_FILE_TIP;
|
||||
readonly pathTemplatePattern = PATH_TEMPLATE_PATTERN;
|
||||
readonly filesizeLimitOptions = cloneDeep(FILESIZE_LIMIT_OPTIONS) as Mutable<
|
||||
typeof FILESIZE_LIMIT_OPTIONS
|
||||
>;
|
||||
readonly durationLimitOptions = cloneDeep(DURATION_LIMIT_OPTIONS) as Mutable<
|
||||
typeof DURATION_LIMIT_OPTIONS
|
||||
>;
|
||||
readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable<
|
||||
typeof STREAM_FORMAT_OPTIONS
|
||||
>;
|
||||
readonly recordingModeOptions = cloneDeep(RECORDING_MODE_OPTIONS) as Mutable<
|
||||
typeof RECORDING_MODE_OPTIONS
|
||||
>;
|
||||
readonly fmp4StreamTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||
typeof TIMEOUT_OPTIONS
|
||||
>;
|
||||
@@ -111,7 +117,6 @@ export class TaskSettingsDialogComponent implements OnChanges {
|
||||
}
|
||||
|
||||
handleConfirm(): void {
|
||||
debugger;
|
||||
this.confirm.emit(difference(this.options, this.taskOptions!));
|
||||
this.close();
|
||||
}
|
||||
@@ -123,6 +128,7 @@ export class TaskSettingsDialogComponent implements OnChanges {
|
||||
const prop = key as keyof TaskOptions;
|
||||
const options = this.options[prop];
|
||||
const globalSettings = this.globalSettings[prop];
|
||||
|
||||
Reflect.set(
|
||||
model,
|
||||
prop,
|
||||
|
||||
Reference in New Issue
Block a user