Compare commits

..

41 Commits

Author SHA1 Message Date
acgnhiki
47f16f2c4a chore: update workflows 2025-06-03 22:39:22 +08:00
acgnhiki
340972c878 release: 2.0.0-beta.5 2025-06-03 21:59:39 +08:00
acgnhiki
228e5ad46d fix: update web api 2025-06-03 21:50:14 +08:00
mosh
f75d91e7f5 fix: ensure room_id scope 2025-06-03 21:42:34 +08:00
acgnhiki
975fa2794a release: 2.0.0-beta.4 2024-06-21 20:10:28 +08:00
imkero
1206d6e80f fix: live check_connectivity behaviour 2024-06-21 20:02:05 +08:00
acgnhiki
772eb5e3e7 fix: avoid Found duplicated MOOV Atom. Skipped it 2024-06-21 19:56:45 +08:00
acgnhiki
fff5994f0b fix: fix remuxing progress
fix #254
2024-06-20 20:41:17 +08:00
acgnhiki
7fc31e9e11 feat: split file as long as the init section is changed
fix #214
2024-06-20 19:53:49 +08:00
acgnhiki
1d681868f5 perf: stop sync data when ui is not visible 2024-06-20 12:12:19 +08:00
acgnhiki
2cc69db88e fix: failed to add tasks due to long room id
fix #267
fix #271
2024-06-19 22:40:39 +08:00
acgnhiki
da2d4715d1 feat: use ipv4 only 2024-06-19 22:20:05 +08:00
acgnhik
8dc32e5e6e release: 2.0.0-beta.3 2023-12-24 22:58:37 +08:00
acgnhik
d00c504de7 feat: set danmaku protocol version via environment variable 2023-12-24 22:52:16 +08:00
acgnhik
71ca3f84f4 feat: do not use sequence number as record start time
resolve #202
2023-12-24 22:32:47 +08:00
acgnhik
6cd97ab3da fix: do not escape quotation mark characters
fix #203
2023-12-24 10:56:18 +08:00
acgnhik
293e7db2d0 fix: remux failed due to segment data incomplete
fix #217
2023-12-24 10:52:43 +08:00
acgnhik
5b49b1dd33 fix: failed to add task
fix #211
fix #215
fix #218
fix #221
fix #223
2023-12-24 10:45:44 +08:00
acgnhik
f6330aabf3 fix: TypeError: 'type' object is not subscriptable
fix #200
2023-12-23 13:35:01 +08:00
acgnhik
375817fb4b release: 2.0.0-beta.2 2023-11-05 14:41:01 +08:00
acgnhik
a4bbd81488 chore: update api url 2023-11-05 14:39:03 +08:00
acgnhiki
d919190ca1 suppress exceptions of calling malloc_trim 2023-11-05 14:28:50 +08:00
Frank Cui
b504dc04c2 call malloc_trim only on linux 2023-11-05 14:28:50 +08:00
acgnhik
78c561c12e refactor: use loguru 2023-11-05 14:21:15 +08:00
acgnhik
c03a122453 fix: UnicodeDecodeError
fix #190
2023-11-05 14:07:30 +08:00
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
138 changed files with 2331 additions and 2010 deletions

View File

@@ -18,7 +18,7 @@ jobs:
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
- name: Cache Docker layers - name: Cache Docker layers
uses: actions/cache@v2 uses: actions/cache@v4
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }} key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

@@ -17,7 +17,7 @@ jobs:
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
- name: Cache Docker layers - name: Cache Docker layers
uses: actions/cache@v2 uses: actions/cache@v4
with: with:
path: /tmp/.buildx-cache path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }} key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

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

View File

@@ -1,5 +1,54 @@
# 更新日志 # 更新日志
## 2.0.0-beta.5
- 兼容长 room id
- 更新 web api
## 2.0.0-beta.4
- 添加只使用 ipv4 的命令行选项
- 修复因直播间号较长而添加任务失败
- 检测到 init seciton 改变就分割文件
- 修复 remux 的进度条显示异常
- 修复 remux 出现 `Found duplicated MOOV Atom. Skipped it`
- 修复断网检测
## 2.0.0-beta.3
- 修复 Python 3.8 运行出错
- 修复添加任务出错
- 修复片段数据不全导致转封装失败
- 不转义弹幕的引号字符
- 改进弹幕时间同步
- 环境变量设置弹幕协议版本
## 2.0.0-beta.2
- 修复 bug
- 重构日志功能
## 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 ## 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` `ctrl + c`

View File

@@ -30,6 +30,6 @@ set api_key=bili2233
set BLREC_DEFAULT_LOG_DIR=日志文件 set BLREC_DEFAULT_LOG_DIR=日志文件
set BLREC_DEFAULT_OUT_DIR=录播文件 set BLREC_DEFAULT_OUT_DIR=录播文件
python -m blrec -c settings.toml --open --host %host% --port %port% --api-key %api_key% python -m blrec -c settings.toml --open --host %host% --port %port% --api-key %api_key% --ipv4
pause pause

View File

@@ -22,6 +22,6 @@ $env:api_key = "bili2233"
$env:BLREC_DEFAULT_LOG_DIR = "日志文件" $env:BLREC_DEFAULT_LOG_DIR = "日志文件"
$env:BLREC_DEFAULT_OUT_DIR = "录播文件" $env:BLREC_DEFAULT_OUT_DIR = "录播文件"
python -m blrec -c settings.toml --open --host $env:host --port $env:port --api-key $env:api_key python -m blrec -c settings.toml --open --host $env:host --port $env:port --api-key $env:api_key --ipv4
pause pause

View File

@@ -46,14 +46,13 @@ install_requires =
requests >= 2.24.0, < 3.0.0 requests >= 2.24.0, < 3.0.0
aiofiles >= 22.1.0, < 23.0.0 aiofiles >= 22.1.0, < 23.0.0
tenacity >= 8.0.1, < 9.0.0 tenacity >= 8.0.1, < 9.0.0
colorama >= 0.4.4, < 0.5.0 loguru >= 0.7.2, < 0.8.0
humanize >= 3.13.1, < 4.0.0 humanize >= 3.13.1, < 4.0.0
tqdm >= 4.62.3, < 5.0.0 tqdm >= 4.62.3, < 5.0.0
attrs >= 21.2.0, < 22.0.0 attrs >= 21.2.0, < 22.0.0
lxml >= 4.6.4, < 5.0.0 lxml >= 4.6.4, < 5.0.0
toml >= 0.10.2, < 0.11.0 toml >= 0.10.2, < 0.11.0
m3u8 >= 3.3.0, < 4.0.0 m3u8 >= 3.3.0, < 4.0.0
av >= 10.0.0, < 11.0.0
jsonpath == 0.82 jsonpath == 0.82
psutil >= 5.8.0, < 6.0.0 psutil >= 5.8.0, < 6.0.0
reactivex >= 4.0.0, < 5.0.0 reactivex >= 4.0.0, < 5.0.0

View File

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

View File

@@ -1,17 +1,19 @@
import asyncio import asyncio
import logging
import os import os
from contextlib import suppress
from typing import Iterator, List, Optional from typing import Iterator, List, Optional
import attr import attr
import psutil import psutil
from loguru import logger
from . import __prog__, __version__ from . import __prog__, __version__
from .bili.helpers import ensure_room_id from .bili.helpers import ensure_room_id
from .core.typing import MetaData
from .disk_space import SpaceMonitor, SpaceReclaimer from .disk_space import SpaceMonitor, SpaceReclaimer
from .event.event_submitters import SpaceEventSubmitter from .event.event_submitters import SpaceEventSubmitter
from .exception import ExceptionHandler, ExistsError, exception_callback from .exception import ExceptionHandler, ExistsError, exception_callback
from .flv.operators import MetaData, StreamProfile from .flv.operators import StreamProfile
from .notification import ( from .notification import (
BarkNotifier, BarkNotifier,
EmailNotifier, EmailNotifier,
@@ -31,8 +33,6 @@ from .task import (
) )
from .webhook import WebHookEmitter from .webhook import WebHookEmitter
logger = logging.getLogger(__name__)
@attr.s(auto_attribs=True, slots=True, frozen=True) @attr.s(auto_attribs=True, slots=True, frozen=True)
class AppInfo: class AppInfo:
@@ -98,12 +98,18 @@ class Application:
await self.exit() await self.exit()
async def launch(self) -> None: async def launch(self) -> None:
self._setup_logger()
logger.info('Launching Application...') logger.info('Launching Application...')
self._setup() self._setup()
logger.debug(f'Default umask {os.umask(0o000)}') logger.debug(f'Default umask {os.umask(0o000)}')
logger.info(f'Launched Application v{__version__}') logger.info(f'Launched Application v{__version__}')
task = asyncio.create_task(self._task_manager.load_all_tasks()) self._loading_task = asyncio.create_task(self._task_manager.load_all_tasks())
task.add_done_callback(exception_callback)
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: async def exit(self) -> None:
logger.info('Exiting Application...') logger.info('Exiting Application...')
@@ -116,6 +122,10 @@ class Application:
logger.info('Aborted Application') logger.info('Aborted Application')
async def _exit(self, force: bool = False) -> None: 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.stop_all_tasks(force=force)
await self._task_manager.destroy_all_tasks() await self._task_manager.destroy_all_tasks()
self._destroy() self._destroy()
@@ -285,7 +295,6 @@ class Application:
return await self._settings_manager.change_task_options(room_id, options) return await self._settings_manager.change_task_options(room_id, options)
def _setup(self) -> None: def _setup(self) -> None:
self._setup_logger()
self._setup_exception_handler() self._setup_exception_handler()
self._setup_space_monitor() self._setup_space_monitor()
self._setup_space_event_submitter() self._setup_space_event_submitter()

View File

@@ -1,30 +1,44 @@
import asyncio import asyncio
import hashlib import hashlib
import logging import time
import os
from abc import ABC from abc import ABC
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Mapping, Optional from typing import Any, Dict, Final, List, Mapping, Optional
from urllib.parse import urlencode from urllib.parse import urlencode
import aiohttp import aiohttp
from loguru import logger
from tenacity import retry, stop_after_delay, wait_exponential from tenacity import retry, stop_after_delay, wait_exponential
from .exceptions import ApiRequestError from .exceptions import ApiRequestError
from . import wbi
from .typing import JsonResponse, QualityNumber, ResponseData from .typing import JsonResponse, QualityNumber, ResponseData
__all__ = 'AppApi', 'WebApi' __all__ = 'AppApi', 'WebApi'
logger = logging.getLogger(__name__) BASE_HEADERS: Final = {
'Accept-Encoding': 'gzip, deflate, br',
TRACE_API_REQ = bool(os.environ.get('BLREC_TRACE_API_REQ')) '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/136.0.0.0 Safari/537.36', # noqa
}
class BaseApi(ABC): class BaseApi(ABC):
def __init__( def __init__(
self, session: aiohttp.ClientSession, headers: Optional[Dict[str, str]] = None self,
session: aiohttp.ClientSession,
headers: Optional[Dict[str, str]] = None,
*,
room_id: Optional[int] = None,
): ):
self._logger = logger.bind(room_id=room_id or '')
self.base_api_urls: List[str] = ['https://api.bilibili.com'] self.base_api_urls: List[str] = ['https://api.bilibili.com']
self.base_live_api_urls: List[str] = ['https://api.live.bilibili.com'] self.base_live_api_urls: List[str] = ['https://api.live.bilibili.com']
self.base_play_info_api_urls: List[str] = ['https://api.live.bilibili.com'] self.base_play_info_api_urls: List[str] = ['https://api.live.bilibili.com']
@@ -39,7 +53,7 @@ class BaseApi(ABC):
@headers.setter @headers.setter
def headers(self, value: Dict[str, str]) -> None: def headers(self, value: Dict[str, str]) -> None:
self._headers = {**value} self._headers = {**BASE_HEADERS, **value}
@staticmethod @staticmethod
def _check_response(json_res: JsonResponse) -> None: def _check_response(json_res: JsonResponse) -> None:
@@ -50,17 +64,19 @@ class BaseApi(ABC):
@retry(reraise=True, stop=stop_after_delay(5), wait=wait_exponential(0.1)) @retry(reraise=True, stop=stop_after_delay(5), wait=wait_exponential(0.1))
async def _get_json_res(self, *args: Any, **kwds: Any) -> JsonResponse: 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} kwds = {'timeout': self.timeout, 'headers': self.headers, **kwds}
async with self._session.get(*args, **kwds) as res: async with self._session.get(*args, **kwds) as res:
if TRACE_API_REQ: self._logger.trace('Request: {}', res.request_info)
logger.debug(f'Request info: {res.request_info}') self._logger.trace('Response: {}', await res.text())
try: try:
json_res = await res.json() json_res = await res.json()
except aiohttp.ContentTypeError: except aiohttp.ContentTypeError:
text_res = await res.text() text_res = await res.text()
logger.debug(f'Response text: {text_res[:200]}') self._logger.debug(f'Response text: {text_res[:200]}')
raise raise
self._check_response(json_res) if should_check_response:
self._check_response(json_res)
return json_res return json_res
async def _get_json( async def _get_json(
@@ -75,8 +91,7 @@ class BaseApi(ABC):
return await self._get_json_res(url, *args, **kwds) return await self._get_json_res(url, *args, **kwds)
except Exception as exc: except Exception as exc:
exception = exc exception = exc
if TRACE_API_REQ: self._logger.trace('Failed to get json from {}: {}', url, repr(exc))
logger.debug(f'Failed to get json from {url}', exc_info=exc)
else: else:
assert exception is not None assert exception is not None
raise exception raise exception
@@ -93,14 +108,14 @@ class BaseApi(ABC):
json_responses = [] json_responses = []
for idx, item in enumerate(results): for idx, item in enumerate(results):
if isinstance(item, Exception): if isinstance(item, Exception):
if TRACE_API_REQ: self._logger.trace(
logger.debug(f'Failed to get json from {urls[idx]}', exc_info=item) 'Failed to get json from {}: {}', urls[idx], repr(item)
)
exceptions.append(item) exceptions.append(item)
elif isinstance(item, dict): elif isinstance(item, dict):
json_responses.append(item) json_responses.append(item)
else: else:
if TRACE_API_REQ: self._logger.trace('{}', repr(item))
logger.debug(repr(item))
if not json_responses: if not json_responses:
raise exceptions[0] raise exceptions[0]
return json_responses return json_responses
@@ -229,6 +244,30 @@ class AppApi(BaseApi):
class WebApi(BaseApi): class WebApi(BaseApi):
_wbi_key = wbi.make_key(
img_key="7cd084941338484aae1ad9425b84077c",
sub_key="4932caff0ff746eab6f01bf08b70ac45",
)
_wbi_key_mtime = 0.0
@retry(reraise=True, stop=stop_after_delay(20), wait=wait_exponential(0.1))
async def _get_json_res(
self, url: str, with_wbi: bool = False, *args: Any, **kwds: Any
) -> JsonResponse:
if with_wbi:
key = self.__class__._wbi_key
ts = int(datetime.now().timestamp())
params = list(kwds.pop("params").items())
query = wbi.build_query(key, ts, params)
url = f'{url}?{query}'
try:
return await super()._get_json_res(url, *args, **kwds)
except ApiRequestError as e:
if e.code == -352 and time.monotonic() - self.__class__._wbi_key_mtime > 60:
await self._update_wbi_key()
raise
async def room_init(self, room_id: int) -> ResponseData: async def room_init(self, room_id: int) -> ResponseData:
path = '/room/v1/Room/room_init' path = '/room/v1/Room/room_init'
params = {'id': room_id} params = {'id': room_id}
@@ -249,14 +288,16 @@ class WebApi(BaseApi):
'ptype': 8, 'ptype': 8,
} }
json_responses = await self._get_jsons_concurrently( json_responses = await self._get_jsons_concurrently(
self.base_play_info_api_urls, path, params=params self.base_play_info_api_urls, path, with_wbi=True, params=params
) )
return [r['data'] for r in json_responses] return [r['data'] for r in json_responses]
async def get_info_by_room(self, room_id: int) -> ResponseData: async def get_info_by_room(self, room_id: int) -> ResponseData:
path = '/xlive/web-room/v1/index/getInfoByRoom' path = '/xlive/web-room/v1/index/getInfoByRoom'
params = {'room_id': room_id} params = {'room_id': room_id}
json_res = await self._get_json(self.base_live_api_urls, path, params=params) json_res = await self._get_json(
self.base_live_api_urls, path, with_wbi=True, params=params
)
return json_res['data'] return json_res['data']
async def get_info(self, room_id: int) -> ResponseData: async def get_info(self, room_id: int) -> ResponseData:
@@ -272,13 +313,29 @@ class WebApi(BaseApi):
return json_res['data']['timestamp'] return json_res['data']['timestamp']
async def get_user_info(self, uid: int) -> ResponseData: async def get_user_info(self, uid: int) -> ResponseData:
path = '/x/space/acc/info' path = '/x/space/wbi/acc/info'
params = {'mid': uid} params = {'mid': uid}
json_res = await self._get_json(self.base_api_urls, path, params=params) json_res = await self._get_json(
self.base_api_urls, path, with_wbi=True, params=params
)
return json_res['data'] return json_res['data']
async def get_danmu_info(self, room_id: int) -> ResponseData: async def get_danmu_info(self, room_id: int) -> ResponseData:
path = '/xlive/web-room/v1/index/getDanmuInfo' path = '/xlive/web-room/v1/index/getDanmuInfo'
params = {'id': room_id} params = {'id': room_id}
json_res = await self._get_json(self.base_live_api_urls, path, params=params) json_res = await self._get_json(
self.base_live_api_urls, path, with_wbi=True, params=params
)
return json_res['data'] 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
async def _update_wbi_key(self) -> None:
nav = await self.get_nav()
img_key = wbi.extract_key(nav['data']['wbi_img']['img_url'])
sub_key = wbi.extract_key(nav['data']['wbi_img']['sub_url'])
self.__class__._wbi_key = wbi.make_key(img_key, sub_key)
self.__class__._wbi_key_mtime = time.monotonic()

View File

@@ -1,7 +1,8 @@
import asyncio import asyncio
import json import json
import logging import os
import struct import struct
import zlib
from contextlib import suppress from contextlib import suppress
from enum import Enum, IntEnum from enum import Enum, IntEnum
from typing import Any, Dict, Final, List, Optional, Tuple, Union, cast from typing import Any, Dict, Final, List, Optional, Tuple, Union, cast
@@ -9,13 +10,15 @@ from typing import Any, Dict, Final, List, Optional, Tuple, Union, cast
import aiohttp import aiohttp
import brotli import brotli
from aiohttp import ClientSession from aiohttp import ClientSession
from loguru import logger
from tenacity import retry, retry_if_exception_type, wait_exponential from tenacity import retry, retry_if_exception_type, wait_exponential
from blrec.logging.context import async_task_with_logger_context
from ..event.event_emitter import EventEmitter, EventListener from ..event.event_emitter import EventEmitter, EventListener
from ..exception import exception_callback from ..exception import exception_callback
from ..logging.room_id import aio_task_with_room_id
from ..utils.mixins import AsyncStoppableMixin from ..utils.mixins import AsyncStoppableMixin
from ..utils.string import extract_uid_from_cookie, extract_buvid_from_cookie from ..utils.string import extract_buvid_from_cookie, extract_uid_from_cookie
from .api import AppApi, WebApi from .api import AppApi, WebApi
from .exceptions import DanmakuClientAuthError from .exceptions import DanmakuClientAuthError
from .typing import ApiPlatform, Danmaku from .typing import ApiPlatform, Danmaku
@@ -23,9 +26,6 @@ from .typing import ApiPlatform, Danmaku
__all__ = 'DanmakuClient', 'DanmakuListener', 'Danmaku', 'DanmakuCommand' __all__ = 'DanmakuClient', 'DanmakuListener', 'Danmaku', 'DanmakuCommand'
logger = logging.getLogger(__name__)
class DanmakuListener(EventListener): class DanmakuListener(EventListener):
async def on_client_connected(self) -> None: async def on_client_connected(self) -> None:
... ...
@@ -57,6 +57,9 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
headers: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self._logger_context = {'room_id': room_id}
self._logger = logger.bind(**self._logger_context)
self.session = session self.session = session
self.appapi = appapi self.appapi = appapi
self.webapi = webapi self.webapi = webapi
@@ -71,6 +74,20 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
self._retry_delay: int = 0 self._retry_delay: int = 0
self._MAX_RETRIES: Final[int] = max_retries self._MAX_RETRIES: Final[int] = max_retries
self._protover: int = WS.BODY_PROTOCOL_VERSION_BROTLI
if ver := os.environ.get('BLREC_DANMAKU_PROTOCOL_VERSION'):
if ver in (
str(WS.BODY_PROTOCOL_VERSION_NORMAL),
str(WS.BODY_PROTOCOL_VERSION_DEFLATE),
str(WS.BODY_PROTOCOL_VERSION_BROTLI),
):
self._protover = int(ver)
else:
self._logger.warning(
f'Invalid value of BLREC_DANMAKU_PROTOCOL_VERSION: {ver}'
)
self._logger.debug(f'protover: {self._protover}')
@property @property
def headers(self) -> Dict[str, str]: def headers(self) -> Dict[str, str]:
return self._headers return self._headers
@@ -86,24 +103,25 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
await self._update_danmu_info() await self._update_danmu_info()
await self._connect() await self._connect()
await self._create_message_loop() await self._create_message_loop()
logger.debug('Started danmaku client') self._logger.debug('Started danmaku client')
async def _do_stop(self) -> None: async def _do_stop(self) -> None:
await self._terminate_message_loop() await self._terminate_message_loop()
await self._disconnect() await self._disconnect()
logger.debug('Stopped danmaku client') self._logger.debug('Stopped danmaku client')
@async_task_with_logger_context
async def restart(self) -> None: async def restart(self) -> None:
logger.debug('Restarting danmaku client...') self._logger.debug('Restarting danmaku client...')
await self.stop() await self.stop()
await self.start() await self.start()
logger.debug('Restarted danmaku client') self._logger.debug('Restarted danmaku client')
async def reconnect(self) -> None: async def reconnect(self) -> None:
if self.stopped: if self.stopped:
return return
logger.debug('Reconnecting...') self._logger.debug('Reconnecting...')
await self._disconnect() await self._disconnect()
await self._connect() await self._connect()
await self._emit('client_reconnected') await self._emit('client_reconnected')
@@ -115,7 +133,7 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
), ),
) )
async def _connect(self) -> None: async def _connect(self) -> None:
logger.debug('Connecting to server...') self._logger.debug('Connecting to server...')
try: try:
await self._connect_websocket() await self._connect_websocket()
await self._send_auth() await self._send_auth()
@@ -129,7 +147,7 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
await self._update_danmu_info() await self._update_danmu_info()
raise raise
else: else:
logger.debug('Connected to server') self._logger.debug('Connected to server')
await self._emit('client_connected') await self._emit('client_connected')
async def _connect_websocket(self) -> None: async def _connect_websocket(self) -> None:
@@ -137,23 +155,23 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
self._danmu_info['host_list'][self._host_index]['host'], self._danmu_info['host_list'][self._host_index]['host'],
self._danmu_info['host_list'][self._host_index]['wss_port'], self._danmu_info['host_list'][self._host_index]['wss_port'],
) )
logger.debug(f'Connecting WebSocket... {url}') self._logger.debug(f'Connecting WebSocket... {url}')
try: try:
self._ws = await self.session.ws_connect( self._ws = await self.session.ws_connect(
url, timeout=5, headers=self.headers url, timeout=5, headers=self.headers
) )
except Exception as exc: except Exception as exc:
logger.debug(f'Failed to connect WebSocket: {repr(exc)}') self._logger.debug(f'Failed to connect WebSocket: {repr(exc)}')
raise raise
else: else:
logger.debug('Connected WebSocket') self._logger.debug('Connected WebSocket')
async def _send_auth(self) -> None: async def _send_auth(self) -> None:
auth_msg = json.dumps( auth_msg = json.dumps(
{ {
"uid": self._uid, "uid": self._uid,
'roomid': self._room_id, # must not be the short id! 'roomid': self._room_id, # must not be the short id!
'protover': WS.BODY_PROTOCOL_VERSION_BROTLI, 'protover': self._protover,
"buvid": self._buvid, "buvid": self._buvid,
'platform': 'web', 'platform': 'web',
'type': 2, 'type': 2,
@@ -161,26 +179,28 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
} }
) )
data = Frame.encode(WS.OP_USER_AUTHENTICATION, auth_msg) data = Frame.encode(WS.OP_USER_AUTHENTICATION, auth_msg)
logger.debug('Sending user authentication...') self._logger.debug('Sending user authentication...')
try: try:
await self._ws.send_bytes(data) await self._ws.send_bytes(data)
except Exception as exc: except Exception as exc:
logger.debug(f'Failed to sent user authentication: {repr(exc)}') self._logger.debug(f'Failed to sent user authentication: {repr(exc)}')
raise raise
else: else:
logger.debug('Sent user authentication') self._logger.debug('Sent user authentication')
async def _recieve_auth_reply(self) -> aiohttp.WSMessage: async def _recieve_auth_reply(self) -> aiohttp.WSMessage:
logger.debug('Receiving user authentication reply...') self._logger.debug('Receiving user authentication reply...')
try: try:
msg = await self._ws.receive(timeout=5) msg = await self._ws.receive(timeout=5)
if msg.type != aiohttp.WSMsgType.BINARY: if msg.type != aiohttp.WSMsgType.BINARY:
raise aiohttp.ClientError(msg) raise aiohttp.ClientError(msg)
except Exception as exc: except Exception as exc:
logger.debug(f'Failed to receive user authentication reply: {repr(exc)}') self._logger.debug(
f'Failed to receive user authentication reply: {repr(exc)}'
)
raise raise
else: else:
logger.debug('Recieved user authentication reply') self._logger.debug('Recieved user authentication reply')
return msg return msg
async def _handle_auth_reply(self, reply: aiohttp.WSMessage) -> None: async def _handle_auth_reply(self, reply: aiohttp.WSMessage) -> None:
@@ -190,7 +210,7 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
code = cast(int, json.loads(msg)['code']) code = cast(int, json.loads(msg)['code'])
if code == WS.AUTH_OK: if code == WS.AUTH_OK:
logger.debug('Auth OK') self._logger.debug('Auth OK')
self._create_heartbeat_task() self._create_heartbeat_task()
elif code == WS.AUTH_TOKEN_ERROR: elif code == WS.AUTH_TOKEN_ERROR:
raise DanmakuClientAuthError(f'Token expired: {code}') raise DanmakuClientAuthError(f'Token expired: {code}')
@@ -204,7 +224,7 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
self._api_platform = 'android' self._api_platform = 'android'
async def _update_danmu_info(self) -> None: async def _update_danmu_info(self) -> None:
logger.debug(f'Updating danmu info via {self._api_platform} api...') self._logger.debug(f'Updating danmu info via {self._api_platform} api...')
api: Union[WebApi, AppApi] api: Union[WebApi, AppApi]
if self._api_platform == 'web': if self._api_platform == 'web':
api = self.webapi api = self.webapi
@@ -213,15 +233,15 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
try: try:
self._danmu_info = await api.get_danmu_info(self._room_id) self._danmu_info = await api.get_danmu_info(self._room_id)
except Exception as exc: except Exception as exc:
logger.warning(f'Failed to update danmu info: {repr(exc)}') self._logger.warning(f'Failed to update danmu info: {repr(exc)}')
self._danmu_info = COMMON_DANMU_INFO self._danmu_info = COMMON_DANMU_INFO
else: else:
logger.debug('Danmu info updated') self._logger.debug('Danmu info updated')
async def _disconnect(self) -> None: async def _disconnect(self) -> None:
await self._cancel_heartbeat_task() await self._cancel_heartbeat_task()
await self._close_websocket() await self._close_websocket()
logger.debug('Disconnected from server') self._logger.debug('Disconnected from server')
await self._emit('client_disconnected') await self._emit('client_disconnected')
async def _close_websocket(self) -> None: async def _close_websocket(self) -> None:
@@ -237,14 +257,14 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await self._heartbeat_task await self._heartbeat_task
@aio_task_with_room_id @async_task_with_logger_context
async def _send_heartbeat(self) -> None: async def _send_heartbeat(self) -> None:
data = Frame.encode(WS.OP_HEARTBEAT, '') data = Frame.encode(WS.OP_HEARTBEAT, '')
while True: while True:
try: try:
await self._ws.send_bytes(data) await self._ws.send_bytes(data)
except Exception as exc: except Exception as exc:
logger.warning(f'Failed to send heartbeat: {repr(exc)}') self._logger.warning(f'Failed to send heartbeat: {repr(exc)}')
await self._emit('error_occurred', exc) await self._emit('error_occurred', exc)
task = asyncio.create_task(self.restart()) task = asyncio.create_task(self.restart())
task.add_done_callback(exception_callback) task.add_done_callback(exception_callback)
@@ -254,15 +274,15 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
async def _create_message_loop(self) -> None: async def _create_message_loop(self) -> None:
self._message_loop_task = asyncio.create_task(self._message_loop()) self._message_loop_task = asyncio.create_task(self._message_loop())
self._message_loop_task.add_done_callback(exception_callback) self._message_loop_task.add_done_callback(exception_callback)
logger.debug('Created message loop') self._logger.debug('Created message loop')
async def _terminate_message_loop(self) -> None: async def _terminate_message_loop(self) -> None:
self._message_loop_task.cancel() self._message_loop_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await self._message_loop_task await self._message_loop_task
logger.debug('Terminated message loop') self._logger.debug('Terminated message loop')
@aio_task_with_room_id @async_task_with_logger_context
async def _message_loop(self) -> None: async def _message_loop(self) -> None:
while True: while True:
for msg in await self._receive(): for msg in await self._receive():
@@ -292,8 +312,7 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
else: else:
await self._handle_receive_error(ValueError(wsmsg)) await self._handle_receive_error(ValueError(wsmsg))
@staticmethod async def _handle_data(self, data: bytes) -> Optional[List[Dict[str, Any]]]:
async def _handle_data(data: bytes) -> Optional[List[Dict[str, Any]]]:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
try: try:
@@ -304,12 +323,14 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
elif op == WS.OP_HEARTBEAT_REPLY: elif op == WS.OP_HEARTBEAT_REPLY:
pass pass
except Exception as e: except Exception as e:
logger.warning(f'Failed to handle data: {repr(e)}, data: {repr(data)}') self._logger.warning(
f'Failed to handle data: {repr(e)}, data: {repr(data)}'
)
return None return None
async def _handle_receive_error(self, exc: Exception) -> None: async def _handle_receive_error(self, exc: Exception) -> None:
logger.warning(f'Failed to receive message: {repr(exc)}') self._logger.warning(f'Failed to receive message: {repr(exc)}')
await self._emit('error_occurred', exc) await self._emit('error_occurred', exc)
if isinstance(exc, asyncio.TimeoutError): if isinstance(exc, asyncio.TimeoutError):
return return
@@ -322,7 +343,7 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
async def _retry(self) -> None: async def _retry(self) -> None:
if self._retry_count < self._MAX_RETRIES: if self._retry_count < self._MAX_RETRIES:
if self._retry_delay > 0: if self._retry_delay > 0:
logger.debug( self._logger.debug(
'Retry after {} second{}'.format( 'Retry after {} second{}'.format(
self._retry_delay, 's' if self._retry_delay > 1 else '' self._retry_delay, 's' if self._retry_delay > 1 else ''
) )
@@ -365,6 +386,12 @@ class Frame:
if op == WS.OP_MESSAGE: if op == WS.OP_MESSAGE:
if ver == WS.BODY_PROTOCOL_VERSION_BROTLI: if ver == WS.BODY_PROTOCOL_VERSION_BROTLI:
data = brotli.decompress(body) data = brotli.decompress(body)
elif ver == WS.BODY_PROTOCOL_VERSION_DEFLATE:
data = zlib.decompress(body)
elif ver == WS.BODY_PROTOCOL_VERSION_NORMAL:
pass
else:
raise NotImplementedError(f'Unsupported protocol version: {ver}')
msg_list = [] msg_list = []
offset = 0 offset = 0
@@ -401,6 +428,7 @@ class WS(IntEnum):
OPERATION_OFFSET = 8 OPERATION_OFFSET = 8
SEQUENCE_OFFSET = 12 SEQUENCE_OFFSET = 12
BODY_PROTOCOL_VERSION_NORMAL = 0 BODY_PROTOCOL_VERSION_NORMAL = 0
BODY_PROTOCOL_VERSION_DEFLATE = 2
BODY_PROTOCOL_VERSION_BROTLI = 3 BODY_PROTOCOL_VERSION_BROTLI = 3
HEADER_DEFAULT_VERSION = 1 HEADER_DEFAULT_VERSION = 1
HEADER_DEFAULT_OPERATION = 1 HEADER_DEFAULT_OPERATION = 1

View File

@@ -1,18 +1,26 @@
from typing import Any, Dict, List
import aiohttp import aiohttp
from jsonpath import jsonpath
from .api import WebApi
from .typing import ResponseData, QualityNumber
from .exceptions import ApiRequestError
from ..exception import NotFoundError from ..exception import NotFoundError
from .api import WebApi
from .exceptions import ApiRequestError
from .net import connector, timeout
from .typing import QualityNumber, ResponseData, StreamCodec, StreamFormat
__all__ = 'room_init', 'ensure_room_id' __all__ = 'room_init', 'ensure_room_id'
async def room_init(room_id: int) -> ResponseData: async def room_init(room_id: int) -> ResponseData:
async with aiohttp.ClientSession(raise_for_status=True) as session: async with aiohttp.ClientSession(
api = WebApi(session) connector=connector,
connector_owner=False,
raise_for_status=True,
trust_env=True,
timeout=timeout,
) as session:
api = WebApi(session, room_id=room_id)
return await api.room_init(room_id) return await api.room_init(room_id)
@@ -29,6 +37,23 @@ async def ensure_room_id(room_id: int) -> int:
return result['room_id'] return result['room_id']
async def get_nav(cookie: str) -> ResponseData:
async with aiohttp.ClientSession(
connector=connector,
connector_owner=False,
raise_for_status=True,
trust_env=True,
timeout=timeout,
) 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: def get_quality_name(qn: QualityNumber) -> str:
QUALITY_MAPPING = { QUALITY_MAPPING = {
20000: '4K', 20000: '4K',
@@ -40,3 +65,18 @@ def get_quality_name(qn: QualityNumber) -> str:
80: '流畅', 80: '流畅',
} }
return QUALITY_MAPPING.get(qn, '') 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

@@ -1,6 +1,5 @@
import asyncio import asyncio
import json import json
import logging
import re import re
import time import time
from typing import Any, Dict, List from typing import Any, Dict, List
@@ -9,7 +8,7 @@ import aiohttp
from jsonpath import jsonpath from jsonpath import jsonpath
from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential 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 ( from .exceptions import (
LiveRoomEncrypted, LiveRoomEncrypted,
LiveRoomHidden, LiveRoomHidden,
@@ -20,12 +19,14 @@ from .exceptions import (
NoStreamFormatAvailable, NoStreamFormatAvailable,
NoStreamQualityAvailable, NoStreamQualityAvailable,
) )
from .helpers import extract_codecs, extract_formats, extract_streams
from .models import LiveStatus, RoomInfo, UserInfo from .models import LiveStatus, RoomInfo, UserInfo
from .net import connector, timeout
from .typing import ApiPlatform, QualityNumber, ResponseData, StreamCodec, StreamFormat from .typing import ApiPlatform, QualityNumber, ResponseData, StreamCodec, StreamFormat
__all__ = ('Live',) __all__ = ('Live',)
logger = logging.getLogger(__name__) from loguru import logger
_INFO_PATTERN = re.compile( _INFO_PATTERN = re.compile(
rb'<script>\s*window\.__NEPTUNE_IS_MY_WAIFU__\s*=\s*(\{.*?\})\s*</script>' rb'<script>\s*window\.__NEPTUNE_IS_MY_WAIFU__\s*=\s*(\{.*?\})\s*</script>'
@@ -35,21 +36,27 @@ _LIVE_STATUS_PATTERN = re.compile(rb'"live_status"\s*:\s*(\d)')
class Live: class Live:
def __init__(self, room_id: int, user_agent: str = '', cookie: str = '') -> None: def __init__(self, room_id: int, user_agent: str = '', cookie: str = '') -> None:
self._logger = logger.bind(room_id=room_id)
self._room_id = room_id self._room_id = room_id
self._user_agent = user_agent self._user_agent = user_agent
self._cookie = cookie self._cookie = cookie
self._update_headers()
self._html_page_url = f'https://live.bilibili.com/{room_id}' self._html_page_url = f'https://live.bilibili.com/{room_id}'
self._session = aiohttp.ClientSession( self._session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit=200), connector=connector,
connector_owner=False,
raise_for_status=True, raise_for_status=True,
trust_env=True, trust_env=True,
timeout=timeout,
) )
self._appapi = AppApi(self._session, self.headers) self._appapi = AppApi(self._session, self.headers, room_id=room_id)
self._webapi = WebApi(self._session, self.headers) self._webapi = WebApi(self._session, self.headers, room_id=room_id)
self._room_info: RoomInfo self._room_info: RoomInfo
self._user_info: UserInfo self._user_info: UserInfo
self._no_flv_stream: bool
@property @property
def base_api_urls(self) -> List[str]: def base_api_urls(self) -> List[str]:
@@ -85,6 +92,7 @@ class Live:
@user_agent.setter @user_agent.setter
def user_agent(self, value: str) -> None: def user_agent(self, value: str) -> None:
self._user_agent = value self._user_agent = value
self._update_headers()
self._webapi.headers = self.headers self._webapi.headers = self.headers
self._appapi.headers = self.headers self._appapi.headers = self.headers
@@ -95,25 +103,20 @@ class Live:
@cookie.setter @cookie.setter
def cookie(self, value: str) -> None: def cookie(self, value: str) -> None:
self._cookie = value self._cookie = value
self._update_headers()
self._webapi.headers = self.headers self._webapi.headers = self.headers
self._appapi.headers = self.headers self._appapi.headers = self.headers
@property @property
def headers(self) -> Dict[str, str]: def headers(self) -> Dict[str, str]:
return { return self._headers
'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 def _update_headers(self) -> None:
self._headers = {
**BASE_HEADERS,
'Referer': f'https://live.bilibili.com/{self._room_id}', '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, 'User-Agent': self._user_agent,
'Cookie': self._cookie, 'Cookie': self._cookie,
'Accept-Encoding': 'gzip',
} }
@property @property
@@ -144,9 +147,19 @@ class Live:
self._room_info = await self.get_room_info() self._room_info = await self.get_room_info()
self._user_info = await self.get_user_info(self._room_info.uid) 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: async def deinit(self) -> None:
await self._session.close() await self._session.close()
def has_no_flv_streams(self) -> bool:
return self._no_flv_stream
async def get_live_status(self) -> LiveStatus: async def get_live_status(self) -> LiveStatus:
try: try:
# frequent requests will be intercepted by the server's firewall! # frequent requests will be intercepted by the server's firewall!
@@ -162,11 +175,13 @@ class Live:
async def check_connectivity(self) -> bool: async def check_connectivity(self) -> bool:
try: try:
await self._session.head('https://live.bilibili.com/', timeout=3) await self._session.head('https://live.bilibili.com/', timeout=3, headers={
except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 'User-Agent': self._user_agent,
return False })
else:
return True return True
except Exception as e:
self._logger.warning(f'Check connectivity failed: {repr(e)}')
return False
async def update_info(self, raise_exception: bool = False) -> bool: async def update_info(self, raise_exception: bool = False) -> bool:
return all( return all(
@@ -180,7 +195,7 @@ class Live:
try: try:
self._user_info = await self.get_user_info(self._room_info.uid) self._user_info = await self.get_user_info(self._room_info.uid)
except Exception as e: except Exception as e:
logger.error(f'Failed to update user info: {repr(e)}') self._logger.error(f'Failed to update user info: {repr(e)}')
if raise_exception: if raise_exception:
raise raise
return False return False
@@ -191,7 +206,7 @@ class Live:
try: try:
self._room_info = await self.get_room_info() self._room_info = await self.get_room_info()
except Exception as e: except Exception as e:
logger.error(f'Failed to update room info: {repr(e)}') self._logger.error(f'Failed to update room info: {repr(e)}')
if raise_exception: if raise_exception:
raise raise
return False return False
@@ -221,17 +236,15 @@ class Live:
) )
async def get_user_info(self, uid: int) -> UserInfo: async def get_user_info(self, uid: int) -> UserInfo:
try: try:
user_info_data = await self._webapi.get_user_info(uid) return await self._get_user_info_via_api(uid)
return UserInfo.from_web_api_data(user_info_data)
except Exception: except Exception:
user_info_data = await self._appapi.get_user_info(uid) return await self._get_user_info_via_html_page()
return UserInfo.from_app_api_data(user_info_data)
async def get_timestamp(self) -> int: async def get_timestamp(self) -> int:
try: try:
ts = await self.get_server_timestamp() ts = await self.get_server_timestamp()
except Exception as e: except Exception as e:
logger.warning(f'Failed to get timestamp from server: {repr(e)}') self._logger.warning(f'Failed to get timestamp from server: {repr(e)}')
ts = int(time.time()) ts = int(time.time())
return ts return ts
@@ -239,6 +252,26 @@ class Live:
# the timestamp on the server at the moment in seconds # the timestamp on the server at the moment in seconds
return await self._webapi.get_timestamp() 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( async def get_live_stream_url(
self, self,
qn: QualityNumber = 10000, qn: QualityNumber = 10000,
@@ -248,23 +281,15 @@ class Live:
stream_codec: StreamCodec = 'avc', stream_codec: StreamCodec = 'avc',
select_alternative: bool = False, select_alternative: bool = False,
) -> str: ) -> str:
if api_platform == 'web': streams = await self.get_live_streams(qn, api_platform=api_platform)
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[*]')
if not streams: if not streams:
raise NoStreamAvailable(stream_format, stream_codec, qn) 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: if not formats:
raise NoStreamFormatAvailable(stream_format, stream_codec, qn) 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: if not codecs:
raise NoStreamCodecAvailable(stream_format, stream_codec, qn) raise NoStreamCodecAvailable(stream_format, stream_codec, qn)
@@ -321,6 +346,18 @@ class Live:
room_info_data = await self._get_room_info_via_api() room_info_data = await self._get_room_info_via_api()
return int(room_info_data['live_status']) return int(room_info_data['live_status'])
async def _get_user_info_via_api(self, uid: int) -> UserInfo:
try:
data = await self._webapi.get_info_by_room(self._room_id)
return UserInfo.from_info_by_room(data)
except Exception:
try:
data = await self._appapi.get_info_by_room(self._room_id)
return UserInfo.from_info_by_room(data)
except Exception:
data = await self._appapi.get_user_info(uid)
return UserInfo.from_app_api_data(data)
async def _get_room_info_via_api(self) -> ResponseData: async def _get_room_info_via_api(self) -> ResponseData:
try: try:
info_data = await self._webapi.get_info_by_room(self._room_id) info_data = await self._webapi.get_info_by_room(self._room_id)
@@ -343,6 +380,10 @@ class Live:
return int(m.group(1)) return int(m.group(1))
async def _get_user_info_via_html_page(self) -> UserInfo:
info_res = await self._get_room_info_res_via_html_page()
return UserInfo.from_info_by_room(info_res)
async def _get_room_info_via_html_page(self) -> ResponseData: async def _get_room_info_via_html_page(self) -> ResponseData:
info_res = await self._get_room_info_res_via_html_page() info_res = await self._get_room_info_res_via_html_page()
return info_res['room_info'] return info_res['room_info']

View File

@@ -1,14 +1,16 @@
import asyncio import asyncio
import logging
import random import random
from contextlib import suppress from contextlib import suppress
from loguru import logger
from blrec.exception import exception_callback from blrec.exception import exception_callback
from blrec.logging.room_id import aio_task_with_room_id from blrec.logging.context import async_task_with_logger_context
from ..event.event_emitter import EventEmitter, EventListener from ..event.event_emitter import EventEmitter, EventListener
from ..utils.mixins import SwitchableMixin from ..utils.mixins import SwitchableMixin
from .danmaku_client import DanmakuClient, DanmakuCommand, DanmakuListener from .danmaku_client import DanmakuClient, DanmakuCommand, DanmakuListener
from .helpers import extract_formats
from .live import Live from .live import Live
from .models import LiveStatus, RoomInfo from .models import LiveStatus, RoomInfo
from .typing import Danmaku from .typing import Danmaku
@@ -16,9 +18,6 @@ from .typing import Danmaku
__all__ = 'LiveMonitor', 'LiveEventListener' __all__ = 'LiveMonitor', 'LiveEventListener'
logger = logging.getLogger(__name__)
class LiveEventListener(EventListener): class LiveEventListener(EventListener):
async def on_live_status_changed( async def on_live_status_changed(
self, current_status: LiveStatus, previous_status: LiveStatus self, current_status: LiveStatus, previous_status: LiveStatus
@@ -44,6 +43,8 @@ class LiveEventListener(EventListener):
class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMixin): class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMixin):
def __init__(self, danmaku_client: DanmakuClient, live: Live) -> None: def __init__(self, danmaku_client: DanmakuClient, live: Live) -> None:
super().__init__() super().__init__()
self._logger_context = {'room_id': live.room_id}
self._logger = logger.bind(**self._logger_context)
self._danmaku_client = danmaku_client self._danmaku_client = danmaku_client
self._live = live self._live = live
@@ -60,30 +61,30 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
self._init_status() self._init_status()
self._danmaku_client.add_listener(self) self._danmaku_client.add_listener(self)
self._start_polling() self._start_polling()
logger.debug('Enabled live monitor') self._logger.debug('Enabled live monitor')
def _do_disable(self) -> None: def _do_disable(self) -> None:
self._danmaku_client.remove_listener(self) self._danmaku_client.remove_listener(self)
asyncio.create_task(self._stop_polling()) asyncio.create_task(self._stop_polling())
asyncio.create_task(self._stop_checking()) asyncio.create_task(self._stop_checking())
logger.debug('Disabled live monitor') self._logger.debug('Disabled live monitor')
def _start_polling(self) -> None: def _start_polling(self) -> None:
self._polling_task = asyncio.create_task(self._poll_live_status()) self._polling_task = asyncio.create_task(self._poll_live_status())
self._polling_task.add_done_callback(exception_callback) self._polling_task.add_done_callback(exception_callback)
logger.debug('Started polling live status')
async def _stop_polling(self) -> None: async def _stop_polling(self) -> None:
self._polling_task.cancel() self._polling_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await self._polling_task await self._polling_task
del self._polling_task del self._polling_task
logger.debug('Stopped polling live status')
def _start_checking(self) -> None: def _start_checking(self) -> None:
self._checking_task = asyncio.create_task(self._check_if_stream_available()) self._checking_task = asyncio.create_task(self._check_if_stream_available())
self._checking_task.add_done_callback(exception_callback) 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: async def _stop_checking(self) -> None:
if not hasattr(self, '_checking_task'): if not hasattr(self, '_checking_task'):
@@ -92,30 +93,29 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await self._checking_task await self._checking_task
del self._checking_task del self._checking_task
logger.debug('Stopped checking if stream available')
async def on_client_reconnected(self) -> None: async def on_client_reconnected(self) -> None:
# check the live status after the client reconnected and simulate # check the live status after the client reconnected and simulate
# events if necessary. # events if necessary.
# make sure the recorder works well continuously after interruptions # make sure the recorder works well continuously after interruptions
# such as an operating system hibernation. # such as an operating system hibernation.
logger.warning('The Danmaku Client Reconnected') self._logger.warning('The Danmaku Client Reconnected')
await self._live.update_room_info() await self._live.update_room_info()
current_status = self._live.room_info.live_status current_status = self._live.room_info.live_status
if current_status == self._previous_status: if current_status == self._previous_status:
if current_status == LiveStatus.LIVE: if current_status == LiveStatus.LIVE:
logger.debug('Simulating stream reset event') self._logger.debug('Simulating stream reset event')
await self._handle_status_change(current_status) await self._handle_status_change(current_status)
else: else:
if current_status == LiveStatus.LIVE: if current_status == LiveStatus.LIVE:
logger.debug('Simulating live began event') self._logger.debug('Simulating live began event')
await self._handle_status_change(current_status) await self._handle_status_change(current_status)
logger.debug('Simulating live stream available event') self._logger.debug('Simulating live stream available event')
await self._handle_status_change(current_status) await self._handle_status_change(current_status)
else: else:
logger.debug('Simulating live ended event') self._logger.debug('Simulating live ended event')
await self._handle_status_change(current_status) await self._handle_status_change(current_status)
async def on_danmaku_received(self, danmu: Danmaku) -> None: async def on_danmaku_received(self, danmu: Danmaku) -> None:
@@ -135,7 +135,7 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
await self._emit('room_changed', self._live.room_info) await self._emit('room_changed', self._live.room_info)
async def _handle_status_change(self, current_status: LiveStatus) -> None: async def _handle_status_change(self, current_status: LiveStatus) -> None:
logger.debug( self._logger.debug(
'Live status changed from {} to {}'.format( 'Live status changed from {} to {}'.format(
self._previous_status.name, current_status.name self._previous_status.name, current_status.name
) )
@@ -147,45 +147,78 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
self._status_count = 0 self._status_count = 0
self._stream_available = False self._stream_available = False
await self._emit('live_ended', self._live) await self._emit('live_ended', self._live)
await self._stop_checking()
else: else:
self._status_count += 1 self._status_count += 1
if self._status_count == 1: if self._status_count == 1:
assert self._previous_status != LiveStatus.LIVE assert self._previous_status != LiveStatus.LIVE
self._start_checking()
await self._emit('live_began', self._live) await self._emit('live_began', self._live)
self._start_checking()
elif self._status_count == 2: elif self._status_count == 2:
assert self._previous_status == LiveStatus.LIVE 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: elif self._status_count > 2:
assert self._previous_status == LiveStatus.LIVE assert self._previous_status == LiveStatus.LIVE
await self._emit('live_stream_reset', self._live) await self._emit('live_stream_reset', self._live)
else: else:
pass pass
logger.debug('Number of sequential LIVE status: {}'.format(self._status_count)) self._logger.debug(
'Number of sequential LIVE status: {}'.format(self._status_count)
)
self._previous_status = current_status self._previous_status = current_status
@aio_task_with_room_id async def check_live_status(self) -> None:
async def _poll_live_status(self) -> None: self._logger.debug('Checking live status...')
while True: try:
await asyncio.sleep(600 + random.randrange(-60, 60)) await self._check_live_status()
await self._live.update_room_info() except Exception as e:
current_status = self._live.room_info.live_status self._logger.warning(f'Failed to check live status: {repr(e)}')
if current_status != self._previous_status: self._logger.debug('Done checking live status')
await self._handle_status_change(current_status)
@aio_task_with_room_id async def _check_live_status(self) -> None:
async def _check_if_stream_available(self) -> None: await self._live.update_room_info()
while not self._stream_available: current_status = self._live.room_info.live_status
if current_status != self._previous_status:
await self._handle_status_change(current_status)
@async_task_with_logger_context
async def _poll_live_status(self) -> None:
self._logger.debug('Started polling live status')
while True:
try: try:
await self._live.get_live_stream_url() await asyncio.sleep(600 + random.randrange(-60, 60))
except Exception: await self._check_live_status()
await asyncio.sleep(1) except asyncio.CancelledError:
else: self._logger.debug('Cancelled polling live status')
self._stream_available = True break
await self._emit('live_stream_available', self._live) except Exception as e:
self._logger.warning(f'Failed to poll live status: {repr(e)}')
self._logger.debug('Stopped polling live status')
@async_task_with_logger_context
async def _check_if_stream_available(self) -> None:
self._logger.debug('Started checking if stream available')
while True:
try:
streams = await self._live.get_live_streams()
if streams:
self._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:
self._logger.debug('Cancelled checking if stream available')
break
except Exception as e:
self._logger.warning(f'Failed to check if stream available: {repr(e)}')
await asyncio.sleep(1)
self._logger.debug('Stopped checking if stream available')

View File

@@ -50,10 +50,10 @@ class RoomInfo:
else: else:
raise ValueError(f'Failed to init live_start_time: {data}') raise ValueError(f'Failed to init live_start_time: {data}')
if (cover := data.get('cover') or data.get('user_cover', '')): if cover := data.get('cover') or data.get('user_cover', ''):
cover = ensure_scheme(cover, 'https') cover = ensure_scheme(cover, 'https')
if (description := data['description']): if description := data['description']:
description = re.sub(r'<br\s*/?>', '\n', description) description = re.sub(r'<br\s*/?>', '\n', description)
tree = html.fromstring(description) tree = html.fromstring(description)
description = clean_html(tree).text_content().strip() description = clean_html(tree).text_content().strip()
@@ -82,8 +82,6 @@ class UserInfo:
gender: str gender: str
face: str face: str
uid: int uid: int
level: int
sign: str
@staticmethod @staticmethod
def from_web_api_data(data: ResponseData) -> 'UserInfo': def from_web_api_data(data: ResponseData) -> 'UserInfo':
@@ -92,8 +90,6 @@ class UserInfo:
gender=data['sex'], gender=data['sex'],
face=ensure_scheme(data['face'], 'https'), face=ensure_scheme(data['face'], 'https'),
uid=data['mid'], uid=data['mid'],
level=data['level'],
sign=data['sign'],
) )
@staticmethod @staticmethod
@@ -101,9 +97,18 @@ class UserInfo:
card = data['card'] card = data['card']
return UserInfo( return UserInfo(
name=card['name'], name=card['name'],
gender=card['sex'], gender=card.get('sex', ''),
face=ensure_scheme(card['face'], 'https'), face=ensure_scheme(card['face'], 'https'),
uid=card['mid'], uid=card['mid'],
level=card['level_info']['current_level'], )
sign=card['sign'],
@staticmethod
def from_info_by_room(data: ResponseData) -> 'UserInfo':
room_info = data['room_info']
base_info = data['anchor_info']['base_info']
return UserInfo(
name=base_info['uname'],
gender=base_info['gender'],
face=ensure_scheme(base_info['face'], 'https'),
uid=room_info['uid'],
) )

18
src/blrec/bili/net.py Normal file
View File

@@ -0,0 +1,18 @@
import os
import socket
import aiohttp
import requests
__all__ = ('connector', 'timeout')
USE_IPV4_ONLY = bool(os.environ.get('BLREC_IPV4'))
if not USE_IPV4_ONLY:
family = 0
else:
requests.packages.urllib3.util.connection.HAS_IPV6 = False # type: ignore
family = socket.AF_INET
connector = aiohttp.TCPConnector(family=family, limit=200)
timeout = aiohttp.ClientTimeout(total=10)

84
src/blrec/bili/wbi.py Normal file
View File

@@ -0,0 +1,84 @@
import hashlib
from typing import Any, List, Tuple
def extract_key(url: str) -> str:
return url.rsplit("/", 1)[-1].rsplit(".", 1)[0]
def make_key(img_key: str, sub_key: str) -> str:
# fmt: off
MAPPING = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
]
key = (img_key + sub_key).encode()
return bytes([key[n] for n in MAPPING]).decode()
def encode_value(value: str) -> str:
chars = []
for c in value:
if c in "!'()*":
continue
if (c.isascii() and c.isalnum()) or c in "-_.~":
chars.append(c)
else:
for b in c.encode():
chars.append(f"%{b:02X}")
return "".join(chars)
def build_query(key: str, ts: int, params: List[Tuple[str, Any]]) -> str:
params.append(("wts", str(ts)))
params.sort(key=lambda p: p[0])
parts = []
for name, value in params:
parts.append(f"{name}={encode_value(str(value))}")
query = "&".join(parts)
sign = hashlib.md5((query + key).encode()).hexdigest()
query += f"&w_rid={sign}"
return query
def test_extract_key() -> None:
url = "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png"
key = extract_key(url)
assert key == "7cd084941338484aae1ad9425b84077c"
def test_make_key() -> None:
img_key = "7cd084941338484aae1ad9425b84077c"
sub_key = "4932caff0ff746eab6f01bf08b70ac45"
expected = "ea1db124af3c7062474693fa704f4ff8"
key = make_key(img_key, sub_key)
assert key == expected
def test_encode_value() -> None:
expected = "-_-%20F%20%E5%93%94~"
assert encode_value(")-_-( F**' 哔~!") == expected
def test_build_query() -> None:
img_key = "7cd084941338484aae1ad9425b84077c"
sub_key = "4932caff0ff746eab6f01bf08b70ac45"
key = make_key(img_key, sub_key)
ts = 1748867128
params = [("foo", ")-_-( F**' 哔~!"), ("bar", 2333)]
expected = "bar=2333&foo=-_-%20F%20%E5%93%94~&wts=1748867128&w_rid=6ba96e28a3f09b40e704f1e4b4f8e3e3" # noqa
assert build_query(key, ts, params) == expected
if __name__ == "__main__":
test_extract_key()
test_make_key()
test_encode_value()
test_build_query()

View File

@@ -1,4 +1,3 @@
import logging
import os import os
import sys import sys
from copy import deepcopy from copy import deepcopy
@@ -6,13 +5,12 @@ from typing import Optional
import typer import typer
import uvicorn import uvicorn
from loguru import logger
from uvicorn.config import LOGGING_CONFIG from uvicorn.config import LOGGING_CONFIG
from .. import __prog__, __version__ from .. import __prog__, __version__
from ..logging import TqdmOutputStream from ..logging import TqdmOutputStream
logger = logging.getLogger(__name__)
cli = typer.Typer() cli = typer.Typer()
@@ -49,6 +47,7 @@ def cli_main(
host: str = typer.Option('localhost', help='webapp host bind'), host: str = typer.Option('localhost', help='webapp host bind'),
port: int = typer.Option(2233, help='webapp port bind'), port: int = typer.Option(2233, help='webapp port bind'),
open: bool = typer.Option(False, help='open webapp in default browser'), open: bool = typer.Option(False, help='open webapp in default browser'),
ipv4: bool = typer.Option(False, help='use IPv4 only'),
root_path: str = typer.Option('', help='ASGI root path'), root_path: str = typer.Option('', help='ASGI root path'),
key_file: Optional[str] = typer.Option(None, help='SSL key file'), key_file: Optional[str] = typer.Option(None, help='SSL key file'),
cert_file: Optional[str] = typer.Option(None, help='SSL certificate file'), cert_file: Optional[str] = typer.Option(None, help='SSL certificate file'),
@@ -63,6 +62,8 @@ def cli_main(
os.environ['BLREC_OUT_DIR'] = out_dir os.environ['BLREC_OUT_DIR'] = out_dir
if log_dir is not None: if log_dir is not None:
os.environ['BLREC_LOG_DIR'] = log_dir os.environ['BLREC_LOG_DIR'] = log_dir
if ipv4 is not None:
os.environ['BLREC_IPV4'] = '1'
if not sys.stderr.isatty(): if not sys.stderr.isatty():
progress = False progress = False

View File

@@ -1,16 +1,16 @@
import logging
from enum import Enum from enum import Enum
from threading import Lock from threading import Lock
from typing import Set from typing import Set
import aiofiles import aiofiles
import aiohttp import aiohttp
from loguru import logger
from tenacity import retry, stop_after_attempt, wait_fixed from tenacity import retry, stop_after_attempt, wait_fixed
from blrec.bili.live import Live from blrec.bili.live import Live
from blrec.bili.net import connector, timeout
from blrec.event.event_emitter import EventEmitter, EventListener from blrec.event.event_emitter import EventEmitter, EventListener
from blrec.exception import submit_exception from blrec.exception import submit_exception
from blrec.logging.room_id import aio_task_with_room_id
from blrec.path import cover_path from blrec.path import cover_path
from blrec.utils.hash import sha1sum from blrec.utils.hash import sha1sum
from blrec.utils.mixins import SwitchableMixin from blrec.utils.mixins import SwitchableMixin
@@ -20,12 +20,8 @@ from .stream_recorder import StreamRecorder, StreamRecorderEventListener
__all__ = 'CoverDownloader', 'CoverDownloaderEventListener' __all__ = 'CoverDownloader', 'CoverDownloaderEventListener'
logger = logging.getLogger(__name__)
class CoverDownloaderEventListener(EventListener): class CoverDownloaderEventListener(EventListener):
async def on_cover_image_downloaded(self, path: str) -> None: async def on_cover_image_downloaded(self, path: str) -> None: ...
...
class CoverSaveStrategy(Enum): class CoverSaveStrategy(Enum):
@@ -54,6 +50,8 @@ class CoverDownloader(
cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT, cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT,
) -> None: ) -> None:
super().__init__() super().__init__()
self._logger_context = {'room_id': live.room_id}
self._logger = logger.bind(**self._logger_context)
self._live = live self._live = live
self._stream_recorder = stream_recorder self._stream_recorder = stream_recorder
self._lock: Lock = Lock() self._lock: Lock = Lock()
@@ -64,11 +62,11 @@ class CoverDownloader(
def _do_enable(self) -> None: def _do_enable(self) -> None:
self._sha1_set.clear() self._sha1_set.clear()
self._stream_recorder.add_listener(self) self._stream_recorder.add_listener(self)
logger.debug('Enabled cover downloader') self._logger.debug('Enabled cover downloader')
def _do_disable(self) -> None: def _do_disable(self) -> None:
self._stream_recorder.remove_listener(self) self._stream_recorder.remove_listener(self)
logger.debug('Disabled cover downloader') self._logger.debug('Disabled cover downloader')
async def on_video_file_completed(self, video_path: str) -> None: async def on_video_file_completed(self, video_path: str) -> None:
with self._lock: with self._lock:
@@ -76,7 +74,6 @@ class CoverDownloader(
return return
await self._save_cover(video_path) await self._save_cover(video_path)
@aio_task_with_room_id
async def _save_cover(self, video_path: str) -> None: async def _save_cover(self, video_path: str) -> None:
try: try:
await self._live.update_room_info() await self._live.update_room_info()
@@ -92,15 +89,21 @@ class CoverDownloader(
await self._save_file(path, data) await self._save_file(path, data)
self._sha1_set.add(sha1) self._sha1_set.add(sha1)
except Exception as e: except Exception as e:
logger.error(f'Failed to save cover image: {repr(e)}') self._logger.error(f'Failed to save cover image: {repr(e)}')
submit_exception(e) submit_exception(e)
else: else:
logger.info(f'Saved cover image: {path}') self._logger.info(f'Saved cover image: {path}')
await self._emit('cover_image_downloaded', path) await self._emit('cover_image_downloaded', path)
@retry(reraise=True, wait=wait_fixed(1), stop=stop_after_attempt(3)) @retry(reraise=True, wait=wait_fixed(1), stop=stop_after_attempt(3))
async def _fetch_cover(self, url: str) -> bytes: async def _fetch_cover(self, url: str) -> bytes:
async with aiohttp.ClientSession(raise_for_status=True) as session: async with aiohttp.ClientSession(
connector=connector,
connector_owner=False,
raise_for_status=True,
trust_env=True,
timeout=timeout,
) as session:
async with session.get(url) as response: async with session.get(url) as response:
return await response.read() return await response.read()

View File

@@ -1,10 +1,11 @@
import asyncio import asyncio
import html import html
import logging
from contextlib import suppress from contextlib import suppress
from decimal import Decimal
from threading import Lock from threading import Lock
from typing import Iterator, List, Optional from typing import Iterator, List, Optional
from loguru import logger
from tenacity import AsyncRetrying, retry_if_not_exception_type, stop_after_attempt from tenacity import AsyncRetrying, retry_if_not_exception_type, stop_after_attempt
from blrec import __github__, __prog__, __version__ from blrec import __github__, __prog__, __version__
@@ -21,7 +22,7 @@ from blrec.danmaku.models import (
) )
from blrec.event.event_emitter import EventEmitter, EventListener from blrec.event.event_emitter import EventEmitter, EventListener
from blrec.exception import exception_callback, submit_exception from blrec.exception import exception_callback, submit_exception
from blrec.logging.room_id import aio_task_with_room_id from blrec.logging.context import async_task_with_logger_context
from blrec.path import danmaku_path from blrec.path import danmaku_path
from blrec.utils.mixins import SwitchableMixin from blrec.utils.mixins import SwitchableMixin
@@ -32,9 +33,6 @@ from .stream_recorder import StreamRecorder, StreamRecorderEventListener
__all__ = 'DanmakuDumper', 'DanmakuDumperEventListener' __all__ = 'DanmakuDumper', 'DanmakuDumperEventListener'
logger = logging.getLogger(__name__)
class DanmakuDumperEventListener(EventListener): class DanmakuDumperEventListener(EventListener):
async def on_danmaku_file_created(self, path: str) -> None: async def on_danmaku_file_created(self, path: str) -> None:
... ...
@@ -61,6 +59,8 @@ class DanmakuDumper(
record_super_chat: bool = False, record_super_chat: bool = False,
) -> None: ) -> None:
super().__init__() super().__init__()
self._logger_context = {'room_id': live.room_id}
self._logger = logger.bind(**self._logger_context)
self._live = live self._live = live
self._stream_recorder = stream_recorder self._stream_recorder = stream_recorder
@@ -96,13 +96,13 @@ class DanmakuDumper(
def _do_enable(self) -> None: def _do_enable(self) -> None:
self._stream_recorder.add_listener(self) self._stream_recorder.add_listener(self)
self._statistics.reset() self._statistics.reset()
logger.debug('Enabled danmaku dumper') self._logger.debug('Enabled danmaku dumper')
def _do_disable(self) -> None: def _do_disable(self) -> None:
self._stream_recorder.remove_listener(self) self._stream_recorder.remove_listener(self)
asyncio.create_task(self._stop_dumping()) asyncio.create_task(self._stop_dumping())
self._statistics.freeze() self._statistics.freeze()
logger.debug('Disabled danmaku dumper') self._logger.debug('Disabled danmaku dumper')
def set_live_start_time(self, time: int) -> None: def set_live_start_time(self, time: int) -> None:
self._live_start_time = time self._live_start_time = time
@@ -123,7 +123,6 @@ class DanmakuDumper(
with self._lock: with self._lock:
self._delta: float = 0 self._delta: float = 0
self._record_start_time: int = record_start_time self._record_start_time: int = record_start_time
self._timebase: int = self._record_start_time * 1000
self._stream_recording_interrupted: bool = False self._stream_recording_interrupted: bool = False
self._path = danmaku_path(video_path) self._path = danmaku_path(video_path)
self._files.append(self._path) self._files.append(self._path)
@@ -135,19 +134,32 @@ class DanmakuDumper(
await self._stop_dumping() await self._stop_dumping()
self._path = None self._path = None
async def on_stream_recording_interrupted(self, duration: float) -> None: async def on_stream_recording_interrupted(
logger.debug(f'Stream recording interrupted, {duration}') self, timestamp: float, duration: float
) -> None:
self._interrupted_timestamp = timestamp
self._duration = duration self._duration = duration
self._stream_recording_recovered = asyncio.Condition()
self._stream_recording_interrupted = True self._stream_recording_interrupted = True
self._logger.debug(
'Stream recording interrupted, '
f'timestamp: {timestamp}, duration: {duration}'
)
async def on_stream_recording_recovered(self, timestamp: int) -> None: async def on_stream_recording_recovered(self, timestamp: float) -> None:
logger.debug(f'Stream recording recovered, {timestamp}') self._recovered_timestamp = timestamp
self._timebase = timestamp * 1000 self._delta += -float(
self._delta = self._duration * 1000 Decimal(str(self._recovered_timestamp))
- Decimal(str(self._interrupted_timestamp))
)
self._stream_recording_interrupted = False self._stream_recording_interrupted = False
async with self._stream_recording_recovered: self._logger.debug(
self._stream_recording_recovered.notify_all() 'Stream recording recovered, '
f'timestamp: {timestamp}, delta: {self._delta}'
)
async def on_duration_lost(self, duration: float) -> None:
self._logger.debug(f'Total duration lost: ≈ {(duration)} s')
self._delta = -duration
def _start_dumping(self) -> None: def _start_dumping(self) -> None:
self._create_dump_task() self._create_dump_task()
@@ -166,14 +178,14 @@ class DanmakuDumper(
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await self._dump_task await self._dump_task
@aio_task_with_room_id @async_task_with_logger_context
async def _do_dump(self) -> None: async def _do_dump(self) -> None:
assert self._path is not None assert self._path is not None
logger.debug('Started dumping danmaku') self._logger.debug('Started dumping danmaku')
try: try:
async with DanmakuWriter(self._path) as writer: async with DanmakuWriter(self._path) as writer:
logger.info(f"Danmaku file created: '{self._path}'") self._logger.info(f"Danmaku file created: '{self._path}'")
await self._emit('danmaku_file_created', self._path) await self._emit('danmaku_file_created', self._path)
await writer.write_metadata(self._make_metadata()) await writer.write_metadata(self._make_metadata())
@@ -188,9 +200,9 @@ class DanmakuDumper(
submit_exception(e) submit_exception(e)
raise raise
finally: finally:
logger.info(f"Danmaku file completed: '{self._path}'") self._logger.info(f"Danmaku file completed: '{self._path}'")
await self._emit('danmaku_file_completed', self._path) await self._emit('danmaku_file_completed', self._path)
logger.debug('Stopped dumping danmaku') self._logger.debug('Stopped dumping danmaku')
async def _dumping_loop(self, writer: DanmakuWriter) -> None: async def _dumping_loop(self, writer: DanmakuWriter) -> None:
while True: while True:
@@ -217,14 +229,7 @@ class DanmakuDumper(
continue continue
await writer.write_super_chat_record(self._make_super_chat_record(msg)) await writer.write_super_chat_record(self._make_super_chat_record(msg))
else: else:
logger.warning('Unsupported message type:', repr(msg)) self._logger.warning(f'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()
def _make_metadata(self) -> Metadata: def _make_metadata(self) -> Metadata:
return Metadata( return Metadata(
@@ -243,10 +248,10 @@ class DanmakuDumper(
text = f'{msg.uname}: {msg.text}' text = f'{msg.uname}: {msg.text}'
else: else:
text = msg.text text = msg.text
text = html.escape(text) text = html.escape(text, quote=False)
return Danmu( return Danmu(
stime=self._calc_stime(msg.date), stime=self._calc_stime(msg.date / 1000),
mode=msg.mode, mode=msg.mode,
size=msg.size, size=msg.size,
color=msg.color, color=msg.color,
@@ -261,7 +266,7 @@ class DanmakuDumper(
def _make_gift_send_record(self, msg: GiftSendMsg) -> GiftSendRecord: def _make_gift_send_record(self, msg: GiftSendMsg) -> GiftSendRecord:
return GiftSendRecord( return GiftSendRecord(
ts=self._calc_stime(msg.timestamp * 1000), ts=self._calc_stime(msg.timestamp),
uid=msg.uid, uid=msg.uid,
user=msg.uname, user=msg.uname,
giftname=msg.gift_name, giftname=msg.gift_name,
@@ -272,7 +277,7 @@ class DanmakuDumper(
def _make_guard_buy_record(self, msg: GuardBuyMsg) -> GuardBuyRecord: def _make_guard_buy_record(self, msg: GuardBuyMsg) -> GuardBuyRecord:
return GuardBuyRecord( return GuardBuyRecord(
ts=self._calc_stime(msg.timestamp * 1000), ts=self._calc_stime(msg.timestamp),
uid=msg.uid, uid=msg.uid,
user=msg.uname, user=msg.uname,
giftname=msg.gift_name, giftname=msg.gift_name,
@@ -283,7 +288,7 @@ class DanmakuDumper(
def _make_super_chat_record(self, msg: SuperChatMsg) -> SuperChatRecord: def _make_super_chat_record(self, msg: SuperChatMsg) -> SuperChatRecord:
return SuperChatRecord( return SuperChatRecord(
ts=self._calc_stime(msg.timestamp * 1000), ts=self._calc_stime(msg.timestamp),
uid=msg.uid, uid=msg.uid,
user=msg.uname, user=msg.uname,
price=msg.price * msg.rate, price=msg.price * msg.rate,
@@ -293,7 +298,7 @@ class DanmakuDumper(
def _make_user_toast(self, msg: UserToastMsg) -> UserToast: def _make_user_toast(self, msg: UserToastMsg) -> UserToast:
return UserToast( return UserToast(
ts=self._calc_stime(msg.start_time * 1000), ts=self._calc_stime(msg.start_time),
uid=msg.uid, uid=msg.uid,
user=msg.username, user=msg.username,
unit=msg.unit, unit=msg.unit,
@@ -304,5 +309,15 @@ class DanmakuDumper(
msg=msg.toast_msg, msg=msg.toast_msg,
) )
def _calc_stime(self, timestamp: int) -> float: def _calc_stime(self, timestamp: float) -> float:
return (max(timestamp - self._timebase, 0) + self._delta) / 1000 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

@@ -1,8 +1,10 @@
import logging
from asyncio import Queue, QueueFull from asyncio import Queue, QueueFull
from typing import Final from typing import Final
from loguru import logger
from blrec.bili.danmaku_client import DanmakuClient, DanmakuCommand, DanmakuListener from blrec.bili.danmaku_client import DanmakuClient, DanmakuCommand, DanmakuListener
from blrec.bili.live import Live
from blrec.bili.typing import Danmaku from blrec.bili.typing import Danmaku
from blrec.utils.mixins import StoppableMixin from blrec.utils.mixins import StoppableMixin
@@ -12,25 +14,23 @@ from .typing import DanmakuMsg
__all__ = ('DanmakuReceiver',) __all__ = ('DanmakuReceiver',)
logger = logging.getLogger(__name__)
class DanmakuReceiver(DanmakuListener, StoppableMixin): class DanmakuReceiver(DanmakuListener, StoppableMixin):
_MAX_QUEUE_SIZE: Final[int] = 2000 _MAX_QUEUE_SIZE: Final[int] = 2000
def __init__(self, danmaku_client: DanmakuClient) -> None: def __init__(self, live: Live, danmaku_client: DanmakuClient) -> None:
super().__init__() super().__init__()
self._logger = logger.bind(room_id=live.room_id)
self._danmaku_client = danmaku_client self._danmaku_client = danmaku_client
self._queue: Queue[DanmakuMsg] = Queue(maxsize=self._MAX_QUEUE_SIZE) self._queue: Queue[DanmakuMsg] = Queue(maxsize=self._MAX_QUEUE_SIZE)
def _do_start(self) -> None: def _do_start(self) -> None:
self._danmaku_client.add_listener(self) self._danmaku_client.add_listener(self)
logger.debug('Started danmaku receiver') self._logger.debug('Started danmaku receiver')
def _do_stop(self) -> None: def _do_stop(self) -> None:
self._danmaku_client.remove_listener(self) self._danmaku_client.remove_listener(self)
self._clear_queue() self._clear_queue()
logger.debug('Stopped danmaku receiver') self._logger.debug('Stopped danmaku receiver')
async def get_message(self) -> DanmakuMsg: async def get_message(self) -> DanmakuMsg:
return await self._queue.get() return await self._queue.get()

View File

@@ -1,9 +1,10 @@
import logging
from typing import Optional from typing import Optional
from loguru import logger
from reactivex.scheduler import NewThreadScheduler from reactivex.scheduler import NewThreadScheduler
from blrec.bili.live import Live from blrec.bili.live import Live
from blrec.bili.live_monitor import LiveMonitor
from blrec.bili.typing import QualityNumber from blrec.bili.typing import QualityNumber
from blrec.flv import operators as flv_ops from blrec.flv import operators as flv_ops
from blrec.flv.metadata_dumper import MetadataDumper from blrec.flv.metadata_dumper import MetadataDumper
@@ -15,13 +16,11 @@ from .stream_recorder_impl import StreamRecorderImpl
__all__ = ('FLVStreamRecorderImpl',) __all__ = ('FLVStreamRecorderImpl',)
logger = logging.getLogger(__name__)
class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin): class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
def __init__( def __init__(
self, self,
live: Live, live: Live,
live_monitor: LiveMonitor,
out_dir: str, out_dir: str,
path_template: str, path_template: str,
*, *,
@@ -34,6 +33,7 @@ class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
) -> None: ) -> None:
super().__init__( super().__init__(
live=live, live=live,
live_monitor=live_monitor,
out_dir=out_dir, out_dir=out_dir,
path_template=path_template, path_template=path_template,
stream_format='flv', stream_format='flv',
@@ -66,7 +66,7 @@ class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
) )
self._recording_monitor = core_ops.RecordingMonitor( 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) self._prober.profiles.subscribe(self._on_profile_updated)
@@ -131,30 +131,33 @@ class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
self._metadata_dumper.disable() self._metadata_dumper.disable()
def _run(self) -> None: def _run(self) -> None:
self._subscription = ( with logger.contextualize(room_id=self._live.room_id):
self._stream_param_holder.get_stream_params() # type: ignore self._subscription = (
.pipe( self._stream_param_holder.get_stream_params() # type: ignore
self._stream_url_resolver, .pipe(
self._stream_fetcher, self._stream_url_resolver,
self._recording_monitor, self._stream_fetcher,
self._dl_statistics, self._recording_monitor,
self._stream_parser, self._dl_statistics,
self._connection_error_handler, self._stream_parser,
self._request_exception_handler, self._connection_error_handler,
flv_ops.process(sort_tags=True), self._request_exception_handler,
self._cutter, flv_ops.process(sort_tags=True),
self._limiter, self._cutter,
self._join_point_extractor, self._limiter,
self._prober, self._join_point_extractor,
self._injector, self._prober,
self._analyser, self._injector,
self._dumper, self._analyser,
self._rec_statistics, self._dumper,
self._progress_bar, self._rec_statistics,
self._exception_handler, self._progress_bar,
self._exception_handler,
)
.subscribe(
on_completed=self._on_completed,
scheduler=NewThreadScheduler(
self._thread_factory('StreamRecorder')
),
)
) )
.subscribe(
on_completed=self._on_completed,
scheduler=NewThreadScheduler(self._thread_factory('StreamRecorder')),
)
)

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

@@ -1,13 +1,13 @@
import logging
from typing import Optional from typing import Optional
from loguru import logger
from reactivex.scheduler import NewThreadScheduler from reactivex.scheduler import NewThreadScheduler
from blrec.bili.live import Live from blrec.bili.live import Live
from blrec.bili.live_monitor import LiveMonitor
from blrec.bili.typing import QualityNumber 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 import operators as hls_ops
from blrec.hls.metadata_dumper import MetadataDumper
from blrec.utils import operators as utils_ops from blrec.utils import operators as utils_ops
from . import operators as core_ops from . import operators as core_ops
@@ -16,13 +16,11 @@ from .stream_recorder_impl import StreamRecorderImpl
__all__ = ('HLSStreamRecorderImpl',) __all__ = ('HLSStreamRecorderImpl',)
logger = logging.getLogger(__name__)
class HLSStreamRecorderImpl(StreamRecorderImpl): class HLSStreamRecorderImpl(StreamRecorderImpl):
def __init__( def __init__(
self, self,
live: Live, live: Live,
live_monitor: LiveMonitor,
out_dir: str, out_dir: str,
path_template: str, path_template: str,
*, *,
@@ -35,6 +33,7 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
) -> None: ) -> None:
super().__init__( super().__init__(
live=live, live=live,
live_monitor=live_monitor,
out_dir=out_dir, out_dir=out_dir,
path_template=path_template, path_template=path_template,
stream_format='fmp4', stream_format='fmp4',
@@ -52,43 +51,41 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
self._segment_fetcher = hls_ops.SegmentFetcher( self._segment_fetcher = hls_ops.SegmentFetcher(
self._live, self._session, self._stream_url_resolver self._live, self._session, self._stream_url_resolver
) )
self._segment_remuxer = hls_ops.SegmentRemuxer(live) self._segment_dumper = hls_ops.SegmentDumper(self._path_provider)
self._playlist_dumper = hls_ops.PlaylistDumper(self._segment_dumper)
self._prober = hls_ops.Prober() self._ff_metadata_dumper = MetadataDumper(
self._dl_statistics = core_ops.SizedStatistics() self._segment_dumper, self._metadata_provider
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._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( 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._prober.profiles.subscribe(self._on_profile_updated)
self._dumper.file_opened.subscribe(self._on_video_file_opened) self._segment_dumper.file_opened.subscribe(self._on_video_file_opened)
self._dumper.file_closed.subscribe(self._on_video_file_closed) 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.interrupted.subscribe(self._on_recording_interrupted)
self._recording_monitor.recovered.subscribe(self._on_recording_recovered) 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 @property
def recording_path(self) -> Optional[str]: def recording_path(self) -> Optional[str]:
return self._dumper.path return self._segment_dumper.path
@property @property
def filesize_limit(self) -> int: def filesize_limit(self) -> int:
@@ -107,11 +104,8 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
self._limiter.duration_limit = value self._limiter.duration_limit = value
@property @property
def metadata(self) -> Optional[flv_ops.MetaData]: def metadata(self) -> Optional[hls_ops.MetaData]:
try: return self._analyser.make_metadata()
return self._analyser.make_metadata()
except Exception:
return None
def can_cut_stream(self) -> bool: def can_cut_stream(self) -> bool:
return self._cutter.can_cut_stream() return self._cutter.can_cut_stream()
@@ -120,45 +114,43 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
return self._cutter.cut_stream() return self._cutter.cut_stream()
def _on_start(self) -> None: def _on_start(self) -> None:
self._metadata_dumper.enable() self._ff_metadata_dumper.enable()
def _on_stop(self) -> None: def _on_stop(self) -> None:
self._metadata_dumper.disable() self._ff_metadata_dumper.disable()
def _run(self) -> None: def _run(self) -> None:
self._subscription = ( with logger.contextualize(room_id=self._live.room_id):
self._stream_param_holder.get_stream_params() # type: ignore self._subscription = (
.pipe( self._stream_param_holder.get_stream_params() # type: ignore
self._stream_url_resolver, .pipe(
self._playlist_fetcher, self._stream_url_resolver,
self._recording_monitor, self._playlist_fetcher,
self._connection_error_handler, self._recording_monitor,
self._request_exception_handler, self._connection_error_handler,
self._playlist_resolver, self._request_exception_handler,
utils_ops.observe_on_new_thread( self._playlist_resolver,
queue_size=60, thread_name=f'SegmentFetcher::{self._live.room_id}' utils_ops.observe_on_new_thread(
), queue_size=60,
self._segment_fetcher, thread_name=f'SegmentFetcher::{self._live.room_id}',
self._dl_statistics, logger_context={'room_id': self._live.room_id},
self._prober, ),
utils_ops.observe_on_new_thread( self._segment_fetcher,
queue_size=10, thread_name=f'StreamRecorder::{self._live.room_id}' self._dl_statistics,
), self._prober,
self._segment_remuxer, self._analyser,
self._segment_parser, self._cutter,
flv_ops.process(), self._limiter,
self._cutter, self._segment_dumper,
self._limiter, self._rec_statistics,
self._join_point_extractor, self._progress_bar,
self._injector, self._playlist_dumper,
self._analyser, self._exception_handler,
self._dumper, )
self._rec_statistics, .subscribe(
self._progress_bar, on_completed=self._on_completed,
self._exception_handler, scheduler=NewThreadScheduler(
self._thread_factory('HLSStreamRecorder')
),
)
) )
.subscribe(
on_completed=self._on_completed,
scheduler=NewThreadScheduler(self._thread_factory('HLSStreamRecorder')),
)
)

View File

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

View File

@@ -1,12 +1,9 @@
import logging
from typing import Literal from typing import Literal
import attr import attr
from blrec.bili.typing import Danmaku from blrec.bili.typing import Danmaku
logger = logging.getLogger(__name__)
@attr.s(auto_attribs=True, frozen=True, slots=True) @attr.s(auto_attribs=True, frozen=True, slots=True)
class DanmuMsg: class DanmuMsg:

View File

@@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
import logging
import time import time
from typing import Optional, TypeVar from typing import Optional, TypeVar
import aiohttp import aiohttp
import requests import requests
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from blrec.bili.live import Live from blrec.bili.live import Live
@@ -15,8 +15,6 @@ from blrec.utils.mixins import AsyncCooperationMixin
__all__ = ('ConnectionErrorHandler',) __all__ = ('ConnectionErrorHandler',)
logger = logging.getLogger(__name__)
_T = TypeVar('_T') _T = TypeVar('_T')
@@ -77,7 +75,7 @@ class ConnectionErrorHandler(AsyncCooperationMixin):
timeout = self.disconnection_timeout timeout = self.disconnection_timeout
logger.info(f'Waiting {timeout} seconds for connection recovery... ') logger.info(f'Waiting {timeout} seconds for connection recovery... ')
timebase = time.monotonic() timebase = time.monotonic()
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: if timeout is not None and time.monotonic() - timebase > timeout:
logger.error(f'Connection not recovered in {timeout} seconds') logger.error(f'Connection not recovered in {timeout} seconds')
return False return False

View File

@@ -1,20 +1,19 @@
from __future__ import annotations from __future__ import annotations
import errno import errno
import logging
from typing import Optional, TypeVar from typing import Optional, TypeVar
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from blrec.bili.exceptions import LiveRoomEncrypted, LiveRoomHidden, LiveRoomLocked from blrec.bili.exceptions import LiveRoomEncrypted, LiveRoomHidden, LiveRoomLocked
from blrec.exception.helpers import format_exception
from blrec.utils import operators as utils_ops from blrec.utils import operators as utils_ops
from blrec.utils.mixins import AsyncCooperationMixin from blrec.utils.mixins import AsyncCooperationMixin
__all__ = ('ExceptionHandler',) __all__ = ('ExceptionHandler',)
logger = logging.getLogger(__name__)
_T = TypeVar('_T') _T = TypeVar('_T')
@@ -28,12 +27,11 @@ class ExceptionHandler(AsyncCooperationMixin):
scheduler: Optional[abc.SchedulerBase] = None, scheduler: Optional[abc.SchedulerBase] = None,
) -> abc.DisposableBase: ) -> abc.DisposableBase:
def on_error(exc: Exception) -> None: def on_error(exc: Exception) -> None:
logger.exception(repr(exc))
self._submit_exception(exc) self._submit_exception(exc)
try: try:
raise exc raise exc
except OSError as e: except OSError as e:
logger.critical(repr(e), exc_info=e) logger.critical('{}\n{}', repr(exc), format_exception(exc))
if e.errno == errno.ENOSPC: if e.errno == errno.ENOSPC:
# OSError(28, 'No space left on device') # OSError(28, 'No space left on device')
observer.on_completed() observer.on_completed()

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import logging
import os import os
from typing import Optional from typing import Optional
@@ -13,11 +12,8 @@ from blrec.flv.operators.typing import FLVStream, FLVStreamItem
__all__ = ('ProgressBar',) __all__ = ('ProgressBar',)
DISPLAY_PROGRESS = bool(os.environ.get('BLREC_PROGRESS')) DISPLAY_PROGRESS = bool(os.environ.get('BLREC_PROGRESS'))
logger = logging.getLogger(__name__)
class ProgressBar: class ProgressBar:
def __init__(self, live: Live) -> None: def __init__(self, live: Live) -> None:

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import time
from typing import Callable, Final, Optional, TypeVar from typing import Callable, Final, Optional, Tuple, TypeVar
from reactivex import Observable, Subject, abc from reactivex import Observable, Subject, abc
@@ -11,27 +11,39 @@ from blrec.utils.mixins import AsyncCooperationMixin
__all__ = ('RecordingMonitor',) __all__ = ('RecordingMonitor',)
logger = logging.getLogger(__name__)
_T = TypeVar('_T') _T = TypeVar('_T')
class RecordingMonitor(AsyncCooperationMixin): 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__() super().__init__()
self._live = live self._live = live
self._duration_provider = duration_provider self._duration_provider = duration_provider
self._interrupted: Subject[float] = Subject() self._duration_updated = duration_updated
self._recovered: Subject[int] = Subject() self._duration_subscription: Optional[abc.DisposableBase] = None
self._interrupted: Subject[Tuple[float, float]] = Subject()
self._recovered: Subject[float] = Subject()
@property @property
def interrupted(self) -> Observable[float]: def interrupted(self) -> Observable[Tuple[float, float]]:
return self._interrupted return self._interrupted
@property @property
def recovered(self) -> Observable[int]: def recovered(self) -> Observable[float]:
return self._recovered 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]: def __call__(self, source: Observable[_T]) -> Observable[_T]:
return self._monitor(source) return self._monitor(source)
@@ -48,8 +60,11 @@ class RecordingMonitor(AsyncCooperationMixin):
nonlocal recording, failed_count nonlocal recording, failed_count
recording = True recording = True
if failed_count >= CRITERIA: if failed_count >= CRITERIA:
ts = self._run_coroutine(self._live.get_timestamp()) if self._duration_subscription is not None:
self._recovered.on_next(ts) self._duration_subscription.dispose()
self._duration_subscription = self._duration_updated.subscribe(
self._on_duration_updated
)
failed_count = 0 failed_count = 0
observer.on_next(item) observer.on_next(item)
@@ -58,8 +73,9 @@ class RecordingMonitor(AsyncCooperationMixin):
if recording: if recording:
failed_count += 1 failed_count += 1
if failed_count == CRITERIA: if failed_count == CRITERIA:
ts = time.time()
duration = self._duration_provider() duration = self._duration_provider()
self._interrupted.on_next(duration) self._interrupted.on_next((ts, duration))
observer.on_error(exc) observer.on_error(exc)
return source.subscribe( return source.subscribe(

View File

@@ -1,32 +1,34 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import time import time
from typing import Optional, TypeVar from typing import Optional, TypeVar
import aiohttp import aiohttp
import requests import requests
import urllib3 import urllib3
from loguru import logger
from reactivex import Observable, abc 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 from blrec.utils import operators as utils_ops
__all__ = ('RequestExceptionHandler',) __all__ = ('RequestExceptionHandler',)
logger = logging.getLogger(__name__)
_T = TypeVar('_T') _T = TypeVar('_T')
class RequestExceptionHandler: 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() self._last_retry_time = time.monotonic()
def __call__(self, source: Observable[_T]) -> Observable[_T]: def __call__(self, source: Observable[_T]) -> Observable[_T]:
return self._handle(source).pipe( 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]: def _handle(self, source: Observable[_T]) -> Observable[_T]:
@@ -74,3 +76,10 @@ class RequestExceptionHandler:
return True return True
else: else:
return False 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,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
import io import io
import logging
from typing import Optional from typing import Optional
import requests import requests
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from blrec.bili.live import Live from blrec.bili.live import Live
@@ -13,9 +13,6 @@ from blrec.utils.mixins import AsyncCooperationMixin
__all__ = ('StreamFetcher',) __all__ = ('StreamFetcher',)
logger = logging.getLogger(__name__)
class StreamFetcher(AsyncCooperationMixin): class StreamFetcher(AsyncCooperationMixin):
def __init__( def __init__(
self, self,

View File

@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import io import io
import logging
from loguru import logger
from reactivex import Observable from reactivex import Observable
from reactivex import operators as ops from reactivex import operators as ops
@@ -16,9 +16,6 @@ from ..stream_param_holder import StreamParamHolder
__all__ = ('StreamParser',) __all__ = ('StreamParser',)
logger = logging.getLogger(__name__)
class StreamParser: class StreamParser:
def __init__( def __init__(
self, self,

View File

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

View File

@@ -1,4 +1,3 @@
import logging
import os import os
import re import re
from datetime import datetime from datetime import datetime
@@ -10,8 +9,6 @@ from blrec.utils.mixins import AsyncCooperationMixin
__all__ = ('PathProvider',) __all__ = ('PathProvider',)
logger = logging.getLogger(__name__)
class PathProvider(AsyncCooperationMixin): class PathProvider(AsyncCooperationMixin):
def __init__(self, live: Live, out_dir: str, path_template: str) -> None: def __init__(self, live: Live, out_dir: str, path_template: str) -> None:
@@ -20,10 +17,11 @@ class PathProvider(AsyncCooperationMixin):
self.out_dir = out_dir self.out_dir = out_dir
self.path_template = path_template self.path_template = path_template
def __call__(self) -> Tuple[str, int]: def __call__(self, timestamp: int = None) -> Tuple[str, int]:
ts = self._run_coroutine(self._live.get_timestamp()) if timestamp is None:
path = self._make_path(ts) timestamp = self._call_coroutine(self._live.get_timestamp())
return path, ts path = self._make_path(timestamp)
return path, timestamp
def _make_path(self, timestamp: int) -> str: def _make_path(self, timestamp: int) -> str:
date_time = datetime.fromtimestamp(timestamp) date_time = datetime.fromtimestamp(timestamp)

View File

@@ -1,17 +1,17 @@
import asyncio import asyncio
import json import json
import logging
from contextlib import suppress from contextlib import suppress
from threading import Lock from threading import Lock
import aiofiles import aiofiles
from aiofiles.threadpool.text import AsyncTextIOWrapper from aiofiles.threadpool.text import AsyncTextIOWrapper
from loguru import logger
from tenacity import AsyncRetrying, retry_if_not_exception_type, stop_after_attempt from tenacity import AsyncRetrying, retry_if_not_exception_type, stop_after_attempt
from blrec.bili.live import Live from blrec.bili.live import Live
from blrec.event.event_emitter import EventEmitter, EventListener from blrec.event.event_emitter import EventEmitter, EventListener
from blrec.exception import exception_callback, submit_exception from blrec.exception import exception_callback, submit_exception
from blrec.logging.room_id import aio_task_with_room_id from blrec.logging.context import async_task_with_logger_context
from blrec.path import raw_danmaku_path from blrec.path import raw_danmaku_path
from blrec.utils.mixins import SwitchableMixin from blrec.utils.mixins import SwitchableMixin
@@ -21,9 +21,6 @@ from .stream_recorder import StreamRecorder, StreamRecorderEventListener
__all__ = 'RawDanmakuDumper', 'RawDanmakuDumperEventListener' __all__ = 'RawDanmakuDumper', 'RawDanmakuDumperEventListener'
logger = logging.getLogger(__name__)
class RawDanmakuDumperEventListener(EventListener): class RawDanmakuDumperEventListener(EventListener):
async def on_raw_danmaku_file_created(self, path: str) -> None: async def on_raw_danmaku_file_created(self, path: str) -> None:
... ...
@@ -44,19 +41,20 @@ class RawDanmakuDumper(
danmaku_receiver: RawDanmakuReceiver, danmaku_receiver: RawDanmakuReceiver,
) -> None: ) -> None:
super().__init__() super().__init__()
self._live = live # @aio_task_with_room_id self._logger_context = {'room_id': live.room_id}
self._logger = logger.bind(**self._logger_context)
self._stream_recorder = stream_recorder self._stream_recorder = stream_recorder
self._receiver = danmaku_receiver self._receiver = danmaku_receiver
self._lock: Lock = Lock() self._lock: Lock = Lock()
def _do_enable(self) -> None: def _do_enable(self) -> None:
self._stream_recorder.add_listener(self) self._stream_recorder.add_listener(self)
logger.debug('Enabled raw danmaku dumper') self._logger.debug('Enabled raw danmaku dumper')
def _do_disable(self) -> None: def _do_disable(self) -> None:
self._stream_recorder.remove_listener(self) self._stream_recorder.remove_listener(self)
asyncio.create_task(self._stop_dumping()) asyncio.create_task(self._stop_dumping())
logger.debug('Disabled raw danmaku dumper') self._logger.debug('Disabled raw danmaku dumper')
async def on_video_file_created( async def on_video_file_created(
self, video_path: str, record_start_time: int self, video_path: str, record_start_time: int
@@ -87,12 +85,12 @@ class RawDanmakuDumper(
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await self._dump_task await self._dump_task
@aio_task_with_room_id @async_task_with_logger_context
async def _do_dump(self) -> None: async def _do_dump(self) -> None:
logger.debug('Started dumping raw danmaku') self._logger.debug('Started dumping raw danmaku')
try: try:
async with aiofiles.open(self._path, 'wt', encoding='utf8') as f: async with aiofiles.open(self._path, 'wt', encoding='utf8') as f:
logger.info(f"Raw danmaku file created: '{self._path}'") self._logger.info(f"Raw danmaku file created: '{self._path}'")
await self._emit('raw_danmaku_file_created', self._path) await self._emit('raw_danmaku_file_created', self._path)
async for attempt in AsyncRetrying( async for attempt in AsyncRetrying(
@@ -110,9 +108,9 @@ class RawDanmakuDumper(
json_string = json.dumps(danmu, ensure_ascii=False) json_string = json.dumps(danmu, ensure_ascii=False)
await f.write(json_string + '\n') await f.write(json_string + '\n')
finally: finally:
logger.info(f"Raw danmaku file completed: '{self._path}'") self._logger.info(f"Raw danmaku file completed: '{self._path}'")
await self._emit('raw_danmaku_file_completed', self._path) await self._emit('raw_danmaku_file_completed', self._path)
logger.debug('Stopped dumping raw danmaku') self._logger.debug('Stopped dumping raw danmaku')
async def _dumping_loop(self, file: AsyncTextIOWrapper) -> None: async def _dumping_loop(self, file: AsyncTextIOWrapper) -> None:
while True: while True:

View File

@@ -1,33 +1,33 @@
import logging
from asyncio import Queue, QueueFull from asyncio import Queue, QueueFull
from typing import Final from typing import Final
from loguru import logger
from blrec.bili.danmaku_client import DanmakuClient, DanmakuListener from blrec.bili.danmaku_client import DanmakuClient, DanmakuListener
from blrec.bili.live import Live
from blrec.bili.typing import Danmaku from blrec.bili.typing import Danmaku
from blrec.utils.mixins import StoppableMixin from blrec.utils.mixins import StoppableMixin
__all__ = ('RawDanmakuReceiver',) __all__ = ('RawDanmakuReceiver',)
logger = logging.getLogger(__name__)
class RawDanmakuReceiver(DanmakuListener, StoppableMixin): class RawDanmakuReceiver(DanmakuListener, StoppableMixin):
_MAX_QUEUE_SIZE: Final[int] = 2000 _MAX_QUEUE_SIZE: Final[int] = 2000
def __init__(self, danmaku_client: DanmakuClient) -> None: def __init__(self, live: Live, danmaku_client: DanmakuClient) -> None:
super().__init__() super().__init__()
self._logger = logger.bind(room_id=live.room_id)
self._danmaku_client = danmaku_client self._danmaku_client = danmaku_client
self._queue: Queue[Danmaku] = Queue(maxsize=self._MAX_QUEUE_SIZE) self._queue: Queue[Danmaku] = Queue(maxsize=self._MAX_QUEUE_SIZE)
def _do_start(self) -> None: def _do_start(self) -> None:
self._danmaku_client.add_listener(self) self._danmaku_client.add_listener(self)
logger.debug('Started raw danmaku receiver') self._logger.debug('Started raw danmaku receiver')
def _do_stop(self) -> None: def _do_stop(self) -> None:
self._danmaku_client.remove_listener(self) self._danmaku_client.remove_listener(self)
self._clear_queue() self._clear_queue()
logger.debug('Stopped raw danmaku receiver') self._logger.debug('Stopped raw danmaku receiver')
async def get_raw_danmaku(self) -> Danmaku: async def get_raw_danmaku(self) -> Danmaku:
return await self._queue.get() return await self._queue.get()

View File

@@ -1,19 +1,20 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
from datetime import datetime from datetime import datetime
from typing import Iterator, Optional from typing import Iterator, Optional
import humanize import humanize
from loguru import logger
from blrec.bili.danmaku_client import DanmakuClient from blrec.bili.danmaku_client import DanmakuClient
from blrec.bili.live import Live from blrec.bili.live import Live
from blrec.bili.live_monitor import LiveEventListener, LiveMonitor from blrec.bili.live_monitor import LiveEventListener, LiveMonitor
from blrec.bili.models import RoomInfo from blrec.bili.models import RoomInfo
from blrec.bili.typing import QualityNumber, StreamFormat from blrec.bili.typing import QualityNumber, StreamFormat
from blrec.core.typing import MetaData
from blrec.event.event_emitter import EventEmitter, EventListener 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.setting.typing import RecordingMode
from blrec.utils.mixins import AsyncStoppableMixin from blrec.utils.mixins import AsyncStoppableMixin
@@ -31,9 +32,6 @@ from .stream_recorder import StreamRecorder, StreamRecorderEventListener
__all__ = 'RecorderEventListener', 'Recorder' __all__ = 'RecorderEventListener', 'Recorder'
logger = logging.getLogger(__name__)
class RecorderEventListener(EventListener): class RecorderEventListener(EventListener):
async def on_recording_started(self, recorder: Recorder) -> None: async def on_recording_started(self, recorder: Recorder) -> None:
... ...
@@ -104,6 +102,8 @@ class Recorder(
save_raw_danmaku: bool = False, save_raw_danmaku: bool = False,
) -> None: ) -> None:
super().__init__() super().__init__()
self._logger_context = {'room_id': live.room_id}
self._logger = logger.bind(**self._logger_context)
self._live = live self._live = live
self._danmaku_client = danmaku_client self._danmaku_client = danmaku_client
@@ -114,7 +114,8 @@ class Recorder(
self._stream_available: bool = False self._stream_available: bool = False
self._stream_recorder = StreamRecorder( self._stream_recorder = StreamRecorder(
self._live, live,
live_monitor,
out_dir=out_dir, out_dir=out_dir,
path_template=path_template, path_template=path_template,
stream_format=stream_format, stream_format=stream_format,
@@ -128,9 +129,9 @@ class Recorder(
duration_limit=duration_limit, duration_limit=duration_limit,
) )
self._danmaku_receiver = DanmakuReceiver(danmaku_client) self._danmaku_receiver = DanmakuReceiver(live, danmaku_client)
self._danmaku_dumper = DanmakuDumper( self._danmaku_dumper = DanmakuDumper(
self._live, live,
self._stream_recorder, self._stream_recorder,
self._danmaku_receiver, self._danmaku_receiver,
danmu_uname=danmu_uname, danmu_uname=danmu_uname,
@@ -139,13 +140,13 @@ class Recorder(
record_guard_buy=record_guard_buy, record_guard_buy=record_guard_buy,
record_super_chat=record_super_chat, record_super_chat=record_super_chat,
) )
self._raw_danmaku_receiver = RawDanmakuReceiver(danmaku_client) self._raw_danmaku_receiver = RawDanmakuReceiver(live, danmaku_client)
self._raw_danmaku_dumper = RawDanmakuDumper( self._raw_danmaku_dumper = RawDanmakuDumper(
self._live, self._stream_recorder, self._raw_danmaku_receiver live, self._stream_recorder, self._raw_danmaku_receiver
) )
self._cover_downloader = CoverDownloader( self._cover_downloader = CoverDownloader(
self._live, live,
self._stream_recorder, self._stream_recorder,
save_cover=save_cover, save_cover=save_cover,
cover_save_strategy=cover_save_strategy, cover_save_strategy=cover_save_strategy,
@@ -378,12 +379,12 @@ class Recorder(
return self._stream_recorder.cut_stream() return self._stream_recorder.cut_stream()
async def on_live_began(self, live: Live) -> None: async def on_live_began(self, live: Live) -> None:
logger.info('The live has began') self._logger.info('The live has began')
self._print_live_info() self._print_live_info()
await self._start_recording() await self._start_recording()
async def on_live_ended(self, live: Live) -> None: async def on_live_ended(self, live: Live) -> None:
logger.info('The live has ended') self._logger.info('The live has ended')
await asyncio.sleep(3) await asyncio.sleep(3)
self._stream_available = False self._stream_available = False
self._stream_recorder.stream_available_time = None self._stream_recorder.stream_available_time = None
@@ -391,13 +392,13 @@ class Recorder(
self._print_waiting_message() self._print_waiting_message()
async def on_live_stream_available(self, live: Live) -> None: async def on_live_stream_available(self, live: Live) -> None:
logger.debug('The live stream becomes available') self._logger.debug('The live stream becomes available')
self._stream_available = True self._stream_available = True
self._stream_recorder.stream_available_time = await live.get_timestamp() self._stream_recorder.stream_available_time = await live.get_timestamp()
await self._stream_recorder.start() await self._stream_recorder.start()
async def on_live_stream_reset(self, live: Live) -> None: async def on_live_stream_reset(self, live: Live) -> None:
logger.warning('The live stream has been reset') self._logger.warning('The live stream has been reset')
if not self._recording: if not self._recording:
await self._start_recording() await self._start_recording()
@@ -427,7 +428,7 @@ class Recorder(
await self._emit('cover_image_downloaded', self, path) await self._emit('cover_image_downloaded', self, path)
async def on_stream_recording_completed(self) -> None: async def on_stream_recording_completed(self) -> None:
logger.debug('Stream recording completed') self._logger.debug('Stream recording completed')
await self._stop_recording() await self._stop_recording()
async def _do_start(self) -> None: async def _do_start(self) -> None:
@@ -435,7 +436,7 @@ class Recorder(
self._danmaku_dumper.add_listener(self) self._danmaku_dumper.add_listener(self)
self._raw_danmaku_dumper.add_listener(self) self._raw_danmaku_dumper.add_listener(self)
self._cover_downloader.add_listener(self) self._cover_downloader.add_listener(self)
logger.debug('Started recorder') self._logger.debug('Started recorder')
self._print_live_info() self._print_live_info()
if self._live.is_living(): if self._live.is_living():
@@ -450,7 +451,7 @@ class Recorder(
self._danmaku_dumper.remove_listener(self) self._danmaku_dumper.remove_listener(self)
self._raw_danmaku_dumper.remove_listener(self) self._raw_danmaku_dumper.remove_listener(self)
self._cover_downloader.remove_listener(self) self._cover_downloader.remove_listener(self)
logger.debug('Stopped recorder') self._logger.debug('Stopped recorder')
async def _start_recording(self) -> None: async def _start_recording(self) -> None:
if self._recording: if self._recording:
@@ -469,7 +470,7 @@ class Recorder(
if self._stream_available: if self._stream_available:
await self._stream_recorder.start() await self._stream_recorder.start()
logger.info('Started recording') self._logger.info('Started recording')
await self._emit('recording_started', self) await self._emit('recording_started', self)
async def _stop_recording(self) -> None: async def _stop_recording(self) -> None:
@@ -487,10 +488,10 @@ class Recorder(
self._stream_recorder.remove_listener(self) self._stream_recorder.remove_listener(self)
if self._stopped: if self._stopped:
logger.info('Recording Cancelled') self._logger.info('Recording Cancelled')
await self._emit('recording_cancelled', self) await self._emit('recording_cancelled', self)
else: else:
logger.info('Recording Finished') self._logger.info('Recording Finished')
await self._emit('recording_finished', self) await self._emit('recording_finished', self)
async def _prepare(self) -> None: async def _prepare(self) -> None:
@@ -500,7 +501,7 @@ class Recorder(
self._stream_recorder.clear_files() self._stream_recorder.clear_files()
def _print_waiting_message(self) -> None: def _print_waiting_message(self) -> None:
logger.info('Waiting... until the live starts') self._logger.info('Waiting... until the live starts')
def _print_live_info(self) -> None: def _print_live_info(self) -> None:
room_info = self._live.room_info room_info = self._live.room_info
@@ -513,11 +514,9 @@ class Recorder(
msg = f""" msg = f"""
================================== User Info ================================== ================================== User Info ==================================
user id : {user_info.uid}
user name : {user_info.name} user name : {user_info.name}
gender : {user_info.gender} gender : {user_info.gender}
sign : {user_info.sign}
uid : {user_info.uid}
level : {user_info.level}
---------------------------------- Room Info ---------------------------------- ---------------------------------- Room Info ----------------------------------
title : {room_info.title} title : {room_info.title}
cover : {room_info.cover} cover : {room_info.cover}
@@ -535,7 +534,7 @@ description :
{room_info.description} {room_info.description}
=============================================================================== ===============================================================================
""" """
logger.info(msg) self._logger.info(msg)
def _print_changed_room_info(self, room_info: RoomInfo) -> None: def _print_changed_room_info(self, room_info: RoomInfo) -> None:
msg = f""" msg = f"""
@@ -547,4 +546,4 @@ parent area id : {room_info.parent_area_id}
parent area name : {room_info.parent_area_name} parent area name : {room_info.parent_area_name}
=============================================================================== ===============================================================================
""" """
logger.info(msg) self._logger.info(msg)

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any, Final, Optional from typing import Any, Final, Optional
import attr import attr
@@ -13,9 +12,6 @@ from blrec.bili.typing import ApiPlatform, QualityNumber, StreamFormat
__all__ = ('StreamParamHolder',) __all__ = ('StreamParamHolder',)
logger = logging.getLogger(__name__)
@attr.s(auto_attribs=True, frozen=True, slots=True) @attr.s(auto_attribs=True, frozen=True, slots=True)
class StreamParams: class StreamParams:
stream_format: StreamFormat stream_format: StreamFormat

View File

@@ -1,27 +1,26 @@
import asyncio import asyncio
import logging
import time import time
from typing import Iterator, Optional from typing import Iterator, Optional
from loguru import logger
from blrec.bili.live import Live from blrec.bili.live import Live
from blrec.bili.live_monitor import LiveMonitor
from blrec.bili.typing import QualityNumber, StreamFormat from blrec.bili.typing import QualityNumber, StreamFormat
from blrec.event.event_emitter import EventEmitter 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.setting.typing import RecordingMode
from blrec.utils.libc import malloc_trim from blrec.utils.libc import malloc_trim
from blrec.utils.mixins import AsyncStoppableMixin from blrec.utils.mixins import AsyncStoppableMixin
from .flv_stream_recorder_impl import FLVStreamRecorderImpl from .flv_stream_recorder_impl import FLVStreamRecorderImpl
from .hls_raw_stream_recorder_impl import HLSRawStreamRecorderImpl
from .hls_stream_recorder_impl import HLSStreamRecorderImpl from .hls_stream_recorder_impl import HLSStreamRecorderImpl
from .stream_recorder_impl import StreamRecorderEventListener from .stream_recorder_impl import StreamRecorderEventListener
from .typing import MetaData
__all__ = 'StreamRecorder', 'StreamRecorderEventListener' __all__ = 'StreamRecorder', 'StreamRecorderEventListener'
logger = logging.getLogger(__name__)
class StreamRecorder( class StreamRecorder(
StreamRecorderEventListener, StreamRecorderEventListener,
EventEmitter[StreamRecorderEventListener], EventEmitter[StreamRecorderEventListener],
@@ -30,6 +29,7 @@ class StreamRecorder(
def __init__( def __init__(
self, self,
live: Live, live: Live,
live_monitor: LiveMonitor,
out_dir: str, out_dir: str,
path_template: str, path_template: str,
*, *,
@@ -44,8 +44,11 @@ class StreamRecorder(
duration_limit: int = 0, duration_limit: int = 0,
) -> None: ) -> None:
super().__init__() super().__init__()
self._logger_context = {'room_id': live.room_id}
self._logger = logger.bind(**self._logger_context)
self._live = live self._live = live
self._live_monitor = live_monitor
self.stream_format = stream_format self.stream_format = stream_format
self.recording_mode = recording_mode self.recording_mode = recording_mode
self.fmp4_stream_timeout = fmp4_stream_timeout self.fmp4_stream_timeout = fmp4_stream_timeout
@@ -53,12 +56,9 @@ class StreamRecorder(
if stream_format == 'flv': if stream_format == 'flv':
cls = FLVStreamRecorderImpl cls = FLVStreamRecorderImpl
elif stream_format == 'fmp4': elif stream_format == 'fmp4':
if recording_mode == 'standard': cls = HLSStreamRecorderImpl # type: ignore
cls = HLSStreamRecorderImpl # type: ignore
else:
cls = HLSRawStreamRecorderImpl # type: ignore
else: else:
logger.warning( self._logger.warning(
f'The specified stream format ({stream_format}) is ' f'The specified stream format ({stream_format}) is '
'unsupported, will using the stream format (flv) instead.' 'unsupported, will using the stream format (flv) instead.'
) )
@@ -67,6 +67,7 @@ class StreamRecorder(
self._impl = cls( self._impl = cls(
live=live, live=live,
live_monitor=live_monitor,
out_dir=out_dir, out_dir=out_dir,
path_template=path_template, path_template=path_template,
quality_number=quality_number, quality_number=quality_number,
@@ -238,19 +239,32 @@ class StreamRecorder(
async def _do_start(self) -> None: async def _do_start(self) -> None:
self.hls_stream_available_time = None self.hls_stream_available_time = None
stream_format = self.stream_format stream_format = self.stream_format
if stream_format == 'fmp4':
logger.info('Waiting for the fmp4 stream becomes available...') if self._live.has_no_flv_streams():
available = await self._wait_fmp4_stream() if stream_format == 'flv':
if available: self._logger.warning(
if self.stream_available_time is not None: 'The specified stream format (flv) is not available, '
self.hls_stream_available_time = await self._live.get_timestamp() 'falling back to stream format (fmp4).'
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' stream_format = 'fmp4'
self.hls_stream_available_time = self.stream_available_time
else:
if stream_format == 'fmp4':
self._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:
self._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) self._change_impl(stream_format)
await self._impl.start() await self._impl.start()
@@ -264,10 +278,12 @@ class StreamRecorder(
async def on_video_file_completed(self, path: str) -> None: async def on_video_file_completed(self, path: str) -> None:
await self._emit('video_file_completed', path) await self._emit('video_file_completed', path)
async def on_stream_recording_interrupted(self, duration: float) -> None: async def on_stream_recording_interrupted(
await self._emit('stream_recording_interrupted', duration) 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) await self._emit('stream_recording_recovered', timestamp)
async def on_stream_recording_completed(self) -> None: async def on_stream_recording_completed(self) -> None:
@@ -294,12 +310,9 @@ class StreamRecorder(
if stream_format == 'flv': if stream_format == 'flv':
cls = FLVStreamRecorderImpl cls = FLVStreamRecorderImpl
elif stream_format == 'fmp4': elif stream_format == 'fmp4':
if self.recording_mode == 'standard': cls = HLSStreamRecorderImpl # type: ignore
cls = HLSStreamRecorderImpl # type: ignore
else:
cls = HLSRawStreamRecorderImpl # type: ignore
else: else:
logger.warning( self._logger.warning(
f'The specified stream format ({stream_format}) is ' f'The specified stream format ({stream_format}) is '
'unsupported, will using the stream format (flv) instead.' 'unsupported, will using the stream format (flv) instead.'
) )
@@ -314,6 +327,7 @@ class StreamRecorder(
self._impl = cls( self._impl = cls(
live=self._impl._live, live=self._impl._live,
live_monitor=self._impl._live_monitor,
out_dir=self._impl.out_dir, out_dir=self._impl.out_dir,
path_template=self._impl.path_template, path_template=self._impl.path_template,
quality_number=self._impl.quality_number, quality_number=self._impl.quality_number,
@@ -328,4 +342,4 @@ class StreamRecorder(
self._impl.stream_available_time = stream_available_time self._impl.stream_available_time = stream_available_time
self._impl.hls_stream_available_time = hls_stream_available_time self._impl.hls_stream_available_time = hls_stream_available_time
logger.debug(f'Changed stream recorder impl to {cls.__name__}') self._logger.debug(f'Changed stream recorder impl to {cls.__name__}')

View File

@@ -7,16 +7,18 @@ from typing import Any, Iterator, List, Optional, Tuple, Union
import requests import requests
import urllib3 import urllib3
from loguru import logger
from reactivex import abc from reactivex import abc
from reactivex.typing import StartableFactory, StartableTarget from reactivex.typing import StartableFactory, StartableTarget
from blrec.bili.live import Live from blrec.bili.live import Live
from blrec.bili.live_monitor import LiveMonitor
from blrec.bili.typing import QualityNumber, StreamFormat from blrec.bili.typing import QualityNumber, StreamFormat
from blrec.event.event_emitter import EventEmitter, EventListener from blrec.event.event_emitter import EventEmitter, EventListener
from blrec.flv import operators as flv_ops from blrec.flv import operators as flv_ops
from blrec.flv.operators import StreamProfile from blrec.flv.operators import StreamProfile
from blrec.flv.utils import format_timestamp 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.setting.typing import RecordingMode
from blrec.utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin from blrec.utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin
@@ -28,7 +30,6 @@ from .stream_param_holder import StreamParamHolder
__all__ = ('StreamRecorderImpl',) __all__ = ('StreamRecorderImpl',)
logger = logging.getLogger(__name__)
logging.getLogger(urllib3.__name__).setLevel(logging.WARNING) logging.getLogger(urllib3.__name__).setLevel(logging.WARNING)
@@ -39,10 +40,15 @@ class StreamRecorderEventListener(EventListener):
async def on_video_file_completed(self, path: str) -> None: 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: async def on_stream_recording_completed(self) -> None:
@@ -58,6 +64,7 @@ class StreamRecorderImpl(
def __init__( def __init__(
self, self,
live: Live, live: Live,
live_monitor: LiveMonitor,
out_dir: str, out_dir: str,
path_template: str, path_template: str,
*, *,
@@ -71,8 +78,11 @@ class StreamRecorderImpl(
duration_limit: int = 0, duration_limit: int = 0,
) -> None: ) -> None:
super().__init__() super().__init__()
self._logger_context = {'room_id': live.room_id}
self._logger = logger.bind(**self._logger_context)
self._live = live self._live = live
self._live_monitor = live_monitor
self._session = requests.Session() self._session = requests.Session()
self._recording_mode = recording_mode self._recording_mode = recording_mode
@@ -85,7 +95,7 @@ class StreamRecorderImpl(
stream_format=stream_format, quality_number=quality_number stream_format=stream_format, quality_number=quality_number
) )
self._stream_url_resolver = core_ops.StreamURLResolver( 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._progress_bar = core_ops.ProgressBar(live)
self._metadata_provider = MetadataProvider(live, self) self._metadata_provider = MetadataProvider(live, self)
@@ -93,7 +103,9 @@ class StreamRecorderImpl(
self._rec_statistics = core_ops.SizedStatistics() self._rec_statistics = core_ops.SizedStatistics()
self._dl_statistics: Union[core_ops.StreamStatistics, 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( self._connection_error_handler = core_ops.ConnectionErrorHandler(
live, disconnection_timeout=disconnection_timeout live, disconnection_timeout=disconnection_timeout
) )
@@ -240,7 +252,7 @@ class StreamRecorderImpl(
return '' return ''
@property @property
def metadata(self) -> Optional[flv_ops.MetaData]: def metadata(self) -> Optional[Union[flv_ops.MetaData, hls_ops.MetaData]]:
return None return None
@property @property
@@ -272,14 +284,14 @@ class StreamRecorderImpl(
self._completed = False self._completed = False
async def _do_start(self) -> None: async def _do_start(self) -> None:
logger.debug('Starting stream recorder...') self._logger.debug('Starting stream recorder...')
self._on_start() self._on_start()
self._reset() self._reset()
self._run() self._run()
logger.debug('Started stream recorder') self._logger.debug('Started stream recorder')
async def _do_stop(self) -> None: async def _do_stop(self) -> None:
logger.debug('Stopping stream recorder...') self._logger.debug('Stopping stream recorder...')
self._stream_param_holder.cancel() self._stream_param_holder.cancel()
thread = self._thread_factory('StreamRecorderDisposer')(self._dispose) thread = self._thread_factory('StreamRecorderDisposer')(self._dispose)
thread.start() thread.start()
@@ -287,7 +299,7 @@ class StreamRecorderImpl(
await self._loop.run_in_executor(None, thread.join, 30) await self._loop.run_in_executor(None, thread.join, 30)
self._threads.clear() self._threads.clear()
self._on_stop() self._on_stop()
logger.debug('Stopped stream recorder') self._logger.debug('Stopped stream recorder')
def _on_start(self) -> None: def _on_start(self) -> None:
pass pass
@@ -301,10 +313,15 @@ class StreamRecorderImpl(
def _thread_factory(self, name: str) -> StartableFactory: def _thread_factory(self, name: str) -> StartableFactory:
def factory(target: StartableTarget) -> Thread: def factory(target: StartableTarget) -> Thread:
def run() -> None:
with logger.contextualize(room_id=self._live.room_id):
target()
thread = Thread( thread = Thread(
target=target, daemon=True, name=f'{name}::{self._live.room_id}' target=run, daemon=True, name=f'{name}::{self._live.room_id}'
) )
self._threads.append(thread) self._threads.append(thread)
return thread return thread
return factory return factory
@@ -324,32 +341,37 @@ class StreamRecorderImpl(
self._emit_event('stream_recording_completed') self._emit_event('stream_recording_completed')
def _on_profile_updated(self, profile: StreamProfile) -> None: def _on_profile_updated(self, profile: StreamProfile) -> None:
logger.debug(f'Stream profile: {profile}') self._logger.debug(f'Stream profile: {profile}')
self._stream_profile = profile self._stream_profile = profile
def _on_video_file_opened(self, args: Tuple[str, int]) -> None: def _on_video_file_opened(self, args: Tuple[str, int]) -> None:
logger.info(f"Video file created: '{args[0]}'") self._logger.info(f"Video file created: '{args[0]}'")
self._files.append(args[0]) self._files.append(args[0])
self._record_start_time = args[1] self._record_start_time = args[1]
self._emit_event('video_file_created', *args) self._emit_event('video_file_created', *args)
def _on_video_file_closed(self, path: str) -> None: def _on_video_file_closed(self, path: str) -> None:
logger.info(f"Video file completed: '{path}'") self._logger.info(f"Video file completed: '{path}'")
self._emit_event('video_file_completed', path) self._emit_event('video_file_completed', path)
def _on_recording_interrupted(self, duration: float) -> None: def _on_recording_interrupted(self, args: Tuple[float, float]) -> None:
duration_string = format_timestamp(int(duration * 1000)) timestamp, duration = args[0], args[1]
logger.info(f'Recording interrupted, current duration: {duration_string}')
self._emit_event('stream_recording_interrupted', duration)
def _on_recording_recovered(self, timestamp: int) -> None:
datetime_string = datetime.fromtimestamp(timestamp).isoformat() datetime_string = datetime.fromtimestamp(timestamp).isoformat()
logger.info(f'Recording recovered, current date time {(datetime_string)}') duration_string = format_timestamp(int(duration * 1000))
self._logger.warning(
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()
self._logger.warning(f'Recording recovered, datetime: {(datetime_string)}')
self._emit_event('stream_recording_recovered', timestamp) self._emit_event('stream_recording_recovered', timestamp)
def _emit_event(self, name: str, *args: Any, **kwds: Any) -> None: def _on_duration_lost(self, duration: float) -> None:
self._run_coroutine(self._emit(name, *args, **kwds)) self._logger.warning(f'Total duration lost: ≈ {(duration)} s')
self._emit_event('duration_lost', duration)
@aio_task_with_room_id def _emit_event(self, name: str, *args: Any, **kwds: Any) -> None:
async def _emit(self, *args: Any, **kwds: Any) -> None: # type: ignore self._call_coroutine(self._emit(name, *args, **kwds))
await super()._emit(*args, **kwds)

View File

@@ -1,5 +1,9 @@
from typing import Union 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 from .models import DanmuMsg, GiftSendMsg, GuardBuyMsg, SuperChatMsg, UserToastMsg
DanmakuMsg = Union[DanmuMsg, GiftSendMsg, GuardBuyMsg, SuperChatMsg, UserToastMsg] DanmakuMsg = Union[DanmuMsg, GiftSendMsg, GuardBuyMsg, SuperChatMsg, UserToastMsg]
MetaData = Union[FLVMetaData, HLSMetaData]

View File

@@ -1,18 +1,12 @@
import logging
from typing import Iterable from typing import Iterable
from .io import DanmakuReader, DanmakuWriter
from .common import copy_damus from .common import copy_damus
from .io import DanmakuReader, DanmakuWriter
from .typing import TimebaseType from .typing import TimebaseType
__all__ = 'TimebaseType', 'DanmakuCombinator' __all__ = 'TimebaseType', 'DanmakuCombinator'
logger = logging.getLogger(__name__)
class DanmakuCombinator: class DanmakuCombinator:
def __init__( def __init__(
self, self,

View File

@@ -1,13 +1,7 @@
import logging
from typing import Iterable from typing import Iterable
from .io import DanmakuReader, DanmakuWriter
from .common import copy_damus from .common import copy_damus
from .io import DanmakuReader, DanmakuWriter
logger = logging.getLogger(__name__)
__all__ = 'DanmakuConcatenator' __all__ = 'DanmakuConcatenator'

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio import asyncio
import html import html
import logging
import unicodedata import unicodedata
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, AsyncIterator, Final, List from typing import Any, AsyncIterator, Final, List
@@ -24,9 +23,6 @@ from .typing import Element
__all__ = 'DanmakuReader', 'DanmakuWriter' __all__ = 'DanmakuReader', 'DanmakuWriter'
logger = logging.getLogger(__name__)
class DanmakuReader: # TODO rewrite class DanmakuReader: # TODO rewrite
def __init__(self, path: str) -> None: def __init__(self, path: str) -> None:
self._path = path self._path = path

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -10,6 +10,6 @@
<body> <body>
<app-root></app-root> <app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript> <noscript>Please enable JavaScript to continue using this application.</noscript>
<script src="runtime.34b6285f086501af.js" type="module"></script><script src="polyfills.4e5433063877ea34.js" type="module"></script><script src="main.f21b7d831ad9cafb.js" type="module"></script> <script src="runtime.5566e7902022ba3e.js" type="module"></script><script src="polyfills.4e5433063877ea34.js" type="module"></script><script src="main.f21b7d831ad9cafb.js" type="module"></script>
</body></html> </body></html>

View File

@@ -1,6 +1,6 @@
{ {
"configVersion": 1, "configVersion": 1,
"timestamp": 1695440376157, "timestamp": 1718810136190,
"index": "/index.html", "index": "/index.html",
"assetGroups": [ "assetGroups": [
{ {
@@ -12,17 +12,17 @@
}, },
"urls": [ "urls": [
"/103.4a2aea63cc3bf42b.js", "/103.4a2aea63cc3bf42b.js",
"/287.360829ef4dfc7f0e.js", "/287.f1c0b0beeb6810b2.js",
"/386.2404f3bc252e1df3.js", "/386.2404f3bc252e1df3.js",
"/503.6553f508f4a9247d.js", "/503.6553f508f4a9247d.js",
"/548.ea53a087779da599.js", "/548.e2df47ddad764d0b.js",
"/688.7032fddba7983cf6.js", "/688.7032fddba7983cf6.js",
"/common.1fc175bce139f4df.js", "/common.1fc175bce139f4df.js",
"/index.html", "/index.html",
"/main.f21b7d831ad9cafb.js", "/main.f21b7d831ad9cafb.js",
"/manifest.webmanifest", "/manifest.webmanifest",
"/polyfills.4e5433063877ea34.js", "/polyfills.4e5433063877ea34.js",
"/runtime.34b6285f086501af.js", "/runtime.5566e7902022ba3e.js",
"/styles.ae81e04dfa5b2860.css" "/styles.ae81e04dfa5b2860.css"
], ],
"patterns": [] "patterns": []
@@ -1635,10 +1635,10 @@
"dataGroups": [], "dataGroups": [],
"hashTable": { "hashTable": {
"/103.4a2aea63cc3bf42b.js": "2711817f2977bfdc18c34fee4fe9385fe012bb22", "/103.4a2aea63cc3bf42b.js": "2711817f2977bfdc18c34fee4fe9385fe012bb22",
"/287.360829ef4dfc7f0e.js": "2f8cb4a318c877840b29d84752e40ed698321e67", "/287.f1c0b0beeb6810b2.js": "875dca7598179957ed411aa5204ce12871a8e958",
"/386.2404f3bc252e1df3.js": "f937945645579b9651be2666f70cec2c5de4e367", "/386.2404f3bc252e1df3.js": "f937945645579b9651be2666f70cec2c5de4e367",
"/503.6553f508f4a9247d.js": "0878ea0e91bfd5458dd55875561e91060ecb0837", "/503.6553f508f4a9247d.js": "0878ea0e91bfd5458dd55875561e91060ecb0837",
"/548.ea53a087779da599.js": "efcdeae60239e68f14b9e410d4bfe64ecb592382", "/548.e2df47ddad764d0b.js": "0b60f5f001bd127b90d490617bba2091c4c39de3",
"/688.7032fddba7983cf6.js": "eae55044529782a51b7e534365255bbfa5522b05", "/688.7032fddba7983cf6.js": "eae55044529782a51b7e534365255bbfa5522b05",
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1", "/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1", "/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
@@ -3234,11 +3234,11 @@
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01", "/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068", "/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.1fc175bce139f4df.js": "af1775164711ec49e5c3a91ee45bd77509c17c54", "/common.1fc175bce139f4df.js": "af1775164711ec49e5c3a91ee45bd77509c17c54",
"/index.html": "16375e9b931dcf8a92d56b4a860ab95fb622e08b", "/index.html": "abe6df528859e9b6fafa3dda8a4001db74c04dd7",
"/main.f21b7d831ad9cafb.js": "fc51efa446c2ac21ee17e165217dd3faeacc5290", "/main.f21b7d831ad9cafb.js": "fc51efa446c2ac21ee17e165217dd3faeacc5290",
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586", "/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/polyfills.4e5433063877ea34.js": "68159ab99e0608976404a17132f60b5ceb6f12d2", "/polyfills.4e5433063877ea34.js": "68159ab99e0608976404a17132f60b5ceb6f12d2",
"/runtime.34b6285f086501af.js": "9ebaf308e01a4110d64e264057c87060e0d629c7", "/runtime.5566e7902022ba3e.js": "c7fa8d060497bd9938aca48eba6f523bf0eb85cd",
"/styles.ae81e04dfa5b2860.css": "5933b4f1c4d8fcc1891b68940ee78af4091472b7" "/styles.ae81e04dfa5b2860.css": "5933b4f1c4d8fcc1891b68940ee78af4091472b7"
}, },
"navigationUrls": [ "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,o,f)=>{if(!t){var a=1/0;for(i=0;i<e.length;i++){for(var[t,o,f]=e[i],c=!0,u=0;u<t.length;u++)(!1&f||a>=f)&&Object.keys(r.O).every(p=>r.O[p](t[u]))?t.splice(u--,1):(c=!1,f<a&&(a=f));if(c){e.splice(i--,1);var d=o();void 0!==d&&(n=d)}}return n}f=f||0;for(var i=e.length;i>0&&e[i-1][2]>f;i--)e[i]=e[i-1];e[i]=[t,o,f]},r.n=e=>{var n=e&&e.__esModule?()=>e.default:()=>e;return r.d(n,{a:n}),n},r.d=(e,n)=>{for(var t in n)r.o(n,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:n[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((n,t)=>(r.f[t](e,n),n),[])),r.u=e=>(592===e?"common":e)+"."+{103:"4a2aea63cc3bf42b",287:"f1c0b0beeb6810b2",386:"2404f3bc252e1df3",503:"6553f508f4a9247d",548:"e2df47ddad764d0b",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,o,f,i)=>{if(e[t])e[t].push(o);else{var a,c;if(void 0!==f)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+f){a=l;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",n+f),a.src=r.tu(t)),e[t]=[o];var s=(g,p)=>{a.onerror=a.onload=null,clearTimeout(b);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(p)),g)return g(p)},b=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),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=(o,f)=>{var i=r.o(e,o)?e[o]:void 0;if(0!==i)if(i)f.push(i[2]);else if(666!=o){var a=new Promise((l,s)=>i=e[o]=[l,s]);f.push(i[2]=a);var c=r.p+r.u(o),u=new Error;r.l(c,l=>{if(r.o(e,o)&&(0!==(i=e[o])&&(e[o]=void 0),i)){var s=l&&("load"===l.type?"missing":l.type),b=l&&l.target&&l.target.src;u.message="Loading chunk "+o+" failed.\n("+s+": "+b+")",u.name="ChunkLoadError",u.type=s,u.request=b,i[1](u)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var n=(o,f)=>{var u,d,[i,a,c]=f,l=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(o&&o(f);l<i.length;l++)r.o(e,d=i[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

@@ -1,20 +1,16 @@
import asyncio
import os import os
import shutil import shutil
import logging
import asyncio
from typing import Literal from typing import Literal
from loguru import logger
logger = logging.getLogger(__name__)
def is_space_enough(path: str, size: int) -> bool: def is_space_enough(path: str, size: int) -> bool:
return shutil.disk_usage(path).free > size return shutil.disk_usage(path).free > size
async def delete_file( async def delete_file(path: str, log_level: Literal['INFO', 'DEBUG'] = 'INFO') -> None:
path: str, log_level: Literal['INFO', 'DEBUG'] = 'INFO'
) -> None:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
try: try:
@@ -22,4 +18,4 @@ async def delete_file(
except Exception as e: except Exception as e:
logger.error(f'Failed to delete {path!r}, due to: {repr(e)}') logger.error(f'Failed to delete {path!r}, due to: {repr(e)}')
else: else:
logger.log(logging.getLevelName(log_level), f'Deleted {path!r}') logger.log(log_level, f'Deleted {path!r}')

View File

@@ -1,11 +1,11 @@
import asyncio import asyncio
import logging
import shutil import shutil
from contextlib import suppress from contextlib import suppress
from loguru import logger
from ..event.event_emitter import EventEmitter, EventListener from ..event.event_emitter import EventEmitter, EventListener
from ..exception import exception_callback from ..exception import exception_callback
from ..logging.room_id import aio_task_with_room_id
from ..utils.mixins import AsyncStoppableMixin, SwitchableMixin from ..utils.mixins import AsyncStoppableMixin, SwitchableMixin
from .helpers import is_space_enough from .helpers import is_space_enough
from .models import DiskUsage from .models import DiskUsage
@@ -13,9 +13,6 @@ from .models import DiskUsage
__all__ = 'SpaceMonitor', 'SpaceEventListener' __all__ = 'SpaceMonitor', 'SpaceEventListener'
logger = logging.getLogger(__name__)
class SpaceEventListener(EventListener): class SpaceEventListener(EventListener):
async def on_space_no_enough( async def on_space_no_enough(
self, path: str, threshold: int, disk_usage: DiskUsage self, path: str, threshold: int, disk_usage: DiskUsage
@@ -76,7 +73,6 @@ class SpaceMonitor(
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await self._polling_task await self._polling_task
@aio_task_with_room_id
async def _polling_loop(self) -> None: async def _polling_loop(self) -> None:
while True: while True:
if not is_space_enough(self.path, self.space_threshold): if not is_space_enough(self.path, self.space_threshold):

View File

@@ -1,28 +1,36 @@
import os
import glob
from datetime import datetime
from pathlib import Path
import logging
import asyncio import asyncio
import glob
import os
from datetime import datetime
from functools import partial from functools import partial
from pathlib import Path
from typing import Iterable, List from typing import Iterable, List
from tenacity import retry, wait_none, stop_after_attempt, retry_if_exception_type from loguru import logger
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_none
from .helpers import delete_file, is_space_enough
from .space_monitor import SpaceMonitor, DiskUsage, SpaceEventListener
from ..utils.mixins import SwitchableMixin from ..utils.mixins import SwitchableMixin
from .helpers import delete_file, is_space_enough
from .space_monitor import DiskUsage, SpaceEventListener, SpaceMonitor
__all__ = ('SpaceReclaimer',) __all__ = ('SpaceReclaimer',)
logger = logging.getLogger(__name__)
class SpaceReclaimer(SpaceEventListener, SwitchableMixin): class SpaceReclaimer(SpaceEventListener, SwitchableMixin):
_SUFFIX_SET = frozenset( _SUFFIX_SET = frozenset(
('.flv', '.mp4', '.ts', '.m4s', '.m3u8' '.xml', '.jsonl', '.jpg') (
'.flv',
'.mp4',
'.ts',
'.m4s',
'.m3u8',
'.xml',
'.json',
'.meta',
'.jsonl',
'.jpg',
'.png',
)
) )
def __init__( def __init__(

View File

@@ -1,12 +1,11 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC from abc import ABC
from contextlib import suppress from contextlib import suppress
from typing import Any, List, Generic, TypeVar from typing import Any, Generic, List, TypeVar
from ..exception import ExceptionSubmitter from ..exception import ExceptionSubmitter
__all__ = 'EventListener', 'EventEmitter' __all__ = 'EventListener', 'EventEmitter'

View File

@@ -1,14 +1,10 @@
import logging from loguru import logger
from .exception_center import ExceptionCenter
from ..utils.mixins import SwitchableMixin from ..utils.mixins import SwitchableMixin
from .exception_center import ExceptionCenter
from .helpers import format_exception
__all__ = ('ExceptionHandler',)
logger = logging.getLogger(__name__)
__all__ = 'ExceptionHandler',
class ExceptionHandler(SwitchableMixin): class ExceptionHandler(SwitchableMixin):
@@ -25,5 +21,4 @@ class ExceptionHandler(SwitchableMixin):
self._log_exception(exc) self._log_exception(exc)
def _log_exception(self, exc: BaseException) -> None: def _log_exception(self, exc: BaseException) -> None:
exc_info = (type(exc), exc, exc.__traceback__) logger.critical('{}\n{}', repr(exc), format_exception(exc))
logger.critical(type(exc).__name__, exc_info=exc_info)

View File

@@ -1,4 +1,3 @@
import logging
from io import SEEK_CUR from io import SEEK_CUR
from typing import Iterable, Iterator from typing import Iterable, Iterator
@@ -11,9 +10,6 @@ from .utils import AutoRollbacker, OffsetRepositor
__all__ = 'FlvReader', 'FlvWriter' __all__ = 'FlvReader', 'FlvWriter'
logger = logging.getLogger(__name__)
class FlvReader: class FlvReader:
def __init__( def __init__(
self, self,

View File

@@ -1,8 +1,8 @@
import json import json
import logging
import os import os
import attr import attr
from loguru import logger
from reactivex import Observable from reactivex import Observable
from reactivex import operators as ops from reactivex import operators as ops
@@ -13,9 +13,6 @@ from .operators.helpers import from_file
__all__ = 'AnalysingProgress', 'analyse_metadata' __all__ = 'AnalysingProgress', 'analyse_metadata'
logger = logging.getLogger(__name__)
@attr.s(auto_attribs=True, slots=True, frozen=True) @attr.s(auto_attribs=True, slots=True, frozen=True)
class AnalysingProgress: class AnalysingProgress:
count: int count: int

View File

@@ -1,9 +1,9 @@
import json import json
import logging
from contextlib import suppress from contextlib import suppress
from typing import List, Optional from typing import List, Optional
import attr import attr
from loguru import logger
from ..flv import operators as flv_ops from ..flv import operators as flv_ops
from ..path import extra_metadata_path from ..path import extra_metadata_path
@@ -11,8 +11,6 @@ from ..utils.mixins import SwitchableMixin
__all__ = ('MetadataDumper',) __all__ = ('MetadataDumper',)
logger = logging.getLogger(__name__)
class MetadataDumper(SwitchableMixin): class MetadataDumper(SwitchableMixin):
def __init__( def __init__(

View File

@@ -1,4 +1,3 @@
import logging
import os import os
from datetime import datetime from datetime import datetime
from typing import Any, Dict from typing import Any, Dict
@@ -16,9 +15,6 @@ from .operators.helpers import from_file
__all__ = 'InjectingProgress', 'inject_metadata' __all__ = 'InjectingProgress', 'inject_metadata'
logger = logging.getLogger(__name__)
@attr.s(auto_attribs=True, slots=True, frozen=True) @attr.s(auto_attribs=True, slots=True, frozen=True)
class InjectingProgress: class InjectingProgress:
count: int count: int

View File

@@ -4,10 +4,10 @@ ref: https://github.com/ioppermann/yamdi/blob/master/yamdi.c
""" """
from __future__ import annotations from __future__ import annotations
import logging
from typing import List, Optional, TypedDict from typing import List, Optional, TypedDict
import attr import attr
from loguru import logger
from reactivex import Observable, Subject, abc from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -25,8 +25,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = 'Analyser', 'MetaData', 'KeyFrames' __all__ = 'Analyser', 'MetaData', 'KeyFrames'
logger = logging.getLogger(__name__)
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True) @attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class KeyFrames: class KeyFrames:
@@ -103,6 +101,7 @@ class MetaDataDict:
class Analyser: class Analyser:
def __init__(self) -> None: def __init__(self) -> None:
self._metadatas: Subject[Optional[MetaData]] = Subject() self._metadatas: Subject[Optional[MetaData]] = Subject()
self._duration_updated: Subject[float] = Subject()
self._reset() self._reset()
def _reset(self) -> None: def _reset(self) -> None:
@@ -135,6 +134,10 @@ class Analyser:
def metadatas(self) -> Observable[Optional[MetaData]]: def metadatas(self) -> Observable[Optional[MetaData]]:
return self._metadatas return self._metadatas
@property
def duration_updated(self) -> Observable[float]:
return self._duration_updated
def __call__(self, source: FLVStream) -> FLVStream: def __call__(self, source: FLVStream) -> FLVStream:
return self._analyse(source) return self._analyse(source)
@@ -307,6 +310,7 @@ class Analyser:
self._size_of_tags += tag.tag_size self._size_of_tags += tag.tag_size
self._size_of_data += tag.data_size self._size_of_data += tag.data_size
self._last_timestamp = tag.timestamp self._last_timestamp = tag.timestamp
self._duration_updated.on_next(self._last_timestamp / 1000)
def _analyse_audio_tag(self, tag: AudioTag) -> None: def _analyse_audio_tag(self, tag: AudioTag) -> None:
if not self._audio_analysed: if not self._audio_analysed:

View File

@@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
import logging
from enum import IntEnum, auto from enum import IntEnum, auto
from typing import Callable, List, Optional, TypedDict, cast from typing import Callable, List, Optional, TypedDict, cast
import attr import attr
from loguru import logger
from reactivex import Observable, Subject, abc from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from typing_extensions import TypeGuard from typing_extensions import TypeGuard
@@ -26,8 +26,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('concat', 'JoinPointExtractor', 'JoinPoint', 'JoinPointData') __all__ = ('concat', 'JoinPointExtractor', 'JoinPoint', 'JoinPointData')
logger = logging.getLogger(__name__)
@attr.s(auto_attribs=True, slots=True, frozen=True) @attr.s(auto_attribs=True, slots=True, frozen=True)
class JoinPoint: class JoinPoint:

View File

@@ -1,6 +1,6 @@
import logging
from typing import Callable, Optional from typing import Callable, Optional
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -10,8 +10,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('correct',) __all__ = ('correct',)
logger = logging.getLogger(__name__)
def correct() -> Callable[[FLVStream], FLVStream]: def correct() -> Callable[[FLVStream], FLVStream]:
def _correct(source: FLVStream) -> FLVStream: def _correct(source: FLVStream) -> FLVStream:

View File

@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Optional from typing import Optional
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -20,9 +20,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('Cutter',) __all__ = ('Cutter',)
logger = logging.getLogger(__name__)
class Cutter: class Cutter:
def __init__(self, min_duration: int = 5_000) -> None: def __init__(self, min_duration: int = 5_000) -> None:
self._min_duration = min_duration # milliseconds self._min_duration = min_duration # milliseconds

View File

@@ -1,6 +1,6 @@
import logging
from typing import Callable, List, Optional from typing import Callable, List, Optional
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -9,8 +9,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('defragment',) __all__ = ('defragment',)
logger = logging.getLogger(__name__)
def defragment(min_tags: int = 10) -> Callable[[FLVStream], FLVStream]: def defragment(min_tags: int = 10) -> Callable[[FLVStream], FLVStream]:
def _defragment(source: FLVStream) -> FLVStream: def _defragment(source: FLVStream) -> FLVStream:

View File

@@ -1,7 +1,7 @@
import io import io
import logging
from typing import Callable, Optional, Tuple from typing import Callable, Optional, Tuple
from loguru import logger
from reactivex import Observable, Subject, abc from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -11,8 +11,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('Dumper',) __all__ = ('Dumper',)
logger = logging.getLogger(__name__)
class Dumper: class Dumper:
def __init__( def __init__(

View File

@@ -1,7 +1,7 @@
import logging
import math import math
from typing import Callable, Optional from typing import Callable, Optional
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -17,8 +17,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('fix',) __all__ = ('fix',)
logger = logging.getLogger(__name__)
def fix() -> Callable[[FLVStream], FLVStream]: def fix() -> Callable[[FLVStream], FLVStream]:
def _fix(source: FLVStream) -> FLVStream: def _fix(source: FLVStream) -> FLVStream:

View File

@@ -1,5 +1,4 @@
import io import io
import logging
from reactivex import of from reactivex import of
@@ -8,8 +7,6 @@ from .typing import FLVStream
__all__ = ('from_file', 'from_stream') __all__ = ('from_file', 'from_stream')
logger = logging.getLogger(__name__)
def from_stream( def from_stream(
stream: io.RawIOBase, stream: io.RawIOBase,
@@ -31,7 +28,7 @@ def from_file(
path: str, *, backup_timestamp: bool = False, restore_timestamp: bool = False path: str, *, backup_timestamp: bool = False, restore_timestamp: bool = False
) -> FLVStream: ) -> FLVStream:
return from_stream( return from_stream(
open(path, 'rb'), open(path, 'rb'), # type: ignore
complete_on_eof=True, complete_on_eof=True,
backup_timestamp=backup_timestamp, backup_timestamp=backup_timestamp,
restore_timestamp=restore_timestamp, restore_timestamp=restore_timestamp,

View File

@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any, Callable, Dict, Optional, cast from typing import Any, Callable, Dict, Optional, cast
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from ..common import ( from ..common import (
@@ -19,9 +19,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('Injector',) __all__ = ('Injector',)
logger = logging.getLogger(__name__)
class Injector: class Injector:
def __init__( def __init__(
self, metadata_provider: Callable[[Dict[str, Any]], Dict[str, Any]] self, metadata_provider: Callable[[Dict[str, Any]], Dict[str, Any]]

View File

@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Optional from typing import Optional
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -19,9 +19,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('Limiter',) __all__ = ('Limiter',)
logger = logging.getLogger(__name__)
class Limiter: class Limiter:
def __init__( def __init__(
self, self,

View File

@@ -1,10 +1,12 @@
import io import io
import logging
from typing import Callable, Optional from typing import Callable, Optional
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from blrec.exception.helpers import format_exception
from ..common import create_avc_end_sequence_tag, is_avc_end_sequence from ..common import create_avc_end_sequence_tag, is_avc_end_sequence
from ..io import FlvReader from ..io import FlvReader
from ..models import FlvTag from ..models import FlvTag
@@ -12,8 +14,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('parse',) __all__ = ('parse',)
logger = logging.getLogger(__name__)
def parse( def parse(
*, *,
@@ -36,7 +36,7 @@ def parse(
try: try:
try: try:
reader = FlvReader( reader = FlvReader(
stream, stream, # type: ignore
backup_timestamp=backup_timestamp, backup_timestamp=backup_timestamp,
restore_timestamp=restore_timestamp, restore_timestamp=restore_timestamp,
) )
@@ -60,7 +60,9 @@ def parse(
observer.on_error(e) observer.on_error(e)
except ValueError as e: except ValueError as e:
logger.debug( logger.debug(
f'Error occurred while parsing stream: {repr(e)}', exc_info=e 'Error occurred while parsing stream: {}\n{}',
repr(e),
format_exception(e),
) )
if not ignore_value_error: if not ignore_value_error:
observer.on_error(e) observer.on_error(e)

View File

@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
import io import io
import logging
from typing import List, Optional, cast from typing import List, Optional, cast
from loguru import logger
from reactivex import Observable, Subject, abc from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable 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 ..common import find_aac_header_tag, find_avc_header_tag
from ..io import FlvWriter from ..io import FlvWriter
from ..models import FlvHeader, FlvTag from ..models import FlvHeader, FlvTag
@@ -16,9 +16,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('Prober', 'StreamProfile') __all__ = ('Prober', 'StreamProfile')
logger = logging.getLogger(__name__)
class Prober: class Prober:
def __init__(self) -> None: def __init__(self) -> None:
self._profiles: Subject[StreamProfile] = Subject() self._profiles: Subject[StreamProfile] = Subject()
@@ -99,4 +96,4 @@ class Prober:
def on_error(e: Exception) -> None: def on_error(e: Exception) -> None:
logger.warning(f'Failed to probe stream by ffprobe: {repr(e)}') 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,4 +1,3 @@
import logging
from typing import Callable from typing import Callable
from reactivex import operators as ops from reactivex import operators as ops
@@ -14,8 +13,6 @@ from .typing import FLVStream
__all__ = ('process',) __all__ = ('process',)
logger = logging.getLogger(__name__)
def process(sort_tags: bool = False) -> Callable[[FLVStream], FLVStream]: def process(sort_tags: bool = False) -> Callable[[FLVStream], FLVStream]:
def _process(source: FLVStream) -> FLVStream: def _process(source: FLVStream) -> FLVStream:

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Optional from typing import Optional
from reactivex import Observable, abc from reactivex import Observable, abc
@@ -12,9 +11,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('ProgressBar',) __all__ = ('ProgressBar',)
logger = logging.getLogger(__name__)
class ProgressBar: class ProgressBar:
def __init__( def __init__(
self, self,

View File

@@ -1,7 +1,6 @@
import logging
import os
from typing import Callable, List, Optional from typing import Callable, List, Optional
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -20,10 +19,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('sort',) __all__ = ('sort',)
logger = logging.getLogger(__name__)
TRACE_OP_SORT = bool(os.environ.get('BLREC_TRACE_OP_SORT'))
def sort() -> Callable[[FLVStream], FLVStream]: def sort() -> Callable[[FLVStream], FLVStream]:
"Sort tags in GOP by timestamp to ensure subsequent operators work as expected." "Sort tags in GOP by timestamp to ensure subsequent operators work as expected."
@@ -46,14 +41,17 @@ def sort() -> Callable[[FLVStream], FLVStream]:
if not gop_tags: if not gop_tags:
return return
if TRACE_OP_SORT: logger.opt(lazy=True).trace(
logger.debug( 'Tags in GOP:\n'
'Tags in GOP:\n' 'Number of tags: {}\n'
f'Number of tags: {len(gop_tags)}\n' 'Total size of tags: {}\n'
f'Total size of tags: {sum(map(len, gop_tags))}\n' 'The first tag is {}\n'
f'The first tag is {gop_tags[0]}\n' 'The last tag is {}',
f'The last tag is {gop_tags[-1]}' lambda: len(gop_tags),
) lambda: sum(map(len, gop_tags)),
lambda: gop_tags[0],
lambda: gop_tags[-1],
)
if len(gop_tags) < 10: if len(gop_tags) < 10:
avc_header_tag = find_avc_header_tag(gop_tags) avc_header_tag = find_avc_header_tag(gop_tags)

View File

@@ -1,6 +1,6 @@
import logging
from typing import Callable, Optional from typing import Callable, Optional
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -15,8 +15,6 @@ from .typing import FLVStream, FLVStreamItem
__all__ = ('split',) __all__ = ('split',)
logger = logging.getLogger(__name__)
def split() -> Callable[[FLVStream], FLVStream]: def split() -> Callable[[FLVStream], FLVStream]:
def _split(source: FLVStream) -> FLVStream: def _split(source: FLVStream) -> FLVStream:

View File

@@ -1,7 +1,8 @@
import logging
from io import BytesIO from io import BytesIO
from typing import Any, BinaryIO, Mapping, TypedDict from typing import Any, BinaryIO, Mapping, TypedDict
from loguru import logger
from .amf import AMFReader, AMFWriter from .amf import AMFReader, AMFWriter
__all__ = ( __all__ = (
@@ -14,8 +15,6 @@ __all__ = (
'ScriptDataDumper', 'ScriptDataDumper',
) )
logger = logging.getLogger(__name__)
class ScriptData(TypedDict): class ScriptData(TypedDict):
name: str name: str

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

@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from contextlib import suppress from contextlib import suppress
from typing import Any, Callable, Dict, Tuple from typing import Any, Callable, Dict, Tuple
from loguru import logger
from blrec.path.helpers import record_metadata_path from blrec.path.helpers import record_metadata_path
from blrec.utils.mixins import SwitchableMixin from blrec.utils.mixins import SwitchableMixin
@@ -12,23 +13,21 @@ from . import operators as hls_ops
__all__ = ('MetadataDumper',) __all__ = ('MetadataDumper',)
logger = logging.getLogger(__name__)
class MetadataDumper(SwitchableMixin): class MetadataDumper(SwitchableMixin):
def __init__( def __init__(
self, self,
playlist_dumper: hls_ops.PlaylistDumper, segment_dumper: hls_ops.SegmentDumper,
metadata_provider: Callable[[Dict[str, Any]], Dict[str, Any]], metadata_provider: Callable[[Dict[str, Any]], Dict[str, Any]],
) -> None: ) -> None:
super().__init__() super().__init__()
self._playlist_dumper = playlist_dumper self._segment_dumper = segment_dumper
self._metadata_provider = metadata_provider self._metadata_provider = metadata_provider
self._metadata: Dict[str, Any] = {} self._metadata: Dict[str, Any] = {}
def _do_enable(self) -> None: def _do_enable(self) -> None:
self._file_opened_subscription = self._playlist_dumper.file_opened.subscribe( self._file_opened_subscription = self._segment_dumper.file_opened.subscribe(
self._on_playlist_file_opened self._on_video_file_opened
) )
logger.debug('Enabled metadata dumper') logger.debug('Enabled metadata dumper')
@@ -39,13 +38,13 @@ class MetadataDumper(SwitchableMixin):
self._metadata.clear() self._metadata.clear()
logger.debug('Disabled metadata dumper') logger.debug('Disabled metadata dumper')
def _on_playlist_file_opened(self, args: Tuple[str, int]) -> None: def _on_video_file_opened(self, args: Tuple[str, int]) -> None:
playlist_path, _ = args video_path, _timestamp = args
metadata = self._metadata_provider({}) 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: def _dump_metadata(self, video_path: str, metadata: Dict[str, Any]) -> None:
path = record_metadata_path(playlist_path) path = record_metadata_path(video_path)
logger.debug(f"Dumping metadata to file: '{path}'") logger.debug(f"Dumping metadata to file: '{path}'")
with open(path, 'wt', encoding='utf8') as file: 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_dumper import PlaylistDumper
from .playlist_fetcher import PlaylistFetcher from .playlist_fetcher import PlaylistFetcher
from .playlist_resolver import PlaylistResolver from .playlist_resolver import PlaylistResolver
from .prober import Prober, StreamProfile from .prober import Prober, StreamProfile
from .segment_dumper import SegmentDumper from .segment_dumper import SegmentDumper
from .segment_fetcher import InitSectionData, SegmentData, SegmentFetcher from .segment_fetcher import InitSectionData, SegmentData, SegmentFetcher
from .segment_parser import SegmentParser
from .segment_remuxer import SegmentRemuxer
__all__ = ( __all__ = (
'Analyser',
'Cutter',
'InitSectionData', 'InitSectionData',
'Limiter',
'MetaData',
'PlaylistDumper', 'PlaylistDumper',
'PlaylistFetcher', 'PlaylistFetcher',
'PlaylistResolver', 'PlaylistResolver',
@@ -16,7 +21,5 @@ __all__ = (
'SegmentData', 'SegmentData',
'SegmentDumper', 'SegmentDumper',
'SegmentFetcher', 'SegmentFetcher',
'SegmentParser',
'SegmentRemuxer',
'StreamProfile', 'StreamProfile',
) )

View File

@@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Optional, Union
import attr
from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from .playlist_dumper import PlaylistDumper
from .prober import Prober, StreamProfile
from .segment_dumper import SegmentDumper
from .segment_fetcher import InitSectionData, SegmentData
__all__ = ('Analyser', 'MetaData')
@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,86 @@
from __future__ import annotations
from typing import Optional, Tuple, Union
from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from .playlist_dumper import PlaylistDumper
from .segment_fetcher import InitSectionData, SegmentData
__all__ = ('Cutter',)
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,90 @@
from __future__ import annotations
from typing import Optional, Union
from loguru import logger
from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from .playlist_dumper import PlaylistDumper
from .segment_dumper import SegmentDumper
from .segment_fetcher import InitSectionData, SegmentData
__all__ = ('Limiter',)
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

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

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import logging
import time import time
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
@@ -8,6 +7,7 @@ from typing import Optional
import m3u8 import m3u8
import requests import requests
import urllib3 import urllib3
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential
@@ -18,9 +18,6 @@ from blrec.utils.mixins import SupportDebugMixin
__all__ = ('PlaylistFetcher',) __all__ = ('PlaylistFetcher',)
logger = logging.getLogger(__name__)
class PlaylistFetcher(SupportDebugMixin): class PlaylistFetcher(SupportDebugMixin):
def __init__(self, live: Live, session: requests.Session) -> None: def __init__(self, live: Live, session: requests.Session) -> None:
super().__init__() super().__init__()
@@ -49,9 +46,14 @@ class PlaylistFetcher(SupportDebugMixin):
def on_next(url: str) -> None: def on_next(url: str) -> None:
logger.info(f'Fetching playlist... {url}') logger.info(f'Fetching playlist... {url}')
while not disposed: while not disposed:
try: try:
content = self._fetch_playlist(url) 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: if self._debug:
playlist_debug_file.write(content + '\n') playlist_debug_file.write(content + '\n')
playlist = m3u8.loads(content, uri=url) playlist = m3u8.loads(content, uri=url)
@@ -59,12 +61,9 @@ class PlaylistFetcher(SupportDebugMixin):
url = self._get_best_quality_url(playlist) url = self._get_best_quality_url(playlist)
logger.debug('Playlist changed to variant playlist') logger.debug('Playlist changed to variant playlist')
on_next(url) on_next(url)
except Exception as e: else:
logger.warning(f'Failed to fetch playlist: {repr(e)}') observer.on_next(playlist)
observer.on_error(e) time.sleep(1)
else:
observer.on_next(playlist)
time.sleep(1)
def dispose() -> None: def dispose() -> None:
nonlocal disposed nonlocal disposed
@@ -96,10 +95,15 @@ class PlaylistFetcher(SupportDebugMixin):
) )
), ),
wait=wait_exponential(multiplier=0.1, max=1), 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: def _fetch_playlist(self, url: str) -> str:
response = self._session.get(url, headers=self._live.headers, timeout=3) try:
response.raise_for_status() response = self._session.get(url, headers=self._live.headers, timeout=3)
response.encoding = 'utf-8' response.raise_for_status()
return response.text 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,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
import logging
import os
from typing import Optional from typing import Optional
import m3u8 import m3u8
from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from reactivex import operators as ops from reactivex import operators as ops
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -13,16 +12,16 @@ from blrec.core import operators as core_ops
from blrec.utils import operators as utils_ops from blrec.utils import operators as utils_ops
from ..exceptions import NoNewSegments from ..exceptions import NoNewSegments
from ..helpler import sequence_number_of
__all__ = ('PlaylistResolver',) __all__ = ('PlaylistResolver',)
logger = logging.getLogger(__name__)
class PlaylistResolver: class PlaylistResolver:
def __init__(self, stream_url_resolver: core_ops.StreamURLResolver) -> None: def __init__(self, stream_url_resolver: core_ops.StreamURLResolver) -> None:
self._stream_url_resolver = stream_url_resolver 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]: def __call__(self, source: Observable[m3u8.M3U8]) -> Observable[m3u8.Segment]:
return self._solve(source).pipe( return self._solve(source).pipe(
@@ -30,14 +29,10 @@ class PlaylistResolver:
utils_ops.retry(should_retry=self._should_retry), 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]: def _solve(self, source: Observable[m3u8.M3U8]) -> Observable[m3u8.Segment]:
self._last_media_sequence = 0
self._last_sequence_number = None
def subscribe( def subscribe(
observer: abc.ObserverBase[m3u8.Segment], observer: abc.ObserverBase[m3u8.Segment],
scheduler: Optional[abc.SchedulerBase] = None, scheduler: Optional[abc.SchedulerBase] = None,
@@ -46,29 +41,43 @@ class PlaylistResolver:
subscription = SerialDisposable() subscription = SerialDisposable()
attempts: int = 0 attempts: int = 0
last_sequence_number: Optional[int] = None
def on_next(playlist: m3u8.M3U8) -> None: def on_next(playlist: m3u8.M3U8) -> None:
nonlocal attempts, last_sequence_number nonlocal attempts
discontinuity = False
if playlist.is_endlist: if playlist.is_endlist:
logger.debug('Playlist ended') 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 = [] new_segments = []
for seg in playlist.segments: for seg in playlist.segments:
num = self._sequence_number_of(seg.uri) num = sequence_number_of(seg.uri)
if last_sequence_number is not None: if self._last_sequence_number is not None:
if last_sequence_number >= num: if num <= self._last_sequence_number:
continue continue
if last_sequence_number + 1 != num: if num == self._last_sequence_number + 1:
discontinuity = False
else:
logger.warning( logger.warning(
'Segments discontinuous: ' 'Segments discontinuous: '
f'last sequence number: {last_sequence_number}, ' f'last sequence number: {self._last_sequence_number}, '
f'current sequence number: {num}' f'current sequence number: {num}'
) )
seg.discontinuity = True discontinuity = True
seg.discontinuity = discontinuity
seg.custom_parser_values['playlist'] = playlist
new_segments.append(seg) new_segments.append(seg)
last_sequence_number = num self._last_sequence_number = num
if not new_segments: if not new_segments:
attempts += 1 attempts += 1
@@ -84,9 +93,7 @@ class PlaylistResolver:
def dispose() -> None: def dispose() -> None:
nonlocal disposed nonlocal disposed
nonlocal last_sequence_number
disposed = True disposed = True
last_sequence_number = None
subscription.disposable = source.subscribe( subscription.disposable = source.subscribe(
on_next, observer.on_error, observer.on_completed, scheduler=scheduler on_next, observer.on_error, observer.on_completed, scheduler=scheduler

View File

@@ -1,22 +1,19 @@
from __future__ import annotations from __future__ import annotations
import io import io
import logging
from typing import List, Optional, Union from typing import List, Optional, Union
from loguru import logger
from reactivex import Observable, Subject, abc from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable 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 from .segment_fetcher import InitSectionData, SegmentData
__all__ = ('Prober', 'StreamProfile') __all__ = ('Prober', 'StreamProfile')
logger = logging.getLogger(__name__)
class Prober: class Prober:
def __init__(self) -> None: def __init__(self) -> None:
self._profiles: Subject[StreamProfile] = Subject() self._profiles: Subject[StreamProfile] = Subject()
@@ -88,4 +85,4 @@ class Prober:
def on_error(e: Exception) -> None: def on_error(e: Exception) -> None:
logger.warning(f'Failed to probe stream by ffprobe: {repr(e)}') 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,31 +1,40 @@
import logging import io
import os from pathlib import PurePath
from typing import Optional, Tuple, Union from typing import Callable, Optional, Tuple, Union
import attr
from loguru import logger
from reactivex import Observable, Subject, abc from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable 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 .segment_fetcher import InitSectionData, SegmentData
__all__ = ('SegmentDumper',) __all__ = ('SegmentDumper',)
logger = logging.getLogger(__name__)
class SegmentDumper: class SegmentDumper:
def __init__(self, playlist_dumper: PlaylistDumper) -> None: def __init__(
self._playlist_dumper = playlist_dumper self, path_provider: Callable[[Optional[int]], Tuple[str, int]]
self._out_dir: str = '' ) -> None:
self._path_provider = path_provider
def on_next(args: Tuple[str, int]) -> None:
path, timestamp = args
self._out_dir = os.path.dirname(path)
self._playlist_dumper.file_opened.subscribe(on_next)
self._file_opened: Subject[Tuple[str, int]] = Subject() self._file_opened: Subject[Tuple[str, int]] = Subject()
self._file_closed: Subject[str] = 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
@property
def path(self) -> str:
return self._path
@property
def filesize(self) -> int:
return self._filesize
@property @property
def file_opened(self) -> Observable[Tuple[str, int]]: def file_opened(self) -> Observable[Tuple[str, int]]:
@@ -40,6 +49,90 @@ class SegmentDumper:
) -> Observable[Union[InitSectionData, SegmentData]]: ) -> Observable[Union[InitSectionData, SegmentData]]:
return self._dump(source) return self._dump(source)
def _open_file(self) -> None:
path, timestamp = self._path_provider()
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 _is_redundant(
self, prev_init_item: Optional[InitSectionData], curr_init_item: InitSectionData
) -> bool:
return (
prev_init_item is not None
and curr_init_item.payload == prev_init_item.payload
)
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}')
if prev_init_item.payload == curr_init_item.payload:
logger.debug('the current init section is identical to the previous one')
return False
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')
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.get('bit_rate') != curr_audio_profile.get('bit_rate')
):
logger.warning('Audio parameters changed')
logger.debug(
'must split the file '
'because the current init section is not identical to the previous one'
)
return True
def _need_split_file(self, item: Union[InitSectionData, SegmentData]) -> bool:
return item.segment.custom_parser_values.get('split', False)
def _dump( def _dump(
self, source: Observable[Union[InitSectionData, SegmentData]] self, source: Observable[Union[InitSectionData, SegmentData]]
) -> Observable[Union[InitSectionData, SegmentData]]: ) -> Observable[Union[InitSectionData, SegmentData]]:
@@ -47,31 +140,64 @@ class SegmentDumper:
observer: abc.ObserverBase[Union[InitSectionData, SegmentData]], observer: abc.ObserverBase[Union[InitSectionData, SegmentData]],
scheduler: Optional[abc.SchedulerBase] = None, scheduler: Optional[abc.SchedulerBase] = None,
) -> abc.DisposableBase: ) -> abc.DisposableBase:
disposed = False
subscription = SerialDisposable() subscription = SerialDisposable()
last_init_item: Optional[InitSectionData] = None
def on_next(item: Union[InitSectionData, SegmentData]) -> None: def on_next(item: Union[InitSectionData, SegmentData]) -> None:
nonlocal last_init_item
split_file = False
if isinstance(item, InitSectionData): if isinstance(item, InitSectionData):
uri = item.init_section.uri if self._is_redundant(last_init_item, item):
path = os.path.join(self._out_dir, 'segments', uri) return
else: split_file = self._must_split_file(last_init_item, item)
uri = item.segment.uri last_init_item = item
name, ext = os.path.splitext(uri)
path = os.path.join(self._out_dir, 'segments', name[:-3], uri) if not split_file:
os.makedirs(os.path.dirname(path), exist_ok=True) split_file = self._need_split_file(item)
if split_file:
self._close_file()
self._reset()
self._open_file()
try: try:
with open(path, 'wb') as file: if split_file and not isinstance(item, InitSectionData):
file.write(item.payload) 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: 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) 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: 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( 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)) return CompositeDisposable(subscription, Disposable(dispose))

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import logging
import time import time
from typing import Optional, Union from typing import Optional, Union
@@ -8,7 +7,7 @@ import attr
import m3u8 import m3u8
import requests import requests
import urllib3 import urllib3
from m3u8.model import InitializationSection from loguru import logger
from reactivex import Observable, abc from reactivex import Observable, abc
from reactivex import operators as ops from reactivex import operators as ops
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -25,19 +24,18 @@ from blrec.bili.live import Live
from blrec.core import operators as core_ops from blrec.core import operators as core_ops
from blrec.utils import operators as utils_ops from blrec.utils import operators as utils_ops
from blrec.utils.hash import cksum from blrec.utils.hash import cksum
from blrec.exception.helpers import format_exception
from ..exceptions import FetchSegmentError from ..exceptions import FetchSegmentError, SegmentDataCorrupted
__all__ = ('SegmentFetcher', 'InitSectionData', 'SegmentData') __all__ = ('SegmentFetcher', 'InitSectionData', 'SegmentData')
logger = logging.getLogger(__name__)
@attr.s(auto_attribs=True, slots=True, frozen=True) @attr.s(auto_attribs=True, slots=True, frozen=True)
class InitSectionData: class InitSectionData:
init_section: InitializationSection segment: m3u8.Segment
payload: bytes payload: bytes
offset: int = 0
def __len__(self) -> int: def __len__(self) -> int:
return len(self.payload) return len(self.payload)
@@ -47,6 +45,7 @@ class InitSectionData:
class SegmentData: class SegmentData:
segment: m3u8.Segment segment: m3u8.Segment
payload: bytes payload: bytes
offset: int = 0
def __len__(self) -> int: def __len__(self) -> int:
return len(self.payload) return len(self.payload)
@@ -93,7 +92,6 @@ class SegmentFetcher:
( (
last_segment is None last_segment is None
or seg.init_section != last_segment.init_section or seg.init_section != last_segment.init_section
or seg.discontinuity
) )
): ):
url = seg.init_section.absolute_uri url = seg.init_section.absolute_uri
@@ -116,28 +114,38 @@ class SegmentFetcher:
f'init section url: {url}' f'init section url: {url}'
) )
data = _data data = _data
observer.on_next( observer.on_next(InitSectionData(segment=seg, payload=data))
InitSectionData(init_section=seg.init_section, payload=data)
)
last_segment = seg last_segment = seg
url = seg.absolute_uri url = seg.absolute_uri
crc32 = seg.title.split('|')[-1] hex_size, crc32, *_ = seg.title.split('|')
size = int(hex_size, 16)
for _ in range(3): for _ in range(3):
data = self._fetch_segment(url) data = self._fetch_segment(url)
if len(data) != size:
logger.debug(
'Segment data incomplete: '
f'size expected: {size}, '
f'size fetched: {len(data)}, '
f'segment url: {url}'
)
continue
crc32_of_data = cksum(data) crc32_of_data = cksum(data)
if crc32_of_data == crc32: if crc32_of_data != crc32:
break logger.debug(
logger.debug( 'Segment data corrupted: '
'Segment data corrupted: ' f'correct crc32: {crc32}, '
f'correct crc32: {crc32}, ' f'crc32 of segment data: {crc32_of_data}, '
f'crc32 of segment data: {crc32_of_data}, ' f'segment url: {url}'
f'segment url: {url}' )
) continue
break
else: else:
logger.warning(f'Segment data corrupted: {url}') raise SegmentDataCorrupted(url)
except Exception as exc: except Exception as exc:
logger.warning(f'Failed to fetch segment {url}', exc_info=exc) logger.warning(
'Failed to fetch segment: {}\n{}', url, format_exception(exc)
)
attempts += 1 attempts += 1
if attempts > 3: if attempts > 3:
attempts = 0 attempts = 0
@@ -172,8 +180,13 @@ class SegmentFetcher:
stop=stop_after_delay(60), stop=stop_after_delay(60),
) )
def _fetch_segment(self, url: str) -> bytes: 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() response.raise_for_status()
except Exception as e:
logger.debug(f'Failed to fetch segment {url}: {repr(e)}')
raise
else:
return response.content return response.content
def _should_retry(self, exc: Exception) -> bool: def _should_retry(self, exc: Exception) -> bool:
@@ -189,3 +202,4 @@ class SegmentFetcher:
'Fetch segments failed continuously, trying to update the stream url.' 'Fetch segments failed continuously, trying to update the stream url.'
) )
self._stream_url_resolver.reset() 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

@@ -1,8 +1,3 @@
from .configure_logging import configure_logger, TqdmOutputStream, ConsoleHandler from .configure_logging import TqdmOutputStream, configure_logger
__all__ = ('configure_logger', 'TqdmOutputStream')
__all__ = (
'configure_logger',
'TqdmOutputStream',
'ConsoleHandler',
)

View File

@@ -1,85 +1,46 @@
import asyncio
import atexit
import logging
import os import os
import threading import sys
from datetime import datetime from datetime import datetime
from logging import Handler, LogRecord from typing import Optional
from logging.handlers import TimedRotatingFileHandler
from typing import Any, List, Optional
from colorama import Back, Fore, Style, deinit, init from loguru import logger
from tqdm import tqdm from tqdm import tqdm
from .typing import LOG_LEVEL from .typing import LOG_LEVEL
__all__ = 'configure_logger', 'ConsoleHandler', 'TqdmOutputStream' __all__ = 'configure_logger', 'TqdmOutputStream'
LOGURU_CONSOLE_FORMAT = (
'<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | '
'<level>{level}</level> | '
'<cyan>{module}</cyan>:<cyan>{line}</cyan> | '
'<level>{extra[room_id]}</level> - '
'<level>{message}</level>'
)
DISPLAY_PROGRESS = bool(os.environ.get('BLREC_PROGRESS')) LOGURU_FILE_FORMAT = (
'<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | '
'<level>{level}</level> | '
'<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | '
'<level>{extra[room_id]}</level> - '
'<level>{message}</level>'
)
class TqdmOutputStream: class TqdmOutputStream:
def write(self, string: str = '') -> None: def write(self, string: str = '') -> None:
tqdm.write(string, end='') tqdm.write(string, file=sys.stderr, end='')
def isatty(self) -> bool:
return sys.stderr.isatty()
class ConsoleHandler(logging.StreamHandler): _console_handler_id: Optional[int] = None
def __init__(self, stream=None) -> None: # type: ignore _file_handler_id: Optional[int] = None
super().__init__(stream)
def format(self, record: LogRecord) -> str: _old_log_dir: Optional[str] = None
msg = super().format(record) _old_console_log_level: Optional[LOG_LEVEL] = None
_old_backup_count: Optional[int] = None
level = record.levelno
if level == logging.DEBUG:
style = Fore.GREEN
elif level == logging.WARNING:
style = Fore.YELLOW
elif level == logging.ERROR:
style = Fore.RED
elif level == logging.CRITICAL:
style = Fore.WHITE + Back.RED + Style.BRIGHT
else:
style = ''
return style + msg + Style.RESET_ALL if style else msg
_old_factory = logging.getLogRecordFactory()
def obtain_room_id() -> str:
try:
task = asyncio.current_task()
assert task is not None
except Exception:
name = threading.current_thread().getName()
else:
name = task.get_name()
if '::' in name:
if room_id := name.split('::')[-1]:
return room_id
return ''
def record_factory(*args: Any, **kwargs: Any) -> LogRecord:
record = _old_factory(*args, **kwargs)
if room_id := obtain_room_id():
record.roomid = '[' + room_id + '] ' # type: ignore
else:
record.roomid = '' # type: ignore
return record
logging.setLogRecordFactory(record_factory)
_old_handlers: List[Handler] = []
def configure_logger( def configure_logger(
@@ -88,52 +49,55 @@ def configure_logger(
console_log_level: LOG_LEVEL = 'INFO', console_log_level: LOG_LEVEL = 'INFO',
backup_count: Optional[int] = None, backup_count: Optional[int] = None,
) -> None: ) -> None:
# config root logger global _console_handler_id, _file_handler_id
logger = logging.getLogger() global _old_log_dir, _old_console_log_level, _old_backup_count
logger.setLevel(logging.DEBUG)
# config formatter logger.configure(extra={'room_id': ''})
formatter = logging.Formatter(
'[%(asctime)s] [%(levelname)s] [%(module)s] %(roomid)s%(message)s'
)
# logging to console if console_log_level != _old_console_log_level:
if DISPLAY_PROGRESS: if _console_handler_id is not None:
console_handler = ConsoleHandler(TqdmOutputStream()) logger.remove(_console_handler_id)
else: else:
console_handler = ConsoleHandler() logger.remove() # remove the default stderr handler
console_handler.setLevel(logging.getLevelName(console_log_level))
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# logging to file if bool(os.environ.get('BLREC_PROGRESS')):
log_file_path = make_log_file_path(log_dir) _console_handler_id = logger.add(
file_handler = TimedRotatingFileHandler( TqdmOutputStream(),
filename=log_file_path, level=console_log_level,
when='MIDNIGHT', format=LOGURU_CONSOLE_FORMAT,
backupCount=backup_count or 0, )
encoding='utf-8', else:
) _console_handler_id = logger.add(
file_handler.setLevel(logging.DEBUG) sys.stderr, level=console_log_level, format=LOGURU_CONSOLE_FORMAT
file_handler.setFormatter(formatter) )
logger.addHandler(file_handler)
# remove old handlers after re-configured _old_console_log_level = console_log_level
for handler in _old_handlers:
logger.removeHandler(handler)
# retain old handlers for the removing if log_dir != _old_log_dir or backup_count != _old_backup_count:
_old_handlers.append(console_handler) log_file_path = make_log_file_path(log_dir)
_old_handlers.append(file_handler) logger.info(f'log file: {log_file_path}')
logger.info(f'log file: {log_file_path}') file_handler_id = logger.add(
log_file_path,
level='TRACE' if bool(os.environ.get('BLREC_TRACE')) else 'DEBUG',
format=LOGURU_FILE_FORMAT,
enqueue=True,
rotation="00:00",
retention=backup_count,
backtrace=True,
diagnose=True,
)
if _file_handler_id is not None:
logger.remove(_file_handler_id)
_file_handler_id = file_handler_id
_old_log_dir = log_dir
_old_backup_count = backup_count
def make_log_file_path(log_dir: str) -> str: def make_log_file_path(log_dir: str) -> str:
data_time_string = datetime.now().strftime("%Y-%m-%d-%H%M%S-%f") data_time_string = datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")
filename = f'blrec_{data_time_string}.log' filename = f'blrec_{data_time_string}.log'
return os.path.abspath(os.path.join(log_dir, filename)) return os.path.abspath(os.path.join(log_dir, filename))
init()
atexit.register(deinit)

View File

@@ -0,0 +1,19 @@
from functools import wraps
from typing import Any, Awaitable, Callable, TypeVar
from loguru import logger
_T = TypeVar('_T')
__all__ = ('async_task_with_logger_context',)
def async_task_with_logger_context(
func: Callable[..., Awaitable[_T]]
) -> Callable[..., Awaitable[_T]]:
@wraps(func)
async def wrapper(obj: Any, *arg: Any, **kwargs: Any) -> _T:
with logger.contextualize(**obj._logger_context):
return await func(obj, *arg, **kwargs)
return wrapper

View File

@@ -1,46 +0,0 @@
import threading
import asyncio
from functools import wraps
from typing import Any, Awaitable, Callable, TypeVar
_T = TypeVar('_T')
def with_room_id(room_id: int): # type: ignore
def decorate(func: Callable[..., _T]) -> Callable[..., _T]:
@wraps(func)
def wrapper(*arg: Any, **kwargs: Any) -> _T:
curr_thread = threading.current_thread()
old_name = curr_thread.getName()
curr_thread.setName(f'{func.__qualname__}::{room_id}')
try:
return func(*arg, **kwargs)
finally:
curr_thread.setName(old_name)
return wrapper
return decorate
def aio_task_with_room_id(
func: Callable[..., Awaitable[_T]]
) -> Callable[..., Awaitable[_T]]:
@wraps(func)
async def wrapper(obj: Any, *arg: Any, **kwargs: Any) -> _T:
if hasattr(obj, '_room_id'):
room_id = obj._room_id
elif hasattr(obj, '_live'):
room_id = obj._live.room_id
else:
room_id = ''
curr_task = asyncio.current_task()
assert curr_task is not None
old_name = curr_task.get_name()
curr_task.set_name(f'{func.__qualname__}::{room_id}')
try:
return await func(obj, *arg, **kwargs)
finally:
curr_task.set_name(old_name)
return wrapper

View File

@@ -1,20 +1,5 @@
from typing import Literal from typing import Literal
# CRITICAL = 50
# FATAL = CRITICAL
# ERROR = 40
# WARNING = 30
# WARN = WARNING
# INFO = 20
# DEBUG = 10
# NOTSET = 0
LOG_LEVEL = Literal[ LOG_LEVEL = Literal[
'CRITICAL', 'TRACE', 'DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR', 'CRITICAL',
'FATAL',
'ERROR',
'WARNING',
'INFO',
'DEBUG',
'NOTSET', # equivalent to verbose
] ]

View File

@@ -1,5 +1,4 @@
import asyncio import asyncio
import logging
import os import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import datetime from datetime import datetime
@@ -30,13 +29,13 @@ from ..exception import ExceptionCenter, format_exception
from ..setting.typing import MessageType from ..setting.typing import MessageType
from ..utils.mixins import SwitchableMixin from ..utils.mixins import SwitchableMixin
from .providers import ( from .providers import (
Bark,
EmailService, EmailService,
MessagingProvider, MessagingProvider,
Pushdeer, Pushdeer,
Pushplus, Pushplus,
Serverchan, Serverchan,
Telegram, Telegram,
Bark,
) )
__all__ = ( __all__ = (
@@ -47,11 +46,11 @@ __all__ = (
'PushdeerNotifier', 'PushdeerNotifier',
'PushplusNotifier', 'PushplusNotifier',
'TelegramNotifier', 'TelegramNotifier',
'BarkNotifer', 'BarkNotifier',
) )
logger = logging.getLogger(__name__) from loguru import logger
class Notifier(SwitchableMixin, ABC): class Notifier(SwitchableMixin, ABC):

Some files were not shown because too many files have changed in this diff Show More