release: 1.7.0

close #42
This commit is contained in:
acgnhik 2022-05-06 13:44:44 +08:00
parent d5d9ece36a
commit 5ebeb38b69
45 changed files with 1037 additions and 505 deletions

View File

@ -1,5 +1,6 @@
[flake8] [flake8]
ignore = D203, W504 max-line-length = 88
ignore = D203, W504, W503
exclude = exclude =
__*, __*,
.*, .*,

View File

@ -1,5 +1,23 @@
# 更新日志 # 更新日志
## 1.7.0
- 添加封面保存策略
- 添加 Telegram bot 通知
- 添加 PushDeer 通知
- 废弃录制 HLS(ts) 流
- 在设定时间内没有 fmp4 流自动切换录制 flv 流
### P.S.
录制 fmp4 流基本没什么问题了
录制 fmp4 流基本不受网络波动影响,大概是不会录制到二压画质的。
人气比较高会被二压的直播间大都是有 fmp4 流的。
WEB 端直播播放器是 `Hls7Player` 的直播间支持录制 fmp4 流, `fMp4Player` 则不支持。
## 1.6.2 ## 1.6.2
- 忽略 Windows 注册表 JavaScript MIME 设置 (issue #12, 27) - 忽略 Windows 注册表 JavaScript MIME 设置 (issue #12, 27)

View File

@ -4,3 +4,13 @@ requires = [
"wheel >= 0.37, < 0.38.0", "wheel >= 0.37, < 0.38.0",
] ]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.black]
line-length = 88
target-version = ['py38']
include = '\.py$'
skip-string-normalization = true
skip-magic-trailing-comma = true
[tool.isort]
profile = 'black'

View File

@ -36,10 +36,11 @@ include_package_data = True
python_requires = >= 3.8 python_requires = >= 3.8
install_requires = install_requires =
typing-extensions >= 3.10.0.0 typing-extensions >= 3.10.0.0
ordered-set >= 4.1.0, < 5.0.0
fastapi >= 0.70.0, < 0.71.0 fastapi >= 0.70.0, < 0.71.0
email_validator >= 1.1.3, < 2.0.0 email_validator >= 1.1.3, < 2.0.0
click < 8.1.0 click < 8.1.0
typer >= 0.4.0, < 0.5.0 typer >= 0.4.1, < 0.5.0
aiohttp >= 3.8.1, < 4.0.0 aiohttp >= 3.8.1, < 4.0.0
requests >= 2.24.0, < 3.0.0 requests >= 2.24.0, < 3.0.0
aiofiles >= 0.8.0, < 0.9.0 aiofiles >= 0.8.0, < 0.9.0
@ -61,7 +62,9 @@ install_requires =
[options.extras_require] [options.extras_require]
dev = dev =
flake8 >= 4.0.1 flake8 >= 4.0.1
mypy >= 0.910 mypy == 0.910 # https://github.com/samuelcolvin/pydantic/issues/3528
isort >= 5.10.1
black >= 22.3.0
setuptools >= 59.4.0 setuptools >= 59.4.0
wheel >= 0.37 wheel >= 0.37

View File

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

View File

@ -0,0 +1,97 @@
import asyncio
import logging
from enum import Enum
from typing import Set
import aiofiles
import aiohttp
from tenacity import retry, stop_after_attempt, wait_fixed
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',)
logger = logging.getLogger(__name__)
class CoverSaveStrategy(Enum):
DEFAULT = 'default'
DEDUP = 'dedup'
def __str__(self) -> str:
return self.value
# workaround for value serialization
def __repr__(self) -> str:
return str(self)
class CoverDownloader(StreamRecorderEventListener, SwitchableMixin):
def __init__(
self,
live: Live,
stream_recorder: StreamRecorder,
*,
save_cover: bool = False,
cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT,
) -> None:
super().__init__()
self._live = live
self._stream_recorder = stream_recorder
self._lock: asyncio.Lock = asyncio.Lock()
self._sha1_set: Set[str] = set()
self.save_cover = save_cover
self.cover_save_strategy = cover_save_strategy
def _do_enable(self) -> None:
self._sha1_set.clear()
self._stream_recorder.add_listener(self)
logger.debug('Enabled cover downloader')
def _do_disable(self) -> None:
self._stream_recorder.remove_listener(self)
logger.debug('Disabled cover downloader')
async def on_video_file_completed(self, video_path: str) -> None:
async with self._lock:
if not self.save_cover:
return
task = asyncio.create_task(self._save_cover(video_path))
task.add_done_callback(exception_callback)
@aio_task_with_room_id
async def _save_cover(self, video_path: str) -> None:
try:
await self._live.update_info()
cover_url = self._live.room_info.cover
data = await self._fetch_cover(cover_url)
sha1 = sha1sum(data)
if (
self.cover_save_strategy == CoverSaveStrategy.DEDUP
and sha1 in self._sha1_set
):
return
path = cover_path(video_path, ext=cover_url.rsplit('.', 1)[-1])
await self._save_file(path, data)
self._sha1_set.add(sha1)
except Exception as e:
logger.error(f'Failed to save cover image: {repr(e)}')
else:
logger.info(f'Saved cover image: {path}')
@retry(reraise=True, wait=wait_fixed(1), stop=stop_after_attempt(3))
async def _fetch_cover(self, url: str) -> bytes:
async with aiohttp.ClientSession(raise_for_status=True) as session:
async with session.get(url) as response:
return await response.read()
async def _save_file(self, path: str, data: bytes) -> None:
async with aiofiles.open(path, 'wb') as file:
await file.write(data)

View File

@ -12,9 +12,7 @@ from tenacity import (
from .. import __version__, __prog__, __github__ from .. import __version__, __prog__, __github__
from .danmaku_receiver import DanmakuReceiver, DanmuMsg from .danmaku_receiver import DanmakuReceiver, DanmuMsg
from .base_stream_recorder import ( from .stream_recorder import StreamRecorder, StreamRecorderEventListener
BaseStreamRecorder, StreamRecorderEventListener
)
from .statistics import StatisticsCalculator from .statistics import StatisticsCalculator
from ..bili.live import Live from ..bili.live import Live
from ..exception import exception_callback, submit_exception from ..exception import exception_callback, submit_exception
@ -51,7 +49,7 @@ class DanmakuDumper(
def __init__( def __init__(
self, self,
live: Live, live: Live,
stream_recorder: BaseStreamRecorder, stream_recorder: StreamRecorder,
danmaku_receiver: DanmakuReceiver, danmaku_receiver: DanmakuReceiver,
*, *,
danmu_uname: bool = False, danmu_uname: bool = False,
@ -72,6 +70,7 @@ class DanmakuDumper(
self.record_guard_buy = record_guard_buy self.record_guard_buy = record_guard_buy
self.record_super_chat = record_super_chat self.record_super_chat = record_super_chat
self._lock: asyncio.Lock = asyncio.Lock()
self._path: Optional[str] = None self._path: Optional[str] = None
self._files: List[str] = [] self._files: List[str] = []
self._calculator = StatisticsCalculator(interval=60) self._calculator = StatisticsCalculator(interval=60)
@ -92,14 +91,6 @@ class DanmakuDumper(
def dumping_path(self) -> Optional[str]: def dumping_path(self) -> Optional[str]:
return self._path return self._path
def change_stream_recorder(
self, stream_recorder: BaseStreamRecorder
) -> None:
self._stream_recorder.remove_listener(self)
self._stream_recorder = stream_recorder
self._stream_recorder.add_listener(self)
logger.debug('Changed stream recorder')
def _do_enable(self) -> None: def _do_enable(self) -> None:
self._stream_recorder.add_listener(self) self._stream_recorder.add_listener(self)
logger.debug('Enabled danmaku dumper') logger.debug('Enabled danmaku dumper')
@ -124,12 +115,14 @@ class DanmakuDumper(
async def on_video_file_created( async def on_video_file_created(
self, video_path: str, record_start_time: int self, video_path: str, record_start_time: int
) -> None: ) -> None:
async with self._lock:
self._path = danmaku_path(video_path) self._path = danmaku_path(video_path)
self._record_start_time = record_start_time self._record_start_time = record_start_time
self._files.append(self._path) self._files.append(self._path)
self._start_dumping() self._start_dumping()
async def on_video_file_completed(self, video_path: str) -> None: async def on_video_file_completed(self, video_path: str) -> None:
async with self._lock:
await self._stop_dumping() await self._stop_dumping()
self._path = None self._path = None

View File

@ -1,3 +0,0 @@
class FailedToFetchSegments(Exception):
pass

View File

@ -1,51 +1,33 @@
import io import io
import errno
import logging import logging
from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from typing import Optional
import urllib3
import requests import requests
import urllib3
from tenacity import TryAgain
from tqdm import tqdm from tqdm import tqdm
from tenacity import (
retry_if_result,
retry_if_not_exception_type,
Retrying,
TryAgain,
)
from .stream_analyzer import StreamProfile
from .base_stream_recorder import BaseStreamRecorder, StreamProxy
from .retry import wait_exponential_for_same_exceptions, before_sleep_log
from ..bili.live import Live from ..bili.live import Live
from ..bili.typing import StreamFormat, QualityNumber from ..bili.typing import QualityNumber
from ..flv.stream_processor import StreamProcessor
from ..utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin
from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError
from ..bili.exceptions import ( from ..flv.stream_processor import StreamProcessor
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamAvailable, from .stream_analyzer import StreamProfile
) from .stream_recorder_impl import StreamProxy, StreamRecorderImpl
__all__ = 'FLVStreamRecorderImpl',
__all__ = 'FLVStreamRecorder',
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class FLVStreamRecorder( class FLVStreamRecorderImpl(StreamRecorderImpl):
BaseStreamRecorder,
AsyncCooperationMixin,
AsyncStoppableMixin,
):
def __init__( def __init__(
self, self,
live: Live, live: Live,
out_dir: str, out_dir: str,
path_template: str, path_template: str,
*, *,
stream_format: StreamFormat = 'flv',
quality_number: QualityNumber = 10000, quality_number: QualityNumber = 10000,
buffer_size: Optional[int] = None, buffer_size: Optional[int] = None,
read_timeout: Optional[int] = None, read_timeout: Optional[int] = None,
@ -57,7 +39,7 @@ class FLVStreamRecorder(
live=live, live=live,
out_dir=out_dir, out_dir=out_dir,
path_template=path_template, path_template=path_template,
stream_format=stream_format, stream_format='flv',
quality_number=quality_number, quality_number=quality_number,
buffer_size=buffer_size, buffer_size=buffer_size,
read_timeout=read_timeout, read_timeout=read_timeout,
@ -73,6 +55,7 @@ class FLVStreamRecorder(
desc='Recording', desc='Recording',
unit='B', unit='B',
unit_scale=True, unit_scale=True,
unit_divisor=1024,
postfix=self._make_pbar_postfix(), postfix=self._make_pbar_postfix(),
) as progress_bar: ) as progress_bar:
self._progress_bar = progress_bar self._progress_bar = progress_bar
@ -116,43 +99,6 @@ class FLVStreamRecorder(
self._emit_event('stream_recording_stopped') self._emit_event('stream_recording_stopped')
logger.debug('Stream recorder thread stopped') logger.debug('Stream recorder thread stopped')
def _main_loop(self) -> None:
for attempt in Retrying(
reraise=True,
retry=(
retry_if_result(lambda r: not self._stopped) |
retry_if_not_exception_type((OSError, NotImplementedError))
),
wait=wait_exponential_for_same_exceptions(max=60),
before_sleep=before_sleep_log(logger, logging.DEBUG, 'main_loop'),
):
with attempt:
try:
self._streaming_loop()
except NoStreamAvailable as e:
logger.warning(f'No stream available: {repr(e)}')
if not self._stopped:
raise TryAgain
except OSError as e:
logger.critical(repr(e), exc_info=e)
if e.errno == errno.ENOSPC:
# OSError(28, 'No space left on device')
self._handle_exception(e)
self._stopped = True
raise TryAgain
except LiveRoomHidden:
logger.error('The live room has been hidden!')
self._stopped = True
except LiveRoomLocked:
logger.error('The live room has been locked!')
self._stopped = True
except LiveRoomEncrypted:
logger.error('The live room has been encrypted!')
self._stopped = True
except Exception as e:
logger.exception(e)
self._handle_exception(e)
def _streaming_loop(self) -> None: def _streaming_loop(self) -> None:
url = self._get_live_stream_url() url = self._get_live_stream_url()

View File

@ -1,68 +1,57 @@
import io import io
import time
import errno
import logging import logging
from queue import Queue, Empty import time
from threading import Thread, Event, Lock, Condition
from datetime import datetime
from contextlib import suppress from contextlib import suppress
from datetime import datetime
from queue import Empty, Queue
from threading import Condition, Event, Lock, Thread
from typing import Final, List, Optional, Set
from urllib.parse import urlparse from urllib.parse import urlparse
from typing import List, Set, Optional
import urllib3
import requests
import m3u8 import m3u8
import requests
import urllib3
from m3u8.model import Segment from m3u8.model import Segment
from tqdm import tqdm from ordered_set import OrderedSet
from tenacity import ( from tenacity import (
retry, RetryError,
wait_exponential,
stop_after_delay,
retry_if_result,
retry_if_exception_type,
retry_if_not_exception_type,
Retrying, Retrying,
TryAgain, TryAgain,
RetryError, retry,
retry_if_exception_type,
retry_if_not_exception_type,
retry_if_result,
stop_after_delay,
wait_exponential,
) )
from tqdm import tqdm
from .stream_remuxer import StreamRemuxer
from .stream_analyzer import ffprobe, StreamProfile
from .base_stream_recorder import BaseStreamRecorder, StreamProxy
from .exceptions import FailedToFetchSegments
from .retry import wait_exponential_for_same_exceptions, before_sleep_log
from ..bili.live import Live from ..bili.live import Live
from ..bili.typing import StreamFormat, QualityNumber from ..bili.typing import QualityNumber
from ..flv.stream_processor import StreamProcessor
from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError
from ..utils.mixins import ( from ..flv.stream_processor import StreamProcessor
AsyncCooperationMixin, AsyncStoppableMixin, SupportDebugMixin from ..utils.mixins import SupportDebugMixin
) from .stream_analyzer import StreamProfile, ffprobe
from ..bili.exceptions import ( from .stream_recorder_impl import StreamProxy, StreamRecorderImpl
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamAvailable, from .stream_remuxer import StreamRemuxer
)
__all__ = 'HLSStreamRecorderImpl',
__all__ = 'HLSStreamRecorder',
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class HLSStreamRecorder( class FailedToFetchSegments(Exception):
BaseStreamRecorder, pass
AsyncCooperationMixin,
AsyncStoppableMixin,
SupportDebugMixin, class HLSStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
):
def __init__( def __init__(
self, self,
live: Live, live: Live,
out_dir: str, out_dir: str,
path_template: str, path_template: str,
*, *,
stream_format: StreamFormat = 'flv',
quality_number: QualityNumber = 10000, quality_number: QualityNumber = 10000,
buffer_size: Optional[int] = None, buffer_size: Optional[int] = None,
read_timeout: Optional[int] = None, read_timeout: Optional[int] = None,
@ -74,7 +63,7 @@ class HLSStreamRecorder(
live=live, live=live,
out_dir=out_dir, out_dir=out_dir,
path_template=path_template, path_template=path_template,
stream_format=stream_format, stream_format='fmp4',
quality_number=quality_number, quality_number=quality_number,
buffer_size=buffer_size, buffer_size=buffer_size,
read_timeout=read_timeout, read_timeout=read_timeout,
@ -87,7 +76,8 @@ class HLSStreamRecorder(
self._ready_to_fetch_segments = Condition() self._ready_to_fetch_segments = Condition()
self._failed_to_fetch_segments = Event() self._failed_to_fetch_segments = Event()
self._stream_analysed_lock = Lock() self._stream_analysed_lock = Lock()
self._last_segment_uris: Set[str] = set() self._last_seg_uris: OrderedSet[str] = OrderedSet()
self._MAX_LAST_SEG_URIS: Final[int] = 30
def _run(self) -> None: def _run(self) -> None:
logger.debug('Stream recorder thread started') logger.debug('Stream recorder thread started')
@ -103,7 +93,10 @@ class HLSStreamRecorder(
self._session = requests.Session() self._session = requests.Session()
self._session.headers.update(self._live.headers) self._session.headers.update(self._live.headers)
self._stream_remuxer = StreamRemuxer(self._live.room_id) self._stream_remuxer = StreamRemuxer(
self._live.room_id,
remove_filler_data=True,
)
self._segment_queue: Queue[Segment] = Queue(maxsize=1000) self._segment_queue: Queue[Segment] = Queue(maxsize=1000)
self._segment_data_queue: Queue[bytes] = Queue(maxsize=100) self._segment_data_queue: Queue[bytes] = Queue(maxsize=100)
self._stream_host_available = Event() self._stream_host_available = Event()
@ -139,7 +132,7 @@ class HLSStreamRecorder(
self._segment_data_feeder_thread.join(timeout=10) self._segment_data_feeder_thread.join(timeout=10)
self._stream_remuxer.stop() self._stream_remuxer.stop()
self._stream_remuxer.raise_for_exception() self._stream_remuxer.raise_for_exception()
self._last_segment_uris.clear() self._last_seg_uris.clear()
del self._segment_queue del self._segment_queue
del self._segment_data_queue del self._segment_data_queue
except TryAgain: except TryAgain:
@ -153,44 +146,6 @@ class HLSStreamRecorder(
self._emit_event('stream_recording_stopped') self._emit_event('stream_recording_stopped')
logger.debug('Stream recorder thread stopped') logger.debug('Stream recorder thread stopped')
def _main_loop(self) -> None:
for attempt in Retrying(
reraise=True,
retry=(
retry_if_result(lambda r: not self._stopped) |
retry_if_not_exception_type((OSError, NotImplementedError))
),
wait=wait_exponential_for_same_exceptions(max=60),
before_sleep=before_sleep_log(logger, logging.DEBUG, 'main_loop'),
):
with attempt:
try:
self._streaming_loop()
except NoStreamAvailable as e:
logger.warning(f'No stream available: {repr(e)}')
if not self._stopped:
raise TryAgain
except OSError as e:
logger.critical(repr(e), exc_info=e)
if e.errno == errno.ENOSPC:
# OSError(28, 'No space left on device')
self._handle_exception(e)
self._stopped = True
raise TryAgain
except LiveRoomHidden:
logger.error('The live room has been hidden!')
self._stopped = True
except LiveRoomLocked:
logger.error('The live room has been locked!')
self._stopped = True
except LiveRoomEncrypted:
logger.error('The live room has been encrypted!')
self._stopped = True
except Exception as e:
logger.exception(e)
self._handle_exception(e)
self._stopped = True
def _streaming_loop(self) -> None: def _streaming_loop(self) -> None:
url = self._get_live_stream_url() url = self._get_live_stream_url()
@ -247,32 +202,29 @@ class HLSStreamRecorder(
self._stream_analysed = False self._stream_analysed = False
continue continue
uris: Set[str] = set() curr_seg_uris: Set[str] = set()
for seg in playlist.segments: for seg in playlist.segments:
uris.add(seg.uri) curr_seg_uris.add(seg.uri)
if seg.uri not in self._last_segment_uris: if seg.uri not in self._last_seg_uris:
self._segment_queue.put(seg, timeout=60) self._segment_queue.put(seg, timeout=60)
self._last_seg_uris.add(seg.uri)
if len(self._last_seg_uris) > self._MAX_LAST_SEG_URIS:
self._last_seg_uris.pop(0)
if ( if (
self._last_segment_uris and self._last_seg_uris and
not uris.intersection(self._last_segment_uris) not curr_seg_uris.intersection(self._last_seg_uris)
): ):
logger.debug( logger.debug(
'segments broken!\n' 'segments broken!\n'
f'last segments: {self._last_segment_uris}\n' f'last segments uris: {self._last_seg_uris}\n'
f'current segments: {uris}' f'current segments uris: {curr_seg_uris}'
) )
with self._stream_analysed_lock: with self._stream_analysed_lock:
self._stream_analysed = False self._stream_analysed = False
self._last_segment_uris = uris
if playlist.is_endlist: if playlist.is_endlist:
logger.debug('playlist ended') logger.debug('playlist ended')
self._run_coroutine(self._live.update_room_info())
if not self._live.is_living():
self._stopped = True
break
time.sleep(1) time.sleep(1)
@ -338,7 +290,7 @@ class HLSStreamRecorder(
break break
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
logger.warning(repr(e)) logger.warning(repr(e))
self._connection_recovered.wait() self._wait_for_connection_error()
except RetryError as e: except RetryError as e:
logger.warning(repr(e)) logger.warning(repr(e))
break break
@ -434,6 +386,7 @@ class HLSStreamRecorder(
desc='Recording', desc='Recording',
unit='B', unit='B',
unit_scale=True, unit_scale=True,
unit_divisor=1024,
postfix=self._make_pbar_postfix(), postfix=self._make_pbar_postfix(),
) as progress_bar: ) as progress_bar:
self._progress_bar = progress_bar self._progress_bar = progress_bar

View File

@ -12,9 +12,7 @@ from tenacity import (
) )
from .raw_danmaku_receiver import RawDanmakuReceiver from .raw_danmaku_receiver import RawDanmakuReceiver
from .base_stream_recorder import ( from .stream_recorder import StreamRecorder, StreamRecorderEventListener
BaseStreamRecorder, StreamRecorderEventListener
)
from ..bili.live import Live from ..bili.live import Live
from ..exception import exception_callback, submit_exception from ..exception import exception_callback, submit_exception
from ..event.event_emitter import EventListener, EventEmitter from ..event.event_emitter import EventListener, EventEmitter
@ -45,7 +43,7 @@ class RawDanmakuDumper(
def __init__( def __init__(
self, self,
live: Live, live: Live,
stream_recorder: BaseStreamRecorder, stream_recorder: StreamRecorder,
danmaku_receiver: RawDanmakuReceiver, danmaku_receiver: RawDanmakuReceiver,
) -> None: ) -> None:
super().__init__() super().__init__()
@ -53,13 +51,7 @@ class RawDanmakuDumper(
self._stream_recorder = stream_recorder self._stream_recorder = stream_recorder
self._receiver = danmaku_receiver self._receiver = danmaku_receiver
def change_stream_recorder( self._lock: asyncio.Lock = asyncio.Lock()
self, stream_recorder: BaseStreamRecorder
) -> None:
self._stream_recorder.remove_listener(self)
self._stream_recorder = stream_recorder
self._stream_recorder.add_listener(self)
logger.debug('Changed stream recorder')
def _do_enable(self) -> None: def _do_enable(self) -> None:
self._stream_recorder.add_listener(self) self._stream_recorder.add_listener(self)
@ -72,10 +64,12 @@ class RawDanmakuDumper(
async def on_video_file_created( async def on_video_file_created(
self, video_path: str, record_start_time: int self, video_path: str, record_start_time: int
) -> None: ) -> None:
async with self._lock:
self._path = raw_danmaku_path(video_path) self._path = raw_danmaku_path(video_path)
self._start_dumping() self._start_dumping()
async def on_video_file_completed(self, video_path: str) -> None: async def on_video_file_completed(self, video_path: str) -> None:
async with self._lock:
await self._stop_dumping() await self._stop_dumping()
def _start_dumping(self) -> None: def _start_dumping(self) -> None:

View File

@ -1,35 +1,28 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Iterator, Optional, Type from typing import Iterator, Optional
import aiohttp
import aiofiles
import humanize import humanize
from tenacity import retry, wait_fixed, stop_after_attempt
from .danmaku_receiver import DanmakuReceiver
from .danmaku_dumper import DanmakuDumper, DanmakuDumperEventListener
from .raw_danmaku_receiver import RawDanmakuReceiver
from .raw_danmaku_dumper import RawDanmakuDumper, RawDanmakuDumperEventListener
from .base_stream_recorder import (
BaseStreamRecorder, StreamRecorderEventListener
)
from .stream_analyzer import StreamProfile
from .flv_stream_recorder import FLVStreamRecorder
from .hls_stream_recorder import HLSStreamRecorder
from ..event.event_emitter import EventListener, EventEmitter
from ..flv.data_analyser import MetaData
from ..bili.live import Live
from ..bili.models import RoomInfo
from ..bili.danmaku_client import DanmakuClient from ..bili.danmaku_client import DanmakuClient
from ..bili.live_monitor import LiveMonitor, LiveEventListener from ..bili.live import Live
from ..bili.typing import StreamFormat, QualityNumber from ..bili.live_monitor import LiveEventListener, LiveMonitor
from ..utils.mixins import AsyncStoppableMixin from ..bili.models import RoomInfo
from ..path import cover_path from ..bili.typing import QualityNumber, StreamFormat
from ..event.event_emitter import EventEmitter, EventListener
from ..flv.data_analyser import MetaData
from ..logging.room_id import aio_task_with_room_id 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
from .raw_danmaku_dumper import RawDanmakuDumper, RawDanmakuDumperEventListener
from .raw_danmaku_receiver import RawDanmakuReceiver
from .stream_analyzer import StreamProfile
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
__all__ = 'RecorderEventListener', 'Recorder' __all__ = 'RecorderEventListener', 'Recorder'
@ -47,29 +40,19 @@ class RecorderEventListener(EventListener):
async def on_recording_cancelled(self, recorder: Recorder) -> None: async def on_recording_cancelled(self, recorder: Recorder) -> None:
... ...
async def on_video_file_created( async def on_video_file_created(self, recorder: Recorder, path: str) -> None:
self, recorder: Recorder, path: str
) -> None:
... ...
async def on_video_file_completed( async def on_video_file_completed(self, recorder: Recorder, path: str) -> None:
self, recorder: Recorder, path: str
) -> None:
... ...
async def on_danmaku_file_created( async def on_danmaku_file_created(self, recorder: Recorder, path: str) -> None:
self, recorder: Recorder, path: str
) -> None:
... ...
async def on_danmaku_file_completed( async def on_danmaku_file_completed(self, recorder: Recorder, path: str) -> None:
self, recorder: Recorder, path: str
) -> None:
... ...
async def on_raw_danmaku_file_created( async def on_raw_danmaku_file_created(self, recorder: Recorder, path: str) -> None:
self, recorder: Recorder, path: str
) -> None:
... ...
async def on_raw_danmaku_file_completed( async def on_raw_danmaku_file_completed(
@ -96,6 +79,7 @@ class Recorder(
*, *,
stream_format: StreamFormat = 'flv', stream_format: StreamFormat = 'flv',
quality_number: QualityNumber = 10000, quality_number: QualityNumber = 10000,
fmp4_stream_timeout: int = 10,
buffer_size: Optional[int] = None, buffer_size: Optional[int] = None,
read_timeout: Optional[int] = None, read_timeout: Optional[int] = None,
disconnection_timeout: Optional[int] = None, disconnection_timeout: Optional[int] = None,
@ -107,6 +91,7 @@ class Recorder(
record_guard_buy: bool = False, record_guard_buy: bool = False,
record_super_chat: bool = False, record_super_chat: bool = False,
save_cover: bool = False, save_cover: bool = False,
cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT,
save_raw_danmaku: bool = False, save_raw_danmaku: bool = False,
) -> None: ) -> None:
super().__init__() super().__init__()
@ -114,23 +99,18 @@ class Recorder(
self._live = live self._live = live
self._danmaku_client = danmaku_client self._danmaku_client = danmaku_client
self._live_monitor = live_monitor self._live_monitor = live_monitor
self.save_cover = save_cover
self.save_raw_danmaku = save_raw_danmaku self.save_raw_danmaku = save_raw_danmaku
self._recording: bool = False self._recording: bool = False
self._stream_available: bool = False self._stream_available: bool = False
cls: Type[BaseStreamRecorder] self._stream_recorder = StreamRecorder(
if stream_format == 'flv':
cls = FLVStreamRecorder
else:
cls = HLSStreamRecorder
self._stream_recorder = cls(
self._live, self._live,
out_dir=out_dir, out_dir=out_dir,
path_template=path_template, path_template=path_template,
stream_format=stream_format, stream_format=stream_format,
quality_number=quality_number, quality_number=quality_number,
fmp4_stream_timeout=fmp4_stream_timeout,
buffer_size=buffer_size, buffer_size=buffer_size,
read_timeout=read_timeout, read_timeout=read_timeout,
disconnection_timeout=disconnection_timeout, disconnection_timeout=disconnection_timeout,
@ -151,9 +131,14 @@ class Recorder(
) )
self._raw_danmaku_receiver = RawDanmakuReceiver(danmaku_client) self._raw_danmaku_receiver = RawDanmakuReceiver(danmaku_client)
self._raw_danmaku_dumper = RawDanmakuDumper( self._raw_danmaku_dumper = RawDanmakuDumper(
self._live, self._stream_recorder, self._raw_danmaku_receiver
)
self._cover_downloader = CoverDownloader(
self._live, self._live,
self._stream_recorder, self._stream_recorder,
self._raw_danmaku_receiver, save_cover=save_cover,
cover_save_strategy=cover_save_strategy,
) )
@property @property
@ -181,11 +166,19 @@ class Recorder(
self._stream_recorder.quality_number = value self._stream_recorder.quality_number = value
@property @property
def real_stream_format(self) -> StreamFormat: def fmp4_stream_timeout(self) -> int:
return self._stream_recorder.fmp4_stream_timeout
@fmp4_stream_timeout.setter
def fmp4_stream_timeout(self, value: int) -> None:
self._stream_recorder.fmp4_stream_timeout = value
@property
def real_stream_format(self) -> Optional[StreamFormat]:
return self._stream_recorder.real_stream_format return self._stream_recorder.real_stream_format
@property @property
def real_quality_number(self) -> QualityNumber: def real_quality_number(self) -> Optional[QualityNumber]:
return self._stream_recorder.real_quality_number return self._stream_recorder.real_quality_number
@property @property
@ -252,6 +245,22 @@ class Recorder(
def record_super_chat(self, value: bool) -> None: def record_super_chat(self, value: bool) -> None:
self._danmaku_dumper.record_super_chat = value self._danmaku_dumper.record_super_chat = value
@property
def save_cover(self) -> bool:
return self._cover_downloader.save_cover
@save_cover.setter
def save_cover(self, value: bool) -> None:
self._cover_downloader.save_cover = value
@property
def cover_save_strategy(self) -> CoverSaveStrategy:
return self._cover_downloader.cover_save_strategy
@cover_save_strategy.setter
def cover_save_strategy(self, value: CoverSaveStrategy) -> None:
self._cover_downloader.cover_save_strategy = value
@property @property
def stream_url(self) -> str: def stream_url(self) -> str:
return self._stream_recorder.stream_url return self._stream_recorder.stream_url
@ -375,15 +384,11 @@ class Recorder(
self._print_changed_room_info(room_info) self._print_changed_room_info(room_info)
self._stream_recorder.update_progress_bar_info() self._stream_recorder.update_progress_bar_info()
async def on_video_file_created( async def on_video_file_created(self, path: str, record_start_time: int) -> None:
self, path: str, record_start_time: int
) -> None:
await self._emit('video_file_created', self, path) await self._emit('video_file_created', self, path)
async def on_video_file_completed(self, path: str) -> None: async def on_video_file_completed(self, path: str) -> None:
await self._emit('video_file_completed', self, path) await self._emit('video_file_completed', self, path)
if self.save_cover:
await self._save_cover_image(path)
async def on_danmaku_file_created(self, path: str) -> None: async def on_danmaku_file_created(self, path: str) -> None:
await self._emit('danmaku_file_created', self, path) await self._emit('danmaku_file_created', self, path)
@ -426,7 +431,6 @@ class Recorder(
async def _start_recording(self) -> None: async def _start_recording(self) -> None:
if self._recording: if self._recording:
return return
self._change_stream_recorder()
self._recording = True self._recording = True
if self.save_raw_danmaku: if self.save_raw_danmaku:
@ -434,6 +438,7 @@ class Recorder(
self._raw_danmaku_receiver.start() self._raw_danmaku_receiver.start()
self._danmaku_dumper.enable() self._danmaku_dumper.enable()
self._danmaku_receiver.start() self._danmaku_receiver.start()
self._cover_downloader.enable()
await self._prepare() await self._prepare()
if self._stream_available: if self._stream_available:
@ -455,6 +460,7 @@ class Recorder(
self._raw_danmaku_receiver.stop() self._raw_danmaku_receiver.stop()
self._danmaku_dumper.disable() self._danmaku_dumper.disable()
self._danmaku_receiver.stop() self._danmaku_receiver.stop()
self._cover_downloader.disable()
if self._stopped: if self._stopped:
logger.info('Recording Cancelled') logger.info('Recording Cancelled')
@ -492,60 +498,6 @@ class Recorder(
if not self._stream_recorder.stopped: if not self._stream_recorder.stopped:
await self.stop() await self.stop()
def _change_stream_recorder(self) -> None:
if self._recording:
logger.debug('Can not change stream recorder while recording')
return
cls: Type[BaseStreamRecorder]
if self.stream_format == 'flv':
cls = FLVStreamRecorder
else:
cls = HLSStreamRecorder
if self._stream_recorder.__class__ == cls:
return
self._stream_recorder.remove_listener(self)
self._stream_recorder = cls(
self._live,
out_dir=self.out_dir,
path_template=self.path_template,
stream_format=self.stream_format,
quality_number=self.quality_number,
buffer_size=self.buffer_size,
read_timeout=self.read_timeout,
disconnection_timeout=self.disconnection_timeout,
filesize_limit=self.filesize_limit,
duration_limit=self.duration_limit,
)
self._stream_recorder.add_listener(self)
self._danmaku_dumper.change_stream_recorder(self._stream_recorder)
self._raw_danmaku_dumper.change_stream_recorder(self._stream_recorder)
logger.debug(f'Changed stream recorder to {cls.__name__}')
@aio_task_with_room_id
async def _save_cover_image(self, video_path: str) -> None:
try:
await self._live.update_info()
url = self._live.room_info.cover
ext = url.rsplit('.', 1)[-1]
path = cover_path(video_path, ext)
await self._save_file(url, path)
except Exception as e:
logger.error(f'Failed to save cover image: {repr(e)}')
else:
logger.info(f'Saved cover image: {path}')
@retry(reraise=True, wait=wait_fixed(1), stop=stop_after_attempt(3))
async def _save_file(self, url: str, path: str) -> None:
async with aiohttp.ClientSession(raise_for_status=True) as session:
async with session.get(url) as response:
async with aiofiles.open(path, 'wb') as file:
await file.write(await response.read())
def _print_waiting_message(self) -> None: def _print_waiting_message(self) -> None:
logger.info('Waiting... until the live starts') logger.info('Waiting... until the live starts')
@ -554,9 +506,7 @@ class Recorder(
user_info = self._live.user_info user_info = self._live.user_info
if room_info.live_start_time > 0: if room_info.live_start_time > 0:
live_start_time = str( live_start_time = str(datetime.fromtimestamp(room_info.live_start_time))
datetime.fromtimestamp(room_info.live_start_time)
)
else: else:
live_start_time = 'NULL' live_start_time = 'NULL'

View File

@ -0,0 +1,284 @@
import asyncio
import logging
import time
from typing import Iterator, Optional
from ..bili.live import Live
from ..bili.typing import QualityNumber, StreamFormat
from ..event.event_emitter import EventEmitter
from ..flv.data_analyser import MetaData
from ..utils.mixins import AsyncStoppableMixin
from .flv_stream_recorder_impl import FLVStreamRecorderImpl
from .hls_stream_recorder_impl import HLSStreamRecorderImpl
from .stream_analyzer import StreamProfile
from .stream_recorder_impl import StreamRecorderEventListener
__all__ = 'StreamRecorder', 'StreamRecorderEventListener'
logger = logging.getLogger(__name__)
class StreamRecorder(
StreamRecorderEventListener,
EventEmitter[StreamRecorderEventListener],
AsyncStoppableMixin,
):
def __init__(
self,
live: Live,
out_dir: str,
path_template: str,
*,
stream_format: StreamFormat = 'flv',
quality_number: QualityNumber = 10000,
fmp4_stream_timeout: int = 10,
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__()
self.stream_format = stream_format
self.fmp4_stream_timeout = fmp4_stream_timeout
if stream_format == 'flv':
cls = FLVStreamRecorderImpl
elif stream_format == 'fmp4':
cls = HLSStreamRecorderImpl # type: ignore
else:
logger.warning(
f'The specified stream format ({stream_format}) is '
'unsupported, will using the stream format (flv) instead.'
)
self.stream_format = 'flv'
cls = FLVStreamRecorderImpl
self._impl = cls(
live=live,
out_dir=out_dir,
path_template=path_template,
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._impl.add_listener(self)
@property
def stream_url(self) -> str:
return self._impl.stream_url
@property
def stream_host(self) -> str:
return self._impl.stream_host
@property
def dl_total(self) -> int:
return self._impl.dl_total
@property
def dl_rate(self) -> float:
return self._impl.dl_rate
@property
def rec_elapsed(self) -> float:
return self._impl.rec_elapsed
@property
def rec_total(self) -> int:
return self._impl.rec_total
@property
def rec_rate(self) -> float:
return self._impl.rec_rate
@property
def out_dir(self) -> str:
return self._impl.out_dir
@out_dir.setter
def out_dir(self, value: str) -> None:
self._impl.out_dir = value
@property
def path_template(self) -> str:
return self._impl.path_template
@path_template.setter
def path_template(self, value: str) -> None:
self._impl.path_template = value
@property
def quality_number(self) -> QualityNumber:
return self._impl.quality_number
@quality_number.setter
def quality_number(self, value: QualityNumber) -> None:
self._impl.quality_number = value
@property
def real_stream_format(self) -> Optional[StreamFormat]:
if self.stopped:
return None
return self._impl.stream_format
@property
def real_quality_number(self) -> Optional[QualityNumber]:
return self._impl.real_quality_number
@property
def buffer_size(self) -> int:
return self._impl.buffer_size
@buffer_size.setter
def buffer_size(self, value: int) -> None:
self._impl.buffer_size = value
@property
def read_timeout(self) -> int:
return self._impl.read_timeout
@read_timeout.setter
def read_timeout(self, value: int) -> None:
self._impl.read_timeout = value
@property
def disconnection_timeout(self) -> int:
return self._impl.disconnection_timeout
@disconnection_timeout.setter
def disconnection_timeout(self, value: int) -> None:
self._impl.disconnection_timeout = value
@property
def filesize_limit(self) -> int:
return self._impl.filesize_limit
@filesize_limit.setter
def filesize_limit(self, value: int) -> None:
self._impl.filesize_limit = value
@property
def duration_limit(self) -> int:
return self._impl.duration_limit
@duration_limit.setter
def duration_limit(self, value: int) -> None:
self._impl.duration_limit = value
@property
def recording_path(self) -> Optional[str]:
return self._impl.recording_path
@property
def metadata(self) -> Optional[MetaData]:
return self._impl.metadata
@property
def stream_profile(self) -> StreamProfile:
return self._impl.stream_profile
def has_file(self) -> bool:
return self._impl.has_file()
def get_files(self) -> Iterator[str]:
yield from self._impl.get_files()
def clear_files(self) -> None:
self._impl.clear_files()
def can_cut_stream(self) -> bool:
return self._impl.can_cut_stream()
def cut_stream(self) -> bool:
return self._impl.cut_stream()
def update_progress_bar_info(self) -> None:
self._impl.update_progress_bar_info()
@property
def stopped(self) -> bool:
return self._impl.stopped
async def _do_start(self) -> None:
stream_format = self.stream_format
if stream_format == 'fmp4':
logger.info('Waiting for the fmp4 stream becomes available...')
available = await self._wait_fmp4_stream()
if not available:
logger.warning(
'The specified stream format (fmp4) is not available '
f'in {self.fmp4_stream_timeout} seconcds, '
'falling back to stream format (flv).'
)
stream_format = 'flv'
self._change_impl(stream_format)
await self._impl.start()
async def _do_stop(self) -> None:
await self._impl.stop()
async def on_video_file_created(self, path: str, record_start_time: int) -> None:
await self._emit('video_file_created', path, record_start_time)
async def on_video_file_completed(self, path: str) -> None:
await self._emit('video_file_completed', path)
async def on_stream_recording_stopped(self) -> None:
await self._emit('stream_recording_stopped')
async def _wait_fmp4_stream(self) -> bool:
end_time = time.monotonic() + self.fmp4_stream_timeout
available = False # debounce
while True:
try:
await self._impl._live.get_live_stream_urls(stream_format='fmp4')
except Exception:
available = False
if time.monotonic() > end_time:
return False
else:
if available:
return True
else:
available = True
await asyncio.sleep(1)
def _change_impl(self, stream_format: StreamFormat) -> None:
if stream_format == 'flv':
cls = FLVStreamRecorderImpl
elif stream_format == 'fmp4':
cls = HLSStreamRecorderImpl # type: ignore
else:
logger.warning(
f'The specified stream format ({stream_format}) is '
'unsupported, will using the stream format (flv) instead.'
)
cls = FLVStreamRecorderImpl
if self._impl.__class__ == cls:
return
self._impl.remove_listener(self)
self._impl = cls(
live=self._impl._live,
out_dir=self._impl.out_dir,
path_template=self._impl.path_template,
quality_number=self._impl.quality_number,
buffer_size=self._impl.buffer_size,
read_timeout=self._impl.read_timeout,
disconnection_timeout=self._impl.disconnection_timeout,
filesize_limit=self._impl.filesize_limit,
duration_limit=self._impl.duration_limit,
)
self._impl.add_listener(self)
logger.debug(f'Changed stream recorder impl to {cls.__name__}')

View File

@ -1,53 +1,61 @@
import asyncio
import errno
import io import io
import logging
import os import os
import re import re
import time import time
import asyncio
import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from threading import Thread, Event
from datetime import datetime, timezone, timedelta
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime, timedelta, timezone
from typing import Any, BinaryIO, Dict, Iterator, Optional, Tuple from threading import Thread
from typing import Any, BinaryIO, Dict, Final, Iterator, Optional, Tuple
import aiohttp import aiohttp
import urllib3 import urllib3
from tqdm import tqdm
from rx.subject import Subject
from rx.core import Observable from rx.core import Observable
from rx.subject import Subject
from tenacity import ( from tenacity import (
Retrying,
TryAgain,
retry, retry,
wait_none, retry_if_exception_type,
wait_fixed, retry_if_not_exception_type,
retry_if_result,
stop_after_attempt,
stop_after_delay,
wait_chain, wait_chain,
wait_exponential, wait_exponential,
stop_after_delay, wait_fixed,
stop_after_attempt, wait_none,
retry_if_exception_type,
TryAgain,
) )
from tqdm import tqdm
from .. import __version__, __prog__, __github__ from .. import __github__, __prog__, __version__
from .stream_remuxer import StreamRemuxer from ..bili.exceptions import (
from .stream_analyzer import StreamProfile LiveRoomEncrypted,
from .statistics import StatisticsCalculator LiveRoomHidden,
from ..event.event_emitter import EventListener, EventEmitter LiveRoomLocked,
from ..bili.live import Live NoStreamAvailable,
from ..bili.typing import ApiPlatform, StreamFormat, QualityNumber NoStreamFormatAvailable,
NoStreamQualityAvailable,
)
from ..bili.helpers import get_quality_name from ..bili.helpers import get_quality_name
from ..bili.live import Live
from ..bili.typing import ApiPlatform, QualityNumber, StreamFormat
from ..event.event_emitter import EventEmitter, EventListener
from ..flv.data_analyser import MetaData from ..flv.data_analyser import MetaData
from ..flv.stream_processor import StreamProcessor, BaseOutputFileManager from ..flv.stream_processor import BaseOutputFileManager, StreamProcessor
from ..logging.room_id import aio_task_with_room_id
from ..path import escape_path
from ..utils.io import wait_for from ..utils.io import wait_for
from ..utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin from ..utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin
from ..path import escape_path from .retry import before_sleep_log, wait_exponential_for_same_exceptions
from ..logging.room_id import aio_task_with_room_id from .statistics import StatisticsCalculator
from ..bili.exceptions import ( from .stream_analyzer import StreamProfile
NoStreamFormatAvailable, NoStreamCodecAvailable, NoStreamQualityAvailable, from .stream_remuxer import StreamRemuxer
)
__all__ = 'StreamRecorderImpl',
__all__ = 'BaseStreamRecorder', 'StreamRecorderEventListener', 'StreamProxy'
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -67,7 +75,7 @@ class StreamRecorderEventListener(EventListener):
... ...
class BaseStreamRecorder( class StreamRecorderImpl(
EventEmitter[StreamRecorderEventListener], EventEmitter[StreamRecorderEventListener],
AsyncCooperationMixin, AsyncCooperationMixin,
AsyncStoppableMixin, AsyncStoppableMixin,
@ -99,9 +107,8 @@ class BaseStreamRecorder(
live, out_dir, path_template, buffer_size live, out_dir, path_template, buffer_size
) )
self._stream_format = stream_format self._stream_format: Final = stream_format
self._quality_number = quality_number self._quality_number = quality_number
self._real_stream_format: Optional[StreamFormat] = None
self._real_quality_number: Optional[QualityNumber] = None self._real_quality_number: Optional[QualityNumber] = None
self._api_platform: ApiPlatform = 'android' self._api_platform: ApiPlatform = 'android'
self._use_alternative_stream: bool = False self._use_alternative_stream: bool = False
@ -116,8 +123,6 @@ class BaseStreamRecorder(
self._stream_host: str = '' self._stream_host: str = ''
self._stream_profile: StreamProfile = {} self._stream_profile: StreamProfile = {}
self._connection_recovered = Event()
def on_file_created(args: Tuple[str, int]) -> None: def on_file_created(args: Tuple[str, int]) -> None:
logger.info(f"Video file created: '{args[0]}'") logger.info(f"Video file created: '{args[0]}'")
self._emit_event('video_file_created', *args) self._emit_event('video_file_created', *args)
@ -177,11 +182,6 @@ class BaseStreamRecorder(
def stream_format(self) -> StreamFormat: def stream_format(self) -> StreamFormat:
return self._stream_format return self._stream_format
@stream_format.setter
def stream_format(self, value: StreamFormat) -> None:
self._stream_format = value
self._real_stream_format = None
@property @property
def quality_number(self) -> QualityNumber: def quality_number(self) -> QualityNumber:
return self._quality_number return self._quality_number
@ -189,15 +189,12 @@ class BaseStreamRecorder(
@quality_number.setter @quality_number.setter
def quality_number(self, value: QualityNumber) -> None: def quality_number(self, value: QualityNumber) -> None:
self._quality_number = value self._quality_number = value
self._real_quality_number = None
@property @property
def real_stream_format(self) -> StreamFormat: def real_quality_number(self) -> Optional[QualityNumber]:
return self._real_stream_format or self.stream_format if self.stopped:
return None
@property return self._real_quality_number
def real_quality_number(self) -> QualityNumber:
return self._real_quality_number or self.quality_number
@property @property
def filesize_limit(self) -> int: def filesize_limit(self) -> int:
@ -263,16 +260,20 @@ class BaseStreamRecorder(
if self._progress_bar is not None: if self._progress_bar is not None:
self._progress_bar.set_postfix_str(self._make_pbar_postfix()) self._progress_bar.set_postfix_str(self._make_pbar_postfix())
async def _do_start(self) -> None: def _reset(self) -> None:
logger.debug('Starting stream recorder...')
self._dl_calculator.reset() self._dl_calculator.reset()
self._rec_calculator.reset() self._rec_calculator.reset()
self._stream_url = '' self._stream_url = ''
self._stream_host = '' self._stream_host = ''
self._stream_profile = {} self._stream_profile = {}
self._api_platform = 'android' self._api_platform = 'android'
self._real_quality_number = None
self._use_alternative_stream = False self._use_alternative_stream = False
self._connection_recovered.clear() self._fall_back_stream_format = False
async def _do_start(self) -> None:
logger.debug('Starting stream recorder...')
self._reset()
self._thread = Thread( self._thread = Thread(
target=self._run, name=f'StreamRecorder::{self._live.room_id}' target=self._run, name=f'StreamRecorder::{self._live.room_id}'
) )
@ -290,6 +291,44 @@ class BaseStreamRecorder(
def _run(self) -> None: def _run(self) -> None:
raise NotImplementedError() raise NotImplementedError()
@abstractmethod
def _streaming_loop(self) -> None:
raise NotImplementedError()
def _main_loop(self) -> None:
for attempt in Retrying(
reraise=True,
retry=(
retry_if_result(lambda r: not self._stopped) |
retry_if_not_exception_type((NotImplementedError))
),
wait=wait_exponential_for_same_exceptions(max=60),
before_sleep=before_sleep_log(logger, logging.DEBUG, 'main_loop'),
):
with attempt:
try:
self._streaming_loop()
except (NoStreamAvailable, NoStreamFormatAvailable) as e:
logger.warning(f'Failed to get live stream url: {repr(e)}')
except OSError as e:
logger.critical(repr(e), exc_info=e)
if e.errno == errno.ENOSPC:
# OSError(28, 'No space left on device')
self._handle_exception(e)
self._stopped = True
except LiveRoomHidden:
logger.error('The live room has been hidden!')
self._stopped = True
except LiveRoomLocked:
logger.error('The live room has been locked!')
self._stopped = True
except LiveRoomEncrypted:
logger.error('The live room has been encrypted!')
self._stopped = True
except Exception as e:
logger.exception(e)
self._handle_exception(e)
def _rotate_api_platform(self) -> None: def _rotate_api_platform(self) -> None:
if self._api_platform == 'android': if self._api_platform == 'android':
self._api_platform = 'web' self._api_platform = 'web'
@ -305,8 +344,8 @@ class BaseStreamRecorder(
stop=stop_after_attempt(300), stop=stop_after_attempt(300),
) )
def _get_live_stream_url(self) -> str: def _get_live_stream_url(self) -> str:
fmt = self._stream_format
qn = self._real_quality_number or self.quality_number qn = self._real_quality_number or self.quality_number
fmt = self._real_stream_format or self.stream_format
logger.info( logger.info(
f'Getting the live stream url... qn: {qn}, format: {fmt}, ' f'Getting the live stream url... qn: {qn}, format: {fmt}, '
f'api platform: {self._api_platform}, ' f'api platform: {self._api_platform}, '
@ -327,35 +366,11 @@ class BaseStreamRecorder(
) )
self._real_quality_number = 10000 self._real_quality_number = 10000
raise TryAgain raise TryAgain
except NoStreamFormatAvailable:
if fmt == 'fmp4':
logger.info(
'The specified stream format (fmp4) is not available, '
'falling back to stream format (ts).'
)
self._real_stream_format = 'ts'
elif fmt == 'ts':
logger.info(
'The specified stream format (ts) is not available, '
'falling back to stream format (flv).'
)
self._real_stream_format = 'flv'
else:
raise NotImplementedError(fmt)
raise TryAgain
except NoStreamCodecAvailable as e:
logger.warning(repr(e))
raise TryAgain
except Exception as e:
logger.warning(f'Failed to get live stream urls: {repr(e)}')
self._rotate_api_platform()
raise TryAgain
else: else:
logger.info( logger.info(
f'Adopted the stream format ({fmt}) and quality ({qn})' f'Adopted the stream format ({fmt}) and quality ({qn})'
) )
self._real_quality_number = qn self._real_quality_number = qn
self._real_stream_format = fmt
if not self._use_alternative_stream: if not self._use_alternative_stream:
url = urls[0] url = urls[0]
@ -366,8 +381,9 @@ class BaseStreamRecorder(
self._use_alternative_stream = False self._use_alternative_stream = False
self._rotate_api_platform() self._rotate_api_platform()
logger.info( logger.info(
'No alternative stream url available, will using the primary' 'No alternative stream url available, '
f' stream url from {self._api_platform} api instead.' 'will using the primary stream url '
f'from {self._api_platform} api instead.'
) )
raise TryAgain raise TryAgain
logger.info(f"Got live stream url: '{url}'") logger.info(f"Got live stream url: '{url}'")
@ -380,16 +396,7 @@ class BaseStreamRecorder(
logger.debug(f'Retry {name} after {seconds} seconds') logger.debug(f'Retry {name} after {seconds} seconds')
time.sleep(seconds) time.sleep(seconds)
def _wait_for_connection_error(self) -> None: def _wait_for_connection_error(self, check_interval: int = 3) -> None:
Thread(
target=self._conectivity_checker,
name=f'ConectivityChecker::{self._live.room_id}',
daemon=True,
).start()
self._connection_recovered.wait()
self._connection_recovered.clear()
def _conectivity_checker(self, check_interval: int = 3) -> None:
timeout = self.disconnection_timeout timeout = self.disconnection_timeout
logger.info(f'Waiting {timeout} seconds for connection recovery... ') logger.info(f'Waiting {timeout} seconds for connection recovery... ')
timebase = time.monotonic() timebase = time.monotonic()
@ -397,11 +404,10 @@ class BaseStreamRecorder(
if timeout is not None and time.monotonic() - timebase > timeout: if timeout is not None and time.monotonic() - timebase > timeout:
logger.error(f'Connection not recovered in {timeout} seconds') logger.error(f'Connection not recovered in {timeout} seconds')
self._stopped = True self._stopped = True
self._connection_recovered.set() break
time.sleep(check_interval) time.sleep(check_interval)
else: else:
logger.info('Connection recovered') logger.info('Connection recovered')
self._connection_recovered.set()
def _make_pbar_postfix(self) -> str: def _make_pbar_postfix(self) -> str:
return '{room_id} - {user_name}: {room_title}'.format( return '{room_id} - {user_name}: {room_title}'.format(
@ -434,7 +440,7 @@ B站直播录像
房间号{self._live.room_info.room_id} 房间号{self._live.room_info.room_id}
开播时间{live_start_time} 开播时间{live_start_time}
流主机: {self._stream_host} 流主机: {self._stream_host}
流格式{self._real_stream_format} 流格式{self._stream_format}
流画质{stream_quality} 流画质{stream_quality}
录制程序{__prog__} v{__version__} {__github__}''', 录制程序{__prog__} v{__version__} {__github__}''',
'description': OrderedDict({ 'description': OrderedDict({
@ -446,7 +452,7 @@ B站直播录像
'ParentArea': self._live.room_info.parent_area_name, 'ParentArea': self._live.room_info.parent_area_name,
'LiveStartTime': str(live_start_time), 'LiveStartTime': str(live_start_time),
'StreamHost': self._stream_host, 'StreamHost': self._stream_host,
'StreamFormat': self._real_stream_format, 'StreamFormat': self._stream_format,
'StreamQuality': stream_quality, 'StreamQuality': stream_quality,
'Recorder': f'{__prog__} v{__version__} {__github__}', 'Recorder': f'{__prog__} v{__version__} {__github__}',
}) })

View File

@ -1,22 +1,20 @@
import re
import os
import io
import errno import errno
import shlex import io
import logging import logging
from threading import Thread, Condition import os
from subprocess import Popen, PIPE, CalledProcessError import re
import shlex
from subprocess import PIPE, CalledProcessError, Popen
from threading import Condition, Thread
from typing import Optional, cast from typing import Optional, cast
from ..utils.mixins import StoppableMixin, SupportDebugMixin
from ..utils.io import wait_for from ..utils.io import wait_for
from ..utils.mixins import StoppableMixin, SupportDebugMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = 'StreamRemuxer', __all__ = ('StreamRemuxer',)
class FFmpegError(Exception): class FFmpegError(Exception):
@ -28,10 +26,10 @@ class StreamRemuxer(StoppableMixin, SupportDebugMixin):
r'\b(error|failed|missing|invalid|corrupt)\b', re.IGNORECASE r'\b(error|failed|missing|invalid|corrupt)\b', re.IGNORECASE
) )
def __init__(self, room_id: int, bufsize: int = 1024 * 1024) -> None: def __init__(self, room_id: int, remove_filler_data: bool = False) -> None:
super().__init__() super().__init__()
self._room_id = room_id self._room_id = room_id
self._bufsize = bufsize self._remove_filler_data = remove_filler_data
self._exception: Optional[Exception] = None self._exception: Optional[Exception] = None
self._ready = Condition() self._ready = Condition()
self._env = None self._env = None
@ -83,9 +81,7 @@ class StreamRemuxer(StoppableMixin, SupportDebugMixin):
def _do_start(self) -> None: def _do_start(self) -> None:
logger.debug('Starting stream remuxer...') logger.debug('Starting stream remuxer...')
self._thread = Thread( self._thread = Thread(
target=self._run, target=self._run, name=f'StreamRemuxer::{self._room_id}', daemon=True
name=f'StreamRemuxer::{self._room_id}',
daemon=True,
) )
self._thread.start() self._thread.start()
@ -124,21 +120,21 @@ class StreamRemuxer(StoppableMixin, SupportDebugMixin):
logger.debug('Stopped stream remuxer') logger.debug('Stopped stream remuxer')
def _run_subprocess(self) -> None: def _run_subprocess(self) -> None:
cmd = 'ffmpeg -xerror -i pipe:0 -c copy -copyts -f flv pipe:1' cmd = 'ffmpeg -xerror -i pipe:0 -c copy -copyts'
if self._remove_filler_data:
cmd += ' -bsf:v filter_units=remove_types=12'
cmd += ' -f flv pipe:1'
args = shlex.split(cmd) args = shlex.split(cmd)
with Popen( with Popen(
args, stdin=PIPE, stdout=PIPE, stderr=PIPE, args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=self._env
bufsize=self._bufsize, env=self._env,
) as self._subprocess: ) as self._subprocess:
with self._ready: with self._ready:
self._ready.notify_all() self._ready.notify_all()
assert self._subprocess.stderr is not None assert self._subprocess.stderr is not None
with io.TextIOWrapper( with io.TextIOWrapper(
self._subprocess.stderr, self._subprocess.stderr, encoding='utf-8', errors='backslashreplace'
encoding='utf-8',
errors='backslashreplace'
) as stderr: ) as stderr:
while not self._stopped: while not self._stopped:
line = wait_for(stderr.readline, timeout=10) line = wait_for(stderr.readline, timeout=10)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -10,6 +10,6 @@
<body> <body>
<app-root></app-root> <app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript> <noscript>Please enable JavaScript to continue using this application.</noscript>
<script src="runtime.5296fd12ffdfadbe.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.b9234f0840c7101a.js" type="module"></script> <script src="runtime.6dfcba40e2a24845.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.b9234f0840c7101a.js" type="module"></script>
</body></html> </body></html>

View File

@ -1,6 +1,6 @@
{ {
"configVersion": 1, "configVersion": 1,
"timestamp": 1651566774775, "timestamp": 1651812628293,
"index": "/index.html", "index": "/index.html",
"assetGroups": [ "assetGroups": [
{ {
@ -14,15 +14,15 @@
"/103.5b5d2a6e5a8a7479.js", "/103.5b5d2a6e5a8a7479.js",
"/146.92e3b29c4c754544.js", "/146.92e3b29c4c754544.js",
"/45.c90c3cea2bf1a66e.js", "/45.c90c3cea2bf1a66e.js",
"/474.88f730916af2dc81.js", "/474.c2cc33b068a782fc.js",
"/66.17103bf51c59b5c8.js", "/66.31f5b9ae46ae9005.js",
"/869.ac675e78fa0ea7cf.js", "/869.42b1fd9a88732b97.js",
"/common.858f777e9296e6f2.js", "/common.858f777e9296e6f2.js",
"/index.html", "/index.html",
"/main.b9234f0840c7101a.js", "/main.b9234f0840c7101a.js",
"/manifest.webmanifest", "/manifest.webmanifest",
"/polyfills.4b08448aee19bb22.js", "/polyfills.4b08448aee19bb22.js",
"/runtime.5296fd12ffdfadbe.js", "/runtime.6dfcba40e2a24845.js",
"/styles.1f581691b230dc4d.css" "/styles.1f581691b230dc4d.css"
], ],
"patterns": [] "patterns": []
@ -1637,9 +1637,9 @@
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a", "/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
"/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d", "/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d",
"/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764", "/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764",
"/474.88f730916af2dc81.js": "e7cb3e7bd68c162633d94c8c848dea2daeac8bc3", "/474.c2cc33b068a782fc.js": "cb59c0b560cdceeccf9c17df5e9be76bfa942775",
"/66.17103bf51c59b5c8.js": "67c9bb3ac7e7c7c25ebe1db69fc87890a2fdc184", "/66.31f5b9ae46ae9005.js": "cc22d2582d8e4c2a83e089d5a1ec32619e439ccd",
"/869.ac675e78fa0ea7cf.js": "f45052016cb5201d5784b3f261e719d96bd1b153", "/869.42b1fd9a88732b97.js": "ca5c951f04d02218b3fe7dc5c022dad22bf36eca",
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1", "/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1", "/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709", "/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
@ -3234,11 +3234,11 @@
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01", "/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068", "/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd", "/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
"/index.html": "4957097d609200632fe355aef8f7603a3bb1addc", "/index.html": "02b6c1c31185bec91e297c4f224b2d193f9c981c",
"/main.b9234f0840c7101a.js": "c8c7b588c070b957a2659f62d6a77de284aa2233", "/main.b9234f0840c7101a.js": "c8c7b588c070b957a2659f62d6a77de284aa2233",
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586", "/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d", "/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
"/runtime.5296fd12ffdfadbe.js": "5b84a91028ab9e0daaaf89d70f2d12c48d5e358e", "/runtime.6dfcba40e2a24845.js": "9dc2eec70103e3ee2249dde68eeb09910a7ae02d",
"/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150" "/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150"
}, },
"navigationUrls": [ "navigationUrls": [

View File

@ -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",66:"17103bf51c59b5c8",103:"5b5d2a6e5a8a7479",146:"92e3b29c4c754544",474:"88f730916af2dc81",592:"858f777e9296e6f2",869:"ac675e78fa0ea7cf"}[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,d=0;d<t.length;d++)(!1&f||a>=f)&&Object.keys(r.O).every(b=>r.O[b](t[d]))?t.splice(d--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var u=o();void 0!==u&&(i=u)}}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:"31f5b9ae46ae9005",103:"5b5d2a6e5a8a7479",146:"92e3b29c4c754544",474:"c2cc33b068a782fc",592:"858f777e9296e6f2",869:"42b1fd9a88732b97"}[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 d=document.getElementsByTagName("script"),u=0;u<d.length;u++){var l=d[u];if(l.getAttribute("src")==t||l.getAttribute("data-webpack")==i+f){a=l;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((l,s)=>n=e[o]=[l,s]);f.push(n[2]=a);var c=r.p+r.u(o),d=new Error;r.l(c,l=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=l&&("load"===l.type?"missing":l.type),p=l&&l.target&&l.target.src;d.message="Loading chunk "+o+" failed.\n("+s+": "+p+")",d.name="ChunkLoadError",d.type=s,d.request=p,n[1](d)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var d,u,[n,a,c]=f,l=0;if(n.some(p=>0!==e[p])){for(d in a)r.o(a,d)&&(r.m[d]=a[d]);if(c)var s=c(r)}for(o&&o(f);l<n.length;l++)r.o(e,u=n[l])&&e[u]&&e[u][0](),e[n[l]]=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))})()})();

View File

@ -41,6 +41,8 @@ def read_tags_in_duration(
yield tag yield tag
if tag.timestamp > 0: if tag.timestamp > 0:
break break
else:
raise EOFError('no tags')
start = tag.timestamp start = tag.timestamp
end = start + duration end = start + duration

View File

@ -381,11 +381,26 @@ class StreamProcessor:
bytes_io = io.BytesIO() bytes_io = io.BytesIO()
writer = FlvWriter(bytes_io) writer = FlvWriter(bytes_io)
writer.write_header(flv_header) writer.write_header(flv_header)
writer.write_tag(self._parameters_checker.last_metadata_tag) writer.write_tag(
writer.write_tag(self._parameters_checker.last_video_header_tag) self._correct_ts(
self._parameters_checker.last_metadata_tag,
-self._parameters_checker.last_metadata_tag.timestamp,
)
)
writer.write_tag(
self._correct_ts(
self._parameters_checker.last_video_header_tag,
-self._parameters_checker.last_video_header_tag.timestamp,
)
)
if self._parameters_checker.last_audio_header_tag is not None: if self._parameters_checker.last_audio_header_tag is not None:
writer.write_tag(self._parameters_checker.last_audio_header_tag) writer.write_tag(
writer.write_tag(first_data_tag) self._correct_ts(
self._parameters_checker.last_audio_header_tag,
-self._parameters_checker.last_audio_header_tag.timestamp,
)
)
writer.write_tag(self._correct_ts(first_data_tag, -first_data_tag.timestamp))
def on_next(profile: StreamProfile) -> None: def on_next(profile: StreamProfile) -> None:
self._stream_profile_updates.on_next(profile) self._stream_profile_updates.on_next(profile)

View File

@ -5,7 +5,7 @@ import ssl
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from email.message import EmailMessage from email.message import EmailMessage
from http.client import HTTPException from http.client import HTTPException
from typing import Final, Literal, TypedDict, cast from typing import Final, Literal, TypedDict, Dict, Any, cast
from urllib.parse import urljoin from urllib.parse import urljoin
import aiohttp import aiohttp
@ -192,7 +192,7 @@ class Pushplus(MessagingProvider):
class TelegramResponse(TypedDict): class TelegramResponse(TypedDict):
ok: bool ok: bool
result: dict result: Dict[str, Any]
class Telegram(MessagingProvider): class Telegram(MessagingProvider):

View File

@ -245,6 +245,7 @@ class Postprocessor(
out_path, out_path,
metadata_path, metadata_path,
report_progress=True, report_progress=True,
remove_filler_data=True,
).subscribe( ).subscribe(
on_next, on_next,
lambda e: future.set_exception(e), lambda e: future.set_exception(e),

View File

@ -61,12 +61,20 @@ class VideoRemuxer:
in_path: str, in_path: str,
out_path: str, out_path: str,
metadata_path: Optional[str] = None, metadata_path: Optional[str] = None,
*,
remove_filler_data: bool = False,
) -> RemuxResult: ) -> RemuxResult:
cmd = f'ffmpeg -i "{in_path}"'
if metadata_path is not None: if metadata_path is not None:
cmd = f'ffmpeg -i "{in_path}" -i "{metadata_path}" ' \ cmd += f' -i "{metadata_path}" -map_metadata 1'
f'-map_metadata 1 -codec copy "{out_path}" -y' cmd += ' -codec copy'
else: if remove_filler_data:
cmd = f'ffmpeg -i "{in_path}" -codec copy "{out_path}" -y' # https://forum.doom9.org/showthread.php?t=152051
# ISO_IEC_14496-10_2020(E)
# Table 7-1 NAL unit type codes, syntax element categories, and NAL unit type classes # noqa
# 7.4.2.7 Filler data RBSP semantics
cmd += ' -bsf:v filter_units=remove_types=12'
cmd += f' "{out_path}" -y'
args = shlex.split(cmd) args = shlex.split(cmd)
out_lines: List[str] = [] out_lines: List[str] = []
@ -140,6 +148,7 @@ def remux_video(
metadata_path: Optional[str] = None, metadata_path: Optional[str] = None,
*, *,
report_progress: bool = False, report_progress: bool = False,
remove_filler_data: bool = False,
) -> Observable: ) -> Observable:
def subscribe( def subscribe(
observer: Observer[Union[RemuxProgress, RemuxResult]], observer: Observer[Union[RemuxProgress, RemuxResult]],
@ -167,7 +176,12 @@ def remux_video(
) )
try: try:
result = remuxer.remux(in_path, out_path, metadata_path) result = remuxer.remux(
in_path,
out_path,
metadata_path,
remove_filler_data=remove_filler_data,
)
except Exception as e: except Exception as e:
observer.on_error(e) observer.on_error(e)
else: else:

View File

@ -20,6 +20,7 @@ from pydantic.networks import HttpUrl, EmailStr
from ..bili.typing import StreamFormat, QualityNumber from ..bili.typing import StreamFormat, QualityNumber
from ..postprocess import DeleteStrategy from ..postprocess import DeleteStrategy
from ..core.cover_downloader import CoverSaveStrategy
from ..logging.typing import LOG_LEVEL from ..logging.typing import LOG_LEVEL
from ..utils.string import camel_case from ..utils.string import camel_case
@ -144,12 +145,21 @@ class DanmakuSettings(DanmakuOptions):
class RecorderOptions(BaseModel): class RecorderOptions(BaseModel):
stream_format: Optional[StreamFormat] stream_format: Optional[StreamFormat]
quality_number: Optional[QualityNumber] quality_number: Optional[QualityNumber]
fmp4_stream_timeout: Optional[int]
read_timeout: Optional[int] # seconds read_timeout: Optional[int] # seconds
disconnection_timeout: Optional[int] # seconds disconnection_timeout: Optional[int] # seconds
buffer_size: Annotated[ # bytes buffer_size: Annotated[ # bytes
Optional[int], Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2) Optional[int], Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2)
] ]
save_cover: Optional[bool] save_cover: Optional[bool]
cover_save_strategy: Optional[CoverSaveStrategy]
@validator('fmp4_stream_timeout')
def _validate_fmp4_stream_timeout(cls, v: Optional[int]) -> Optional[int]:
if v is not None:
allowed_values = frozenset((3, 5, 10, 30, 60, 180, 300, 600))
cls._validate_with_collection(v, allowed_values)
return v
@validator('read_timeout') @validator('read_timeout')
def _validate_read_timeout(cls, value: Optional[int]) -> Optional[int]: def _validate_read_timeout(cls, value: Optional[int]) -> Optional[int]:
@ -171,12 +181,14 @@ class RecorderOptions(BaseModel):
class RecorderSettings(RecorderOptions): class RecorderSettings(RecorderOptions):
stream_format: StreamFormat = 'flv' stream_format: StreamFormat = 'flv'
quality_number: QualityNumber = 20000 # 4K, the highest quality. quality_number: QualityNumber = 20000 # 4K, the highest quality.
fmp4_stream_timeout: int = 10
read_timeout: int = 3 read_timeout: int = 3
disconnection_timeout: int = 600 disconnection_timeout: int = 600
buffer_size: Annotated[ buffer_size: Annotated[
int, Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2) int, Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2)
] = 8192 ] = 8192
save_cover: bool = False save_cover: bool = False
cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT
class PostprocessingOptions(BaseModel): class PostprocessingOptions(BaseModel):
@ -465,8 +477,8 @@ class Settings(BaseModel):
version: str = '1.0' version: str = '1.0'
tasks: Annotated[List[TaskSettings], Field(max_items=100)] = [] tasks: Annotated[List[TaskSettings], Field(max_items=100)] = []
output: OutputSettings = OutputSettings() output: OutputSettings = OutputSettings() # type: ignore
logging: LoggingSettings = LoggingSettings() logging: LoggingSettings = LoggingSettings() # type: ignore
header: HeaderSettings = HeaderSettings() header: HeaderSettings = HeaderSettings()
danmaku: DanmakuSettings = DanmakuSettings() danmaku: DanmakuSettings = DanmakuSettings()
recorder: RecorderSettings = RecorderSettings() recorder: RecorderSettings = RecorderSettings()

View File

@ -6,6 +6,7 @@ import attr
from ..bili.models import RoomInfo, UserInfo from ..bili.models import RoomInfo, UserInfo
from ..bili.typing import StreamFormat, QualityNumber from ..bili.typing import StreamFormat, QualityNumber
from ..core.cover_downloader import CoverSaveStrategy
from ..postprocess import DeleteStrategy, PostprocessorStatus from ..postprocess import DeleteStrategy, PostprocessorStatus
from ..postprocess.typing import Progress from ..postprocess.typing import Progress
@ -32,8 +33,8 @@ class TaskStatus:
rec_rate: float # Number of Bytes per second rec_rate: float # Number of Bytes per second
danmu_total: int # Number of Danmu in total danmu_total: int # Number of Danmu in total
danmu_rate: float # Number of Danmu per minutes danmu_rate: float # Number of Danmu per minutes
real_stream_format: StreamFormat real_stream_format: Optional[StreamFormat]
real_quality_number: QualityNumber real_quality_number: Optional[QualityNumber]
recording_path: Optional[str] = None recording_path: Optional[str] = None
postprocessor_status: PostprocessorStatus = PostprocessorStatus.WAITING postprocessor_status: PostprocessorStatus = PostprocessorStatus.WAITING
postprocessing_path: Optional[str] = None postprocessing_path: Optional[str] = None
@ -60,10 +61,12 @@ class TaskParam:
# RecorderSettings # RecorderSettings
stream_format: StreamFormat stream_format: StreamFormat
quality_number: QualityNumber quality_number: QualityNumber
fmp4_stream_timeout: int
read_timeout: int read_timeout: int
disconnection_timeout: Optional[int] disconnection_timeout: Optional[int]
buffer_size: int buffer_size: int
save_cover: bool save_cover: bool
cover_save_strategy: CoverSaveStrategy
# PostprocessingOptions # PostprocessingOptions
remux_to_mp4: bool remux_to_mp4: bool
inject_extra_metadata: bool inject_extra_metadata: bool

View File

@ -19,6 +19,7 @@ from ..bili.live_monitor import LiveMonitor
from ..bili.typing import StreamFormat, QualityNumber from ..bili.typing import StreamFormat, QualityNumber
from ..core import Recorder from ..core import Recorder
from ..core.stream_analyzer import StreamProfile from ..core.stream_analyzer import StreamProfile
from ..core.cover_downloader import CoverSaveStrategy
from ..postprocess import Postprocessor, PostprocessorStatus, DeleteStrategy from ..postprocess import Postprocessor, PostprocessorStatus, DeleteStrategy
from ..postprocess.remuxer import RemuxProgress from ..postprocess.remuxer import RemuxProgress
from ..flv.metadata_injector import InjectProgress from ..flv.metadata_injector import InjectProgress
@ -257,6 +258,14 @@ class RecordTask:
def save_cover(self, value: bool) -> None: def save_cover(self, value: bool) -> None:
self._recorder.save_cover = value self._recorder.save_cover = value
@property
def cover_save_strategy(self) -> CoverSaveStrategy:
return self._recorder.cover_save_strategy
@cover_save_strategy.setter
def cover_save_strategy(self, value: CoverSaveStrategy) -> None:
self._recorder.cover_save_strategy = value
@property @property
def save_raw_danmaku(self) -> bool: def save_raw_danmaku(self) -> bool:
return self._recorder.save_raw_danmaku return self._recorder.save_raw_danmaku
@ -282,11 +291,19 @@ class RecordTask:
self._recorder.quality_number = value self._recorder.quality_number = value
@property @property
def real_stream_format(self) -> StreamFormat: def fmp4_stream_timeout(self) -> int:
return self._recorder.fmp4_stream_timeout
@fmp4_stream_timeout.setter
def fmp4_stream_timeout(self, value: int) -> None:
self._recorder.fmp4_stream_timeout = value
@property
def real_stream_format(self) -> Optional[StreamFormat]:
return self._recorder.real_stream_format return self._recorder.real_stream_format
@property @property
def real_quality_number(self) -> QualityNumber: def real_quality_number(self) -> Optional[QualityNumber]:
return self._recorder.real_quality_number return self._recorder.real_quality_number
@property @property

View File

@ -285,10 +285,12 @@ class RecordTaskManager:
task = self._get_task(room_id) task = self._get_task(room_id)
task.stream_format = settings.stream_format task.stream_format = settings.stream_format
task.quality_number = settings.quality_number task.quality_number = settings.quality_number
task.fmp4_stream_timeout = settings.fmp4_stream_timeout
task.read_timeout = settings.read_timeout task.read_timeout = settings.read_timeout
task.disconnection_timeout = settings.disconnection_timeout task.disconnection_timeout = settings.disconnection_timeout
task.buffer_size = settings.buffer_size task.buffer_size = settings.buffer_size
task.save_cover = settings.save_cover task.save_cover = settings.save_cover
task.cover_save_strategy = settings.cover_save_strategy
def apply_task_postprocessing_settings( def apply_task_postprocessing_settings(
self, room_id: int, settings: PostprocessingSettings self, room_id: int, settings: PostprocessingSettings
@ -322,9 +324,11 @@ class RecordTaskManager:
record_guard_buy=task.record_guard_buy, record_guard_buy=task.record_guard_buy,
record_super_chat=task.record_super_chat, record_super_chat=task.record_super_chat,
save_cover=task.save_cover, save_cover=task.save_cover,
cover_save_strategy=task.cover_save_strategy,
save_raw_danmaku=task.save_raw_danmaku, save_raw_danmaku=task.save_raw_danmaku,
stream_format=task.stream_format, stream_format=task.stream_format,
quality_number=task.quality_number, quality_number=task.quality_number,
fmp4_stream_timeout=task.fmp4_stream_timeout,
read_timeout=task.read_timeout, read_timeout=task.read_timeout,
disconnection_timeout=task.disconnection_timeout, disconnection_timeout=task.disconnection_timeout,
buffer_size=task.buffer_size, buffer_size=task.buffer_size,

View File

@ -15,7 +15,7 @@
<input <input
id="server" id="server"
type="url" type="url"
placeholder="默认为官方服务器 https://api2.pushdeer.com" placeholder="默认为官方服务器https://api2.pushdeer.com"
nz-input nz-input
formControlName="server" formControlName="server"
/> />

View File

@ -8,15 +8,20 @@
> >
<ng-template #streamFormatTip> <ng-template #streamFormatTip>
<p> <p>
选择要录制的直播流格式<br /> 选择要录制的直播流格式
<br /> <br />
FLV 网络不稳定容易中断丢失数据 <br /> FLV: 网络不稳定容易中断丢失数据或录制到二压画质
HLS (ts) 基本不受本地网络影响 <br />
HLS (fmp4) 只有少数直播间支持 <br />
<br /> <br />
P.S.<br /> HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
非 FLV 格式需要 ffmpeg<br /> <br />
HLS (fmp4) 不支持会自动切换到 HLS (ts)<br /> P.S.
<br />
录制 HLS 流需要 ffmpeg
<br />
在设定时间内没有 fmp4 流会自动切换录制 flv 流
<br />
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
则不支持。
</p> </p>
</ng-template> </ng-template>
<nz-form-control <nz-form-control
@ -33,6 +38,39 @@
</nz-select> </nz-select>
</nz-form-control> </nz-form-control>
</nz-form-item> </nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzNoColon
[nzTooltipTitle]="fmp4StreamTimeoutTip"
>fmp4 流等待时间</nz-form-label
>
<ng-template #fmp4StreamTimeoutTip>
<p>
如果超过所设置的等待时间 fmp4 流还没有就切换为录制 flv 流
<br />
fmp4 流在刚推流是没有的,要过一会才有。
<br />
fmp4 流出现的时间和直播延迟有关,一般都在 10 秒内,但也有延迟比较大超过
1 分钟的。
<br />
推荐全局设置为 10 秒,个别延迟比较大的直播间单独设置。
</p>
</ng-template>
<nz-form-control
class="setting-control select"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.fmp4StreamTimeout ? fmp4StreamTimeoutControl : 'warning'
"
>
<nz-select
formControlName="fmp4StreamTimeout"
[nzOptions]="fmp4StreamTimeoutOptions"
>
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item"> <nz-form-item class="setting-item">
<nz-form-label <nz-form-label
class="setting-label" class="setting-label"
@ -66,6 +104,41 @@
<nz-switch formControlName="saveCover"></nz-switch> <nz-switch formControlName="saveCover"></nz-switch>
</nz-form-control> </nz-form-control>
</nz-form-item> </nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzNoColon
[nzTooltipTitle]="coverSaveStrategyTip"
>封面保存策略</nz-form-label
>
<ng-template #coverSaveStrategyTip>
<p>
默认: 每个分割的录播文件对应保存一个封面文件,不管封面是否相同。<br />
去重: 相同的封面只保存一次<br />
P.S.
<br />
判断是否相同是依据封面数据的 sha1只在单次录制内有效。
</p>
</ng-template>
<nz-form-control
class="setting-control radio"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.coverSaveStrategy ? coverSaveStrategyControl : 'warning'
"
>
<nz-radio-group
formControlName="coverSaveStrategy"
[nzDisabled]="!saveCoverControl.value"
>
<ng-container *ngFor="let strategy of coverSaveStrategies">
<label nz-radio-button [nzValue]="strategy.value">{{
strategy.label
}}</label>
</ng-container>
</nz-radio-group>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item"> <nz-form-item class="setting-item">
<nz-form-label <nz-form-label
class="setting-label" class="setting-label"
@ -84,7 +157,7 @@
: 'warning' : 'warning'
" "
> >
<nz-select formControlName="readTimeout" [nzOptions]="timeoutOptions"> <nz-select formControlName="readTimeout" [nzOptions]="readTimeoutOptions">
</nz-select> </nz-select>
</nz-form-control> </nz-form-control>
</nz-form-item> </nz-form-item>

View File

@ -20,6 +20,7 @@ import {
TIMEOUT_OPTIONS, TIMEOUT_OPTIONS,
DISCONNECTION_TIMEOUT_OPTIONS, DISCONNECTION_TIMEOUT_OPTIONS,
SYNC_FAILED_WARNING_TIP, SYNC_FAILED_WARNING_TIP,
COVER_SAVE_STRATEGIES,
} from '../shared/constants/form'; } from '../shared/constants/form';
import { RecorderSettings } from '../shared/setting.model'; import { RecorderSettings } from '../shared/setting.model';
import { import {
@ -43,10 +44,13 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable< readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable<
typeof STREAM_FORMAT_OPTIONS typeof STREAM_FORMAT_OPTIONS
>; >;
readonly fmp4StreamTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
typeof TIMEOUT_OPTIONS
>;
readonly qualityOptions = cloneDeep(QUALITY_OPTIONS) as Mutable< readonly qualityOptions = cloneDeep(QUALITY_OPTIONS) as Mutable<
typeof QUALITY_OPTIONS typeof QUALITY_OPTIONS
>; >;
readonly timeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable< readonly readTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
typeof TIMEOUT_OPTIONS typeof TIMEOUT_OPTIONS
>; >;
readonly disconnectionTimeoutOptions = cloneDeep( readonly disconnectionTimeoutOptions = cloneDeep(
@ -55,6 +59,9 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
readonly bufferOptions = cloneDeep(BUFFER_OPTIONS) as Mutable< readonly bufferOptions = cloneDeep(BUFFER_OPTIONS) as Mutable<
typeof BUFFER_OPTIONS typeof BUFFER_OPTIONS
>; >;
readonly coverSaveStrategies = cloneDeep(COVER_SAVE_STRATEGIES) as Mutable<
typeof COVER_SAVE_STRATEGIES
>;
constructor( constructor(
formBuilder: FormBuilder, formBuilder: FormBuilder,
@ -64,10 +71,12 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
this.settingsForm = formBuilder.group({ this.settingsForm = formBuilder.group({
streamFormat: [''], streamFormat: [''],
qualityNumber: [''], qualityNumber: [''],
fmp4StreamTimeout: [''],
readTimeout: [''], readTimeout: [''],
disconnectionTimeout: [''], disconnectionTimeout: [''],
bufferSize: [''], bufferSize: [''],
saveCover: [''], saveCover: [''],
coverSaveStrategy: [''],
}); });
} }
@ -79,6 +88,10 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
return this.settingsForm.get('qualityNumber') as FormControl; return this.settingsForm.get('qualityNumber') as FormControl;
} }
get fmp4StreamTimeoutControl() {
return this.settingsForm.get('fmp4StreamTimeout') as FormControl;
}
get readTimeoutControl() { get readTimeoutControl() {
return this.settingsForm.get('readTimeout') as FormControl; return this.settingsForm.get('readTimeout') as FormControl;
} }
@ -95,6 +108,10 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
return this.settingsForm.get('saveCover') as FormControl; return this.settingsForm.get('saveCover') as FormControl;
} }
get coverSaveStrategyControl() {
return this.settingsForm.get('coverSaveStrategy') as FormControl;
}
ngOnChanges(): void { ngOnChanges(): void {
this.syncStatus = mapValues(this.settings, () => true); this.syncStatus = mapValues(this.settings, () => true);
this.settingsForm.setValue(this.settings); this.settingsForm.setValue(this.settings);

View File

@ -1,4 +1,4 @@
import { DeleteStrategy } from '../setting.model'; import { CoverSaveStrategy, DeleteStrategy } from '../setting.model';
import range from 'lodash-es/range'; import range from 'lodash-es/range';
@ -52,9 +52,14 @@ export const DELETE_STRATEGIES = [
{ label: '从不', value: DeleteStrategy.NEVER }, { label: '从不', value: DeleteStrategy.NEVER },
] as const; ] as const;
export const COVER_SAVE_STRATEGIES = [
{ label: '默认', value: CoverSaveStrategy.DEFAULT },
{ label: '去重', value: CoverSaveStrategy.DEDUP },
] as const;
export const STREAM_FORMAT_OPTIONS = [ export const STREAM_FORMAT_OPTIONS = [
{ label: 'FLV', value: 'flv' }, { label: 'FLV', value: 'flv' },
{ label: 'HLS (ts)', value: 'ts' }, // { label: 'HLS (ts)', value: 'ts' },
{ label: 'HLS (fmp4)', value: 'fmp4' }, { label: 'HLS (fmp4)', value: 'fmp4' },
] as const; ] as const;

View File

@ -29,13 +29,20 @@ export type QualityNumber =
| 150 // 高清 | 150 // 高清
| 80; // 流畅 | 80; // 流畅
export enum CoverSaveStrategy {
DEFAULT = 'default',
DEDUP = 'dedup',
}
export interface RecorderSettings { export interface RecorderSettings {
streamFormat: StreamFormat; streamFormat: StreamFormat;
qualityNumber: QualityNumber; qualityNumber: QualityNumber;
fmp4StreamTimeout: number;
readTimeout: number; readTimeout: number;
disconnectionTimeout: number; disconnectionTimeout: number;
bufferSize: number; bufferSize: number;
saveCover: boolean; saveCover: boolean;
coverSaveStrategy: CoverSaveStrategy;
} }
export type RecorderOptions = Nullable<RecorderSettings>; export type RecorderOptions = Nullable<RecorderSettings>;

View File

@ -62,11 +62,19 @@
<span class="label">格式画质</span <span class="label">格式画质</span
><span class="value"> ><span class="value">
<span> <span>
{{ data.task_status.real_stream_format }} {{
data.task_status.real_stream_format
? data.task_status.real_stream_format
: "N/A"
}}
</span> </span>
<span> <span>
{{ data.task_status.real_quality_number | quality }} {{
({{ data.task_status.real_quality_number data.task_status.real_quality_number
? (data.task_status.real_quality_number | quality)
: "N/A"
}}
({{ data.task_status.real_quality_number ?? "N/A"
}}<ng-container *ngIf="isBlurayStreamQuality()">, bluray</ng-container }}<ng-container *ngIf="isBlurayStreamQuality()">, bluray</ng-container
>) >)
</span> </span>

View File

@ -32,10 +32,12 @@ export class TaskManagerService {
return this.taskService.updateTaskInfo(roomId).pipe( return this.taskService.updateTaskInfo(roomId).pipe(
tap( tap(
() => { () => {
this.message.success('成功刷新任务的数据'); this.message.success(`[${roomId}] 成功刷新任务的数据`);
}, },
(error: HttpErrorResponse) => { (error: HttpErrorResponse) => {
this.message.error(`刷新任务的数据出错: ${error.message}`); this.message.error(
`[${roomId}] 刷新任务的数据出错: ${error.message}`
);
} }
) )
); );
@ -101,10 +103,10 @@ export class TaskManagerService {
return this.taskService.removeTask(roomId).pipe( return this.taskService.removeTask(roomId).pipe(
tap( tap(
() => { () => {
this.message.success('任务已删除'); this.message.success(`[${roomId}] 任务已删除`);
}, },
(error: HttpErrorResponse) => { (error: HttpErrorResponse) => {
this.message.error(`删除任务出错: ${error.message}`); this.message.error(`[${roomId}] 删除任务出错: ${error.message}`);
} }
) )
); );
@ -129,18 +131,18 @@ export class TaskManagerService {
} }
startTask(roomId: number): Observable<ResponseMessage> { startTask(roomId: number): Observable<ResponseMessage> {
const messageId = this.message.loading('正在运行任务...', { const messageId = this.message.loading(`[${roomId}] 正在运行任务...`, {
nzDuration: 0, nzDuration: 0,
}).messageId; }).messageId;
return this.taskService.startTask(roomId).pipe( return this.taskService.startTask(roomId).pipe(
tap( tap(
() => { () => {
this.message.remove(messageId); this.message.remove(messageId);
this.message.success('成功运行任务'); this.message.success(`[${roomId}] 成功运行任务`);
}, },
(error: HttpErrorResponse) => { (error: HttpErrorResponse) => {
this.message.remove(messageId); this.message.remove(messageId);
this.message.error(`运行任务出错: ${error.message}`); this.message.error(`[${roomId}] 运行任务出错: ${error.message}`);
} }
) )
); );
@ -168,18 +170,18 @@ export class TaskManagerService {
roomId: number, roomId: number,
force: boolean = false force: boolean = false
): Observable<ResponseMessage> { ): Observable<ResponseMessage> {
const messageId = this.message.loading('正在停止任务...', { const messageId = this.message.loading(`[${roomId}] 正在停止任务...`, {
nzDuration: 0, nzDuration: 0,
}).messageId; }).messageId;
return this.taskService.stopTask(roomId, force).pipe( return this.taskService.stopTask(roomId, force).pipe(
tap( tap(
() => { () => {
this.message.remove(messageId); this.message.remove(messageId);
this.message.success('成功停止任务'); this.message.success(`[${roomId}] 成功停止任务`);
}, },
(error: HttpErrorResponse) => { (error: HttpErrorResponse) => {
this.message.remove(messageId); this.message.remove(messageId);
this.message.error(`停止任务出错: ${error.message}`); this.message.error(`[${roomId}] 停止任务出错: ${error.message}`);
} }
) )
); );
@ -204,18 +206,18 @@ export class TaskManagerService {
} }
enableRecorder(roomId: number): Observable<ResponseMessage> { enableRecorder(roomId: number): Observable<ResponseMessage> {
const messageId = this.message.loading('正在开启录制...', { const messageId = this.message.loading(`[${roomId}] 正在开启录制...`, {
nzDuration: 0, nzDuration: 0,
}).messageId; }).messageId;
return this.taskService.enableTaskRecorder(roomId).pipe( return this.taskService.enableTaskRecorder(roomId).pipe(
tap( tap(
() => { () => {
this.message.remove(messageId); this.message.remove(messageId);
this.message.success('成功开启录制'); this.message.success(`[${roomId}] 成功开启录制`);
}, },
(error: HttpErrorResponse) => { (error: HttpErrorResponse) => {
this.message.remove(messageId); this.message.remove(messageId);
this.message.error(`开启录制出错: ${error.message}`); this.message.error(`[${roomId}] 开启录制出错: ${error.message}`);
} }
) )
); );
@ -248,18 +250,18 @@ export class TaskManagerService {
roomId: number, roomId: number,
force: boolean = false force: boolean = false
): Observable<ResponseMessage> { ): Observable<ResponseMessage> {
const messageId = this.message.loading('正在关闭录制...', { const messageId = this.message.loading(`[${roomId}] 正在关闭录制...`, {
nzDuration: 0, nzDuration: 0,
}).messageId; }).messageId;
return this.taskService.disableTaskRecorder(roomId, force).pipe( return this.taskService.disableTaskRecorder(roomId, force).pipe(
tap( tap(
() => { () => {
this.message.remove(messageId); this.message.remove(messageId);
this.message.success('成功关闭录制'); this.message.success(`[${roomId}] 成功关闭录制`);
}, },
(error: HttpErrorResponse) => { (error: HttpErrorResponse) => {
this.message.remove(messageId); this.message.remove(messageId);
this.message.error(`关闭录制出错: ${error.message}`); this.message.error(`[${roomId}] 关闭录制出错: ${error.message}`);
} }
) )
); );
@ -287,13 +289,13 @@ export class TaskManagerService {
return this.taskService.cutStream(roomId).pipe( return this.taskService.cutStream(roomId).pipe(
tap( tap(
() => { () => {
this.message.success('文件切割已触发'); this.message.success(`[${roomId}] 文件切割已触发`);
}, },
(error: HttpErrorResponse) => { (error: HttpErrorResponse) => {
if (error.status == 403) { if (error.status == 403) {
this.message.warning('时长太短不能切割,请稍后再试。'); this.message.warning(`[${roomId}] 时长太短不能切割,请稍后再试。`);
} else { } else {
this.message.error(`切割文件出错: ${error.message}`); this.message.error(`[${roomId}] 切割文件出错: ${error.message}`);
} }
} }
) )

View File

@ -91,8 +91,8 @@ export interface TaskStatus {
readonly rec_rate: number; readonly rec_rate: number;
readonly danmu_total: number; readonly danmu_total: number;
readonly danmu_rate: number; readonly danmu_rate: number;
readonly real_stream_format: StreamFormat; readonly real_stream_format: StreamFormat | null;
readonly real_quality_number: QualityNumber; readonly real_quality_number: QualityNumber | null;
readonly recording_path: string | null; readonly recording_path: string | null;
readonly postprocessor_status: PostprocessorStatus; readonly postprocessor_status: PostprocessorStatus;
readonly postprocessing_path: string | null; readonly postprocessing_path: string | null;

View File

@ -47,7 +47,11 @@
nzTooltipTitle="录制画质" nzTooltipTitle="录制画质"
nzTooltipPlacement="leftTop" nzTooltipPlacement="leftTop"
> >
{{ status.real_quality_number | quality }} {{
status.real_quality_number
? (status.real_quality_number | quality)
: ""
}}
</span> </span>
</p> </p>
</div> </div>

View File

@ -29,11 +29,13 @@
[nzValueTemplate]="recordingQuality" [nzValueTemplate]="recordingQuality"
></nz-statistic> ></nz-statistic>
<ng-template #recordingQuality>{{ <ng-template #recordingQuality>{{
(taskStatus.real_quality_number | quality)! + taskStatus.real_quality_number
? (taskStatus.real_quality_number | quality) +
" " + " " +
"(" + "(" +
taskStatus.real_quality_number + taskStatus.real_quality_number +
")" ")"
: ""
}}</ng-template> }}</ng-template>
<nz-statistic <nz-statistic

View File

@ -119,15 +119,20 @@
> >
<ng-template #streamFormatTip> <ng-template #streamFormatTip>
<p> <p>
选择要录制的直播流格式<br /> 选择要录制的直播流格式
<br /> <br />
FLV 网络不稳定容易中断丢失数据 <br /> FLV: 网络不稳定容易中断丢失数据或录制到二压画质
HLS (ts) 基本不受本地网络影响 <br />
HLS (fmp4) 只有少数直播间支持 <br />
<br /> <br />
P.S.<br /> HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
非 FLV 格式需要 ffmpeg<br /> <br />
HLS (fmp4) 不支持会自动切换到 HLS (ts)<br /> P.S.
<br />
录制 HLS 流需要 ffmpeg
<br />
在设定时间内没有 fmp4 流会自动切换录制 flv 流
<br />
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
则不支持。
</p> </p>
</ng-template> </ng-template>
<nz-form-control class="setting-control select"> <nz-form-control class="setting-control select">
@ -150,6 +155,46 @@
>覆盖全局设置</label >覆盖全局设置</label
> >
</nz-form-item> </nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzNoColon
[nzTooltipTitle]="fmp4StreamTimeoutTip"
>fmp4 流等待时间</nz-form-label
>
<ng-template #fmp4StreamTimeoutTip>
<p>
如果超过所设置的等待时间 fmp4 流还没有就切换为录制 flv 流
<br />
fmp4 流在刚推流是没有的,要过一会才有。
<br />
fmp4 流出现的时间和直播延迟有关,一般都在 10
秒内,但也有延迟比较大超过 1 分钟的。
<br />
推荐全局设置为 10 秒,个别延迟比较大的直播间单独设置。
</p>
</ng-template>
<nz-form-control class="setting-control select">
<nz-select
#fmp4StreamTimeout="ngModel"
name="fmp4StreamTimeout"
[(ngModel)]="model.recorder.fmp4StreamTimeout"
[disabled]="options.recorder.fmp4StreamTimeout === null"
[nzOptions]="fmp4StreamTimeoutOptions"
>
</nz-select>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.recorder.fmp4StreamTimeout !== null"
(nzCheckedChange)="
options.recorder.fmp4StreamTimeout = $event
? globalSettings.recorder.fmp4StreamTimeout
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item"> <nz-form-item class="setting-item">
<nz-form-label <nz-form-label
class="setting-label" class="setting-label"
@ -202,6 +247,45 @@
>覆盖全局设置</label >覆盖全局设置</label
> >
</nz-form-item> </nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzNoColon
[nzTooltipTitle]="coverSaveStrategyTip"
>封面保存策略</nz-form-label
>
<ng-template #coverSaveStrategyTip>
<p>
默认:
每个分割的录播文件对应保存一个封面文件,不管封面是否相同。<br />
去重: 相同的封面只保存一次<br />
P.S.
<br />
判断是否相同是依据封面数据的 sha1只在单次录制内有效。
</p>
</ng-template>
<nz-form-control class="setting-control select">
<nz-select
name="coverSaveStrategy"
[(ngModel)]="model.recorder.coverSaveStrategy"
[disabled]="
options.recorder.coverSaveStrategy === null ||
!options.recorder.saveCover
"
[nzOptions]="coverSaveStrategies"
></nz-select>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.recorder.coverSaveStrategy !== null"
(nzCheckedChange)="
options.recorder.coverSaveStrategy = $event
? globalSettings.recorder.coverSaveStrategy
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item"> <nz-form-item class="setting-item">
<nz-form-label <nz-form-label
class="setting-label" class="setting-label"
@ -219,7 +303,7 @@
name="readTimeout" name="readTimeout"
[(ngModel)]="model.recorder.readTimeout" [(ngModel)]="model.recorder.readTimeout"
[disabled]="options.recorder.readTimeout === null" [disabled]="options.recorder.readTimeout === null"
[nzOptions]="timeoutOptions" [nzOptions]="readTimeoutOptions"
> >
</nz-select> </nz-select>
</nz-form-control> </nz-form-control>

View File

@ -30,6 +30,7 @@ import {
BUFFER_OPTIONS, BUFFER_OPTIONS,
DELETE_STRATEGIES, DELETE_STRATEGIES,
SPLIT_FILE_TIP, SPLIT_FILE_TIP,
COVER_SAVE_STRATEGIES,
} from '../../settings/shared/constants/form'; } from '../../settings/shared/constants/form';
type OptionsModel = NonNullable<TaskOptions>; type OptionsModel = NonNullable<TaskOptions>;
@ -67,10 +68,13 @@ export class TaskSettingsDialogComponent implements OnChanges {
readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable< readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable<
typeof STREAM_FORMAT_OPTIONS typeof STREAM_FORMAT_OPTIONS
>; >;
readonly fmp4StreamTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
typeof TIMEOUT_OPTIONS
>;
readonly qualityOptions = cloneDeep(QUALITY_OPTIONS) as Mutable< readonly qualityOptions = cloneDeep(QUALITY_OPTIONS) as Mutable<
typeof QUALITY_OPTIONS typeof QUALITY_OPTIONS
>; >;
readonly timeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable< readonly readTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
typeof TIMEOUT_OPTIONS typeof TIMEOUT_OPTIONS
>; >;
readonly disconnectionTimeoutOptions = cloneDeep( readonly disconnectionTimeoutOptions = cloneDeep(
@ -82,6 +86,9 @@ export class TaskSettingsDialogComponent implements OnChanges {
readonly deleteStrategies = cloneDeep(DELETE_STRATEGIES) as Mutable< readonly deleteStrategies = cloneDeep(DELETE_STRATEGIES) as Mutable<
typeof DELETE_STRATEGIES typeof DELETE_STRATEGIES
>; >;
readonly coverSaveStrategies = cloneDeep(COVER_SAVE_STRATEGIES) as Mutable<
typeof COVER_SAVE_STRATEGIES
>;
model!: OptionsModel; model!: OptionsModel;
options!: TaskOptions; options!: TaskOptions;

View File

@ -3,5 +3,11 @@
{ {
"path": "." "path": "."
} }
] ],
"settings": {
"python.linting.mypyPath": "mypy",
"python.linting.flake8Path": "flake8",
"python.formatting.blackPath": "black",
"python.sortImports.path": "isort"
}
} }