Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47f16f2c4a | ||
|
|
340972c878 | ||
|
|
228e5ad46d | ||
|
|
f75d91e7f5 | ||
|
|
975fa2794a | ||
|
|
1206d6e80f | ||
|
|
772eb5e3e7 | ||
|
|
fff5994f0b | ||
|
|
7fc31e9e11 | ||
|
|
1d681868f5 | ||
|
|
2cc69db88e | ||
|
|
da2d4715d1 | ||
|
|
8dc32e5e6e | ||
|
|
d00c504de7 | ||
|
|
71ca3f84f4 | ||
|
|
6cd97ab3da | ||
|
|
293e7db2d0 | ||
|
|
5b49b1dd33 | ||
|
|
f6330aabf3 | ||
|
|
375817fb4b | ||
|
|
a4bbd81488 | ||
|
|
d919190ca1 | ||
|
|
b504dc04c2 | ||
|
|
78c561c12e | ||
|
|
c03a122453 | ||
|
|
1e7d6f71fb | ||
|
|
d2e4d43c52 | ||
|
|
352803e389 | ||
|
|
b1296d964d | ||
|
|
c35271f0e0 | ||
|
|
737cf5be8d | ||
|
|
b05fe4de8a | ||
|
|
8f77e843ad | ||
|
|
5aad77c337 | ||
|
|
38efd5a923 | ||
|
|
5d3f54b02a | ||
|
|
0dda7e7227 | ||
|
|
d3579fae90 | ||
|
|
79372d23ce | ||
|
|
457a974ab7 | ||
|
|
b35b5f2fc1 |
2
.github/workflows/docker-hub.yml
vendored
2
.github/workflows/docker-hub.yml
vendored
@@ -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 }}
|
||||||
|
|||||||
2
.github/workflows/ghcr.yml
vendored
2
.github/workflows/ghcr.yml
vendored
@@ -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 }}
|
||||||
|
|||||||
6
.github/workflows/portable.yml
vendored
6
.github/workflows/portable.yml
vendored
@@ -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
|
||||||
|
|||||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -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
14
FAQ.md
@@ -1,19 +1,5 @@
|
|||||||
# 常见问题
|
# 常见问题
|
||||||
|
|
||||||
## HLS 标准录制模式和原始录制模式有什么区别?
|
|
||||||
|
|
||||||
| | 标准录制模式 | 原始录制模式 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| ffmpeg | 需要 | 不需要 |
|
|
||||||
| 资源占用 | 较多 | 较少 |
|
|
||||||
| 稳定性 | 比较差 | 比较好 |
|
|
||||||
| 录播文件 | 一个 flv 文件 | 很多片段文件 |
|
|
||||||
| 录播信息 | 包含在 flv 文件里 | 单独保存为一个文件 (index.meta.json) |
|
|
||||||
| 播放器支持 | 几乎全部播放器都支持 flv | 支持 m3u8 的播放器很少 (VLC、dandanplay) |
|
|
||||||
| 自动分割文件 | 支持 | 不支持 |
|
|
||||||
| 手动分割文件 | 支持 | 不支持 |
|
|
||||||
| 自动转 mp4 | 支持 | 支持 |
|
|
||||||
|
|
||||||
## 如何终止程序?
|
## 如何终止程序?
|
||||||
|
|
||||||
`ctrl + c`
|
`ctrl + c`
|
||||||
|
|||||||
2
run.bat
2
run.bat
@@ -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
|
||||||
|
|||||||
2
run.ps1
2
run.ps1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
18
src/blrec/bili/net.py
Normal 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
84
src/blrec/bili/wbi.py
Normal 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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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')),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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')
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -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')),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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__}',
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, '
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__}')
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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
1
src/blrec/data/webapp/287.f1c0b0beeb6810b2.js
Normal file
1
src/blrec/data/webapp/287.f1c0b0beeb6810b2.js
Normal file
File diff suppressed because one or more lines are too long
1
src/blrec/data/webapp/548.e2df47ddad764d0b.js
Normal file
1
src/blrec/data/webapp/548.e2df47ddad764d0b.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -10,6 +10,6 @@
|
|||||||
<body>
|
<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>
|
||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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))})()})();
|
|
||||||
1
src/blrec/data/webapp/runtime.5566e7902022ba3e.js
Normal file
1
src/blrec/data/webapp/runtime.5566e7902022ba3e.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
(()=>{"use strict";var e,v={},m={};function r(e){var n=m[e];if(void 0!==n)return n.exports;var t=m[e]={exports:{}};return v[e](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))})()})();
|
||||||
@@ -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}')
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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__(
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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__(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
12
src/blrec/hls/helpler.py
Normal 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))
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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',
|
||||||
)
|
)
|
||||||
|
|||||||
88
src/blrec/hls/operators/analyser.py
Normal file
88
src/blrec/hls/operators/analyser.py
Normal 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)
|
||||||
86
src/blrec/hls/operators/cutter.py
Normal file
86
src/blrec/hls/operators/cutter.py
Normal 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)
|
||||||
90
src/blrec/hls/operators/limiter.py
Normal file
90
src/blrec/hls/operators/limiter.py
Normal 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)
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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',
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
19
src/blrec/logging/context.py
Normal file
19
src/blrec/logging/context.py
Normal 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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user