Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aad77c337 | ||
|
|
38efd5a923 | ||
|
|
5d3f54b02a | ||
|
|
0dda7e7227 | ||
|
|
d3579fae90 | ||
|
|
79372d23ce |
@@ -1,5 +1,13 @@
|
||||
# 更新日志
|
||||
|
||||
## 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,3 +1,3 @@
|
||||
__prog__ = 'blrec'
|
||||
__version__ = '1.14.1'
|
||||
__version__ = '1.15.0'
|
||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from typing import Iterator, List, Optional
|
||||
|
||||
import attr
|
||||
@@ -102,8 +103,13 @@ class Application:
|
||||
self._setup()
|
||||
logger.debug(f'Default umask {os.umask(0o000)}')
|
||||
logger.info(f'Launched Application v{__version__}')
|
||||
task = asyncio.create_task(self._task_manager.load_all_tasks())
|
||||
task.add_done_callback(exception_callback)
|
||||
self._loading_task = asyncio.create_task(self._task_manager.load_all_tasks())
|
||||
|
||||
def callback(future: asyncio.Future) -> None: # type: ignore
|
||||
del self._loading_task
|
||||
|
||||
self._loading_task.add_done_callback(exception_callback)
|
||||
self._loading_task.add_done_callback(callback)
|
||||
|
||||
async def exit(self) -> None:
|
||||
logger.info('Exiting Application...')
|
||||
@@ -116,6 +122,10 @@ class Application:
|
||||
logger.info('Aborted Application')
|
||||
|
||||
async def _exit(self, force: bool = False) -> None:
|
||||
if hasattr(self, '_loading_task'):
|
||||
self._loading_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._loading_task
|
||||
await self._task_manager.stop_all_tasks(force=force)
|
||||
await self._task_manager.destroy_all_tasks()
|
||||
self._destroy()
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import os
|
||||
from abc import ABC
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
from typing import Any, Dict, List, Mapping, Optional, Final
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import aiohttp
|
||||
@@ -20,6 +20,17 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
TRACE_API_REQ = bool(os.environ.get('BLREC_TRACE_API_REQ'))
|
||||
|
||||
BASE_HEADERS: Final = {
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en;q=0.3,en-US;q=0.2', # noqa
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Origin': 'https://live.bilibili.com',
|
||||
'Pragma': 'no-cache',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', # noqa
|
||||
}
|
||||
|
||||
|
||||
class BaseApi(ABC):
|
||||
def __init__(
|
||||
@@ -39,7 +50,7 @@ class BaseApi(ABC):
|
||||
|
||||
@headers.setter
|
||||
def headers(self, value: Dict[str, str]) -> None:
|
||||
self._headers = {**value}
|
||||
self._headers = {**BASE_HEADERS, **value}
|
||||
|
||||
@staticmethod
|
||||
def _check_response(json_res: JsonResponse) -> None:
|
||||
@@ -50,6 +61,7 @@ class BaseApi(ABC):
|
||||
|
||||
@retry(reraise=True, stop=stop_after_delay(5), wait=wait_exponential(0.1))
|
||||
async def _get_json_res(self, *args: Any, **kwds: Any) -> JsonResponse:
|
||||
should_check_response = kwds.pop('check_response', True)
|
||||
kwds = {'timeout': self.timeout, 'headers': self.headers, **kwds}
|
||||
async with self._session.get(*args, **kwds) as res:
|
||||
if TRACE_API_REQ:
|
||||
@@ -60,7 +72,8 @@ class BaseApi(ABC):
|
||||
text_res = await res.text()
|
||||
logger.debug(f'Response text: {text_res[:200]}')
|
||||
raise
|
||||
self._check_response(json_res)
|
||||
if should_check_response:
|
||||
self._check_response(json_res)
|
||||
return json_res
|
||||
|
||||
async def _get_json(
|
||||
@@ -282,3 +295,8 @@ class WebApi(BaseApi):
|
||||
params = {'id': room_id}
|
||||
json_res = await self._get_json(self.base_live_api_urls, path, params=params)
|
||||
return json_res['data']
|
||||
|
||||
async def get_nav(self) -> ResponseData:
|
||||
path = '/x/web-interface/nav'
|
||||
json_res = await self._get_json(self.base_api_urls, path, check_response=False)
|
||||
return json_res
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import aiohttp
|
||||
from jsonpath import jsonpath
|
||||
|
||||
from .api import WebApi
|
||||
from .typing import ResponseData, QualityNumber
|
||||
from .exceptions import ApiRequestError
|
||||
from ..exception import NotFoundError
|
||||
|
||||
from .api import WebApi
|
||||
from .exceptions import ApiRequestError
|
||||
from .typing import QualityNumber, ResponseData, StreamCodec, StreamFormat
|
||||
|
||||
__all__ = 'room_init', 'ensure_room_id'
|
||||
|
||||
@@ -29,6 +30,17 @@ async def ensure_room_id(room_id: int) -> int:
|
||||
return result['room_id']
|
||||
|
||||
|
||||
async def get_nav(cookie: str) -> ResponseData:
|
||||
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
||||
headers = {
|
||||
'Origin': 'https://passport.bilibili.com',
|
||||
'Referer': 'https://passport.bilibili.com/account/security',
|
||||
'Cookie': cookie,
|
||||
}
|
||||
api = WebApi(session, headers)
|
||||
return await api.get_nav()
|
||||
|
||||
|
||||
def get_quality_name(qn: QualityNumber) -> str:
|
||||
QUALITY_MAPPING = {
|
||||
20000: '4K',
|
||||
@@ -40,3 +52,18 @@ def get_quality_name(qn: QualityNumber) -> str:
|
||||
80: '流畅',
|
||||
}
|
||||
return QUALITY_MAPPING.get(qn, '')
|
||||
|
||||
|
||||
def extract_streams(play_infos: List[Dict[str, Any]]) -> List[Any]:
|
||||
streams = jsonpath(play_infos, '$[*].playurl_info.playurl.stream[*]')
|
||||
return streams
|
||||
|
||||
|
||||
def extract_formats(streams: List[Any], stream_format: StreamFormat) -> List[Any]:
|
||||
formats = jsonpath(streams, f'$[*].format[?(@.format_name == "{stream_format}")]')
|
||||
return formats
|
||||
|
||||
|
||||
def extract_codecs(formats: List[Any], stream_codec: StreamCodec) -> List[Any]:
|
||||
codecs = jsonpath(formats, f'$[*].codec[?(@.codec_name == "{stream_codec}")]')
|
||||
return codecs
|
||||
|
||||
@@ -9,7 +9,7 @@ import aiohttp
|
||||
from jsonpath import jsonpath
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential
|
||||
|
||||
from .api import AppApi, WebApi
|
||||
from .api import BASE_HEADERS, AppApi, WebApi
|
||||
from .exceptions import (
|
||||
LiveRoomEncrypted,
|
||||
LiveRoomHidden,
|
||||
@@ -20,6 +20,7 @@ from .exceptions import (
|
||||
NoStreamFormatAvailable,
|
||||
NoStreamQualityAvailable,
|
||||
)
|
||||
from .helpers import extract_codecs, extract_formats, extract_streams
|
||||
from .models import LiveStatus, RoomInfo, UserInfo
|
||||
from .typing import ApiPlatform, QualityNumber, ResponseData, StreamCodec, StreamFormat
|
||||
|
||||
@@ -50,6 +51,7 @@ class Live:
|
||||
|
||||
self._room_info: RoomInfo
|
||||
self._user_info: UserInfo
|
||||
self._no_flv_stream: bool
|
||||
|
||||
@property
|
||||
def base_api_urls(self) -> List[str]:
|
||||
@@ -101,19 +103,10 @@ class Live:
|
||||
@property
|
||||
def headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en;q=0.3,en-US;q=0.2', # noqa
|
||||
**BASE_HEADERS,
|
||||
'Referer': f'https://live.bilibili.com/{self._room_id}',
|
||||
'Origin': 'https://live.bilibili.com',
|
||||
'Connection': 'keep-alive',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-site',
|
||||
'Pragma': 'no-cache',
|
||||
'Cache-Control': 'no-cache',
|
||||
'User-Agent': self._user_agent,
|
||||
'Cookie': self._cookie,
|
||||
'Accept-Encoding': 'gzip',
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -144,9 +137,19 @@ class Live:
|
||||
self._room_info = await self.get_room_info()
|
||||
self._user_info = await self.get_user_info(self._room_info.uid)
|
||||
|
||||
self._no_flv_stream = False
|
||||
if self.is_living():
|
||||
streams = await self.get_live_streams()
|
||||
if streams:
|
||||
flv_formats = extract_formats(streams, 'flv')
|
||||
self._no_flv_stream = not flv_formats
|
||||
|
||||
async def deinit(self) -> None:
|
||||
await self._session.close()
|
||||
|
||||
def has_no_flv_streams(self) -> bool:
|
||||
return self._no_flv_stream
|
||||
|
||||
async def get_live_status(self) -> LiveStatus:
|
||||
try:
|
||||
# frequent requests will be intercepted by the server's firewall!
|
||||
@@ -239,6 +242,26 @@ class Live:
|
||||
# the timestamp on the server at the moment in seconds
|
||||
return await self._webapi.get_timestamp()
|
||||
|
||||
async def get_play_infos(
|
||||
self, qn: QualityNumber = 10000, api_platform: ApiPlatform = 'web'
|
||||
) -> List[Any]:
|
||||
if api_platform == 'web':
|
||||
play_infos = await self._webapi.get_room_play_infos(self._room_id, qn)
|
||||
else:
|
||||
play_infos = await self._appapi.get_room_play_infos(self._room_id, qn)
|
||||
|
||||
return play_infos
|
||||
|
||||
async def get_live_streams(
|
||||
self, qn: QualityNumber = 10000, api_platform: ApiPlatform = 'web'
|
||||
) -> List[Any]:
|
||||
play_infos = await self.get_play_infos(qn, api_platform)
|
||||
|
||||
for info in play_infos:
|
||||
self._check_room_play_info(info)
|
||||
|
||||
return extract_streams(play_infos)
|
||||
|
||||
async def get_live_stream_url(
|
||||
self,
|
||||
qn: QualityNumber = 10000,
|
||||
@@ -248,23 +271,15 @@ class Live:
|
||||
stream_codec: StreamCodec = 'avc',
|
||||
select_alternative: bool = False,
|
||||
) -> str:
|
||||
if api_platform == 'web':
|
||||
paly_infos = await self._webapi.get_room_play_infos(self._room_id, qn)
|
||||
else:
|
||||
paly_infos = await self._appapi.get_room_play_infos(self._room_id, qn)
|
||||
|
||||
for info in paly_infos:
|
||||
self._check_room_play_info(info)
|
||||
|
||||
streams = jsonpath(paly_infos, '$[*].playurl_info.playurl.stream[*]')
|
||||
streams = await self.get_live_streams(qn, api_platform=api_platform)
|
||||
if not streams:
|
||||
raise NoStreamAvailable(stream_format, stream_codec, qn)
|
||||
formats = jsonpath(
|
||||
streams, f'$[*].format[?(@.format_name == "{stream_format}")]'
|
||||
)
|
||||
|
||||
formats = extract_formats(streams, stream_format)
|
||||
if not formats:
|
||||
raise NoStreamFormatAvailable(stream_format, stream_codec, qn)
|
||||
codecs = jsonpath(formats, f'$[*].codec[?(@.codec_name == "{stream_codec}")]')
|
||||
|
||||
codecs = extract_codecs(formats, stream_codec)
|
||||
if not codecs:
|
||||
raise NoStreamCodecAvailable(stream_format, stream_codec, qn)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from blrec.logging.room_id import aio_task_with_room_id
|
||||
from ..event.event_emitter import EventEmitter, EventListener
|
||||
from ..utils.mixins import SwitchableMixin
|
||||
from .danmaku_client import DanmakuClient, DanmakuCommand, DanmakuListener
|
||||
from .helpers import extract_formats
|
||||
from .live import Live
|
||||
from .models import LiveStatus, RoomInfo
|
||||
from .typing import Danmaku
|
||||
@@ -71,19 +72,19 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
|
||||
def _start_polling(self) -> None:
|
||||
self._polling_task = asyncio.create_task(self._poll_live_status())
|
||||
self._polling_task.add_done_callback(exception_callback)
|
||||
logger.debug('Started polling live status')
|
||||
|
||||
async def _stop_polling(self) -> None:
|
||||
self._polling_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._polling_task
|
||||
del self._polling_task
|
||||
logger.debug('Stopped polling live status')
|
||||
|
||||
def _start_checking(self) -> None:
|
||||
self._checking_task = asyncio.create_task(self._check_if_stream_available())
|
||||
self._checking_task.add_done_callback(exception_callback)
|
||||
logger.debug('Started checking if stream available')
|
||||
asyncio.get_running_loop().call_later(
|
||||
1800, lambda: asyncio.create_task(self._stop_checking())
|
||||
)
|
||||
|
||||
async def _stop_checking(self) -> None:
|
||||
if not hasattr(self, '_checking_task'):
|
||||
@@ -92,7 +93,6 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._checking_task
|
||||
del self._checking_task
|
||||
logger.debug('Stopped checking if stream available')
|
||||
|
||||
async def on_client_reconnected(self) -> None:
|
||||
# check the live status after the client reconnected and simulate
|
||||
@@ -147,19 +147,16 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
|
||||
self._status_count = 0
|
||||
self._stream_available = False
|
||||
await self._emit('live_ended', self._live)
|
||||
await self._stop_checking()
|
||||
else:
|
||||
self._status_count += 1
|
||||
|
||||
if self._status_count == 1:
|
||||
assert self._previous_status != LiveStatus.LIVE
|
||||
self._start_checking()
|
||||
await self._emit('live_began', self._live)
|
||||
self._start_checking()
|
||||
elif self._status_count == 2:
|
||||
assert self._previous_status == LiveStatus.LIVE
|
||||
if not self._stream_available:
|
||||
self._stream_available = True
|
||||
await self._stop_checking()
|
||||
await self._emit('live_stream_available', self._live)
|
||||
elif self._status_count > 2:
|
||||
assert self._previous_status == LiveStatus.LIVE
|
||||
await self._emit('live_stream_reset', self._live)
|
||||
@@ -172,20 +169,43 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _poll_live_status(self) -> None:
|
||||
logger.debug('Started polling live status')
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(600 + random.randrange(-60, 60))
|
||||
await self._live.update_room_info()
|
||||
current_status = self._live.room_info.live_status
|
||||
if current_status != self._previous_status:
|
||||
await self._handle_status_change(current_status)
|
||||
try:
|
||||
await asyncio.sleep(600 + random.randrange(-60, 60))
|
||||
await self._live.update_room_info()
|
||||
current_status = self._live.room_info.live_status
|
||||
if current_status != self._previous_status:
|
||||
await self._handle_status_change(current_status)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug('Cancelled polling live status')
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to poll live status: {repr(e)}')
|
||||
|
||||
logger.debug('Stopped polling live status')
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _check_if_stream_available(self) -> None:
|
||||
while not self._stream_available:
|
||||
logger.debug('Started checking if stream available')
|
||||
|
||||
while True:
|
||||
try:
|
||||
await self._live.get_live_stream_url()
|
||||
except Exception:
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
self._stream_available = True
|
||||
await self._emit('live_stream_available', self._live)
|
||||
streams = await self._live.get_live_streams()
|
||||
if streams:
|
||||
logger.debug('live stream available')
|
||||
self._stream_available = True
|
||||
flv_formats = extract_formats(streams, 'flv')
|
||||
self._live._no_flv_stream = not flv_formats
|
||||
await self._emit('live_stream_available', self._live)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
logger.debug('Cancelled checking if stream available')
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to check if stream available: {repr(e)}')
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
logger.debug('Stopped checking if stream available')
|
||||
|
||||
@@ -238,19 +238,32 @@ class StreamRecorder(
|
||||
async def _do_start(self) -> None:
|
||||
self.hls_stream_available_time = None
|
||||
stream_format = self.stream_format
|
||||
if stream_format == 'fmp4':
|
||||
logger.info('Waiting for the fmp4 stream becomes available...')
|
||||
available = await self._wait_fmp4_stream()
|
||||
if available:
|
||||
if self.stream_available_time is not None:
|
||||
self.hls_stream_available_time = await self._live.get_timestamp()
|
||||
else:
|
||||
|
||||
if self._live.has_no_flv_streams():
|
||||
if stream_format == 'flv':
|
||||
logger.warning(
|
||||
'The specified stream format (fmp4) is not available '
|
||||
f'in {self.fmp4_stream_timeout} seconcds, '
|
||||
'falling back to stream format (flv).'
|
||||
'The specified stream format (flv) is not available, '
|
||||
'falling back to stream format (fmp4).'
|
||||
)
|
||||
stream_format = 'flv'
|
||||
stream_format = 'fmp4'
|
||||
self.hls_stream_available_time = self.stream_available_time
|
||||
else:
|
||||
if stream_format == 'fmp4':
|
||||
logger.info('Waiting for the fmp4 stream becomes available...')
|
||||
available = await self._wait_fmp4_stream()
|
||||
if available:
|
||||
if self.stream_available_time is not None:
|
||||
self.hls_stream_available_time = (
|
||||
await self._live.get_timestamp()
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'The specified stream format (fmp4) is not available '
|
||||
f'in {self.fmp4_stream_timeout} seconcds, '
|
||||
'falling back to stream format (flv).'
|
||||
)
|
||||
stream_format = 'flv'
|
||||
|
||||
self._change_impl(stream_format)
|
||||
await self._impl.start()
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
src/blrec/data/webapp/548.73ee5c2419f2617e.js
Normal file
1
src/blrec/data/webapp/548.73ee5c2419f2617e.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>
|
||||
<app-root></app-root>
|
||||
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
||||
<script src="runtime.34b6285f086501af.js" type="module"></script><script src="polyfills.4e5433063877ea34.js" type="module"></script><script src="main.f21b7d831ad9cafb.js" type="module"></script>
|
||||
<script src="runtime.bad8d115055bada4.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": 1695440376157,
|
||||
"timestamp": 1697282115210,
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
@@ -12,17 +12,17 @@
|
||||
},
|
||||
"urls": [
|
||||
"/103.4a2aea63cc3bf42b.js",
|
||||
"/287.360829ef4dfc7f0e.js",
|
||||
"/287.5c768f00dcd24631.js",
|
||||
"/386.2404f3bc252e1df3.js",
|
||||
"/503.6553f508f4a9247d.js",
|
||||
"/548.ea53a087779da599.js",
|
||||
"/548.73ee5c2419f2617e.js",
|
||||
"/688.7032fddba7983cf6.js",
|
||||
"/common.1fc175bce139f4df.js",
|
||||
"/index.html",
|
||||
"/main.f21b7d831ad9cafb.js",
|
||||
"/manifest.webmanifest",
|
||||
"/polyfills.4e5433063877ea34.js",
|
||||
"/runtime.34b6285f086501af.js",
|
||||
"/runtime.bad8d115055bada4.js",
|
||||
"/styles.ae81e04dfa5b2860.css"
|
||||
],
|
||||
"patterns": []
|
||||
@@ -1635,10 +1635,10 @@
|
||||
"dataGroups": [],
|
||||
"hashTable": {
|
||||
"/103.4a2aea63cc3bf42b.js": "2711817f2977bfdc18c34fee4fe9385fe012bb22",
|
||||
"/287.360829ef4dfc7f0e.js": "2f8cb4a318c877840b29d84752e40ed698321e67",
|
||||
"/287.5c768f00dcd24631.js": "4cd0f85040b1a482bf9796575738afdd2dcda00e",
|
||||
"/386.2404f3bc252e1df3.js": "f937945645579b9651be2666f70cec2c5de4e367",
|
||||
"/503.6553f508f4a9247d.js": "0878ea0e91bfd5458dd55875561e91060ecb0837",
|
||||
"/548.ea53a087779da599.js": "efcdeae60239e68f14b9e410d4bfe64ecb592382",
|
||||
"/548.73ee5c2419f2617e.js": "93153313f106aed86859050288e59e6c19e3d4cf",
|
||||
"/688.7032fddba7983cf6.js": "eae55044529782a51b7e534365255bbfa5522b05",
|
||||
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
|
||||
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
|
||||
@@ -3234,11 +3234,11 @@
|
||||
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
|
||||
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
|
||||
"/common.1fc175bce139f4df.js": "af1775164711ec49e5c3a91ee45bd77509c17c54",
|
||||
"/index.html": "16375e9b931dcf8a92d56b4a860ab95fb622e08b",
|
||||
"/index.html": "8dc97bacb089295cf14011c3c3e21b14ba7108a1",
|
||||
"/main.f21b7d831ad9cafb.js": "fc51efa446c2ac21ee17e165217dd3faeacc5290",
|
||||
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
|
||||
"/polyfills.4e5433063877ea34.js": "68159ab99e0608976404a17132f60b5ceb6f12d2",
|
||||
"/runtime.34b6285f086501af.js": "9ebaf308e01a4110d64e264057c87060e0d629c7",
|
||||
"/runtime.bad8d115055bada4.js": "a679fb0193729e75a72e77d0551dc85e080d9b41",
|
||||
"/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 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))})()})();
|
||||
(()=>{"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(b=>r.O[b](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:"5c768f00dcd24631",386:"2404f3bc252e1df3",503:"6553f508f4a9247d",548:"73ee5c2419f2617e",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,b)=>{a.onerror=a.onload=null,clearTimeout(p);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(b)),g)return g(b)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),c&&document.head.appendChild(a)}}})(),r.r=e=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:n=>n},typeof trustedTypes<"u"&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="",(()=>{var e={666:0};r.f.j=(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),p=l&&l.target&&l.target.src;u.message="Loading chunk "+f+" failed.\n("+s+": "+p+")",u.name="ChunkLoadError",u.type=s,u.request=p,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(p=>0!==e[p])){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))})()})();
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from pathlib import PurePath
|
||||
from typing import Iterator, List, Optional
|
||||
|
||||
@@ -565,22 +566,29 @@ class RecordTask:
|
||||
self._destroy_danmaku_client()
|
||||
|
||||
def _destroy_danmaku_client(self) -> None:
|
||||
del self._danmaku_client
|
||||
with suppress(AttributeError):
|
||||
del self._danmaku_client
|
||||
|
||||
def _destroy_live_monitor(self) -> None:
|
||||
del self._live_monitor
|
||||
with suppress(AttributeError):
|
||||
del self._live_monitor
|
||||
|
||||
def _destroy_live_event_submitter(self) -> None:
|
||||
del self._live_event_submitter
|
||||
with suppress(AttributeError):
|
||||
del self._live_event_submitter
|
||||
|
||||
def _destroy_recorder(self) -> None:
|
||||
del self._recorder
|
||||
with suppress(AttributeError):
|
||||
del self._recorder
|
||||
|
||||
def _destroy_recorder_event_submitter(self) -> None:
|
||||
del self._recorder_event_submitter
|
||||
with suppress(AttributeError):
|
||||
del self._recorder_event_submitter
|
||||
|
||||
def _destroy_postprocessor(self) -> None:
|
||||
del self._postprocessor
|
||||
with suppress(AttributeError):
|
||||
del self._postprocessor
|
||||
|
||||
def _destroy_postprocessor_event_submitter(self) -> None:
|
||||
del self._postprocessor_event_submitter
|
||||
with suppress(AttributeError):
|
||||
del self._postprocessor_event_submitter
|
||||
|
||||
@@ -106,8 +106,9 @@ class RecordTaskManager:
|
||||
await task.enable_monitor()
|
||||
if settings.enable_recorder:
|
||||
await task.enable_recorder()
|
||||
except Exception as e:
|
||||
except BaseException as e:
|
||||
logger.error(f'Failed to add task {settings.room_id} due to: {repr(e)}')
|
||||
await task.destroy()
|
||||
del self._tasks[settings.room_id]
|
||||
raise
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
from threading import Thread, current_thread
|
||||
from typing import Any, Callable, Optional, TypeVar
|
||||
|
||||
from reactivex import Observable, abc
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
|
||||
|
||||
_T = TypeVar('_T')
|
||||
|
||||
|
||||
@@ -41,7 +40,8 @@ def observe_on_new_thread(
|
||||
nonlocal disposed
|
||||
disposed = True
|
||||
queue.put(lambda: None)
|
||||
thread.join()
|
||||
if thread is not current_thread():
|
||||
thread.join()
|
||||
|
||||
subscription.disposable = source.subscribe(
|
||||
on_next, on_error, on_completed, scheduler=scheduler
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import os
|
||||
import errno
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from ..schemas import ResponseMessage
|
||||
from ...application import Application
|
||||
from blrec.bili.helpers import get_nav
|
||||
|
||||
from ...application import Application
|
||||
from ..schemas import ResponseMessage
|
||||
|
||||
app: Application = None # type: ignore # bypass flake8 F821
|
||||
|
||||
router = APIRouter(
|
||||
prefix='/api/v1/validation',
|
||||
tags=['validation'],
|
||||
)
|
||||
router = APIRouter(prefix='/api/v1/validation', tags=['validation'])
|
||||
|
||||
|
||||
@router.post(
|
||||
'/dir',
|
||||
response_model=ResponseMessage,
|
||||
)
|
||||
@router.post('/dir', response_model=ResponseMessage)
|
||||
async def validate_dir(path: str = Body(..., embed=True)) -> ResponseMessage:
|
||||
"""Check if the path is a directory and grants the read, write permissions
|
||||
"""
|
||||
"""Check if the path is a directory and grants the read, write permissions"""
|
||||
if not os.path.isdir(path):
|
||||
return ResponseMessage(code=errno.ENOTDIR, message='not a directory')
|
||||
elif not os.access(path, os.F_OK | os.R_OK | os.W_OK):
|
||||
return ResponseMessage(code=errno.EACCES, message='no permissions')
|
||||
else:
|
||||
return ResponseMessage(code=0, message='ok')
|
||||
|
||||
|
||||
@router.post('/cookie', response_model=ResponseMessage)
|
||||
async def validate_cookie(cookie: str = Body(..., embed=True)) -> ResponseMessage:
|
||||
"""Check if the cookie is valid"""
|
||||
json_res = await get_nav(cookie)
|
||||
return ResponseMessage(
|
||||
code=json_res['code'], message=json_res['message'], data=json_res['data']
|
||||
)
|
||||
|
||||
@@ -16,4 +16,9 @@ export class ValidationService {
|
||||
const url = this.url.makeApiUrl(`/api/v1/validation/dir`);
|
||||
return this.http.post<ResponseMessage>(url, { path });
|
||||
}
|
||||
|
||||
validateCookie(cookie: string): Observable<ResponseMessage> {
|
||||
const url = this.url.makeApiUrl(`/api/v1/validation/cookie`);
|
||||
return this.http.post<ResponseMessage>(url, { cookie });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
<nz-modal
|
||||
nzTitle="修改 Cookie"
|
||||
nzOkDanger
|
||||
nzCentered
|
||||
[(nzVisible)]="visible"
|
||||
[nzOkDisabled]="control.invalid || control.value.trim() === value"
|
||||
(nzOnOk)="handleConfirm()"
|
||||
(nzOnCancel)="handleCancel()"
|
||||
>
|
||||
<nz-modal nzTitle="修改 Cookie" nzCentered [(nzVisible)]="visible">
|
||||
<ng-container *nzModalContent>
|
||||
<form nz-form [formGroup]="settingsForm">
|
||||
<nz-form-item>
|
||||
@@ -28,4 +20,24 @@
|
||||
</nz-form-item>
|
||||
</form>
|
||||
</ng-container>
|
||||
<ng-template [nzModalFooter]>
|
||||
<button
|
||||
nz-button
|
||||
nzType="primary"
|
||||
(click)="testCookie()"
|
||||
[disabled]="control.value.trim() === ''"
|
||||
>
|
||||
测试
|
||||
</button>
|
||||
<button nz-button nzType="default" (click)="handleCancel()">取消</button>
|
||||
<button
|
||||
nz-button
|
||||
nzType="primary"
|
||||
nzDanger
|
||||
(click)="handleConfirm()"
|
||||
[disabled]="control.invalid || control.value.trim() === value"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</ng-template>
|
||||
</nz-modal>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
Input,
|
||||
Output,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
|
||||
|
||||
import { NzMessageService } from 'ng-zorro-antd/message';
|
||||
|
||||
import { ValidationService } from 'src/app/core/services/validation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cookie-edit-dialog',
|
||||
templateUrl: './cookie-edit-dialog.component.html',
|
||||
@@ -28,7 +32,9 @@ export class CookieEditDialogComponent implements OnChanges {
|
||||
|
||||
constructor(
|
||||
formBuilder: FormBuilder,
|
||||
private changeDetector: ChangeDetectorRef
|
||||
private changeDetector: ChangeDetectorRef,
|
||||
private validationService: ValidationService,
|
||||
private message: NzMessageService
|
||||
) {
|
||||
this.settingsForm = formBuilder.group({
|
||||
cookie: [''],
|
||||
@@ -72,4 +78,18 @@ export class CookieEditDialogComponent implements OnChanges {
|
||||
this.confirm.emit(this.control.value.trim());
|
||||
this.close();
|
||||
}
|
||||
|
||||
testCookie(): void {
|
||||
this.validationService
|
||||
.validateCookie(this.control.value)
|
||||
.subscribe((result) => {
|
||||
if (result.code === 0) {
|
||||
this.message.success(
|
||||
`uid: ${result.data?.mid}, uname: ${result.data?.uname}`
|
||||
);
|
||||
} else {
|
||||
this.message.error(`${result.code}: ${result.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
<p>
|
||||
选择要录制的直播流格式
|
||||
<br />
|
||||
FLV: 网络不稳定容易中断丢失数据或录制到二压画质
|
||||
<b>FLV:</b>
|
||||
flv 流在网络不稳定的情况下容易中断丢失数据或录制到二压画质。没有 flv
|
||||
流的直播会自动切换录制 fmp4 流。
|
||||
<br />
|
||||
HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
|
||||
<b>HLS (fmp4):</b>
|
||||
hls 流基本不受网络波动影响,但不是所有直播间都支持。有 flv
|
||||
流的直播,在设定的等待时间内没有 fmp4 流会自动切换录制 flv 流。
|
||||
<br />
|
||||
P.S.
|
||||
<br />
|
||||
录制 HLS 流需要 ffmpeg
|
||||
<br />
|
||||
在设定时间内没有 fmp4 流会自动切换录制 flv 流
|
||||
<b>P.S.</b>
|
||||
<br />
|
||||
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
|
||||
则不支持。
|
||||
|
||||
@@ -157,15 +157,15 @@
|
||||
<p>
|
||||
选择要录制的直播流格式
|
||||
<br />
|
||||
FLV: 网络不稳定容易中断丢失数据或录制到二压画质
|
||||
<b>FLV:</b>
|
||||
flv 流在网络不稳定的情况下容易中断丢失数据或录制到二压画质。没有
|
||||
flv 流的直播会自动切换录制 fmp4 流。
|
||||
<br />
|
||||
HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
|
||||
<b>HLS (fmp4):</b>
|
||||
hls 流基本不受网络波动影响,但不是所有直播间都支持。有 flv
|
||||
流的直播,在设定的等待时间内没有 fmp4 流会自动切换录制 flv 流。
|
||||
<br />
|
||||
P.S.
|
||||
<br />
|
||||
录制 HLS 流需要 ffmpeg
|
||||
<br />
|
||||
在设定时间内没有 fmp4 流会自动切换录制 flv 流
|
||||
<b>P.S.</b>
|
||||
<br />
|
||||
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
|
||||
则不支持。
|
||||
|
||||
Reference in New Issue
Block a user