Compare commits

...

16 Commits

Author SHA1 Message Date
acgnhik
1e7d6f71fb release: 2.0.0-beta.1 2023-10-22 14:00:46 +08:00
acgnhik
d2e4d43c52 fix: failed to remux m4s file
urllib.error.URLError: <urlopen error unknown url type: w>
2023-10-22 13:54:16 +08:00
acgnhik
352803e389 release: 2.0.0-beta 2023-10-22 13:12:03 +08:00
acgnhik
b1296d964d feat: deprecated old implementations and adopted a new one for HLS recording 2023-10-22 12:58:15 +08:00
ashesofdream
c35271f0e0 Update bark server url tips. 2023-10-22 12:15:40 +08:00
ashesofdream
737cf5be8d Update providers.py
remove the revise of server property.
2023-10-22 12:15:40 +08:00
ashesofdream
b05fe4de8a fix _server 2023-10-22 12:15:40 +08:00
ashesofdream
8f77e843ad Fix the bark server url with path will cause erroneously url join .
If bark server url is "https://xxxurl/path1" , urllib.parse.urljoin("https://xxxurl/path1","/push") will return "https://xxxurl/push".   And  urllib.parse.urljoin("https://xxxurl/path1/","push") will return the expected result with "https://xxxurl/path1/push".  Server url end with "/", and pathurl begin without "/" is neccessary.
2023-10-22 12:15:40 +08:00
acgnhik
5aad77c337 release: 1.15.0 2023-10-14 19:43:51 +08:00
acgnhik
38efd5a923 feat: test cookie
resolve #181
2023-10-14 19:22:16 +08:00
acgnhik
5d3f54b02a fix: fix Unclosed client session
fix #176
2023-10-12 19:20:35 +08:00
acgnhik
0dda7e7227 fix: fix RuntimeError: cannot join current thread
fix #161
2023-10-09 16:08:20 +08:00
acgnhik
d3579fae90 feat: recording fmp4 streams if no flv streams
resolve #169
2023-10-07 11:52:18 +08:00
acgnhik
79372d23ce fix: fix no auto recording for live streaming that has no flv streams
fix #183
2023-10-07 11:27:45 +08:00
acgnhik
457a974ab7 release: 1.14.1 2023-09-28 16:29:51 +08:00
acgnhik
b35b5f2fc1 chore: update portable.yml 2023-09-28 15:56:25 +08:00
78 changed files with 1513 additions and 1163 deletions

View File

@@ -8,7 +8,7 @@ on:
env:
FFMPEG_ARCHIVE_URL: https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-lgpl-shared.zip
FFMPEG_ARCHIVE_NAME: ffmpeg-master-latest-win64-lgpl-shared.zip
PYTHON_ARCHIVE_URL: https://www.python.org/ftp/python/3.11.1/python-3.11.1-embed-amd64.zip
PYTHON_ARCHIVE_URL: https://www.python.org/ftp/python/3.11.5/python-3.11.5-embed-amd64.zip
jobs:
@@ -23,7 +23,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v3
with:
python-version: "3.11.1"
python-version: "3.11.5"
- name: Download ffmpeg archive
run: Invoke-WebRequest -Uri $($env:FFMPEG_ARCHIVE_URL) -OutFile ffmpeg.zip
@@ -92,7 +92,7 @@ jobs:
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: dist\*
file: dist/*
tag: ${{ github.ref }}
overwrite: true
file_glob: true

View File

@@ -1,5 +1,26 @@
# 更新日志
## 2.0.0-beta.1
- 修复 `m4s` 文件转 `mp4` 文件出错
## 2.0.0-beta
- Bark 通知的 server 设置支持带路径
- 废弃旧的 HLS 录制实现,采用新的实现。
## 1.15.0
- 修复部分只有 `HLS` 流的直播不能自动录制 (issue #183)
- 没有 `flv` 流切换录制 `fmp4`issue #169
- 修复 `RuntimeError: cannot join current thread`issue #161
- 修复 `Unclosed client session` (issue #176)
- 添加测试 `cookie` 的功能 (issue #181)
## 1.14.1
修复 `ModuleNotFoundError: No module named 'blrec.web.middlewares'`
## 1.14.0
- 修复删除全部任务失败

14
FAQ.md
View File

@@ -1,19 +1,5 @@
# 常见问题
## HLS 标准录制模式和原始录制模式有什么区别?
| | 标准录制模式 | 原始录制模式 |
| --- | --- | --- |
| ffmpeg | 需要 | 不需要 |
| 资源占用 | 较多 | 较少 |
| 稳定性 | 比较差 | 比较好 |
| 录播文件 | 一个 flv 文件 | 很多片段文件 |
| 录播信息 | 包含在 flv 文件里 | 单独保存为一个文件 (index.meta.json) |
| 播放器支持 | 几乎全部播放器都支持 flv | 支持 m3u8 的播放器很少 (VLC、dandanplay) |
| 自动分割文件 | 支持 | 不支持 |
| 手动分割文件 | 支持 | 不支持 |
| 自动转 mp4 | 支持 | 支持 |
## 如何终止程序?
`ctrl + c`

View File

@@ -53,7 +53,6 @@ install_requires =
lxml >= 4.6.4, < 5.0.0
toml >= 0.10.2, < 0.11.0
m3u8 >= 3.3.0, < 4.0.0
av >= 10.0.0, < 11.0.0
jsonpath == 0.82
psutil >= 5.8.0, < 6.0.0
reactivex >= 4.0.0, < 5.0.0

View File

@@ -1,3 +1,3 @@
__prog__ = 'blrec'
__version__ = '1.14.0'
__version__ = '2.0.0-beta.1'
__github__ = 'https://github.com/acgnhiki/blrec'

View File

@@ -1,6 +1,7 @@
import asyncio
import logging
import os
from contextlib import suppress
from typing import Iterator, List, Optional
import attr
@@ -8,10 +9,11 @@ import psutil
from . import __prog__, __version__
from .bili.helpers import ensure_room_id
from .core.typing import MetaData
from .disk_space import SpaceMonitor, SpaceReclaimer
from .event.event_submitters import SpaceEventSubmitter
from .exception import ExceptionHandler, ExistsError, exception_callback
from .flv.operators import MetaData, StreamProfile
from .flv.operators import StreamProfile
from .notification import (
BarkNotifier,
EmailNotifier,
@@ -102,8 +104,13 @@ class Application:
self._setup()
logger.debug(f'Default umask {os.umask(0o000)}')
logger.info(f'Launched Application v{__version__}')
task = asyncio.create_task(self._task_manager.load_all_tasks())
task.add_done_callback(exception_callback)
self._loading_task = asyncio.create_task(self._task_manager.load_all_tasks())
def callback(future: asyncio.Future) -> None: # type: ignore
del self._loading_task
self._loading_task.add_done_callback(exception_callback)
self._loading_task.add_done_callback(callback)
async def exit(self) -> None:
logger.info('Exiting Application...')
@@ -116,6 +123,10 @@ class Application:
logger.info('Aborted Application')
async def _exit(self, force: bool = False) -> None:
if hasattr(self, '_loading_task'):
self._loading_task.cancel()
with suppress(asyncio.CancelledError):
await self._loading_task
await self._task_manager.stop_all_tasks(force=force)
await self._task_manager.destroy_all_tasks()
self._destroy()

View File

@@ -4,7 +4,7 @@ import logging
import os
from abc import ABC
from datetime import datetime
from typing import Any, Dict, List, Mapping, Optional
from typing import Any, Dict, List, Mapping, Optional, Final
from urllib.parse import urlencode
import aiohttp
@@ -20,6 +20,17 @@ logger = logging.getLogger(__name__)
TRACE_API_REQ = bool(os.environ.get('BLREC_TRACE_API_REQ'))
BASE_HEADERS: Final = {
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en;q=0.3,en-US;q=0.2', # noqa
'Accept': 'application/json, text/plain, */*',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Origin': 'https://live.bilibili.com',
'Pragma': 'no-cache',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', # noqa
}
class BaseApi(ABC):
def __init__(
@@ -39,7 +50,7 @@ class BaseApi(ABC):
@headers.setter
def headers(self, value: Dict[str, str]) -> None:
self._headers = {**value}
self._headers = {**BASE_HEADERS, **value}
@staticmethod
def _check_response(json_res: JsonResponse) -> None:
@@ -50,6 +61,7 @@ class BaseApi(ABC):
@retry(reraise=True, stop=stop_after_delay(5), wait=wait_exponential(0.1))
async def _get_json_res(self, *args: Any, **kwds: Any) -> JsonResponse:
should_check_response = kwds.pop('check_response', True)
kwds = {'timeout': self.timeout, 'headers': self.headers, **kwds}
async with self._session.get(*args, **kwds) as res:
if TRACE_API_REQ:
@@ -60,7 +72,8 @@ class BaseApi(ABC):
text_res = await res.text()
logger.debug(f'Response text: {text_res[:200]}')
raise
self._check_response(json_res)
if should_check_response:
self._check_response(json_res)
return json_res
async def _get_json(
@@ -282,3 +295,8 @@ class WebApi(BaseApi):
params = {'id': room_id}
json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return json_res['data']
async def get_nav(self) -> ResponseData:
path = '/x/web-interface/nav'
json_res = await self._get_json(self.base_api_urls, path, check_response=False)
return json_res

View File

@@ -1,11 +1,12 @@
from typing import Any, Dict, List
import aiohttp
from jsonpath import jsonpath
from .api import WebApi
from .typing import ResponseData, QualityNumber
from .exceptions import ApiRequestError
from ..exception import NotFoundError
from .api import WebApi
from .exceptions import ApiRequestError
from .typing import QualityNumber, ResponseData, StreamCodec, StreamFormat
__all__ = 'room_init', 'ensure_room_id'
@@ -29,6 +30,17 @@ async def ensure_room_id(room_id: int) -> int:
return result['room_id']
async def get_nav(cookie: str) -> ResponseData:
async with aiohttp.ClientSession(raise_for_status=True) as session:
headers = {
'Origin': 'https://passport.bilibili.com',
'Referer': 'https://passport.bilibili.com/account/security',
'Cookie': cookie,
}
api = WebApi(session, headers)
return await api.get_nav()
def get_quality_name(qn: QualityNumber) -> str:
QUALITY_MAPPING = {
20000: '4K',
@@ -40,3 +52,18 @@ def get_quality_name(qn: QualityNumber) -> str:
80: '流畅',
}
return QUALITY_MAPPING.get(qn, '')
def extract_streams(play_infos: List[Dict[str, Any]]) -> List[Any]:
streams = jsonpath(play_infos, '$[*].playurl_info.playurl.stream[*]')
return streams
def extract_formats(streams: List[Any], stream_format: StreamFormat) -> List[Any]:
formats = jsonpath(streams, f'$[*].format[?(@.format_name == "{stream_format}")]')
return formats
def extract_codecs(formats: List[Any], stream_codec: StreamCodec) -> List[Any]:
codecs = jsonpath(formats, f'$[*].codec[?(@.codec_name == "{stream_codec}")]')
return codecs

View File

@@ -9,7 +9,7 @@ import aiohttp
from jsonpath import jsonpath
from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential
from .api import AppApi, WebApi
from .api import BASE_HEADERS, AppApi, WebApi
from .exceptions import (
LiveRoomEncrypted,
LiveRoomHidden,
@@ -20,6 +20,7 @@ from .exceptions import (
NoStreamFormatAvailable,
NoStreamQualityAvailable,
)
from .helpers import extract_codecs, extract_formats, extract_streams
from .models import LiveStatus, RoomInfo, UserInfo
from .typing import ApiPlatform, QualityNumber, ResponseData, StreamCodec, StreamFormat
@@ -38,6 +39,7 @@ class Live:
self._room_id = room_id
self._user_agent = user_agent
self._cookie = cookie
self._update_headers()
self._html_page_url = f'https://live.bilibili.com/{room_id}'
self._session = aiohttp.ClientSession(
@@ -50,6 +52,7 @@ class Live:
self._room_info: RoomInfo
self._user_info: UserInfo
self._no_flv_stream: bool
@property
def base_api_urls(self) -> List[str]:
@@ -85,6 +88,7 @@ class Live:
@user_agent.setter
def user_agent(self, value: str) -> None:
self._user_agent = value
self._update_headers()
self._webapi.headers = self.headers
self._appapi.headers = self.headers
@@ -95,25 +99,20 @@ class Live:
@cookie.setter
def cookie(self, value: str) -> None:
self._cookie = value
self._update_headers()
self._webapi.headers = self.headers
self._appapi.headers = self.headers
@property
def headers(self) -> Dict[str, str]:
return {
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en;q=0.3,en-US;q=0.2', # noqa
return self._headers
def _update_headers(self) -> None:
self._headers = {
**BASE_HEADERS,
'Referer': f'https://live.bilibili.com/{self._room_id}',
'Origin': 'https://live.bilibili.com',
'Connection': 'keep-alive',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-site',
'Pragma': 'no-cache',
'Cache-Control': 'no-cache',
'User-Agent': self._user_agent,
'Cookie': self._cookie,
'Accept-Encoding': 'gzip',
}
@property
@@ -144,9 +143,19 @@ class Live:
self._room_info = await self.get_room_info()
self._user_info = await self.get_user_info(self._room_info.uid)
self._no_flv_stream = False
if self.is_living():
streams = await self.get_live_streams()
if streams:
flv_formats = extract_formats(streams, 'flv')
self._no_flv_stream = not flv_formats
async def deinit(self) -> None:
await self._session.close()
def has_no_flv_streams(self) -> bool:
return self._no_flv_stream
async def get_live_status(self) -> LiveStatus:
try:
# frequent requests will be intercepted by the server's firewall!
@@ -239,6 +248,26 @@ class Live:
# the timestamp on the server at the moment in seconds
return await self._webapi.get_timestamp()
async def get_play_infos(
self, qn: QualityNumber = 10000, api_platform: ApiPlatform = 'web'
) -> List[Any]:
if api_platform == 'web':
play_infos = await self._webapi.get_room_play_infos(self._room_id, qn)
else:
play_infos = await self._appapi.get_room_play_infos(self._room_id, qn)
return play_infos
async def get_live_streams(
self, qn: QualityNumber = 10000, api_platform: ApiPlatform = 'web'
) -> List[Any]:
play_infos = await self.get_play_infos(qn, api_platform)
for info in play_infos:
self._check_room_play_info(info)
return extract_streams(play_infos)
async def get_live_stream_url(
self,
qn: QualityNumber = 10000,
@@ -248,23 +277,15 @@ class Live:
stream_codec: StreamCodec = 'avc',
select_alternative: bool = False,
) -> str:
if api_platform == 'web':
paly_infos = await self._webapi.get_room_play_infos(self._room_id, qn)
else:
paly_infos = await self._appapi.get_room_play_infos(self._room_id, qn)
for info in paly_infos:
self._check_room_play_info(info)
streams = jsonpath(paly_infos, '$[*].playurl_info.playurl.stream[*]')
streams = await self.get_live_streams(qn, api_platform=api_platform)
if not streams:
raise NoStreamAvailable(stream_format, stream_codec, qn)
formats = jsonpath(
streams, f'$[*].format[?(@.format_name == "{stream_format}")]'
)
formats = extract_formats(streams, stream_format)
if not formats:
raise NoStreamFormatAvailable(stream_format, stream_codec, qn)
codecs = jsonpath(formats, f'$[*].codec[?(@.codec_name == "{stream_codec}")]')
codecs = extract_codecs(formats, stream_codec)
if not codecs:
raise NoStreamCodecAvailable(stream_format, stream_codec, qn)

View File

@@ -9,6 +9,7 @@ from blrec.logging.room_id import aio_task_with_room_id
from ..event.event_emitter import EventEmitter, EventListener
from ..utils.mixins import SwitchableMixin
from .danmaku_client import DanmakuClient, DanmakuCommand, DanmakuListener
from .helpers import extract_formats
from .live import Live
from .models import LiveStatus, RoomInfo
from .typing import Danmaku
@@ -71,19 +72,19 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
def _start_polling(self) -> None:
self._polling_task = asyncio.create_task(self._poll_live_status())
self._polling_task.add_done_callback(exception_callback)
logger.debug('Started polling live status')
async def _stop_polling(self) -> None:
self._polling_task.cancel()
with suppress(asyncio.CancelledError):
await self._polling_task
del self._polling_task
logger.debug('Stopped polling live status')
def _start_checking(self) -> None:
self._checking_task = asyncio.create_task(self._check_if_stream_available())
self._checking_task.add_done_callback(exception_callback)
logger.debug('Started checking if stream available')
asyncio.get_running_loop().call_later(
1800, lambda: asyncio.create_task(self._stop_checking())
)
async def _stop_checking(self) -> None:
if not hasattr(self, '_checking_task'):
@@ -92,7 +93,6 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
with suppress(asyncio.CancelledError):
await self._checking_task
del self._checking_task
logger.debug('Stopped checking if stream available')
async def on_client_reconnected(self) -> None:
# check the live status after the client reconnected and simulate
@@ -147,19 +147,16 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
self._status_count = 0
self._stream_available = False
await self._emit('live_ended', self._live)
await self._stop_checking()
else:
self._status_count += 1
if self._status_count == 1:
assert self._previous_status != LiveStatus.LIVE
self._start_checking()
await self._emit('live_began', self._live)
self._start_checking()
elif self._status_count == 2:
assert self._previous_status == LiveStatus.LIVE
if not self._stream_available:
self._stream_available = True
await self._stop_checking()
await self._emit('live_stream_available', self._live)
elif self._status_count > 2:
assert self._previous_status == LiveStatus.LIVE
await self._emit('live_stream_reset', self._live)
@@ -170,22 +167,58 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
self._previous_status = current_status
@aio_task_with_room_id
async def check_live_status(self) -> None:
logger.debug('Checking live status...')
try:
await self._check_live_status()
except Exception as e:
logger.warning(f'Failed to check live status: {repr(e)}')
logger.debug('Done checking live status')
@aio_task_with_room_id
async def _check_live_status(self) -> None:
await self._live.update_room_info()
current_status = self._live.room_info.live_status
if current_status != self._previous_status:
await self._handle_status_change(current_status)
@aio_task_with_room_id
async def _poll_live_status(self) -> None:
logger.debug('Started polling live status')
while True:
await asyncio.sleep(600 + random.randrange(-60, 60))
await self._live.update_room_info()
current_status = self._live.room_info.live_status
if current_status != self._previous_status:
await self._handle_status_change(current_status)
try:
await asyncio.sleep(600 + random.randrange(-60, 60))
await self._check_live_status()
except asyncio.CancelledError:
logger.debug('Cancelled polling live status')
break
except Exception as e:
logger.warning(f'Failed to poll live status: {repr(e)}')
logger.debug('Stopped polling live status')
@aio_task_with_room_id
async def _check_if_stream_available(self) -> None:
while not self._stream_available:
logger.debug('Started checking if stream available')
while True:
try:
await self._live.get_live_stream_url()
except Exception:
await asyncio.sleep(1)
else:
self._stream_available = True
await self._emit('live_stream_available', self._live)
streams = await self._live.get_live_streams()
if streams:
logger.debug('live stream available')
self._stream_available = True
flv_formats = extract_formats(streams, 'flv')
self._live._no_flv_stream = not flv_formats
await self._emit('live_stream_available', self._live)
break
except asyncio.CancelledError:
logger.debug('Cancelled checking if stream available')
break
except Exception as e:
logger.warning(f'Failed to check if stream available: {repr(e)}')
await asyncio.sleep(1)
logger.debug('Stopped checking if stream available')

View File

@@ -2,6 +2,7 @@ import asyncio
import html
import logging
from contextlib import suppress
from decimal import Decimal
from threading import Lock
from typing import Iterator, List, Optional
@@ -123,7 +124,6 @@ class DanmakuDumper(
with self._lock:
self._delta: float = 0
self._record_start_time: int = record_start_time
self._timebase: int = self._record_start_time * 1000
self._stream_recording_interrupted: bool = False
self._path = danmaku_path(video_path)
self._files.append(self._path)
@@ -135,19 +135,32 @@ class DanmakuDumper(
await self._stop_dumping()
self._path = None
async def on_stream_recording_interrupted(self, duration: float) -> None:
logger.debug(f'Stream recording interrupted, {duration}')
async def on_stream_recording_interrupted(
self, timestamp: float, duration: float
) -> None:
self._interrupted_timestamp = timestamp
self._duration = duration
self._stream_recording_recovered = asyncio.Condition()
self._stream_recording_interrupted = True
logger.debug(
'Stream recording interrupted, '
f'timestamp: {timestamp}, duration: {duration}'
)
async def on_stream_recording_recovered(self, timestamp: int) -> None:
logger.debug(f'Stream recording recovered, {timestamp}')
self._timebase = timestamp * 1000
self._delta = self._duration * 1000
async def on_stream_recording_recovered(self, timestamp: float) -> None:
self._recovered_timestamp = timestamp
self._delta += -float(
Decimal(str(self._recovered_timestamp))
- Decimal(str(self._interrupted_timestamp))
)
self._stream_recording_interrupted = False
async with self._stream_recording_recovered:
self._stream_recording_recovered.notify_all()
logger.debug(
'Stream recording recovered, '
f'timestamp: {timestamp}, delta: {self._delta}'
)
async def on_duration_lost(self, duration: float) -> None:
logger.debug(f'Total duration lost: {(duration)}')
self._delta = -duration
def _start_dumping(self) -> None:
self._create_dump_task()
@@ -217,14 +230,7 @@ class DanmakuDumper(
continue
await writer.write_super_chat_record(self._make_super_chat_record(msg))
else:
logger.warning('Unsupported message type:', repr(msg))
if self._stream_recording_interrupted:
logger.debug(
f'Last message before stream recording interrupted: {repr(msg)}'
)
async with self._stream_recording_recovered:
await self._stream_recording_recovered.wait()
logger.warning(f'Unsupported message type: {repr(msg)}')
def _make_metadata(self) -> Metadata:
return Metadata(
@@ -246,7 +252,7 @@ class DanmakuDumper(
text = html.escape(text)
return Danmu(
stime=self._calc_stime(msg.date),
stime=self._calc_stime(msg.date / 1000),
mode=msg.mode,
size=msg.size,
color=msg.color,
@@ -261,7 +267,7 @@ class DanmakuDumper(
def _make_gift_send_record(self, msg: GiftSendMsg) -> GiftSendRecord:
return GiftSendRecord(
ts=self._calc_stime(msg.timestamp * 1000),
ts=self._calc_stime(msg.timestamp),
uid=msg.uid,
user=msg.uname,
giftname=msg.gift_name,
@@ -272,7 +278,7 @@ class DanmakuDumper(
def _make_guard_buy_record(self, msg: GuardBuyMsg) -> GuardBuyRecord:
return GuardBuyRecord(
ts=self._calc_stime(msg.timestamp * 1000),
ts=self._calc_stime(msg.timestamp),
uid=msg.uid,
user=msg.uname,
giftname=msg.gift_name,
@@ -283,7 +289,7 @@ class DanmakuDumper(
def _make_super_chat_record(self, msg: SuperChatMsg) -> SuperChatRecord:
return SuperChatRecord(
ts=self._calc_stime(msg.timestamp * 1000),
ts=self._calc_stime(msg.timestamp),
uid=msg.uid,
user=msg.uname,
price=msg.price * msg.rate,
@@ -293,7 +299,7 @@ class DanmakuDumper(
def _make_user_toast(self, msg: UserToastMsg) -> UserToast:
return UserToast(
ts=self._calc_stime(msg.start_time * 1000),
ts=self._calc_stime(msg.start_time),
uid=msg.uid,
user=msg.username,
unit=msg.unit,
@@ -304,5 +310,15 @@ class DanmakuDumper(
msg=msg.toast_msg,
)
def _calc_stime(self, timestamp: int) -> float:
return (max(timestamp - self._timebase, 0) + self._delta) / 1000
def _calc_stime(self, timestamp: float) -> float:
if self._stream_recording_interrupted:
return self._duration
else:
return (
max(
timestamp * 1000
- self._record_start_time * 1000
+ self._delta * 1000,
0,
)
) / 1000

View File

@@ -4,6 +4,7 @@ from typing import Optional
from reactivex.scheduler import NewThreadScheduler
from blrec.bili.live import Live
from blrec.bili.live_monitor import LiveMonitor
from blrec.bili.typing import QualityNumber
from blrec.flv import operators as flv_ops
from blrec.flv.metadata_dumper import MetadataDumper
@@ -22,6 +23,7 @@ class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
def __init__(
self,
live: Live,
live_monitor: LiveMonitor,
out_dir: str,
path_template: str,
*,
@@ -34,6 +36,7 @@ class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
) -> None:
super().__init__(
live=live,
live_monitor=live_monitor,
out_dir=out_dir,
path_template=path_template,
stream_format='flv',
@@ -66,7 +69,7 @@ class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
)
self._recording_monitor = core_ops.RecordingMonitor(
live, lambda: self._analyser.duration
live, lambda: self._analyser.duration, self._analyser.duration_updated
)
self._prober.profiles.subscribe(self._on_profile_updated)

View File

@@ -1,110 +0,0 @@
import logging
from typing import Optional
from reactivex.scheduler import NewThreadScheduler
from blrec.bili.live import Live
from blrec.bili.typing import QualityNumber
from blrec.hls import operators as hls_ops
from blrec.hls.metadata_dumper import MetadataDumper
from blrec.utils import operators as utils_ops
from . import operators as core_ops
from .stream_recorder_impl import StreamRecorderImpl
__all__ = ('HLSRawStreamRecorderImpl',)
logger = logging.getLogger(__name__)
class HLSRawStreamRecorderImpl(StreamRecorderImpl):
def __init__(
self,
live: Live,
out_dir: str,
path_template: str,
*,
quality_number: QualityNumber = 10000,
buffer_size: Optional[int] = None,
read_timeout: Optional[int] = None,
disconnection_timeout: Optional[int] = None,
filesize_limit: int = 0,
duration_limit: int = 0,
) -> None:
super().__init__(
live=live,
out_dir=out_dir,
path_template=path_template,
stream_format='fmp4',
recording_mode='raw',
quality_number=quality_number,
buffer_size=buffer_size,
read_timeout=read_timeout,
disconnection_timeout=disconnection_timeout,
filesize_limit=filesize_limit,
duration_limit=duration_limit,
)
self._playlist_fetcher = hls_ops.PlaylistFetcher(self._live, self._session)
self._playlist_dumper = hls_ops.PlaylistDumper(self._path_provider)
self._segment_fetcher = hls_ops.SegmentFetcher(
self._live, self._session, self._stream_url_resolver
)
self._segment_dumper = hls_ops.SegmentDumper(self._playlist_dumper)
self._ff_metadata_dumper = MetadataDumper(
self._playlist_dumper, self._metadata_provider
)
self._prober = hls_ops.Prober()
self._dl_statistics = core_ops.SizedStatistics()
self._recording_monitor = core_ops.RecordingMonitor(
live, lambda: self._playlist_dumper.duration
)
self._prober.profiles.subscribe(self._on_profile_updated)
self._playlist_dumper.file_opened.subscribe(self._on_video_file_opened)
self._playlist_dumper.file_closed.subscribe(self._on_video_file_closed)
self._recording_monitor.interrupted.subscribe(self._on_recording_interrupted)
self._recording_monitor.recovered.subscribe(self._on_recording_recovered)
@property
def recording_path(self) -> Optional[str]:
return self._playlist_dumper.path
def _on_start(self) -> None:
self._ff_metadata_dumper.enable()
def _on_stop(self) -> None:
self._ff_metadata_dumper.disable()
def _run(self) -> None:
self._subscription = (
self._stream_param_holder.get_stream_params() # type: ignore
.pipe(
self._stream_url_resolver,
self._playlist_fetcher,
self._recording_monitor,
self._connection_error_handler,
self._request_exception_handler,
self._playlist_dumper,
utils_ops.observe_on_new_thread(
queue_size=60,
thread_name=f'SegmentDownloader::{self._live.room_id}',
),
self._segment_fetcher,
self._dl_statistics,
self._prober,
self._segment_dumper,
self._rec_statistics,
self._progress_bar,
self._exception_handler,
)
.subscribe(
on_completed=self._on_completed,
scheduler=NewThreadScheduler(
self._thread_factory('HLSRawStreamRecorder')
),
)
)

View File

@@ -4,10 +4,10 @@ from typing import Optional
from reactivex.scheduler import NewThreadScheduler
from blrec.bili.live import Live
from blrec.bili.live_monitor import LiveMonitor
from blrec.bili.typing import QualityNumber
from blrec.flv import operators as flv_ops
from blrec.flv.metadata_dumper import MetadataDumper
from blrec.hls import operators as hls_ops
from blrec.hls.metadata_dumper import MetadataDumper
from blrec.utils import operators as utils_ops
from . import operators as core_ops
@@ -23,6 +23,7 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
def __init__(
self,
live: Live,
live_monitor: LiveMonitor,
out_dir: str,
path_template: str,
*,
@@ -35,6 +36,7 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
) -> None:
super().__init__(
live=live,
live_monitor=live_monitor,
out_dir=out_dir,
path_template=path_template,
stream_format='fmp4',
@@ -52,43 +54,41 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
self._segment_fetcher = hls_ops.SegmentFetcher(
self._live, self._session, self._stream_url_resolver
)
self._segment_remuxer = hls_ops.SegmentRemuxer(live)
self._prober = hls_ops.Prober()
self._dl_statistics = core_ops.SizedStatistics()
self._segment_parser = hls_ops.SegmentParser()
self._analyser = flv_ops.Analyser()
self._injector = flv_ops.Injector(self._metadata_provider)
self._join_point_extractor = flv_ops.JoinPointExtractor()
self._limiter = flv_ops.Limiter(filesize_limit, duration_limit)
self._cutter = flv_ops.Cutter()
self._dumper = flv_ops.Dumper(self._path_provider, buffer_size)
self._metadata_dumper = MetadataDumper(
self._dumper, self._analyser, self._join_point_extractor
self._segment_dumper = hls_ops.SegmentDumper(self._path_provider)
self._playlist_dumper = hls_ops.PlaylistDumper(self._segment_dumper)
self._ff_metadata_dumper = MetadataDumper(
self._segment_dumper, self._metadata_provider
)
self._cutter = hls_ops.Cutter(self._playlist_dumper)
self._limiter = hls_ops.Limiter(
self._playlist_dumper,
self._segment_dumper,
filesize_limit=filesize_limit,
duration_limit=duration_limit,
)
self._prober = hls_ops.Prober()
self._analyser = hls_ops.Analyser(
self._playlist_dumper, self._segment_dumper, self._prober
)
self._dl_statistics = core_ops.SizedStatistics()
self._recording_monitor = core_ops.RecordingMonitor(
live, lambda: self._analyser.duration
live,
lambda: self._playlist_dumper.duration,
self._playlist_dumper.duration_updated,
)
self._prober.profiles.subscribe(self._on_profile_updated)
self._dumper.file_opened.subscribe(self._on_video_file_opened)
self._dumper.file_closed.subscribe(self._on_video_file_closed)
self._segment_dumper.file_opened.subscribe(self._on_video_file_opened)
self._segment_dumper.file_closed.subscribe(self._on_video_file_closed)
self._playlist_dumper.segments_lost.subscribe(self._on_duration_lost)
self._recording_monitor.interrupted.subscribe(self._on_recording_interrupted)
self._recording_monitor.recovered.subscribe(self._on_recording_recovered)
@property
def buffer_size(self) -> int:
return self._dumper.buffer_size
@buffer_size.setter
def buffer_size(self, value: int) -> None:
self._dumper.buffer_size = value
@property
def recording_path(self) -> Optional[str]:
return self._dumper.path
return self._segment_dumper.path
@property
def filesize_limit(self) -> int:
@@ -107,11 +107,8 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
self._limiter.duration_limit = value
@property
def metadata(self) -> Optional[flv_ops.MetaData]:
try:
return self._analyser.make_metadata()
except Exception:
return None
def metadata(self) -> Optional[hls_ops.MetaData]:
return self._analyser.make_metadata()
def can_cut_stream(self) -> bool:
return self._cutter.can_cut_stream()
@@ -120,10 +117,10 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
return self._cutter.cut_stream()
def _on_start(self) -> None:
self._metadata_dumper.enable()
self._ff_metadata_dumper.enable()
def _on_stop(self) -> None:
self._metadata_dumper.disable()
self._ff_metadata_dumper.disable()
def _run(self) -> None:
self._subscription = (
@@ -141,20 +138,13 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
self._segment_fetcher,
self._dl_statistics,
self._prober,
utils_ops.observe_on_new_thread(
queue_size=10, thread_name=f'StreamRecorder::{self._live.room_id}'
),
self._segment_remuxer,
self._segment_parser,
flv_ops.process(),
self._analyser,
self._cutter,
self._limiter,
self._join_point_extractor,
self._injector,
self._analyser,
self._dumper,
self._segment_dumper,
self._rec_statistics,
self._progress_bar,
self._playlist_dumper,
self._exception_handler,
)
.subscribe(

View File

@@ -54,13 +54,6 @@ class MetadataProvider:
', bluray' if '_bluray' in self._stream_recorder.stream_url else '',
)
if self._stream_recorder.recording_mode == 'standard':
recording_mode_desc = '标准'
elif self._stream_recorder.recording_mode == 'raw':
recording_mode_desc = '原始'
else:
recording_mode_desc = ''
return {
'Title': self._live.room_info.title,
'Artist': self._live.user_info.name,
@@ -74,11 +67,10 @@ B站直播录像
开播时间:{live_start_time}
开始推流时间: {stream_available_time}
HLS流可用时间: {hls_stream_available_time}
开始录制时间: {record_start_time}
录播起始时间: {record_start_time}
流主机: {self._stream_recorder.stream_host}
流格式:{self._stream_recorder.stream_format}
流画质:{stream_quality}
录制模式: {recording_mode_desc}
录制程序:{__prog__} v{__version__} {__github__}''',
'description': OrderedDict(
{
@@ -95,7 +87,6 @@ HLS流可用时间: {hls_stream_available_time}
'StreamHost': self._stream_recorder.stream_host,
'StreamFormat': self._stream_recorder.stream_format,
'StreamQuality': stream_quality,
'RecordingMode': self._stream_recorder.recording_mode,
'Recorder': f'{__prog__} v{__version__} {__github__}',
}
),

View File

@@ -77,7 +77,7 @@ class ConnectionErrorHandler(AsyncCooperationMixin):
timeout = self.disconnection_timeout
logger.info(f'Waiting {timeout} seconds for connection recovery... ')
timebase = time.monotonic()
while not self._run_coroutine(self._live.check_connectivity()):
while not self._call_coroutine(self._live.check_connectivity()):
if timeout is not None and time.monotonic() - timebase > timeout:
logger.error(f'Connection not recovered in {timeout} seconds')
return False

View File

@@ -1,7 +1,8 @@
from __future__ import annotations
import logging
from typing import Callable, Final, Optional, TypeVar
import time
from typing import Callable, Final, Optional, Tuple, TypeVar
from reactivex import Observable, Subject, abc
@@ -17,21 +18,35 @@ _T = TypeVar('_T')
class RecordingMonitor(AsyncCooperationMixin):
def __init__(self, live: Live, duration_provider: Callable[..., float]) -> None:
def __init__(
self,
live: Live,
duration_provider: Callable[..., float],
duration_updated: Observable[float],
) -> None:
super().__init__()
self._live = live
self._duration_provider = duration_provider
self._interrupted: Subject[float] = Subject()
self._recovered: Subject[int] = Subject()
self._duration_updated = duration_updated
self._duration_subscription: Optional[abc.DisposableBase] = None
self._interrupted: Subject[Tuple[float, float]] = Subject()
self._recovered: Subject[float] = Subject()
@property
def interrupted(self) -> Observable[float]:
def interrupted(self) -> Observable[Tuple[float, float]]:
return self._interrupted
@property
def recovered(self) -> Observable[int]:
def recovered(self) -> Observable[float]:
return self._recovered
def _on_duration_updated(self, duration: float) -> None:
ts = time.time()
self._recovered.on_next(ts)
assert self._duration_subscription is not None
self._duration_subscription.dispose()
self._duration_subscription = None
def __call__(self, source: Observable[_T]) -> Observable[_T]:
return self._monitor(source)
@@ -48,8 +63,11 @@ class RecordingMonitor(AsyncCooperationMixin):
nonlocal recording, failed_count
recording = True
if failed_count >= CRITERIA:
ts = self._run_coroutine(self._live.get_timestamp())
self._recovered.on_next(ts)
if self._duration_subscription is not None:
self._duration_subscription.dispose()
self._duration_subscription = self._duration_updated.subscribe(
self._on_duration_updated
)
failed_count = 0
observer.on_next(item)
@@ -58,8 +76,9 @@ class RecordingMonitor(AsyncCooperationMixin):
if recording:
failed_count += 1
if failed_count == CRITERIA:
ts = time.time()
duration = self._duration_provider()
self._interrupted.on_next(duration)
self._interrupted.on_next((ts, duration))
observer.on_error(exc)
return source.subscribe(

View File

@@ -9,7 +9,9 @@ import aiohttp
import requests
import urllib3
from reactivex import Observable, abc
from reactivex import operators as ops
from blrec.core import operators as core_ops
from blrec.utils import operators as utils_ops
__all__ = ('RequestExceptionHandler',)
@@ -21,12 +23,14 @@ _T = TypeVar('_T')
class RequestExceptionHandler:
def __init__(self) -> None:
def __init__(self, stream_url_resolver: core_ops.StreamURLResolver) -> None:
self._stream_url_resolver = stream_url_resolver
self._last_retry_time = time.monotonic()
def __call__(self, source: Observable[_T]) -> Observable[_T]:
return self._handle(source).pipe(
utils_ops.retry(should_retry=self._should_retry)
ops.do_action(on_error=self._before_retry),
utils_ops.retry(should_retry=self._should_retry),
)
def _handle(self, source: Observable[_T]) -> Observable[_T]:
@@ -74,3 +78,10 @@ class RequestExceptionHandler:
return True
else:
return False
def _before_retry(self, exc: Exception) -> None:
if isinstance(
exc, requests.exceptions.HTTPError
) and exc.response.status_code in (403, 404):
self._stream_url_resolver.reset()
self._stream_url_resolver.rotate_routes()

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import logging
from typing import Optional
from typing import Final, Optional
from urllib.parse import urlparse
import requests
@@ -19,6 +19,7 @@ from blrec.bili.exceptions import (
NoStreamQualityAvailable,
)
from blrec.bili.live import Live
from blrec.bili.live_monitor import LiveMonitor
from blrec.utils import operators as utils_ops
from blrec.utils.mixins import AsyncCooperationMixin
@@ -31,13 +32,24 @@ logger = logging.getLogger(__name__)
class StreamURLResolver(AsyncCooperationMixin):
def __init__(self, live: Live, stream_param_holder: StreamParamHolder) -> None:
_MAX_ATTEMPTS_FOR_NO_STREAM: Final[int] = 10
def __init__(
self,
live: Live,
session: requests.Session,
live_monitor: LiveMonitor,
stream_param_holder: StreamParamHolder,
) -> None:
super().__init__()
self._live = live
self._session = session
self._live_monitor = live_monitor
self._stream_param_holder = stream_param_holder
self._stream_url: str = ''
self._stream_host: str = ''
self._stream_params: Optional[StreamParams] = None
self._attempts_for_no_stream: int = 0
@property
def stream_url(self) -> str:
@@ -47,10 +59,22 @@ class StreamURLResolver(AsyncCooperationMixin):
def stream_host(self) -> str:
return self._stream_host
@property
def use_alternative_stream(self) -> bool:
return self._stream_param_holder.use_alternative_stream
@use_alternative_stream.setter
def use_alternative_stream(self, value: bool) -> None:
self._stream_param_holder.use_alternative_stream = value
def reset(self) -> None:
self._stream_url = ''
self._stream_host = ''
self._stream_params = None
self._attempts_for_no_stream = 0
def rotate_routes(self) -> None:
self.use_alternative_stream = not self.use_alternative_stream
def __call__(self, source: Observable[StreamParams]) -> Observable[str]:
self.reset()
@@ -77,7 +101,7 @@ class StreamURLResolver(AsyncCooperationMixin):
f'api platform: {params.api_platform}, '
f'use alternative stream: {params.use_alternative_stream}'
)
url = self._run_coroutine(
url = self._call_coroutine(
self._live.get_live_stream_url(
params.quality_number,
api_platform=params.api_platform,
@@ -93,6 +117,7 @@ class StreamURLResolver(AsyncCooperationMixin):
self._stream_url = url
self._stream_host = urlparse(url).hostname or ''
self._stream_params = params
self._attempts_for_no_stream = 0
observer.on_next(url)
return source.subscribe(
@@ -104,8 +129,8 @@ class StreamURLResolver(AsyncCooperationMixin):
def _can_resue_url(self, params: StreamParams) -> bool:
if params == self._stream_params and self._stream_url:
try:
response = requests.get(
self._stream_url, stream=True, headers=self._live.headers
response = self._session.get(
self._stream_url, stream=True, headers=self._live.headers, timeout=3
)
response.raise_for_status()
except Exception:
@@ -134,14 +159,20 @@ class StreamURLResolver(AsyncCooperationMixin):
try:
raise exc
except (NoStreamAvailable, NoStreamCodecAvailable, NoStreamFormatAvailable):
pass
self._attempts_for_no_stream += 1
if self._attempts_for_no_stream > self._MAX_ATTEMPTS_FOR_NO_STREAM:
self._run_coroutine(self._live_monitor.check_live_status())
self._attempts_for_no_stream = 0
except NoStreamQualityAvailable:
qn = self._stream_param_holder.quality_number
logger.info(
f'The specified stream quality ({qn}) is not available, '
'will using the original stream quality (10000) instead.'
)
self._stream_param_holder.fall_back_quality()
if qn == 10000:
logger.warning('The original stream quality (10000) is not available')
else:
logger.info(
f'The specified stream quality ({qn}) is not available, '
'will using the original stream quality (10000) instead.'
)
self._stream_param_holder.fall_back_quality()
except NoAlternativeStreamAvailable:
logger.debug(
'No alternative stream url available, '

View File

@@ -20,10 +20,11 @@ class PathProvider(AsyncCooperationMixin):
self.out_dir = out_dir
self.path_template = path_template
def __call__(self) -> Tuple[str, int]:
ts = self._run_coroutine(self._live.get_timestamp())
path = self._make_path(ts)
return path, ts
def __call__(self, timestamp: int = None) -> Tuple[str, int]:
if timestamp is None:
timestamp = self._call_coroutine(self._live.get_timestamp())
path = self._make_path(timestamp)
return path, timestamp
def _make_path(self, timestamp: int) -> str:
date_time = datetime.fromtimestamp(timestamp)

View File

@@ -12,8 +12,9 @@ from blrec.bili.live import Live
from blrec.bili.live_monitor import LiveEventListener, LiveMonitor
from blrec.bili.models import RoomInfo
from blrec.bili.typing import QualityNumber, StreamFormat
from blrec.core.typing import MetaData
from blrec.event.event_emitter import EventEmitter, EventListener
from blrec.flv.operators import MetaData, StreamProfile
from blrec.flv.operators import StreamProfile
from blrec.setting.typing import RecordingMode
from blrec.utils.mixins import AsyncStoppableMixin
@@ -114,7 +115,8 @@ class Recorder(
self._stream_available: bool = False
self._stream_recorder = StreamRecorder(
self._live,
live,
live_monitor,
out_dir=out_dir,
path_template=path_template,
stream_format=stream_format,

View File

@@ -4,17 +4,18 @@ import time
from typing import Iterator, Optional
from blrec.bili.live import Live
from blrec.bili.live_monitor import LiveMonitor
from blrec.bili.typing import QualityNumber, StreamFormat
from blrec.event.event_emitter import EventEmitter
from blrec.flv.operators import MetaData, StreamProfile
from blrec.flv.operators import StreamProfile
from blrec.setting.typing import RecordingMode
from blrec.utils.libc import malloc_trim
from blrec.utils.mixins import AsyncStoppableMixin
from .flv_stream_recorder_impl import FLVStreamRecorderImpl
from .hls_raw_stream_recorder_impl import HLSRawStreamRecorderImpl
from .hls_stream_recorder_impl import HLSStreamRecorderImpl
from .stream_recorder_impl import StreamRecorderEventListener
from .typing import MetaData
__all__ = 'StreamRecorder', 'StreamRecorderEventListener'
@@ -30,6 +31,7 @@ class StreamRecorder(
def __init__(
self,
live: Live,
live_monitor: LiveMonitor,
out_dir: str,
path_template: str,
*,
@@ -46,6 +48,7 @@ class StreamRecorder(
super().__init__()
self._live = live
self._live_monitor = live_monitor
self.stream_format = stream_format
self.recording_mode = recording_mode
self.fmp4_stream_timeout = fmp4_stream_timeout
@@ -53,10 +56,7 @@ class StreamRecorder(
if stream_format == 'flv':
cls = FLVStreamRecorderImpl
elif stream_format == 'fmp4':
if recording_mode == 'standard':
cls = HLSStreamRecorderImpl # type: ignore
else:
cls = HLSRawStreamRecorderImpl # type: ignore
cls = HLSStreamRecorderImpl # type: ignore
else:
logger.warning(
f'The specified stream format ({stream_format}) is '
@@ -67,6 +67,7 @@ class StreamRecorder(
self._impl = cls(
live=live,
live_monitor=live_monitor,
out_dir=out_dir,
path_template=path_template,
quality_number=quality_number,
@@ -238,19 +239,32 @@ class StreamRecorder(
async def _do_start(self) -> None:
self.hls_stream_available_time = 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 available:
if self.stream_available_time is not None:
self.hls_stream_available_time = await self._live.get_timestamp()
else:
if self._live.has_no_flv_streams():
if stream_format == 'flv':
logger.warning(
'The specified stream format (fmp4) is not available '
f'in {self.fmp4_stream_timeout} seconcds, '
'falling back to stream format (flv).'
'The specified stream format (flv) is not available, '
'falling back to stream format (fmp4).'
)
stream_format = 'flv'
stream_format = 'fmp4'
self.hls_stream_available_time = self.stream_available_time
else:
if stream_format == 'fmp4':
logger.info('Waiting for the fmp4 stream becomes available...')
available = await self._wait_fmp4_stream()
if available:
if self.stream_available_time is not None:
self.hls_stream_available_time = (
await self._live.get_timestamp()
)
else:
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()
@@ -264,10 +278,12 @@ class StreamRecorder(
async def on_video_file_completed(self, path: str) -> None:
await self._emit('video_file_completed', path)
async def on_stream_recording_interrupted(self, duration: float) -> None:
await self._emit('stream_recording_interrupted', duration)
async def on_stream_recording_interrupted(
self, timestamp: float, duration: float
) -> None:
await self._emit('stream_recording_interrupted', timestamp, duration)
async def on_stream_recording_recovered(self, timestamp: int) -> None:
async def on_stream_recording_recovered(self, timestamp: float) -> None:
await self._emit('stream_recording_recovered', timestamp)
async def on_stream_recording_completed(self) -> None:
@@ -294,10 +310,7 @@ class StreamRecorder(
if stream_format == 'flv':
cls = FLVStreamRecorderImpl
elif stream_format == 'fmp4':
if self.recording_mode == 'standard':
cls = HLSStreamRecorderImpl # type: ignore
else:
cls = HLSRawStreamRecorderImpl # type: ignore
cls = HLSStreamRecorderImpl # type: ignore
else:
logger.warning(
f'The specified stream format ({stream_format}) is '
@@ -314,6 +327,7 @@ class StreamRecorder(
self._impl = cls(
live=self._impl._live,
live_monitor=self._impl._live_monitor,
out_dir=self._impl.out_dir,
path_template=self._impl.path_template,
quality_number=self._impl.quality_number,

View File

@@ -11,12 +11,13 @@ from reactivex import abc
from reactivex.typing import StartableFactory, StartableTarget
from blrec.bili.live import Live
from blrec.bili.live_monitor import LiveMonitor
from blrec.bili.typing import QualityNumber, StreamFormat
from blrec.event.event_emitter import EventEmitter, EventListener
from blrec.flv import operators as flv_ops
from blrec.flv.operators import StreamProfile
from blrec.flv.utils import format_timestamp
from blrec.logging.room_id import aio_task_with_room_id
from blrec.hls import operators as hls_ops
from blrec.setting.typing import RecordingMode
from blrec.utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin
@@ -39,10 +40,15 @@ class StreamRecorderEventListener(EventListener):
async def on_video_file_completed(self, path: str) -> None:
...
async def on_stream_recording_interrupted(self, duratin: float) -> None:
async def on_stream_recording_interrupted(
self, timestamp: float, duration: float
) -> None:
...
async def on_stream_recording_recovered(self, timestamp: int) -> None:
async def on_stream_recording_recovered(self, timestamp: float) -> None:
...
async def on_duration_lost(self, duration: float) -> None:
...
async def on_stream_recording_completed(self) -> None:
@@ -58,6 +64,7 @@ class StreamRecorderImpl(
def __init__(
self,
live: Live,
live_monitor: LiveMonitor,
out_dir: str,
path_template: str,
*,
@@ -73,6 +80,7 @@ class StreamRecorderImpl(
super().__init__()
self._live = live
self._live_monitor = live_monitor
self._session = requests.Session()
self._recording_mode = recording_mode
@@ -85,7 +93,7 @@ class StreamRecorderImpl(
stream_format=stream_format, quality_number=quality_number
)
self._stream_url_resolver = core_ops.StreamURLResolver(
live, self._stream_param_holder
live, self._session, live_monitor, self._stream_param_holder
)
self._progress_bar = core_ops.ProgressBar(live)
self._metadata_provider = MetadataProvider(live, self)
@@ -93,7 +101,9 @@ class StreamRecorderImpl(
self._rec_statistics = core_ops.SizedStatistics()
self._dl_statistics: Union[core_ops.StreamStatistics, core_ops.SizedStatistics]
self._request_exception_handler = core_ops.RequestExceptionHandler()
self._request_exception_handler = core_ops.RequestExceptionHandler(
self._stream_url_resolver
)
self._connection_error_handler = core_ops.ConnectionErrorHandler(
live, disconnection_timeout=disconnection_timeout
)
@@ -240,7 +250,7 @@ class StreamRecorderImpl(
return ''
@property
def metadata(self) -> Optional[flv_ops.MetaData]:
def metadata(self) -> Optional[Union[flv_ops.MetaData, hls_ops.MetaData]]:
return None
@property
@@ -337,19 +347,24 @@ class StreamRecorderImpl(
logger.info(f"Video file completed: '{path}'")
self._emit_event('video_file_completed', path)
def _on_recording_interrupted(self, duration: float) -> None:
duration_string = format_timestamp(int(duration * 1000))
logger.info(f'Recording interrupted, current duration: {duration_string}')
self._emit_event('stream_recording_interrupted', duration)
def _on_recording_recovered(self, timestamp: int) -> None:
def _on_recording_interrupted(self, args: Tuple[float, float]) -> None:
timestamp, duration = args[0], args[1]
datetime_string = datetime.fromtimestamp(timestamp).isoformat()
logger.info(f'Recording recovered, current date time {(datetime_string)}')
duration_string = format_timestamp(int(duration * 1000))
logger.info(
f'Recording interrupted, datetime: {datetime_string}, '
f'duration: {duration_string}'
)
self._emit_event('stream_recording_interrupted', timestamp, duration)
def _on_recording_recovered(self, timestamp: float) -> None:
datetime_string = datetime.fromtimestamp(timestamp).isoformat()
logger.info(f'Recording recovered, datetime: {(datetime_string)}')
self._emit_event('stream_recording_recovered', timestamp)
def _emit_event(self, name: str, *args: Any, **kwds: Any) -> None:
self._run_coroutine(self._emit(name, *args, **kwds))
def _on_duration_lost(self, duration: float) -> None:
logger.info(f'Total duration lost: {(duration)}')
self._emit_event('duration_lost', duration)
@aio_task_with_room_id
async def _emit(self, *args: Any, **kwds: Any) -> None: # type: ignore
await super()._emit(*args, **kwds)
def _emit_event(self, name: str, *args: Any, **kwds: Any) -> None:
self._call_coroutine(self._emit(name, *args, **kwds))

View File

@@ -1,5 +1,9 @@
from typing import Union
from blrec.flv.operators import MetaData as FLVMetaData
from blrec.hls.operators import MetaData as HLSMetaData
from .models import DanmuMsg, GiftSendMsg, GuardBuyMsg, SuperChatMsg, UserToastMsg
DanmakuMsg = Union[DanmuMsg, GiftSendMsg, GuardBuyMsg, SuperChatMsg, UserToastMsg]
MetaData = Union[FLVMetaData, HLSMetaData]

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>
<app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
<script src="runtime.34b6285f086501af.js" type="module"></script><script src="polyfills.4e5433063877ea34.js" type="module"></script><script src="main.f21b7d831ad9cafb.js" type="module"></script>
<script src="runtime.8688afa20dbe5cc7.js" type="module"></script><script src="polyfills.4e5433063877ea34.js" type="module"></script><script src="main.f21b7d831ad9cafb.js" type="module"></script>
</body></html>

View File

@@ -1,6 +1,6 @@
{
"configVersion": 1,
"timestamp": 1695440376157,
"timestamp": 1697949101780,
"index": "/index.html",
"assetGroups": [
{
@@ -12,17 +12,17 @@
},
"urls": [
"/103.4a2aea63cc3bf42b.js",
"/287.360829ef4dfc7f0e.js",
"/287.63ace7ac80c3d9f2.js",
"/386.2404f3bc252e1df3.js",
"/503.6553f508f4a9247d.js",
"/548.ea53a087779da599.js",
"/548.f8a3199ca2412e0d.js",
"/688.7032fddba7983cf6.js",
"/common.1fc175bce139f4df.js",
"/index.html",
"/main.f21b7d831ad9cafb.js",
"/manifest.webmanifest",
"/polyfills.4e5433063877ea34.js",
"/runtime.34b6285f086501af.js",
"/runtime.8688afa20dbe5cc7.js",
"/styles.ae81e04dfa5b2860.css"
],
"patterns": []
@@ -1635,10 +1635,10 @@
"dataGroups": [],
"hashTable": {
"/103.4a2aea63cc3bf42b.js": "2711817f2977bfdc18c34fee4fe9385fe012bb22",
"/287.360829ef4dfc7f0e.js": "2f8cb4a318c877840b29d84752e40ed698321e67",
"/287.63ace7ac80c3d9f2.js": "7a52c7715de66142dae39668a3a0fb0f9ee4bb50",
"/386.2404f3bc252e1df3.js": "f937945645579b9651be2666f70cec2c5de4e367",
"/503.6553f508f4a9247d.js": "0878ea0e91bfd5458dd55875561e91060ecb0837",
"/548.ea53a087779da599.js": "efcdeae60239e68f14b9e410d4bfe64ecb592382",
"/548.f8a3199ca2412e0d.js": "58ae6ac139c0b62ed266313e7a75a8266770387f",
"/688.7032fddba7983cf6.js": "eae55044529782a51b7e534365255bbfa5522b05",
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
@@ -3234,11 +3234,11 @@
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.1fc175bce139f4df.js": "af1775164711ec49e5c3a91ee45bd77509c17c54",
"/index.html": "16375e9b931dcf8a92d56b4a860ab95fb622e08b",
"/index.html": "28dc5eb629ca29943d45677dac7fded24c0362c3",
"/main.f21b7d831ad9cafb.js": "fc51efa446c2ac21ee17e165217dd3faeacc5290",
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/polyfills.4e5433063877ea34.js": "68159ab99e0608976404a17132f60b5ceb6f12d2",
"/runtime.34b6285f086501af.js": "9ebaf308e01a4110d64e264057c87060e0d629c7",
"/runtime.8688afa20dbe5cc7.js": "602d7051e97524a7becae76c8e76e7db29370b2b",
"/styles.ae81e04dfa5b2860.css": "5933b4f1c4d8fcc1891b68940ee78af4091472b7"
},
"navigationUrls": [

View File

@@ -1 +0,0 @@
(()=>{"use strict";var e,v={},m={};function r(e){var n=m[e];if(void 0!==n)return n.exports;var t=m[e]={exports:{}};return v[e](t,t.exports,r),t.exports}r.m=v,e=[],r.O=(n,t,f,o)=>{if(!t){var a=1/0;for(i=0;i<e.length;i++){for(var[t,f,o]=e[i],c=!0,u=0;u<t.length;u++)(!1&o||a>=o)&&Object.keys(r.O).every(p=>r.O[p](t[u]))?t.splice(u--,1):(c=!1,o<a&&(a=o));if(c){e.splice(i--,1);var l=f();void 0!==l&&(n=l)}}return n}o=o||0;for(var i=e.length;i>0&&e[i-1][2]>o;i--)e[i]=e[i-1];e[i]=[t,f,o]},r.n=e=>{var n=e&&e.__esModule?()=>e.default:()=>e;return r.d(n,{a:n}),n},r.d=(e,n)=>{for(var t in n)r.o(n,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:n[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((n,t)=>(r.f[t](e,n),n),[])),r.u=e=>(592===e?"common":e)+"."+{103:"4a2aea63cc3bf42b",287:"360829ef4dfc7f0e",386:"2404f3bc252e1df3",503:"6553f508f4a9247d",548:"ea53a087779da599",592:"1fc175bce139f4df",688:"7032fddba7983cf6"}[e]+".js",r.miniCssF=e=>{},r.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),(()=>{var e={},n="blrec:";r.l=(t,f,o,i)=>{if(e[t])e[t].push(f);else{var a,c;if(void 0!==o)for(var u=document.getElementsByTagName("script"),l=0;l<u.length;l++){var d=u[l];if(d.getAttribute("src")==t||d.getAttribute("data-webpack")==n+o){a=d;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",n+o),a.src=r.tu(t)),e[t]=[f];var s=(g,p)=>{a.onerror=a.onload=null,clearTimeout(b);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(p)),g)return g(p)},b=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),c&&document.head.appendChild(a)}}})(),r.r=e=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:n=>n},typeof trustedTypes<"u"&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="",(()=>{var e={666:0};r.f.j=(f,o)=>{var i=r.o(e,f)?e[f]:void 0;if(0!==i)if(i)o.push(i[2]);else if(666!=f){var a=new Promise((d,s)=>i=e[f]=[d,s]);o.push(i[2]=a);var c=r.p+r.u(f),u=new Error;r.l(c,d=>{if(r.o(e,f)&&(0!==(i=e[f])&&(e[f]=void 0),i)){var s=d&&("load"===d.type?"missing":d.type),b=d&&d.target&&d.target.src;u.message="Loading chunk "+f+" failed.\n("+s+": "+b+")",u.name="ChunkLoadError",u.type=s,u.request=b,i[1](u)}},"chunk-"+f,f)}else e[f]=0},r.O.j=f=>0===e[f];var n=(f,o)=>{var u,l,[i,a,c]=o,d=0;if(i.some(b=>0!==e[b])){for(u in a)r.o(a,u)&&(r.m[u]=a[u]);if(c)var s=c(r)}for(f&&f(o);d<i.length;d++)r.o(e,l=i[d])&&e[l]&&e[l][0](),e[l]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(n.bind(null,0)),t.push=n.bind(null,t.push.bind(t))})()})();

View File

@@ -0,0 +1 @@
(()=>{"use strict";var e,v={},m={};function r(e){var n=m[e];if(void 0!==n)return n.exports;var t=m[e]={exports:{}};return v[e](t,t.exports,r),t.exports}r.m=v,e=[],r.O=(n,t,i,o)=>{if(!t){var a=1/0;for(f=0;f<e.length;f++){for(var[t,i,o]=e[f],c=!0,u=0;u<t.length;u++)(!1&o||a>=o)&&Object.keys(r.O).every(b=>r.O[b](t[u]))?t.splice(u--,1):(c=!1,o<a&&(a=o));if(c){e.splice(f--,1);var d=i();void 0!==d&&(n=d)}}return n}o=o||0;for(var f=e.length;f>0&&e[f-1][2]>o;f--)e[f]=e[f-1];e[f]=[t,i,o]},r.n=e=>{var n=e&&e.__esModule?()=>e.default:()=>e;return r.d(n,{a:n}),n},r.d=(e,n)=>{for(var t in n)r.o(n,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:n[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((n,t)=>(r.f[t](e,n),n),[])),r.u=e=>(592===e?"common":e)+"."+{103:"4a2aea63cc3bf42b",287:"63ace7ac80c3d9f2",386:"2404f3bc252e1df3",503:"6553f508f4a9247d",548:"f8a3199ca2412e0d",592:"1fc175bce139f4df",688:"7032fddba7983cf6"}[e]+".js",r.miniCssF=e=>{},r.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),(()=>{var e={},n="blrec:";r.l=(t,i,o,f)=>{if(e[t])e[t].push(i);else{var a,c;if(void 0!==o)for(var u=document.getElementsByTagName("script"),d=0;d<u.length;d++){var l=u[d];if(l.getAttribute("src")==t||l.getAttribute("data-webpack")==n+o){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",n+o),a.src=r.tu(t)),e[t]=[i];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=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:n=>n},typeof trustedTypes<"u"&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="",(()=>{var e={666:0};r.f.j=(i,o)=>{var f=r.o(e,i)?e[i]:void 0;if(0!==f)if(f)o.push(f[2]);else if(666!=i){var a=new Promise((l,s)=>f=e[i]=[l,s]);o.push(f[2]=a);var c=r.p+r.u(i),u=new Error;r.l(c,l=>{if(r.o(e,i)&&(0!==(f=e[i])&&(e[i]=void 0),f)){var s=l&&("load"===l.type?"missing":l.type),p=l&&l.target&&l.target.src;u.message="Loading chunk "+i+" failed.\n("+s+": "+p+")",u.name="ChunkLoadError",u.type=s,u.request=p,f[1](u)}},"chunk-"+i,i)}else e[i]=0},r.O.j=i=>0===e[i];var n=(i,o)=>{var u,d,[f,a,c]=o,l=0;if(f.some(p=>0!==e[p])){for(u in a)r.o(a,u)&&(r.m[u]=a[u]);if(c)var s=c(r)}for(i&&i(o);l<f.length;l++)r.o(e,d=f[l])&&e[d]&&e[d][0](),e[d]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(n.bind(null,0)),t.push=n.bind(null,t.push.bind(t))})()})();

View File

@@ -22,7 +22,19 @@ logger = logging.getLogger(__name__)
class SpaceReclaimer(SpaceEventListener, SwitchableMixin):
_SUFFIX_SET = frozenset(
('.flv', '.mp4', '.ts', '.m4s', '.m3u8' '.xml', '.jsonl', '.jpg')
(
'.flv',
'.mp4',
'.ts',
'.m4s',
'.m3u8',
'.xml',
'.json',
'.meta',
'.jsonl',
'.jpg',
'.png',
)
)
def __init__(

View File

@@ -103,6 +103,7 @@ class MetaDataDict:
class Analyser:
def __init__(self) -> None:
self._metadatas: Subject[Optional[MetaData]] = Subject()
self._duration_updated: Subject[float] = Subject()
self._reset()
def _reset(self) -> None:
@@ -135,6 +136,10 @@ class Analyser:
def metadatas(self) -> Observable[Optional[MetaData]]:
return self._metadatas
@property
def duration_updated(self) -> Observable[float]:
return self._duration_updated
def __call__(self, source: FLVStream) -> FLVStream:
return self._analyse(source)
@@ -307,6 +312,7 @@ class Analyser:
self._size_of_tags += tag.tag_size
self._size_of_data += tag.data_size
self._last_timestamp = tag.timestamp
self._duration_updated.on_next(self._last_timestamp / 1000)
def _analyse_audio_tag(self, tag: AudioTag) -> None:
if not self._audio_analysed:

View File

@@ -31,7 +31,7 @@ def from_file(
path: str, *, backup_timestamp: bool = False, restore_timestamp: bool = False
) -> FLVStream:
return from_stream(
open(path, 'rb'),
open(path, 'rb'), # type: ignore
complete_on_eof=True,
backup_timestamp=backup_timestamp,
restore_timestamp=restore_timestamp,

View File

@@ -36,7 +36,7 @@ def parse(
try:
try:
reader = FlvReader(
stream,
stream, # type: ignore
backup_timestamp=backup_timestamp,
restore_timestamp=restore_timestamp,
)

View File

@@ -7,7 +7,7 @@ from typing import List, Optional, cast
from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from ...utils.ffprobe import StreamProfile, ffprobe
from ...utils.ffprobe import StreamProfile, ffprobe_on
from ..common import find_aac_header_tag, find_avc_header_tag
from ..io import FlvWriter
from ..models import FlvHeader, FlvTag
@@ -99,4 +99,4 @@ class Prober:
def on_error(e: Exception) -> None:
logger.warning(f'Failed to probe stream by ffprobe: {repr(e)}')
ffprobe(bytes_io.getvalue()).subscribe(on_next, on_error)
ffprobe_on(bytes_io.getvalue()).subscribe(on_next, on_error)

12
src/blrec/hls/helpler.py Normal file
View File

@@ -0,0 +1,12 @@
import os
__all__ = ('name_of', 'sequence_number_of')
def name_of(uri: str) -> str:
name, _ext = os.path.splitext(uri)
return name
def sequence_number_of(uri: str) -> int:
return int(name_of(uri))

View File

@@ -18,17 +18,17 @@ logger = logging.getLogger(__name__)
class MetadataDumper(SwitchableMixin):
def __init__(
self,
playlist_dumper: hls_ops.PlaylistDumper,
segment_dumper: hls_ops.SegmentDumper,
metadata_provider: Callable[[Dict[str, Any]], Dict[str, Any]],
) -> None:
super().__init__()
self._playlist_dumper = playlist_dumper
self._segment_dumper = segment_dumper
self._metadata_provider = metadata_provider
self._metadata: Dict[str, Any] = {}
def _do_enable(self) -> None:
self._file_opened_subscription = self._playlist_dumper.file_opened.subscribe(
self._on_playlist_file_opened
self._file_opened_subscription = self._segment_dumper.file_opened.subscribe(
self._on_video_file_opened
)
logger.debug('Enabled metadata dumper')
@@ -39,13 +39,13 @@ class MetadataDumper(SwitchableMixin):
self._metadata.clear()
logger.debug('Disabled metadata dumper')
def _on_playlist_file_opened(self, args: Tuple[str, int]) -> None:
playlist_path, _ = args
def _on_video_file_opened(self, args: Tuple[str, int]) -> None:
video_path, _timestamp = args
metadata = self._metadata_provider({})
self._dump_metadata(playlist_path, metadata)
self._dump_metadata(video_path, metadata)
def _dump_metadata(self, playlist_path: str, metadata: Dict[str, Any]) -> None:
path = record_metadata_path(playlist_path)
def _dump_metadata(self, video_path: str, metadata: Dict[str, Any]) -> None:
path = record_metadata_path(video_path)
logger.debug(f"Dumping metadata to file: '{path}'")
with open(path, 'wt', encoding='utf8') as file:

View File

@@ -1,14 +1,19 @@
from .analyser import Analyser, MetaData
from .cutter import Cutter
from .limiter import Limiter
from .playlist_dumper import PlaylistDumper
from .playlist_fetcher import PlaylistFetcher
from .playlist_resolver import PlaylistResolver
from .prober import Prober, StreamProfile
from .segment_dumper import SegmentDumper
from .segment_fetcher import InitSectionData, SegmentData, SegmentFetcher
from .segment_parser import SegmentParser
from .segment_remuxer import SegmentRemuxer
__all__ = (
'Analyser',
'Cutter',
'InitSectionData',
'Limiter',
'MetaData',
'PlaylistDumper',
'PlaylistFetcher',
'PlaylistResolver',
@@ -16,7 +21,5 @@ __all__ = (
'SegmentData',
'SegmentDumper',
'SegmentFetcher',
'SegmentParser',
'SegmentRemuxer',
'StreamProfile',
)

View File

@@ -0,0 +1,91 @@
from __future__ import annotations
import logging
from typing import Optional, Union
import attr
from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from .prober import Prober, StreamProfile
from .segment_fetcher import InitSectionData, SegmentData
from .playlist_dumper import PlaylistDumper
from .segment_dumper import SegmentDumper
__all__ = ('Analyser', 'MetaData')
logger = logging.getLogger(__name__)
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class MetaData:
duration: float
filesize: int
width: int
height: int
class Analyser:
def __init__(
self,
playlist_dumper: PlaylistDumper,
segment_dumper: SegmentDumper,
prober: Prober,
) -> None:
self._playlist_dumper = playlist_dumper
self._segment_dumper = segment_dumper
self._prober = prober
self._reset()
self._prober.profiles.subscribe(self._on_profile_updated)
def _reset(self) -> None:
self._video_width: int = 0
self._video_height: int = 0
def _on_profile_updated(self, profile: StreamProfile) -> None:
video_profile = profile['streams'][0]
assert video_profile['codec_type'] == 'video'
self._video_width = video_profile['width']
self._video_height = video_profile['height']
def make_metadata(self) -> MetaData:
return MetaData(
duration=self._playlist_dumper.duration,
filesize=self._segment_dumper.filesize,
width=self._video_width,
height=self._video_height,
)
def __call__(
self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[Union[InitSectionData, SegmentData]]:
return self._analyse(source)
def _analyse(
self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[Union[InitSectionData, SegmentData]]:
def subscribe(
observer: abc.ObserverBase[Union[InitSectionData, SegmentData]],
scheduler: Optional[abc.SchedulerBase] = None,
) -> abc.DisposableBase:
disposed = False
subscription = SerialDisposable()
self._reset()
def on_next(item: Union[InitSectionData, SegmentData]) -> None:
observer.on_next(item)
def dispose() -> None:
nonlocal disposed
disposed = True
self._reset()
subscription.disposable = source.subscribe(
on_next, observer.on_error, observer.on_completed, scheduler=scheduler
)
return CompositeDisposable(subscription, Disposable(dispose))
return Observable(subscribe)

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import logging
from typing import Optional, Tuple, Union
from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from .segment_fetcher import InitSectionData, SegmentData
from .playlist_dumper import PlaylistDumper
__all__ = ('Cutter',)
logger = logging.getLogger(__name__)
class Cutter:
def __init__(
self, playlist_dumper: PlaylistDumper, min_duration: float = 5.0
) -> None:
self._playlist_dumper = playlist_dumper
self._min_duration = min_duration # seconds
self._cutting: bool
self._triggered: bool
self._reset()
def on_open(args: Tuple[str, int]) -> None:
self._cutting = False
self._playlist_dumper.file_opened.subscribe(on_open)
def _reset(self) -> None:
self._cutting = False
self._triggered = False
def is_cutting(self) -> bool:
return self._cutting
def can_cut_stream(self) -> bool:
if self._triggered or self._cutting:
return False
return self._playlist_dumper.duration >= self._min_duration
def cut_stream(self) -> bool:
if self.can_cut_stream():
self._triggered = True
return True
return False
def _add_flag(self, item: Union[InitSectionData, SegmentData]) -> None:
item.segment.custom_parser_values['split'] = True
def __call__(
self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[Union[InitSectionData, SegmentData]]:
return self._cut(source)
def _cut(
self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[Union[InitSectionData, SegmentData]]:
def subscribe(
observer: abc.ObserverBase[Union[InitSectionData, SegmentData]],
scheduler: Optional[abc.SchedulerBase] = None,
) -> abc.DisposableBase:
disposed = False
subscription = SerialDisposable()
self._reset()
def on_next(item: Union[InitSectionData, SegmentData]) -> None:
if self._triggered:
self._add_flag(item)
self._cutting = True
self._triggered = False
observer.on_next(item)
def dispose() -> None:
nonlocal disposed
disposed = True
subscription.disposable = source.subscribe(
on_next, observer.on_error, observer.on_completed, scheduler=scheduler
)
return CompositeDisposable(subscription, Disposable(dispose))
return Observable(subscribe)

View File

@@ -0,0 +1,93 @@
from __future__ import annotations
import logging
from typing import Optional, Union
from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from .segment_fetcher import InitSectionData, SegmentData
from .playlist_dumper import PlaylistDumper
from .segment_dumper import SegmentDumper
__all__ = ('Limiter',)
logger = logging.getLogger(__name__)
class Limiter:
def __init__(
self,
playlist_dumper: PlaylistDumper,
segment_dumper: SegmentDumper,
*,
filesize_limit: int = 0, # file size in bytes, no limit by default.
duration_limit: int = 0, # duration in seconds, no limit by default.
) -> None:
self._playlist_dumper = playlist_dumper
self._segment_dumper = segment_dumper
self.filesize_limit = filesize_limit
self.duration_limit = duration_limit
def _will_over_limits(self, item: Union[InitSectionData, SegmentData]) -> bool:
if (
self.filesize_limit > 0
and self._segment_dumper.filesize + len(item) >= self.filesize_limit
):
logger.debug(
'File size will be over the limit: {} + {}'.format(
self._segment_dumper.filesize, len(item)
)
)
return True
if (
self.duration_limit > 0
and self._playlist_dumper.duration + float(item.segment.duration)
>= self.duration_limit
):
logger.debug(
'Duration will be over the limit: {} + {}'.format(
self._playlist_dumper.duration, item.segment.duration
)
)
return True
return False
def _add_flag(self, item: Union[InitSectionData, SegmentData]) -> None:
item.segment.custom_parser_values['split'] = True
def __call__(
self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[Union[InitSectionData, SegmentData]]:
return self._limit(source)
def _limit(
self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[Union[InitSectionData, SegmentData]]:
def subscribe(
observer: abc.ObserverBase[Union[InitSectionData, SegmentData]],
scheduler: Optional[abc.SchedulerBase] = None,
) -> abc.DisposableBase:
disposed = False
subscription = SerialDisposable()
def on_next(item: Union[InitSectionData, SegmentData]) -> None:
if self._will_over_limits(item):
self._add_flag(item)
observer.on_next(item)
def dispose() -> None:
nonlocal disposed
disposed = True
subscription.disposable = source.subscribe(
on_next, observer.on_error, observer.on_completed, scheduler=scheduler
)
return CompositeDisposable(subscription, Disposable(dispose))
return Observable(subscribe)

View File

@@ -2,31 +2,52 @@ from __future__ import annotations
import io
import logging
import os
from copy import deepcopy
from decimal import Decimal
from typing import Callable, Optional, Tuple
from pathlib import PurePath
from typing import Optional, Tuple, Union
import m3u8
from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from ..helpler import sequence_number_of
from .segment_fetcher import InitSectionData, SegmentData
from .segment_dumper import SegmentDumper
__all__ = ('PlaylistDumper',)
logger = logging.getLogger(__name__)
class PlaylistDumper:
def __init__(self, path_provider: Callable[..., Tuple[str, int]]) -> None:
self._path_provider = path_provider
def __init__(self, segment_dumper: SegmentDumper) -> None:
self._segment_dumper = segment_dumper
def on_open(args: Tuple[str, int]) -> None:
self._open_file(*args)
def on_close(path: str) -> None:
self._close_file()
self._reset()
self._segment_dumper.file_opened.subscribe(on_open)
self._segment_dumper.file_closed.subscribe(on_close)
self._file_opened: Subject[Tuple[str, int]] = Subject()
self._file_closed: Subject[str] = Subject()
self._duration_updated: Subject[float] = Subject()
self._segments_lost: Subject[int] = Subject()
self._reset()
def _reset(self) -> None:
self._path: str = ''
self._file: Optional[io.TextIOWrapper] = None
self._duration: Decimal = Decimal()
self._last_segment: Optional[m3u8.Segment] = None
self._last_seq_num: Optional[int] = None
self._num_of_segments_lost: int = 0
@property
def path(self) -> str:
@@ -36,6 +57,10 @@ class PlaylistDumper:
def duration(self) -> float:
return float(self._duration)
@property
def num_of_segments_lost(self) -> int:
return self._num_of_segments_lost
@property
def file_opened(self) -> Observable[Tuple[str, int]]:
return self._file_opened
@@ -44,17 +69,27 @@ class PlaylistDumper:
def file_closed(self) -> Observable[str]:
return self._file_closed
def __call__(self, source: Observable[m3u8.M3U8]) -> Observable[m3u8.Segment]:
@property
def duration_updated(self) -> Observable[float]:
return self._duration_updated
@property
def segments_lost(self) -> Observable[int]:
return self._segments_lost
def __call__(
self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[Union[InitSectionData, SegmentData]]:
return self._dump(source)
def _open_file(self) -> None:
path, timestamp = self._path_provider()
root, ext = os.path.splitext(path)
os.makedirs(root, exist_ok=True)
self._path = os.path.join(root, 'index.m3u8')
def _open_file(self, video_path: str, timestamp: int) -> None:
path = PurePath(video_path)
self._video_file_name = path.name
self._path = str(path.with_suffix('.m3u8'))
self._file = open(self._path, 'wt', encoding='utf8') # type: ignore
logger.debug(f'Opened file: {self._path}')
self._file_opened.on_next((self._path, timestamp))
self._header_dumped = False
def _close_file(self) -> None:
if self._file is not None and not self._file.closed:
@@ -63,97 +98,87 @@ class PlaylistDumper:
logger.debug(f'Closed file: {self._path}')
self._file_closed.on_next(self._path)
def _name_of(self, uri: str) -> str:
name, ext = os.path.splitext(uri)
return name
def _dump_header(self, item: InitSectionData) -> None:
if self._header_dumped:
return
playlist: m3u8.M3U8 = deepcopy(item.segment.custom_parser_values['playlist'])
playlist.segments.clear()
playlist.is_endlist = False
assert self._file is not None
self._file.write(playlist.dumps())
self._file.flush()
self._header_dumped = True
def _sequence_number_of(self, uri: str) -> int:
return int(self._name_of(uri))
def _replace_uri(self, segment: m3u8.Segment) -> m3u8.Segment:
copied_seg = deepcopy(segment)
if init_section := getattr(copied_seg, 'init_section', None):
init_section.uri = f'segments/{init_section.uri}'
uri = segment.uri
name = self._name_of(uri)
copied_seg.uri = 'segments/%s/%s' % (name[:-3], uri)
return copied_seg
def _replace_all_uri(self, playlist: m3u8.M3U8) -> m3u8.M3U8:
copied_playlist = deepcopy(playlist)
copied_playlist.segments = m3u8.SegmentList(
self._replace_uri(s) for s in copied_playlist.segments
def _dump_segment(self, init_item: InitSectionData, item: SegmentData) -> None:
seg = self._make_segment(
item.segment,
self._video_file_name,
init_section_byterange=f'{len(init_item)}@{init_item.offset}',
segment_byterange=f'{len(item)}@{item.offset}',
)
return copied_playlist
curr_seq_num = sequence_number_of(item.segment.uri)
if self._last_seq_num is not None:
if self._last_seq_num + 1 != curr_seq_num:
seg.discontinuity = True
if self._last_seq_num + 1 < curr_seq_num:
self._num_of_segments_lost += curr_seq_num - self._last_seq_num - 1
self._segments_lost.on_next(self._num_of_segments_lost)
assert self._file is not None
self._file.write(seg.dumps(self._last_segment) + '\n')
self._file.flush()
self._last_segment = seg
self._last_seq_num = curr_seq_num
def _make_segment(
self,
segment: m3u8.Segment,
uri: str,
init_section_byterange: str,
segment_byterange: str,
) -> m3u8.Segment:
seg = deepcopy(segment)
if init_section := getattr(seg, 'init_section', None):
init_section.uri = uri
init_section.byterange = init_section_byterange
seg.uri = uri
seg.byterange = segment_byterange
seg.title += '|' + segment.uri
return seg
def _update_duration(self, segment: m3u8.Segment) -> None:
self._duration += Decimal(str(segment.duration))
self._duration_updated.on_next(float(self._duration))
def _dump(self, source: Observable[m3u8.M3U8]) -> Observable[m3u8.Segment]:
def _dump(
self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[Union[InitSectionData, SegmentData]]:
def subscribe(
observer: abc.ObserverBase[m3u8.Segment],
observer: abc.ObserverBase[Union[InitSectionData, SegmentData]],
scheduler: Optional[abc.SchedulerBase] = None,
) -> abc.DisposableBase:
disposed = False
subscription = SerialDisposable()
last_init_item: Optional[InitSectionData] = None
last_segment: Optional[m3u8.Segment] = None
last_sequence_number: Optional[int] = None
first_playlist_dumped: bool = False
self._close_file()
self._reset()
def on_next(playlist: m3u8.M3U8) -> None:
nonlocal last_sequence_number, last_segment, first_playlist_dumped
if playlist.is_endlist:
logger.debug('Playlist ended')
def on_next(item: Union[InitSectionData, SegmentData]) -> None:
nonlocal last_init_item
try:
if not first_playlist_dumped:
self._close_file()
self._reset()
self._open_file()
assert self._file is not None
playlist.is_endlist = False
self._file.write(self._replace_all_uri(playlist).dumps())
self._file.flush()
for seg in playlist.segments:
observer.on_next(seg)
self._update_duration(seg)
last_segment = seg
last_sequence_number = self._sequence_number_of(seg.uri)
first_playlist_dumped = True
logger.debug('The first playlist has been dumped')
return
assert self._file is not None
for seg in playlist.segments:
num = self._sequence_number_of(seg.uri)
discontinuity = False
if last_sequence_number is not None:
if last_sequence_number >= num:
continue
if last_sequence_number + 1 != num:
logger.warning(
'Segments discontinuous: '
f'last sequence number: {last_sequence_number}, '
f'current sequence number: {num}'
)
discontinuity = True
new_seg = self._replace_uri(seg)
new_seg.discontinuity = discontinuity
new_last_seg = self._replace_uri(last_segment)
self._file.write(new_seg.dumps(new_last_seg) + '\n')
observer.on_next(seg)
self._update_duration(seg)
last_segment = seg
last_sequence_number = num
if isinstance(item, InitSectionData):
self._dump_header(item)
last_init_item = item
else:
assert last_init_item is not None
self._dump_segment(last_init_item, item)
self._update_duration(item.segment)
except Exception as e:
self._close_file()
self._reset()
observer.on_error(e)
else:
observer.on_next(item)
def on_completed() -> None:
self._close_file()
@@ -167,10 +192,9 @@ class PlaylistDumper:
def dispose() -> None:
nonlocal disposed
nonlocal last_segment, last_sequence_number
nonlocal last_init_item
disposed = True
last_segment = None
last_sequence_number = None
last_init_item = None
self._close_file()
self._reset()

View File

@@ -49,9 +49,14 @@ class PlaylistFetcher(SupportDebugMixin):
def on_next(url: str) -> None:
logger.info(f'Fetching playlist... {url}')
while not disposed:
try:
content = self._fetch_playlist(url)
except Exception as e:
logger.warning(f'Failed to fetch playlist: {repr(e)}')
observer.on_error(e)
else:
if self._debug:
playlist_debug_file.write(content + '\n')
playlist = m3u8.loads(content, uri=url)
@@ -59,12 +64,9 @@ class PlaylistFetcher(SupportDebugMixin):
url = self._get_best_quality_url(playlist)
logger.debug('Playlist changed to variant playlist')
on_next(url)
except Exception as e:
logger.warning(f'Failed to fetch playlist: {repr(e)}')
observer.on_error(e)
else:
observer.on_next(playlist)
time.sleep(1)
else:
observer.on_next(playlist)
time.sleep(1)
def dispose() -> None:
nonlocal disposed
@@ -96,10 +98,15 @@ class PlaylistFetcher(SupportDebugMixin):
)
),
wait=wait_exponential(multiplier=0.1, max=1),
stop=stop_after_delay(10),
stop=stop_after_delay(8),
)
def _fetch_playlist(self, url: str) -> str:
response = self._session.get(url, headers=self._live.headers, timeout=3)
response.raise_for_status()
response.encoding = 'utf-8'
return response.text
try:
response = self._session.get(url, headers=self._live.headers, timeout=3)
response.raise_for_status()
except Exception as e:
logger.debug(f'Failed to fetch playlist: {repr(e)}')
raise
else:
response.encoding = 'utf-8'
return response.text

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import logging
import os
from typing import Optional
import m3u8
@@ -13,6 +12,7 @@ from blrec.core import operators as core_ops
from blrec.utils import operators as utils_ops
from ..exceptions import NoNewSegments
from ..helpler import sequence_number_of
__all__ = ('PlaylistResolver',)
@@ -23,6 +23,8 @@ logger = logging.getLogger(__name__)
class PlaylistResolver:
def __init__(self, stream_url_resolver: core_ops.StreamURLResolver) -> None:
self._stream_url_resolver = stream_url_resolver
self._last_media_sequence: int = 0
self._last_sequence_number: Optional[int] = None
def __call__(self, source: Observable[m3u8.M3U8]) -> Observable[m3u8.Segment]:
return self._solve(source).pipe(
@@ -30,14 +32,10 @@ class PlaylistResolver:
utils_ops.retry(should_retry=self._should_retry),
)
def _name_of(self, uri: str) -> str:
name, ext = os.path.splitext(uri)
return name
def _sequence_number_of(self, uri: str) -> int:
return int(self._name_of(uri))
def _solve(self, source: Observable[m3u8.M3U8]) -> Observable[m3u8.Segment]:
self._last_media_sequence = 0
self._last_sequence_number = None
def subscribe(
observer: abc.ObserverBase[m3u8.Segment],
scheduler: Optional[abc.SchedulerBase] = None,
@@ -46,29 +44,43 @@ class PlaylistResolver:
subscription = SerialDisposable()
attempts: int = 0
last_sequence_number: Optional[int] = None
def on_next(playlist: m3u8.M3U8) -> None:
nonlocal attempts, last_sequence_number
nonlocal attempts
discontinuity = False
if playlist.is_endlist:
logger.debug('Playlist ended')
if playlist.media_sequence < self._last_media_sequence:
logger.warning(
'Segments discontinuous: '
f'last media sequence: {self._last_media_sequence}, '
f'current media sequence: {playlist.media_sequence}'
)
discontinuity = True
self._last_sequence_number = None
self._last_media_sequence = playlist.media_sequence
new_segments = []
for seg in playlist.segments:
num = self._sequence_number_of(seg.uri)
if last_sequence_number is not None:
if last_sequence_number >= num:
num = sequence_number_of(seg.uri)
if self._last_sequence_number is not None:
if num <= self._last_sequence_number:
continue
if last_sequence_number + 1 != num:
if num == self._last_sequence_number + 1:
discontinuity = False
else:
logger.warning(
'Segments discontinuous: '
f'last sequence number: {last_sequence_number}, '
f'last sequence number: {self._last_sequence_number}, '
f'current sequence number: {num}'
)
seg.discontinuity = True
discontinuity = True
seg.discontinuity = discontinuity
seg.custom_parser_values['playlist'] = playlist
new_segments.append(seg)
last_sequence_number = num
self._last_sequence_number = num
if not new_segments:
attempts += 1
@@ -84,9 +96,7 @@ class PlaylistResolver:
def dispose() -> None:
nonlocal disposed
nonlocal last_sequence_number
disposed = True
last_sequence_number = None
subscription.disposable = source.subscribe(
on_next, observer.on_error, observer.on_completed, scheduler=scheduler

View File

@@ -7,7 +7,7 @@ from typing import List, Optional, Union
from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from blrec.utils.ffprobe import StreamProfile, ffprobe
from blrec.utils.ffprobe import StreamProfile, ffprobe_on
from .segment_fetcher import InitSectionData, SegmentData
@@ -88,4 +88,4 @@ class Prober:
def on_error(e: Exception) -> None:
logger.warning(f'Failed to probe stream by ffprobe: {repr(e)}')
ffprobe(bytes_io.getvalue()).subscribe(on_next, on_error)
ffprobe_on(bytes_io.getvalue()).subscribe(on_next, on_error)

View File

@@ -1,13 +1,17 @@
import io
import logging
import os
from typing import Optional, Tuple, Union
from datetime import datetime, timedelta, timezone
from pathlib import PurePath
from typing import Callable, Optional, Tuple, Union
import attr
from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from blrec.hls.operators.segment_fetcher import InitSectionData, SegmentData
from blrec.utils.ffprobe import ffprobe
from .playlist_dumper import PlaylistDumper
from ..helpler import sequence_number_of
from .segment_fetcher import InitSectionData, SegmentData
__all__ = ('SegmentDumper',)
@@ -15,17 +19,31 @@ logger = logging.getLogger(__name__)
class SegmentDumper:
def __init__(self, playlist_dumper: PlaylistDumper) -> None:
self._playlist_dumper = playlist_dumper
self._out_dir: str = ''
def on_next(args: Tuple[str, int]) -> None:
path, timestamp = args
self._out_dir = os.path.dirname(path)
self._playlist_dumper.file_opened.subscribe(on_next)
def __init__(
self, path_provider: Callable[[Optional[int]], Tuple[str, int]]
) -> None:
self._path_provider = path_provider
self._file_opened: Subject[Tuple[str, int]] = Subject()
self._file_closed: Subject[str] = Subject()
self._reset()
def _reset(self) -> None:
self._path: str = ''
self._file: Optional[io.BufferedWriter] = None
self._filesize: int = 0
self._record_start_time: Optional[int] = None
@property
def path(self) -> str:
return self._path
@property
def filesize(self) -> int:
return self._filesize
@property
def record_start_time(self) -> Optional[int]:
return self._record_start_time
@property
def file_opened(self) -> Observable[Tuple[str, int]]:
@@ -40,6 +58,84 @@ class SegmentDumper:
) -> Observable[Union[InitSectionData, SegmentData]]:
return self._dump(source)
def _open_file(self) -> None:
assert self._record_start_time is not None
path, timestamp = self._path_provider(self._record_start_time)
self._path = str(PurePath(path).with_suffix('.m4s'))
self._file = open(self._path, 'wb') # type: ignore
logger.debug(f'Opened file: {self._path}')
self._file_opened.on_next((self._path, timestamp))
def _close_file(self) -> None:
if self._file is not None and not self._file.closed:
self._file.close()
logger.debug(f'Closed file: {self._path}')
self._file_closed.on_next(self._path)
def _write_data(self, item: Union[InitSectionData, SegmentData]) -> Tuple[int, int]:
assert self._file is not None
offset = self._file.tell()
size = self._file.write(item.payload)
assert size == len(item)
return offset, size
def _update_filesize(self, size: int) -> None:
self._filesize += size
def _set_record_start_time(self, item: Union[InitSectionData, SegmentData]) -> None:
seq = sequence_number_of(item.segment.uri)
dt = datetime.utcfromtimestamp(seq)
tz = timezone(timedelta(hours=8))
ts = dt.replace(year=datetime.today().year, tzinfo=tz).timestamp()
self._record_start_time = int(ts)
def _must_split_file(
self, prev_init_item: Optional[InitSectionData], curr_init_item: InitSectionData
) -> bool:
if prev_init_item is None:
curr_profile = ffprobe(curr_init_item.payload)
logger.debug(f'current init section profile: {curr_profile}')
return True
prev_profile = ffprobe(prev_init_item.payload)
logger.debug(f'previous init section profile: {prev_profile}')
curr_profile = ffprobe(curr_init_item.payload)
logger.debug(f'current init section profile: {curr_profile}')
prev_video_profile = prev_profile['streams'][0]
prev_audio_profile = prev_profile['streams'][1]
assert prev_video_profile['codec_type'] == 'video'
assert prev_audio_profile['codec_type'] == 'audio'
curr_video_profile = curr_profile['streams'][0]
curr_audio_profile = curr_profile['streams'][1]
assert curr_video_profile['codec_type'] == 'video'
assert curr_audio_profile['codec_type'] == 'audio'
if (
prev_video_profile['codec_name'] != curr_video_profile['codec_name']
or prev_video_profile['width'] != curr_video_profile['width']
or prev_video_profile['height'] != curr_video_profile['height']
or prev_video_profile['coded_width'] != curr_video_profile['coded_width']
or prev_video_profile['coded_height'] != curr_video_profile['coded_height']
):
logger.warning('Video parameters changed')
return True
if (
prev_audio_profile['codec_name'] != curr_audio_profile['codec_name']
or prev_audio_profile['channels'] != curr_audio_profile['channels']
or prev_audio_profile['sample_rate'] != curr_audio_profile['sample_rate']
or prev_audio_profile['bit_rate'] != curr_audio_profile['bit_rate']
):
logger.warning('Audio parameters changed')
return True
return False
def _need_split_file(self, item: Union[InitSectionData, SegmentData]) -> bool:
return item.segment.custom_parser_values.get('split', False)
def _dump(
self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[Union[InitSectionData, SegmentData]]:
@@ -47,31 +143,63 @@ class SegmentDumper:
observer: abc.ObserverBase[Union[InitSectionData, SegmentData]],
scheduler: Optional[abc.SchedulerBase] = None,
) -> abc.DisposableBase:
disposed = False
subscription = SerialDisposable()
last_init_item: Optional[InitSectionData] = None
def on_next(item: Union[InitSectionData, SegmentData]) -> None:
nonlocal last_init_item
split_file = False
if isinstance(item, InitSectionData):
uri = item.init_section.uri
path = os.path.join(self._out_dir, 'segments', uri)
else:
uri = item.segment.uri
name, ext = os.path.splitext(uri)
path = os.path.join(self._out_dir, 'segments', name[:-3], uri)
os.makedirs(os.path.dirname(path), exist_ok=True)
split_file = self._must_split_file(last_init_item, item)
last_init_item = item
if not split_file:
split_file = self._need_split_file(item)
if split_file:
self._close_file()
self._reset()
self._set_record_start_time(item)
self._open_file()
try:
with open(path, 'wb') as file:
file.write(item.payload)
if split_file and not isinstance(item, InitSectionData):
assert last_init_item is not None
offset, size = self._write_data(last_init_item)
self._update_filesize(size)
observer.on_next(attr.evolve(last_init_item, offset=offset))
offset, size = self._write_data(item)
self._update_filesize(size)
observer.on_next(attr.evolve(item, offset=offset))
except Exception as e:
logger.error(f'Failed to dump segmemt: {repr(e)}')
logger.error(f'Failed to write data: {repr(e)}')
self._close_file()
self._reset()
observer.on_error(e)
else:
observer.on_next(item)
def on_completed() -> None:
self._close_file()
self._reset()
observer.on_completed()
def on_error(e: Exception) -> None:
self._close_file()
self._reset()
observer.on_error(e)
def dispose() -> None:
pass
nonlocal disposed
nonlocal last_init_item
disposed = True
last_init_item = None
self._close_file()
self._reset()
subscription.disposable = source.subscribe(
on_next, observer.on_error, observer.on_completed, scheduler=scheduler
on_next, on_error, on_completed, scheduler=scheduler
)
return CompositeDisposable(subscription, Disposable(dispose))

View File

@@ -8,7 +8,6 @@ import attr
import m3u8
import requests
import urllib3
from m3u8.model import InitializationSection
from reactivex import Observable, abc
from reactivex import operators as ops
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -36,8 +35,9 @@ logger = logging.getLogger(__name__)
@attr.s(auto_attribs=True, slots=True, frozen=True)
class InitSectionData:
init_section: InitializationSection
segment: m3u8.Segment
payload: bytes
offset: int = 0
def __len__(self) -> int:
return len(self.payload)
@@ -47,6 +47,7 @@ class InitSectionData:
class SegmentData:
segment: m3u8.Segment
payload: bytes
offset: int = 0
def __len__(self) -> int:
return len(self.payload)
@@ -116,9 +117,7 @@ class SegmentFetcher:
f'init section url: {url}'
)
data = _data
observer.on_next(
InitSectionData(init_section=seg.init_section, payload=data)
)
observer.on_next(InitSectionData(segment=seg, payload=data))
last_segment = seg
url = seg.absolute_uri
@@ -172,8 +171,13 @@ class SegmentFetcher:
stop=stop_after_delay(60),
)
def _fetch_segment(self, url: str) -> bytes:
with self._session.get(url, headers=self._live.headers, timeout=10) as response:
try:
response = self._session.get(url, headers=self._live.headers, timeout=5)
response.raise_for_status()
except Exception as e:
logger.debug(f'Failed to fetch segment {url}: {repr(e)}')
raise
else:
return response.content
def _should_retry(self, exc: Exception) -> bool:
@@ -189,3 +193,4 @@ class SegmentFetcher:
'Fetch segments failed continuously, trying to update the stream url.'
)
self._stream_url_resolver.reset()
self._stream_url_resolver.rotate_routes()

View File

@@ -1,104 +0,0 @@
from __future__ import annotations
import io
import logging
from typing import Optional
from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from blrec.flv.common import (
is_audio_sequence_header,
is_metadata_tag,
is_video_sequence_header,
)
from blrec.flv.io import FlvReader
from blrec.flv.models import AudioTag, FlvHeader, ScriptTag, VideoTag
from blrec.flv.operators.typing import FLVStream, FLVStreamItem
__all__ = ('SegmentParser',)
logger = logging.getLogger(__name__)
class SegmentParser:
def __init__(self) -> None:
self._backup_timestamp = True
def __call__(self, source: Observable[bytes]) -> FLVStream:
return self._parse(source)
def _parse(self, source: Observable[bytes]) -> FLVStream:
def subscribe(
observer: abc.ObserverBase[FLVStreamItem],
scheduler: Optional[abc.SchedulerBase] = None,
) -> abc.DisposableBase:
disposed = False
subscription = SerialDisposable()
last_flv_header: Optional[FlvHeader] = None
last_metadata_tag: Optional[ScriptTag] = None
last_audio_sequence_header: Optional[AudioTag] = None
last_video_sequence_header: Optional[VideoTag] = None
def reset() -> None:
nonlocal last_flv_header, last_metadata_tag
nonlocal last_audio_sequence_header, last_video_sequence_header
last_flv_header = None
last_metadata_tag = None
last_audio_sequence_header = None
last_video_sequence_header = None
def on_next(data: bytes) -> None:
nonlocal last_flv_header, last_metadata_tag
nonlocal last_audio_sequence_header, last_video_sequence_header
if b'' == data:
reset()
return
try:
reader = FlvReader(
io.BytesIO(data), backup_timestamp=self._backup_timestamp
)
flv_header = reader.read_header()
if not last_flv_header:
observer.on_next(flv_header)
last_flv_header = flv_header
else:
assert last_flv_header == flv_header
while not disposed:
tag = reader.read_tag()
if is_metadata_tag(tag):
if last_metadata_tag is not None:
continue
last_metadata_tag = tag
elif is_video_sequence_header(tag):
if tag == last_video_sequence_header:
continue
last_video_sequence_header = tag
elif is_audio_sequence_header(tag):
if tag == last_audio_sequence_header:
continue
last_audio_sequence_header = tag
observer.on_next(tag)
except EOFError:
pass
except Exception as e:
observer.on_error(e)
def dispose() -> None:
nonlocal disposed
disposed = True
reset()
subscription.disposable = source.subscribe(
on_next, observer.on_error, observer.on_completed, scheduler=scheduler
)
return CompositeDisposable(subscription, Disposable(dispose))
return Observable(subscribe)

View File

@@ -1,111 +0,0 @@
from __future__ import annotations
import io
import logging
import os
from typing import Optional, Union
import av
from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from blrec.bili.live import Live
from .segment_fetcher import InitSectionData, SegmentData
__all__ = ('SegmentRemuxer',)
logger = logging.getLogger(__name__)
TRACE_REMUX_SEGMENT = bool(os.environ.get('BLREC_TRACE_REMUX_SEGMENT'))
TRACE_LIBAV = bool(os.environ.get('BLREC_TRACE_LIBAV'))
if TRACE_LIBAV:
logging.getLogger('libav').setLevel(5)
else:
av.logging.set_level(av.logging.FATAL)
class SegmentRemuxer:
def __init__(self, live: Live) -> None:
self._live = live
def __call__(
self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[bytes]:
return self._remux(source)
def _remux(
self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[bytes]:
def subscribe(
observer: abc.ObserverBase[bytes],
scheduler: Optional[abc.SchedulerBase] = None,
) -> abc.DisposableBase:
disposed = False
subscription = SerialDisposable()
init_section_data: Optional[bytes] = None
def reset() -> None:
nonlocal init_section_data
init_section_data = None
def on_next(data: Union[InitSectionData, SegmentData]) -> None:
nonlocal init_section_data
if isinstance(data, InitSectionData):
init_section_data = data.payload
observer.on_next(b'')
return
if init_section_data is None:
return
try:
remuxed_data = self._remux_segemnt(init_section_data + data.payload)
except av.FFmpegError as e:
logger.warning(f'Failed to remux segment: {repr(e)}', exc_info=e)
else:
observer.on_next(remuxed_data)
def dispose() -> None:
nonlocal disposed
disposed = True
reset()
subscription.disposable = source.subscribe(
on_next, observer.on_error, observer.on_completed, scheduler=scheduler
)
return CompositeDisposable(subscription, Disposable(dispose))
return Observable(subscribe)
def _remux_segemnt(self, data: bytes, format: str = 'flv') -> bytes:
in_file = io.BytesIO(data)
out_file = io.BytesIO()
with av.open(in_file) as in_container:
with av.open(out_file, mode='w', format=format) as out_container:
in_video_stream = in_container.streams.video[0]
in_audio_stream = in_container.streams.audio[0]
out_video_stream = out_container.add_stream(template=in_video_stream)
out_audio_stream = out_container.add_stream(template=in_audio_stream)
for packet in in_container.demux():
if TRACE_REMUX_SEGMENT:
logger.debug(repr(packet))
# We need to skip the "flushing" packets that `demux` generates.
if packet.dts is None:
continue
# We need to assign the packet to the new stream.
if packet.stream.type == 'video':
packet.stream = out_video_stream
elif packet.stream.type == 'audio':
packet.stream = out_audio_stream
else:
raise NotImplementedError(packet.stream.type)
out_container.mux(packet)
return out_file.getvalue()

View File

@@ -47,7 +47,7 @@ __all__ = (
'PushdeerNotifier',
'PushplusNotifier',
'TelegramNotifier',
'BarkNotifer',
'BarkNotifier',
)

View File

@@ -267,11 +267,11 @@ class BarkResponse(TypedDict):
class Bark(MessagingProvider):
_server: Final = 'https://api.day.app'
_endpoint: Final = '/push'
_endpoint: Final = 'push'
def __init__(self, server: str = '', pushkey: str = '') -> None:
super().__init__()
self.server = server
self.server = server if len(server) > 0 else self._server
self.pushkey = pushkey
async def send_message(
@@ -287,7 +287,7 @@ class Bark(MessagingProvider):
async def _post_message(
self, title: str, content: str, msg_type: BarkMessageType
) -> None:
url = urljoin(self.server or self._server, self._endpoint)
url = urljoin(self.server, self._endpoint)
# content size is limited to a maximum size of 4 KB (4096 bytes)
if len(content.encode()) >= 4096:
content = content.encode()[:4090].decode(errors='ignore') + ' ...'
@@ -296,7 +296,7 @@ class Bark(MessagingProvider):
"body": content,
"device_key": self.pushkey,
"badge": 1,
"icon": "https://raw.githubusercontent.com/acgnhiki/blrec/master/webapp/src/assets/icons/icon-72x72.png",
"icon": "https://raw.githubusercontent.com/acgnhiki/blrec/master/webapp/src/assets/icons/icon-72x72.png", # noqa
"group": "blrec",
}
async with aiohttp.ClientSession(raise_for_status=True) as session:

View File

@@ -4,9 +4,12 @@ from .helpers import (
danmaku_path,
escape_path,
extra_metadata_path,
ffmpeg_metadata_path,
file_exists,
playlist_path,
raw_danmaku_path,
record_metadata_path,
video_path,
)
__all__ = (
@@ -15,7 +18,10 @@ __all__ = (
'danmaku_path',
'escape_path',
'extra_metadata_path',
'ffmpeg_metadata_path',
'file_exists',
'playlist_path',
'raw_danmaku_path',
'record_metadata_path',
'video_path',
)

View File

@@ -6,6 +6,7 @@ __all__ = (
'cover_path',
'create_file',
'danmaku_path',
'playlist_path',
'escape_path',
'extra_metadata_path',
'ffmpeg_metadata_path',
@@ -29,6 +30,14 @@ def danmaku_path(video_path: str) -> str:
return str(PurePath(video_path).with_suffix('.xml'))
def playlist_path(video_path: str) -> str:
return str(PurePath(video_path).with_suffix('.m3u8'))
def video_path(playlist_path: str) -> str:
return str(PurePath(playlist_path).with_suffix('.m4s'))
def cover_path(video_path: str, ext: str = 'jpg') -> str:
return str(PurePath(video_path).with_suffix('.' + ext))

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
from decimal import Decimal
from typing import Iterable, List, Tuple, cast
@@ -12,7 +13,7 @@ import m3u8
from blrec.flv.helpers import make_comment_for_joinpoints
from blrec.flv.operators import JoinPoint
from blrec.flv.utils import format_timestamp
from blrec.path.helpers import ffmpeg_metadata_path
from blrec.path.helpers import ffmpeg_metadata_path, playlist_path
from .helpers import get_extra_metadata, get_metadata, get_record_metadata
@@ -21,11 +22,13 @@ logger = logging.getLogger(__name__)
async def make_metadata_file(video_path: str) -> str:
path = ffmpeg_metadata_path(video_path)
async with aiofiles.open(path, 'wb') as file:
if video_path.endswith('.flv'):
_, ext = os.path.splitext(video_path)
if ext == '.flv':
content = await _make_metadata_content_for_flv(video_path)
elif video_path.endswith('.m3u8'):
content = await _make_metadata_content_for_m3u8(video_path)
elif ext == '.m4s':
content = await _make_metadata_content_for_m3u8(playlist_path(video_path))
else:
raise NotImplementedError(video_path)
await file.write(content.encode(encoding='utf8'))
@@ -154,11 +157,13 @@ def _make_comment_for_discontinuities(timestamps: Iterable[int]) -> str:
async def _get_discontinuities(playlist_path: str) -> Tuple[List[int], float]:
loop = asyncio.get_running_loop()
playlist = await loop.run_in_executor(None, m3u8.load, playlist_path)
duration = Decimal()
timestamps: List[int] = []
for seg in playlist.segments:
if seg.discontinuity:
timestamps.append(int(duration * 1000))
duration += Decimal(str(seg.duration))
return timestamps, float(duration)
async with aiofiles.open(playlist_path) as file:
content = await file.read()
playlist = await loop.run_in_executor(None, m3u8.loads, content)
duration = Decimal()
timestamps: List[int] = []
for seg in playlist.segments:
if seg.discontinuity:
timestamps.append(int(duration * 1000))
duration += Decimal(str(seg.duration))
return timestamps, float(duration)

View File

@@ -3,7 +3,6 @@ import json
import logging
import os
import shutil
from pathlib import PurePath
from typing import Any, Dict, Iterable, Literal
import aiofiles
@@ -11,6 +10,7 @@ import aiofiles
from blrec.path.helpers import (
cover_path,
danmaku_path,
playlist_path,
raw_danmaku_path,
record_metadata_path,
)
@@ -45,31 +45,21 @@ async def discard_dir(path: str, log_level: Literal['INFO', 'DEBUG'] = 'INFO') -
def files_related(video_path: str) -> Iterable[str]:
for path in [
file_paths = [
danmaku_path(video_path),
raw_danmaku_path(video_path),
cover_path(video_path, ext='jpg'),
cover_path(video_path, ext='png'),
]:
]
if video_path.endswith('.m4s'):
file_paths.append(playlist_path(video_path))
for path in file_paths:
if os.path.isfile(path):
yield path
async def copy_files_related(video_path: str) -> None:
loop = asyncio.get_running_loop()
dirname = os.path.dirname(video_path)
for src_path in files_related(video_path):
root, ext = os.path.splitext(src_path)
dst_path = PurePath(dirname).with_suffix(ext)
try:
await loop.run_in_executor(None, shutil.copy, src_path, dst_path)
except Exception as e:
logger.error(f"Failed to copy '{src_path}' to '{dst_path}': {repr(e)}")
else:
logger.info(f"Copied '{src_path}' to '{dst_path}'")
async def get_metadata(flv_path: str) -> Dict[str, Any]:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _get_metadata, flv_path)

View File

@@ -17,16 +17,15 @@ from ..flv.helpers import is_valid_flv_file
from ..flv.metadata_analysis import analyse_metadata
from ..flv.metadata_injection import InjectingProgress, inject_metadata
from ..logging.room_id import aio_task_with_room_id
from ..path import danmaku_path, extra_metadata_path, record_metadata_path
from ..path import (
extra_metadata_path,
ffmpeg_metadata_path,
playlist_path,
record_metadata_path,
)
from ..utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin, SupportDebugMixin
from .ffmpeg_metadata import make_metadata_file
from .helpers import (
copy_files_related,
discard_dir,
discard_file,
files_related,
get_extra_metadata,
)
from .helpers import discard_file, files_related, get_extra_metadata
from .models import DeleteStrategy, PostprocessorStatus
from .remux import RemuxingProgress, RemuxingResult, remux_video
from .typing import Progress
@@ -144,75 +143,78 @@ class Postprocessor(
@aio_task_with_room_id
async def _worker(self) -> None:
while True:
self._status = PostprocessorStatus.WAITING
self._postprocessing_path = None
self._postprocessing_progress = None
await self._postprocess()
video_path = await self._queue.get()
async def _postprocess(self) -> None:
self._status = PostprocessorStatus.WAITING
self._postprocessing_path = None
self._postprocessing_progress = None
async with self._worker_semaphore:
logger.debug(f'Postprocessing... {video_path}')
video_path = await self._queue.get()
self._completed_files.append(video_path)
await self._wait_for_metadata_file(video_path)
async with self._worker_semaphore:
logger.debug(f'Postprocessing... {video_path}')
await self._wait_for_metadata_file(video_path)
try:
if video_path.endswith('.flv'):
if not await self._is_vaild_flv_file(video_path):
logger.warning(f'The flv file may be invalid: {video_path}')
if os.path.getsize(video_path) < 1024**2:
continue
if self.remux_to_mp4:
self._status = PostprocessorStatus.REMUXING
(
result_path,
remuxing_result,
) = await self._remux_video_to_mp4(video_path)
if not self._debug:
if self._should_delete_source_files(remuxing_result):
await discard_file(video_path)
elif self.inject_extra_metadata:
self._status = PostprocessorStatus.INJECTING
result_path = await self._inject_extra_metadata(video_path)
else:
result_path = video_path
if not self._debug:
await discard_file(extra_metadata_path(video_path), 'DEBUG')
elif video_path.endswith('.m3u8'):
if self.remux_to_mp4:
self._status = PostprocessorStatus.REMUXING
(
result_path,
remuxing_result,
) = await self._remux_video_to_mp4(video_path)
await copy_files_related(video_path)
if result_path != video_path:
self._completed_files.append(danmaku_path(result_path))
with suppress(ValueError):
self._completed_files.remove(
danmaku_path(video_path)
)
if not self._debug:
if self._should_delete_source_files(remuxing_result):
await discard_dir(os.path.dirname(video_path))
else:
result_path = video_path
else:
result_path = video_path
try:
_, ext = os.path.splitext(video_path)
if ext == '.flv':
result_path = await self._process_flv(video_path)
elif ext == '.m4s':
result_path = await self._process_m4s(video_path)
else:
result_path = video_path
if result_path != video_path:
self._completed_files.append(result_path)
await self._emit(
'video_postprocessing_completed', self, result_path
)
await self._emit('video_postprocessing_completed', self, result_path)
files = [result_path, *files_related(result_path)]
await self._emit('postprocessing_completed', self, files)
except Exception as exc:
submit_exception(exc)
finally:
self._queue.task_done()
files = [result_path, *files_related(result_path)]
await self._emit('postprocessing_completed', self, files)
except Exception as exc:
submit_exception(exc)
finally:
self._queue.task_done()
async def _process_flv(self, video_path: str) -> str:
if not await self._is_vaild_flv_file(video_path):
logger.warning(f'The flv file may be invalid: {video_path}')
if os.path.getsize(video_path) < 1024**2:
return video_path
if self.remux_to_mp4:
self._status = PostprocessorStatus.REMUXING
result_path, remuxing_result = await self._remux_video_to_mp4(video_path)
if not self._debug:
if self._should_delete_source_files(remuxing_result):
await discard_file(video_path)
elif self.inject_extra_metadata:
self._status = PostprocessorStatus.INJECTING
result_path = await self._inject_extra_metadata(video_path)
else:
result_path = video_path
if not self._debug:
await discard_file(extra_metadata_path(video_path), 'DEBUG')
return result_path
async def _process_m4s(self, video_path: str) -> str:
if not self.remux_to_mp4:
return video_path
self._status = PostprocessorStatus.REMUXING
result_path, remuxing_result = await self._remux_video_to_mp4(video_path)
if not self._debug and self._should_delete_source_files(remuxing_result):
await discard_file(video_path)
await discard_file(playlist_path(video_path))
await discard_file(record_metadata_path(video_path), 'DEBUG')
await discard_file(ffmpeg_metadata_path(video_path), 'DEBUG')
return result_path
async def _inject_extra_metadata(self, path: str) -> str:
logger.info(f"Injecting metadata for '{path}' ...")
@@ -240,12 +242,16 @@ class Postprocessor(
return path
async def _remux_video_to_mp4(self, in_path: str) -> Tuple[str, RemuxingResult]:
if in_path.endswith('.flv'):
_, ext = os.path.splitext(in_path)
if ext == '.flv':
out_path = str(PurePath(in_path).with_suffix('.mp4'))
metadata_path = await make_metadata_file(in_path)
elif in_path.endswith('.m3u8'):
out_path = str(PurePath(in_path).parent.with_suffix('.mp4'))
metadata_path = await make_metadata_file(in_path)
elif ext == '.m4s':
_in_path = in_path
in_path = playlist_path(in_path)
out_path = str(PurePath(in_path).with_suffix('.mp4'))
metadata_path = await make_metadata_file(_in_path)
else:
raise NotImplementedError(in_path)
@@ -254,7 +260,7 @@ class Postprocessor(
if remux_result.is_failed():
logger.error(f"Failed to remux '{in_path}' to '{out_path}'")
result_path = in_path
result_path = _in_path if ext == 'm4s' else in_path
elif remux_result.is_warned():
logger.warning('Remuxing done, but ran into problems.')
result_path = out_path
@@ -266,7 +272,7 @@ class Postprocessor(
logger.debug(f'ffmpeg output:\n{remux_result.output}')
if not self._debug and in_path.endswith('.flv'):
if not self._debug and ext == '.flv':
await discard_file(metadata_path, 'DEBUG')
return result_path, remux_result
@@ -349,15 +355,17 @@ class Postprocessor(
return False
async def _wait_for_metadata_file(self, video_path: str) -> None:
loop = asyncio.get_running_loop()
_, ext = os.path.splitext(video_path)
if video_path.endswith('.flv'):
if ext == '.flv':
path = extra_metadata_path(video_path)
elif video_path.endswith('.m3u8'):
elif ext == '.m4s':
path = record_metadata_path(video_path)
else:
return
loop = asyncio.get_running_loop()
for _ in range(10):
if await loop.run_in_executor(None, os.path.isfile, path):
break

View File

@@ -10,6 +10,8 @@ from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposab
from reactivex.scheduler.currentthreadscheduler import CurrentThreadScheduler
from tqdm import tqdm
from blrec.path.helpers import video_path
__all__ = 'RemuxingResult', 'remux_video'
@@ -63,15 +65,9 @@ def remux_video(
) -> Observable[Union[RemuxingProgress, RemuxingResult]]:
SIZE_PATTERN: Final = re.compile(r'size=\s*(?P<number>\d+)(?P<unit>[a-zA-Z]?B)')
if in_path.endswith('.m3u8'):
total = 0
for root, dirs, files in os.walk(os.path.dirname(in_path)):
for filename in files:
if not (filename.endswith('.m4s') or filename.endswith('.ts')):
continue
total += os.path.getsize(os.path.join(root, filename))
postfix = os.path.join(
os.path.basename(os.path.dirname(in_path)), os.path.basename(in_path)
)
_in_path = video_path(in_path)
total = os.path.getsize(_in_path)
postfix = os.path.basename(_in_path)
else:
total = os.path.getsize(in_path)
postfix = os.path.basename(in_path)

View File

@@ -18,13 +18,14 @@ from blrec.postprocess import DeleteStrategy
from blrec.utils.string import camel_case
from .typing import (
BarkMessageType,
EmailMessageType,
MessageType,
PushdeerMessageType,
PushplusMessageType,
RecordingMode,
ServerchanMessageType,
TelegramMessageType,
BarkMessageType,
)
logger = logging.getLogger(__name__)
@@ -462,16 +463,16 @@ class NotificationSettings(BaseModel):
class MessageTemplateSettings(BaseModel):
began_message_type: str
began_message_type: MessageType
began_message_title: str
began_message_content: str
ended_message_type: str
ended_message_type: MessageType
ended_message_title: str
ended_message_content: str
space_message_type: str
space_message_type: MessageType
space_message_title: str
space_message_content: str
error_message_type: str
error_message_type: MessageType
error_message_title: str
error_message_content: str

View File

@@ -93,14 +93,14 @@ class VideoFileStatus(str, Enum):
INJECTING = 'injecting'
COMPLETED = 'completed'
MISSING = 'missing'
BROKEN = 'broken'
UNKNOWN = 'unknown'
class DanmukuFileStatus(str, Enum):
RECORDING = 'recording'
COMPLETED = 'completed'
MISSING = 'missing'
BROKEN = 'broken'
UNKNOWN = 'unknown'
@attr.s(auto_attribs=True, slots=True, frozen=True)

View File

@@ -1,5 +1,6 @@
import logging
import os
from contextlib import suppress
from pathlib import PurePath
from typing import Iterator, List, Optional
@@ -10,13 +11,14 @@ from blrec.bili.models import RoomInfo, UserInfo
from blrec.bili.typing import QualityNumber, StreamFormat
from blrec.core import Recorder
from blrec.core.cover_downloader import CoverSaveStrategy
from blrec.core.typing import MetaData
from blrec.event.event_submitters import (
LiveEventSubmitter,
PostprocessorEventSubmitter,
RecorderEventSubmitter,
)
from blrec.flv.metadata_injection import InjectingProgress
from blrec.flv.operators import MetaData, StreamProfile
from blrec.flv.operators import StreamProfile
from blrec.logging.room_id import aio_task_with_room_id
from blrec.postprocess import DeleteStrategy, Postprocessor, PostprocessorStatus
from blrec.postprocess.remux import RemuxingProgress
@@ -133,10 +135,7 @@ class RecordTask:
size = os.path.getsize(path)
exists = True
except FileNotFoundError:
if path.endswith('.m3u8'):
mp4_path = str(PurePath(path).parent.with_suffix('.mp4'))
else:
mp4_path = str(PurePath(path).with_suffix('.mp4'))
mp4_path = str(PurePath(path).with_suffix('.mp4'))
try:
size = os.path.getsize(mp4_path)
exists = True
@@ -164,7 +163,7 @@ class RecordTask:
status = VideoFileStatus.INJECTING
else:
# disabling recorder by force or stoping task by force
status = VideoFileStatus.BROKEN
status = VideoFileStatus.UNKNOWN
yield VideoFileDetail(path=path, size=size, status=status)
@@ -195,7 +194,7 @@ class RecordTask:
status = DanmukuFileStatus.RECORDING
else:
# disabling recorder by force or stoping task by force
status = DanmukuFileStatus.BROKEN
status = DanmukuFileStatus.UNKNOWN
yield DanmakuFileDetail(path=path, size=size, status=status)
@@ -565,22 +564,29 @@ class RecordTask:
self._destroy_danmaku_client()
def _destroy_danmaku_client(self) -> None:
del self._danmaku_client
with suppress(AttributeError):
del self._danmaku_client
def _destroy_live_monitor(self) -> None:
del self._live_monitor
with suppress(AttributeError):
del self._live_monitor
def _destroy_live_event_submitter(self) -> None:
del self._live_event_submitter
with suppress(AttributeError):
del self._live_event_submitter
def _destroy_recorder(self) -> None:
del self._recorder
with suppress(AttributeError):
del self._recorder
def _destroy_recorder_event_submitter(self) -> None:
del self._recorder_event_submitter
with suppress(AttributeError):
del self._recorder_event_submitter
def _destroy_postprocessor(self) -> None:
del self._postprocessor
with suppress(AttributeError):
del self._postprocessor
def _destroy_postprocessor_event_submitter(self) -> None:
del self._postprocessor_event_submitter
with suppress(AttributeError):
del self._postprocessor_event_submitter

View File

@@ -10,8 +10,9 @@ from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_expo
from blrec.utils.libc import malloc_trim
from ..bili.exceptions import ApiRequestError
from ..core.typing import MetaData
from ..exception import NotFoundError, submit_exception
from ..flv.operators import MetaData, StreamProfile
from ..flv.operators import StreamProfile
from .models import DanmakuFileDetail, TaskData, TaskParam, VideoFileDetail
from .task import RecordTask
@@ -106,8 +107,9 @@ class RecordTaskManager:
await task.enable_monitor()
if settings.enable_recorder:
await task.enable_recorder()
except Exception as e:
except BaseException as e:
logger.error(f'Failed to add task {settings.room_id} due to: {repr(e)}')
await task.destroy()
del self._tasks[settings.room_id]
raise

View File

@@ -2,17 +2,113 @@ from __future__ import annotations
import json
from subprocess import PIPE, Popen
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
from reactivex import Observable, abc
from reactivex.scheduler import CurrentThreadScheduler
__all__ = ('ffprobe', 'StreamProfile')
StreamProfile = Dict[str, Any]
__all__ = (
'ffprobe',
'ffprobe_on',
'StreamProfile',
'FormatProfile',
'VideoProfile',
'AudioProfile',
)
def ffprobe(data: bytes) -> Observable[StreamProfile]:
class VideoProfile(TypedDict, total=False):
index: int
codec_name: str
codec_long_name: str
codec_type: Literal['video']
codec_tag_string: str
codec_tag: str
width: int
height: int
coded_width: int
coded_height: int
closed_captions: int
film_grain: int
has_b_frames: int
level: int
refs: int
is_avc: str
nal_length_size: str
id: str
r_frame_rate: str
avg_frame_rate: str
time_base: str
duration_ts: int
duration: str
extradata_size: int
disposition: Dict[str, int]
tags: Dict[str, Any]
class AudioProfile(TypedDict, total=False):
index: int
codec_name: str
codec_long_name: str
codec_type: Literal['audio']
codec_tag_string: str
codec_tag: str
sample_fmt: str
sample_rate: str
channels: int
channel_layout: str
bits_per_sample: int
id: str
r_frame_rate: str
avg_frame_rate: str
time_base: str
duration_ts: int
duration: str
bit_rate: str
extradata_size: int
disposition: Dict[str, int]
tags: Dict[str, Any]
class FormatProfile(TypedDict, total=False):
filename: str
nb_streams: int
nb_programs: int
format_name: str
format_long_name: str
size: str
probe_score: int
tags: Dict[str, Any]
class StreamProfile(TypedDict, total=False):
streams: List[Union[VideoProfile, AudioProfile]]
format: FormatProfile
def ffprobe(data: bytes) -> StreamProfile:
args = [
'ffprobe',
'-show_streams',
'-show_format',
'-print_format',
'json',
'pipe:0',
]
with Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE) as process:
try:
stdout, _stderr = process.communicate(data, timeout=10)
profile = json.loads(stdout)
except Exception:
process.kill()
process.wait()
raise
else:
return profile
def ffprobe_on(data: bytes) -> Observable[StreamProfile]:
def subscribe(
observer: abc.ObserverBase[StreamProfile],
scheduler: Optional[abc.SchedulerBase] = None,
@@ -20,26 +116,13 @@ def ffprobe(data: bytes) -> Observable[StreamProfile]:
_scheduler = scheduler or CurrentThreadScheduler()
def action(scheduler: abc.SchedulerBase, state: Optional[Any] = None) -> None:
args = [
'ffprobe',
'-show_streams',
'-show_format',
'-print_format',
'json',
'pipe:0',
]
with Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE) as process:
try:
stdout, stderr = process.communicate(data, timeout=10)
except Exception as e:
process.kill()
process.wait()
observer.on_error(e)
else:
profile = json.loads(stdout)
observer.on_next(profile)
observer.on_completed()
try:
profile = ffprobe(data)
except Exception as e:
observer.on_error(e)
else:
observer.on_next(profile)
observer.on_completed()
return _scheduler.schedule(action)

View File

@@ -2,6 +2,7 @@ import asyncio
import os
import threading
from abc import ABC, abstractmethod
from concurrent.futures import Future
from typing import Awaitable, TypeVar, final
from blrec.logging.room_id import aio_task_with_room_id
@@ -130,10 +131,13 @@ class AsyncCooperationMixin(ABC):
# workaround for `RuntimeError: no running event loop`
submit_exception(exc)
self._run_coroutine(wrapper())
self._call_coroutine(wrapper())
def _run_coroutine(self, coro: Awaitable[_T]) -> _T:
future = asyncio.run_coroutine_threadsafe(self._with_room_id(coro), self._loop)
def _run_coroutine(self, coro: Awaitable[_T]) -> Future[_T]:
return asyncio.run_coroutine_threadsafe(coro, self._loop)
def _call_coroutine(self, coro: Awaitable[_T]) -> _T:
future = self._run_coroutine(coro)
return future.result()
@aio_task_with_room_id

View File

@@ -1,11 +1,10 @@
from queue import Queue
from threading import Thread
from threading import Thread, current_thread
from typing import Any, Callable, Optional, TypeVar
from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
_T = TypeVar('_T')
@@ -41,7 +40,8 @@ def observe_on_new_thread(
nonlocal disposed
disposed = True
queue.put(lambda: None)
thread.join()
if thread is not current_thread():
thread.join()
subscription.disposable = source.subscribe(
on_next, on_error, on_completed, scheduler=scheduler

View File

View File

@@ -1,41 +1,32 @@
from typing import Any, Dict, List
import attr
from fastapi import APIRouter, BackgroundTasks, Body, Depends, status
from pydantic import PositiveInt, conint
from fastapi import (
APIRouter,
status,
Body,
Depends,
BackgroundTasks,
)
from ..dependencies import task_data_filter, TaskDataFilter
from ..schemas import ResponseMessage
from ..responses import (
not_found_responses,
forbidden_responses,
confict_responses,
accepted_responses,
created_responses,
)
from ...exception import NotFoundError, ForbiddenError
from ...application import Application
from ...exception import ForbiddenError, NotFoundError
from ...utils.ffprobe import StreamProfile
from ..dependencies import TaskDataFilter, task_data_filter
from ..responses import (
accepted_responses,
confict_responses,
created_responses,
forbidden_responses,
not_found_responses,
)
from ..schemas import ResponseMessage
app: Application = None # type: ignore # bypass flake8 F821
router = APIRouter(
prefix='/api/v1/tasks',
tags=['tasks'],
)
router = APIRouter(prefix='/api/v1/tasks', tags=['tasks'])
@router.get('/data')
async def get_task_data(
page: PositiveInt = 1,
size: conint(ge=10, le=100) = 100, # type: ignore
filter: TaskDataFilter = Depends(task_data_filter)
filter: TaskDataFilter = Depends(task_data_filter),
) -> List[Dict[str, Any]]:
start = (page - 1) * size
stop = page * size
@@ -51,26 +42,17 @@ async def get_task_data(
return task_data
@router.get(
'/{room_id}/data',
responses={**not_found_responses},
)
@router.get('/{room_id}/data', responses={**not_found_responses})
async def get_one_task_data(room_id: int) -> Dict[str, Any]:
return attr.asdict(app.get_task_data(room_id))
@router.get(
'/{room_id}/param',
responses={**not_found_responses},
)
@router.get('/{room_id}/param', responses={**not_found_responses})
async def get_task_param(room_id: int) -> Dict[str, Any]:
return attr.asdict(app.get_task_param(room_id))
@router.get(
'/{room_id}/metadata',
responses={**not_found_responses},
)
@router.get('/{room_id}/metadata', responses={**not_found_responses})
async def get_task_metadata(room_id: int) -> Dict[str, Any]:
metadata = app.get_task_metadata(room_id)
if not metadata:
@@ -78,44 +60,29 @@ async def get_task_metadata(room_id: int) -> Dict[str, Any]:
return attr.asdict(metadata)
@router.get(
'/{room_id}/profile',
responses={**not_found_responses},
)
async def get_task_stream_profile(room_id: int) -> Dict[str, Any]:
@router.get('/{room_id}/profile', responses={**not_found_responses})
async def get_task_stream_profile(room_id: int) -> StreamProfile:
return app.get_task_stream_profile(room_id)
@router.get(
'/{room_id}/videos',
responses={**not_found_responses},
)
@router.get('/{room_id}/videos', responses={**not_found_responses})
async def get_task_video_file_details(room_id: int) -> List[Dict[str, Any]]:
return [attr.asdict(d) for d in app.get_task_video_file_details(room_id)]
@router.get(
'/{room_id}/danmakus',
responses={**not_found_responses},
)
@router.get('/{room_id}/danmakus', responses={**not_found_responses})
async def get_task_danmaku_file_details(room_id: int) -> List[Dict[str, Any]]:
return [attr.asdict(d) for d in app.get_task_danmaku_file_details(room_id)]
@router.post(
'/info',
response_model=ResponseMessage,
responses={**not_found_responses},
)
@router.post('/info', response_model=ResponseMessage, responses={**not_found_responses})
async def update_all_task_infos() -> ResponseMessage:
await app.update_all_task_infos()
return ResponseMessage(message='All task infos have been updated')
@router.post(
'/{room_id}/info',
response_model=ResponseMessage,
responses={**not_found_responses},
'/{room_id}/info', response_model=ResponseMessage, responses={**not_found_responses}
)
async def update_task_info(room_id: int) -> ResponseMessage:
await app.update_task_info(room_id)
@@ -123,20 +90,14 @@ async def update_task_info(room_id: int) -> ResponseMessage:
@router.get(
'/{room_id}/cut',
response_model=ResponseMessage,
responses={**not_found_responses},
'/{room_id}/cut', response_model=ResponseMessage, responses={**not_found_responses}
)
async def can_cut_stream(room_id: int) -> ResponseMessage:
if app.can_cut_stream(room_id):
return ResponseMessage(
message='The stream can been cut',
data={'result': True},
)
return ResponseMessage(message='The stream can been cut', data={'result': True})
else:
return ResponseMessage(
message='The stream cannot been cut',
data={'result': False},
message='The stream cannot been cut', data={'result': False}
)
@@ -153,9 +114,7 @@ async def cut_stream(room_id: int) -> ResponseMessage:
@router.post(
'/start',
response_model=ResponseMessage,
responses={**not_found_responses},
'/start', response_model=ResponseMessage, responses={**not_found_responses}
)
async def start_all_tasks() -> ResponseMessage:
await app.start_all_tasks()
@@ -214,10 +173,7 @@ async def stop_task(
return ResponseMessage(message='The task has been stopped')
@router.post(
'/recorder/enable',
response_model=ResponseMessage,
)
@router.post('/recorder/enable', response_model=ResponseMessage)
async def enable_all_task_recorders() -> ResponseMessage:
await app.enable_all_task_recorders()
return ResponseMessage(message='All task recorders have been enabled')
@@ -246,9 +202,7 @@ async def disable_all_task_recorders(
) -> ResponseMessage:
if background:
background_tasks.add_task(app.disable_all_task_recorders, force)
return ResponseMessage(
message='Disabling all task recorders on the background'
)
return ResponseMessage(message='Disabling all task recorders on the background')
await app.disable_all_task_recorders(force)
return ResponseMessage(message='All task recorders have been disabled')
@@ -271,9 +225,7 @@ async def disable_task_recorder(
if background:
background_tasks.add_task(app.disable_task_recorder, room_id, force)
return ResponseMessage(
message='Disabling the task recorder on the background'
)
return ResponseMessage(message='Disabling the task recorder on the background')
await app.disable_task_recorder(room_id, force)
return ResponseMessage(message='The task recorder has been disabled')
@@ -283,9 +235,7 @@ async def disable_task_recorder(
'/{room_id}',
response_model=ResponseMessage,
status_code=status.HTTP_201_CREATED,
responses={
**created_responses, **confict_responses, **forbidden_responses
},
responses={**created_responses, **confict_responses, **forbidden_responses},
)
async def add_task(room_id: int) -> ResponseMessage:
"""Add a task for a room.
@@ -298,24 +248,18 @@ async def add_task(room_id: int) -> ResponseMessage:
"""
real_room_id = await app.add_task(room_id)
return ResponseMessage(
message='Successfully Added Task',
data={'room_id': real_room_id},
message='Successfully Added Task', data={'room_id': real_room_id}
)
@router.delete(
'',
response_model=ResponseMessage,
)
@router.delete('', response_model=ResponseMessage)
async def remove_all_tasks() -> ResponseMessage:
await app.remove_all_tasks()
return ResponseMessage(message='All tasks have been removed')
@router.delete(
'/{room_id}',
response_model=ResponseMessage,
responses={**not_found_responses},
'/{room_id}', response_model=ResponseMessage, responses={**not_found_responses}
)
async def remove_task(room_id: int) -> ResponseMessage:
await app.remove_task(room_id)

View File

@@ -1,30 +1,33 @@
import os
import errno
import os
from fastapi import APIRouter, Body
from ..schemas import ResponseMessage
from ...application import Application
from blrec.bili.helpers import get_nav
from ...application import Application
from ..schemas import ResponseMessage
app: Application = None # type: ignore # bypass flake8 F821
router = APIRouter(
prefix='/api/v1/validation',
tags=['validation'],
)
router = APIRouter(prefix='/api/v1/validation', tags=['validation'])
@router.post(
'/dir',
response_model=ResponseMessage,
)
@router.post('/dir', response_model=ResponseMessage)
async def validate_dir(path: str = Body(..., embed=True)) -> ResponseMessage:
"""Check if the path is a directory and grants the read, write permissions
"""
"""Check if the path is a directory and grants the read, write permissions"""
if not os.path.isdir(path):
return ResponseMessage(code=errno.ENOTDIR, message='not a directory')
elif not os.access(path, os.F_OK | os.R_OK | os.W_OK):
return ResponseMessage(code=errno.EACCES, message='no permissions')
else:
return ResponseMessage(code=0, message='ok')
@router.post('/cookie', response_model=ResponseMessage)
async def validate_cookie(cookie: str = Body(..., embed=True)) -> ResponseMessage:
"""Check if the cookie is valid"""
json_res = await get_nav(cookie)
return ResponseMessage(
code=json_res['code'], message=json_res['message'], data=json_res['data']
)

View File

@@ -16,4 +16,9 @@ export class ValidationService {
const url = this.url.makeApiUrl(`/api/v1/validation/dir`);
return this.http.post<ResponseMessage>(url, { path });
}
validateCookie(cookie: string): Observable<ResponseMessage> {
const url = this.url.makeApiUrl(`/api/v1/validation/cookie`);
return this.http.post<ResponseMessage>(url, { cookie });
}
}

View File

@@ -1,12 +1,4 @@
<nz-modal
nzTitle="修改 Cookie"
nzOkDanger
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
(nzOnOk)="handleConfirm()"
(nzOnCancel)="handleCancel()"
>
<nz-modal nzTitle="修改 Cookie" nzCentered [(nzVisible)]="visible">
<ng-container *nzModalContent>
<form nz-form [formGroup]="settingsForm">
<nz-form-item>
@@ -28,4 +20,24 @@
</nz-form-item>
</form>
</ng-container>
<ng-template [nzModalFooter]>
<button
nz-button
nzType="primary"
(click)="testCookie()"
[disabled]="control.value.trim() === ''"
>
测试
</button>
<button nz-button nzType="default" (click)="handleCancel()">取消</button>
<button
nz-button
nzType="primary"
nzDanger
(click)="handleConfirm()"
[disabled]="control.invalid || control.value.trim() === value"
>
确定
</button>
</ng-template>
</nz-modal>

View File

@@ -1,14 +1,18 @@
import {
Component,
ChangeDetectionStrategy,
Input,
Output,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnChanges,
Output,
} from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { NzMessageService } from 'ng-zorro-antd/message';
import { ValidationService } from 'src/app/core/services/validation.service';
@Component({
selector: 'app-cookie-edit-dialog',
templateUrl: './cookie-edit-dialog.component.html',
@@ -28,7 +32,9 @@ export class CookieEditDialogComponent implements OnChanges {
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef
private changeDetector: ChangeDetectorRef,
private validationService: ValidationService,
private message: NzMessageService
) {
this.settingsForm = formBuilder.group({
cookie: [''],
@@ -72,4 +78,18 @@ export class CookieEditDialogComponent implements OnChanges {
this.confirm.emit(this.control.value.trim());
this.close();
}
testCookie(): void {
this.validationService
.validateCookie(this.control.value)
.subscribe((result) => {
if (result.code === 0) {
this.message.success(
`uid: ${result.data?.mid}, uname: ${result.data?.uname}`
);
} else {
this.message.error(`${result.code}: ${result.message}`);
}
});
}
}

View File

@@ -5,7 +5,7 @@
[nzWarningTip]="syncFailedWarningTip" [nzValidateStatus]="
serverControl.valid && !syncStatus.server ? 'warning' : serverControl
">
<input id="server" type="url" placeholder="默认为官方服务器 https://api.day.app" nz-input formControlName="server" />
<input id="server" type="url" placeholder="默认为官方服务器如果自定义服务器且url带路径url末尾请加上/" nz-input formControlName="server" />
<ng-template #serverErrorTip let-control>
<ng-container *ngIf="control.hasError('pattern')">
server 无效
@@ -30,4 +30,4 @@
</ng-template>
</nz-form-control>
</nz-form-item>
</form>
</form>

View File

@@ -10,15 +10,15 @@
<p>
选择要录制的直播流格式
<br />
FLV: 网络不稳定容易中断丢失数据或录制到二压画质
<b>FLV:</b>
flv 流在网络不稳定的情况下容易中断丢失数据或录制到二压画质。没有 flv
流的直播会自动切换录制 fmp4 流。
<br />
HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
<b>HLS (fmp4):</b>
hls 流基本不受网络波动影响,但不是所有直播间都支持。有 flv
流的直播,在设定的等待时间内没有 fmp4 流会自动切换录制 flv 流。
<br />
P.S.
<br />
录制 HLS 流需要 ffmpeg
<br />
在设定时间内没有 fmp4 流会自动切换录制 flv 流
<b>P.S.</b>
<br />
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
则不支持。
@@ -74,38 +74,6 @@
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item
class="setting-item"
*ngIf="streamFormatControl.value === 'fmp4'"
>
<nz-form-label
class="setting-label"
nzNoColon
[nzTooltipTitle]="recordingModeTip"
>录制模式</nz-form-label
>
<ng-template #recordingModeTip>
<p>
标准模式: 对下载的流数据进行解析处理,支持自动分割文件等功能。
<br />
原始模式: 直接下载流数据,没有进行解析处理,不支持自动分割文件等功能。
<br />
</p>
</ng-template>
<nz-form-control
class="setting-control select"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.recordingMode ? recordingModeControl : 'warning'
"
>
<nz-select
formControlName="recordingMode"
[nzOptions]="recordingModeOptions"
>
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
@@ -224,11 +192,7 @@
</nz-form-item>
<nz-form-item
class="setting-item"
*ngIf="
streamFormatControl.value === 'flv' ||
(streamFormatControl.value === 'fmp4' &&
recordingModeControl.value === 'standard')
"
*ngIf="streamFormatControl.value === 'flv'"
>
<nz-form-label
class="setting-label"

View File

@@ -214,7 +214,6 @@ export type PushplusMessageType =
export type TelegramMessageType = MarkdownMessageType | HtmlMessageType;
export type BarkMessageType = TextMessageType;
export interface MessageTemplateSettings {
beganMessageType: string;
beganMessageTitle: string;

View File

@@ -11,7 +11,7 @@ const STATUS_MAPPING = new Map([
[VideoFileStatus.REMUXING, '处理中'],
[VideoFileStatus.COMPLETED, '已完成'],
[VideoFileStatus.MISSING, '不存在'],
[VideoFileStatus.BROKEN, '录制中断'],
[VideoFileStatus.UNKNOWN, '???'],
]);
@Pipe({

View File

@@ -296,14 +296,14 @@ export enum VideoFileStatus {
INJECTING = 'injecting',
COMPLETED = 'completed',
MISSING = 'missing',
BROKEN = 'broken',
UNKNOWN = 'unknown',
}
export enum DanmakuFileStatus {
RECORDING = 'recording',
COMPLETED = 'completed',
MISSING = 'missing',
BROKEN = 'broken',
UNKNOWN = 'unknown',
}
export interface VideoFileDetail {

View File

@@ -52,17 +52,7 @@
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item
class="setting-item filesize-limit"
*ngIf="
(options.recorder.streamFormat || model.recorder.streamFormat) ===
'flv' ||
((options.recorder.streamFormat || model.recorder.streamFormat) ===
'fmp4' &&
(options.recorder.recordingMode ||
model.recorder.recordingMode) === 'standard')
"
>
<nz-form-item class="setting-item filesize-limit">
<nz-form-label
class="setting-label"
nzNoColon
@@ -97,17 +87,7 @@
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item
class="setting-item duration-limit"
*ngIf="
(options.recorder.streamFormat || model.recorder.streamFormat) ===
'flv' ||
((options.recorder.streamFormat || model.recorder.streamFormat) ===
'fmp4' &&
(options.recorder.recordingMode ||
model.recorder.recordingMode) === 'standard')
"
>
<nz-form-item class="setting-item duration-limit">
<nz-form-label
class="setting-label"
nzNoColon
@@ -157,15 +137,15 @@
<p>
选择要录制的直播流格式
<br />
FLV: 网络不稳定容易中断丢失数据或录制到二压画质
<b>FLV:</b>
flv 流在网络不稳定的情况下容易中断丢失数据或录制到二压画质。没有
flv 流的直播会自动切换录制 fmp4 流。
<br />
HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
<b>HLS (fmp4):</b>
hls 流基本不受网络波动影响,但不是所有直播间都支持。有 flv
流的直播,在设定的等待时间内没有 fmp4 流会自动切换录制 flv 流。
<br />
P.S.
<br />
录制 HLS 流需要 ffmpeg
<br />
在设定时间内没有 fmp4 流会自动切换录制 flv 流
<b>P.S.</b>
<br />
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
则不支持。
@@ -237,48 +217,6 @@
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item
class="setting-item"
*ngIf="
(options.recorder.streamFormat || model.recorder.streamFormat) ===
'fmp4'
"
>
<nz-form-label
class="setting-label"
nzNoColon
[nzTooltipTitle]="recordingModeTip"
>录制模式</nz-form-label
>
<ng-template #recordingModeTip>
<p>
标准模式: 对下载的流数据进行解析处理,支持自动分割文件等功能。
<br />
原始模式:
直接下载流数据,没有进行解析处理,不支持自动分割文件等功能。
<br />
</p>
</ng-template>
<nz-form-control class="setting-control select">
<nz-select
name="recordingMode"
[(ngModel)]="model.recorder.recordingMode"
[disabled]="options.recorder.recordingMode === null"
[nzOptions]="recordingModeOptions"
>
</nz-select>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.recorder.recordingMode !== null"
(nzCheckedChange)="
options.recorder.recordingMode = $event
? globalSettings.recorder.recordingMode
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
@@ -440,11 +378,7 @@
class="setting-item"
*ngIf="
(options.recorder.streamFormat || model.recorder.streamFormat) ===
'flv' ||
((options.recorder.streamFormat || model.recorder.streamFormat) ===
'fmp4' &&
(options.recorder.recordingMode ||
model.recorder.recordingMode) === 'standard')
'flv'
"
>
<nz-form-label
@@ -644,17 +578,7 @@
<div ngModelGroup="postprocessing" class="form-group postprocessing">
<h2>文件处理</h2>
<nz-form-item
class="setting-item"
*ngIf="
(options.recorder.streamFormat || model.recorder.streamFormat) ===
'flv' ||
((options.recorder.streamFormat || model.recorder.streamFormat) ===
'fmp4' &&
(options.recorder.recordingMode ||
model.recorder.recordingMode) === 'standard')
"
>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzNoColon