Compare commits

...

19 Commits

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

View File

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

View File

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

View File

@@ -1,5 +1,28 @@
# 更新日志
## 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

View File

@@ -30,6 +30,6 @@ set api_key=bili2233
set BLREC_DEFAULT_LOG_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

View File

@@ -22,6 +22,6 @@ $env:api_key = "bili2233"
$env:BLREC_DEFAULT_LOG_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

View File

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

View File

@@ -1,5 +1,6 @@
import asyncio
import hashlib
import time
from abc import ABC
from datetime import datetime
from typing import Any, Dict, Final, List, Mapping, Optional
@@ -10,6 +11,7 @@ from loguru import logger
from tenacity import retry, stop_after_delay, wait_exponential
from .exceptions import ApiRequestError
from . import wbi
from .typing import JsonResponse, QualityNumber, ResponseData
__all__ = 'AppApi', 'WebApi'
@@ -23,7 +25,7 @@ BASE_HEADERS: Final = {
'Connection': 'keep-alive',
'Origin': 'https://live.bilibili.com',
'Pragma': 'no-cache',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', # noqa
'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
}
@@ -242,6 +244,30 @@ class AppApi(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:
path = '/room/v1/Room/room_init'
params = {'id': room_id}
@@ -262,14 +288,16 @@ class WebApi(BaseApi):
'ptype': 8,
}
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]
async def get_info_by_room(self, room_id: int) -> ResponseData:
path = '/xlive/web-room/v1/index/getInfoByRoom'
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']
async def get_info(self, room_id: int) -> ResponseData:
@@ -287,16 +315,27 @@ class WebApi(BaseApi):
async def get_user_info(self, uid: int) -> ResponseData:
path = '/x/space/wbi/acc/info'
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']
async def get_danmu_info(self, room_id: int) -> ResponseData:
path = '/xlive/web-room/v1/index/getDanmuInfo'
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']
async def get_nav(self) -> ResponseData:
path = '/x/web-interface/nav'
json_res = await self._get_json(self.base_api_urls, path, check_response=False)
return json_res
async def _update_wbi_key(self) -> None:
nav = await self.get_nav()
img_key = wbi.extract_key(nav['data']['wbi_img']['img_url'])
sub_key = wbi.extract_key(nav['data']['wbi_img']['sub_url'])
self.__class__._wbi_key = wbi.make_key(img_key, sub_key)
self.__class__._wbi_key_mtime = time.monotonic()

View File

@@ -1,6 +1,8 @@
import asyncio
import json
import os
import struct
import zlib
from contextlib import suppress
from enum import Enum, IntEnum
from typing import Any, Dict, Final, List, Optional, Tuple, Union, cast
@@ -8,6 +10,7 @@ from typing import Any, Dict, Final, List, Optional, Tuple, Union, cast
import aiohttp
import brotli
from aiohttp import ClientSession
from loguru import logger
from tenacity import retry, retry_if_exception_type, wait_exponential
from blrec.logging.context import async_task_with_logger_context
@@ -23,9 +26,6 @@ from .typing import ApiPlatform, Danmaku
__all__ = 'DanmakuClient', 'DanmakuListener', 'Danmaku', 'DanmakuCommand'
from loguru import logger
class DanmakuListener(EventListener):
async def on_client_connected(self) -> None:
...
@@ -74,6 +74,20 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
self._retry_delay: int = 0
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
def headers(self) -> Dict[str, str]:
return self._headers
@@ -157,7 +171,7 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
{
"uid": self._uid,
'roomid': self._room_id, # must not be the short id!
'protover': WS.BODY_PROTOCOL_VERSION_BROTLI,
'protover': self._protover,
"buvid": self._buvid,
'platform': 'web',
'type': 2,
@@ -372,6 +386,12 @@ class Frame:
if op == WS.OP_MESSAGE:
if ver == WS.BODY_PROTOCOL_VERSION_BROTLI:
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 = []
offset = 0
@@ -408,6 +428,7 @@ class WS(IntEnum):
OPERATION_OFFSET = 8
SEQUENCE_OFFSET = 12
BODY_PROTOCOL_VERSION_NORMAL = 0
BODY_PROTOCOL_VERSION_DEFLATE = 2
BODY_PROTOCOL_VERSION_BROTLI = 3
HEADER_DEFAULT_VERSION = 1
HEADER_DEFAULT_OPERATION = 1

View File

@@ -6,13 +6,20 @@ from jsonpath import jsonpath
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'
async def room_init(room_id: int) -> ResponseData:
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:
api = WebApi(session, room_id=room_id)
return await api.room_init(room_id)
@@ -31,7 +38,13 @@ async def ensure_room_id(room_id: int) -> int:
async def get_nav(cookie: str) -> ResponseData:
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:
headers = {
'Origin': 'https://passport.bilibili.com',
'Referer': 'https://passport.bilibili.com/account/security',

View File

@@ -21,6 +21,7 @@ from .exceptions import (
)
from .helpers import extract_codecs, extract_formats, extract_streams
from .models import LiveStatus, RoomInfo, UserInfo
from .net import connector, timeout
from .typing import ApiPlatform, QualityNumber, ResponseData, StreamCodec, StreamFormat
__all__ = ('Live',)
@@ -44,9 +45,11 @@ class Live:
self._html_page_url = f'https://live.bilibili.com/{room_id}'
self._session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit=200),
connector=connector,
connector_owner=False,
raise_for_status=True,
trust_env=True,
timeout=timeout,
)
self._appapi = AppApi(self._session, self.headers, room_id=room_id)
self._webapi = WebApi(self._session, self.headers, room_id=room_id)
@@ -172,11 +175,13 @@ class Live:
async def check_connectivity(self) -> bool:
try:
await self._session.head('https://live.bilibili.com/', timeout=3)
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return False
else:
await self._session.head('https://live.bilibili.com/', timeout=3, headers={
'User-Agent': self._user_agent,
})
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:
return all(
@@ -231,11 +236,9 @@ class Live:
)
async def get_user_info(self, uid: int) -> UserInfo:
try:
user_info_data = await self._webapi.get_user_info(uid)
return UserInfo.from_web_api_data(user_info_data)
return await self._get_user_info_via_api(uid)
except Exception:
user_info_data = await self._appapi.get_user_info(uid)
return UserInfo.from_app_api_data(user_info_data)
return await self._get_user_info_via_html_page()
async def get_timestamp(self) -> int:
try:
@@ -343,6 +346,18 @@ class Live:
room_info_data = await self._get_room_info_via_api()
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:
try:
info_data = await self._webapi.get_info_by_room(self._room_id)
@@ -365,6 +380,10 @@ class Live:
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:
info_res = await self._get_room_info_res_via_html_page()
return info_res['room_info']

View File

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

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

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

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

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

View File

@@ -47,6 +47,7 @@ def cli_main(
host: str = typer.Option('localhost', help='webapp host bind'),
port: int = typer.Option(2233, help='webapp port bind'),
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'),
key_file: Optional[str] = typer.Option(None, help='SSL key file'),
cert_file: Optional[str] = typer.Option(None, help='SSL certificate file'),
@@ -61,6 +62,8 @@ def cli_main(
os.environ['BLREC_OUT_DIR'] = out_dir
if log_dir is not None:
os.environ['BLREC_LOG_DIR'] = log_dir
if ipv4 is not None:
os.environ['BLREC_IPV4'] = '1'
if not sys.stderr.isatty():
progress = False

View File

@@ -8,6 +8,7 @@ from loguru import logger
from tenacity import retry, stop_after_attempt, wait_fixed
from blrec.bili.live import Live
from blrec.bili.net import connector, timeout
from blrec.event.event_emitter import EventEmitter, EventListener
from blrec.exception import submit_exception
from blrec.path import cover_path
@@ -20,8 +21,7 @@ __all__ = 'CoverDownloader', 'CoverDownloaderEventListener'
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):
@@ -97,7 +97,13 @@ class CoverDownloader(
@retry(reraise=True, wait=wait_fixed(1), stop=stop_after_attempt(3))
async def _fetch_cover(self, url: str) -> bytes:
async with aiohttp.ClientSession(raise_for_status=True) as session:
async with 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:
return await response.read()

View File

@@ -248,7 +248,7 @@ class DanmakuDumper(
text = f'{msg.uname}: {msg.text}'
else:
text = msg.text
text = html.escape(text)
text = html.escape(text, quote=False)
return Danmu(
stime=self._calc_stime(msg.date / 1000),

View File

@@ -514,11 +514,9 @@ class Recorder(
msg = f"""
================================== User Info ==================================
user id : {user_info.uid}
user name : {user_info.name}
gender : {user_info.gender}
sign : {user_info.sign}
uid : {user_info.uid}
level : {user_info.level}
---------------------------------- Room Info ----------------------------------
title : {room_info.title}
cover : {room_info.cover}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -10,6 +10,6 @@
<body>
<app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
<script src="runtime.4d25db3be3119aaf.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>

View File

@@ -1,6 +1,6 @@
{
"configVersion": 1,
"timestamp": 1699163406878,
"timestamp": 1718810136190,
"index": "/index.html",
"assetGroups": [
{
@@ -12,7 +12,7 @@
},
"urls": [
"/103.4a2aea63cc3bf42b.js",
"/287.63ace7ac80c3d9f2.js",
"/287.f1c0b0beeb6810b2.js",
"/386.2404f3bc252e1df3.js",
"/503.6553f508f4a9247d.js",
"/548.e2df47ddad764d0b.js",
@@ -22,7 +22,7 @@
"/main.f21b7d831ad9cafb.js",
"/manifest.webmanifest",
"/polyfills.4e5433063877ea34.js",
"/runtime.4d25db3be3119aaf.js",
"/runtime.5566e7902022ba3e.js",
"/styles.ae81e04dfa5b2860.css"
],
"patterns": []
@@ -1635,7 +1635,7 @@
"dataGroups": [],
"hashTable": {
"/103.4a2aea63cc3bf42b.js": "2711817f2977bfdc18c34fee4fe9385fe012bb22",
"/287.63ace7ac80c3d9f2.js": "7a52c7715de66142dae39668a3a0fb0f9ee4bb50",
"/287.f1c0b0beeb6810b2.js": "875dca7598179957ed411aa5204ce12871a8e958",
"/386.2404f3bc252e1df3.js": "f937945645579b9651be2666f70cec2c5de4e367",
"/503.6553f508f4a9247d.js": "0878ea0e91bfd5458dd55875561e91060ecb0837",
"/548.e2df47ddad764d0b.js": "0b60f5f001bd127b90d490617bba2091c4c39de3",
@@ -3234,11 +3234,11 @@
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.1fc175bce139f4df.js": "af1775164711ec49e5c3a91ee45bd77509c17c54",
"/index.html": "2a844a95b7b6367d4be88cef11f92da722cdfb0b",
"/index.html": "abe6df528859e9b6fafa3dda8a4001db74c04dd7",
"/main.f21b7d831ad9cafb.js": "fc51efa446c2ac21ee17e165217dd3faeacc5290",
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/polyfills.4e5433063877ea34.js": "68159ab99e0608976404a17132f60b5ceb6f12d2",
"/runtime.4d25db3be3119aaf.js": "a384a1a5336bd3394ebe6d0560ed6c28b7020af9",
"/runtime.5566e7902022ba3e.js": "c7fa8d060497bd9938aca48eba6f523bf0eb85cd",
"/styles.ae81e04dfa5b2860.css": "5933b4f1c4d8fcc1891b68940ee78af4091472b7"
},
"navigationUrls": [

View File

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

View File

@@ -4,10 +4,11 @@ import io
from copy import deepcopy
from decimal import Decimal
from pathlib import PurePath
from typing import Optional, Tuple, Union
from typing import Optional, Tuple, Union, cast
import m3u8
from loguru import logger
from m3u8.model import InitializationSection
from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -139,7 +140,9 @@ class PlaylistDumper:
) -> 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

View File

@@ -1,5 +1,4 @@
import io
from datetime import datetime, timedelta, timezone
from pathlib import PurePath
from typing import Callable, Optional, Tuple, Union
@@ -10,7 +9,6 @@ from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposab
from blrec.utils.ffprobe import ffprobe
from ..helpler import sequence_number_of
from .segment_fetcher import InitSectionData, SegmentData
__all__ = ('SegmentDumper',)
@@ -29,7 +27,6 @@ class SegmentDumper:
self._path: str = ''
self._file: Optional[io.BufferedWriter] = None
self._filesize: int = 0
self._record_start_time: Optional[int] = None
@property
def path(self) -> str:
@@ -39,10 +36,6 @@ class SegmentDumper:
def filesize(self) -> int:
return self._filesize
@property
def record_start_time(self) -> Optional[int]:
return self._record_start_time
@property
def file_opened(self) -> Observable[Tuple[str, int]]:
return self._file_opened
@@ -57,8 +50,7 @@ class SegmentDumper:
return self._dump(source)
def _open_file(self) -> None:
assert self._record_start_time is not None
path, timestamp = self._path_provider(self._record_start_time)
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}')
@@ -80,12 +72,13 @@ class SegmentDumper:
def _update_filesize(self, size: int) -> None:
self._filesize += size
def _set_record_start_time(self, item: Union[InitSectionData, SegmentData]) -> None:
seq = sequence_number_of(item.segment.uri)
dt = datetime.utcfromtimestamp(seq)
tz = timezone(timedelta(hours=8))
ts = dt.replace(year=datetime.today().year, tzinfo=tz).timestamp()
self._record_start_time = int(ts)
def _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
@@ -100,6 +93,10 @@ class SegmentDumper:
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'
@@ -118,7 +115,6 @@ class SegmentDumper:
or prev_video_profile['coded_height'] != curr_video_profile['coded_height']
):
logger.warning('Video parameters changed')
return True
if (
prev_audio_profile['codec_name'] != curr_audio_profile['codec_name']
@@ -127,9 +123,12 @@ class SegmentDumper:
or prev_audio_profile.get('bit_rate') != curr_audio_profile.get('bit_rate')
):
logger.warning('Audio parameters changed')
return True
return False
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)
@@ -150,6 +149,8 @@ class SegmentDumper:
split_file = False
if isinstance(item, InitSectionData):
if self._is_redundant(last_init_item, item):
return
split_file = self._must_split_file(last_init_item, item)
last_init_item = item
@@ -159,7 +160,6 @@ class SegmentDumper:
if split_file:
self._close_file()
self._reset()
self._set_record_start_time(item)
self._open_file()
try:

View File

@@ -26,7 +26,7 @@ from blrec.utils import operators as utils_ops
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')
@@ -92,7 +92,6 @@ class SegmentFetcher:
(
last_segment is None
or seg.init_section != last_segment.init_section
or seg.discontinuity
)
):
url = seg.init_section.absolute_uri
@@ -119,20 +118,30 @@ class SegmentFetcher:
last_segment = seg
url = seg.absolute_uri
crc32 = seg.title.split('|')[-1]
hex_size, crc32, *_ = seg.title.split('|')
size = int(hex_size, 16)
for _ in range(3):
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)
if crc32_of_data == crc32:
break
logger.debug(
'Segment data corrupted: '
f'correct crc32: {crc32}, '
f'crc32 of segment data: {crc32_of_data}, '
f'segment url: {url}'
)
if crc32_of_data != crc32:
logger.debug(
'Segment data corrupted: '
f'correct crc32: {crc32}, '
f'crc32 of segment data: {crc32_of_data}, '
f'segment url: {url}'
)
continue
break
else:
logger.warning(f'Segment data corrupted: {url}')
raise SegmentDataCorrupted(url)
except Exception as exc:
logger.warning(
'Failed to fetch segment: {}\n{}', url, format_exception(exc)

View File

@@ -151,8 +151,11 @@ def remux_video(
else:
continue
if line.startswith('frame='):
try:
size = parse_size(line)
except Exception:
pass
else:
pbar.update(size - pbar.n)
progress = RemuxingProgress(size, total)
observer.on_next(progress)

View File

@@ -131,9 +131,7 @@ class HeaderOptions(BaseModel):
class HeaderSettings(HeaderOptions):
user_agent: str = (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/89.0.4389.114 Safari/537.36'
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' # noqa
)
cookie: str = ''
@@ -198,9 +196,7 @@ class RecorderSettings(RecorderOptions):
fmp4_stream_timeout: int = 10
read_timeout: int = 3
disconnection_timeout: int = 600
buffer_size: Annotated[
int, Field(ge=4096, le=1024**2 * 512, multiple_of=2)
] = 8192
buffer_size: Annotated[int, Field(ge=4096, le=1024**2 * 512, multiple_of=2)] = 8192
save_cover: bool = False
cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT
@@ -315,7 +311,7 @@ class TaskOptions(BaseModel):
class TaskSettings(TaskOptions):
# must use the real room id rather than the short room id!
room_id: Annotated[int, Field(ge=1, le=99999999)]
room_id: Annotated[int, Field(ge=1, lt=2**100)]
enable_monitor: bool = True
enable_recorder: bool = True

View File

@@ -1,3 +1,4 @@
from __future__ import annotations
import asyncio
import os
import threading

View File

@@ -38,7 +38,15 @@ export class InfoPanelComponent implements OnInit, OnDestroy {
private changeDetector: ChangeDetectorRef,
private notification: NzNotificationService,
private taskService: TaskService
) {}
) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.syncData();
} else {
this.desyncData();
}
});
}
get fps(): string {
const avgFrameRate: string | undefined =

View File

@@ -41,7 +41,15 @@ export class TaskDetailComponent implements OnInit, OnDestroy {
private changeDetector: ChangeDetectorRef,
private notification: NzNotificationService,
private taskService: TaskService
) {}
) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.syncData();
} else {
this.desyncData();
}
});
}
ngOnInit(): void {
this.route.paramMap.subscribe((params: ParamMap) => {

View File

@@ -9,11 +9,5 @@
<nz-descriptions-item nzTitle="UID">{{
userInfo.uid
}}</nz-descriptions-item>
<nz-descriptions-item nzTitle="等级">{{
userInfo.level
}}</nz-descriptions-item>
<nz-descriptions-item nzTitle="签名">
{{ userInfo.sign }}
</nz-descriptions-item>
</nz-descriptions>
</nz-card>

View File

@@ -1,20 +1,20 @@
import { HttpErrorResponse } from '@angular/common/http';
import {
Component,
OnInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { catchError, concatAll, switchMap } from 'rxjs/operators';
import { interval, of, Subscription } from 'rxjs';
import { NzNotificationService } from 'ng-zorro-antd/notification';
import { Subscription, interval, of } from 'rxjs';
import { catchError, concatAll, switchMap } from 'rxjs/operators';
import { TaskData, DataSelection } from './shared/task.model';
import { retry } from 'src/app/shared/rx-operators';
import { TaskService } from './shared/services/task.service';
import { StorageService } from '../core/services/storage.service';
import { TaskService } from './shared/services/task.service';
import { DataSelection, TaskData } from './shared/task.model';
const SELECTION_STORAGE_KEY = 'app-tasks-selection';
const REVERSE_STORAGE_KEY = 'app-tasks-reverse';
@@ -42,6 +42,14 @@ export class TasksComponent implements OnInit, OnDestroy {
) {
this.selection = this.retrieveSelection();
this.reverse = this.retrieveReverse();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.syncTaskData();
} else {
this.desyncTaskData();
}
});
}
ngOnInit(): void {