Compare commits
19 Commits
v2.0.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47f16f2c4a | ||
|
|
340972c878 | ||
|
|
228e5ad46d | ||
|
|
f75d91e7f5 | ||
|
|
975fa2794a | ||
|
|
1206d6e80f | ||
|
|
772eb5e3e7 | ||
|
|
fff5994f0b | ||
|
|
7fc31e9e11 | ||
|
|
1d681868f5 | ||
|
|
2cc69db88e | ||
|
|
da2d4715d1 | ||
|
|
8dc32e5e6e | ||
|
|
d00c504de7 | ||
|
|
71ca3f84f4 | ||
|
|
6cd97ab3da | ||
|
|
293e7db2d0 | ||
|
|
5b49b1dd33 | ||
|
|
f6330aabf3 |
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
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
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
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -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
|
||||
|
||||
2
run.bat
2
run.bat
@@ -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
|
||||
|
||||
2
run.ps1
2
run.ps1
@@ -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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
__prog__ = 'blrec'
|
||||
__version__ = '2.0.0-beta.2'
|
||||
__version__ = '2.0.0-beta.5'
|
||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
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
@@ -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>
|
||||
@@ -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": [
|
||||
|
||||
@@ -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))})()})();
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user