Compare commits

..

6 Commits

Author SHA1 Message Date
acgnhik
5aad77c337 release: 1.15.0 2023-10-14 19:43:51 +08:00
acgnhik
38efd5a923 feat: test cookie
resolve #181
2023-10-14 19:22:16 +08:00
acgnhik
5d3f54b02a fix: fix Unclosed client session
fix #176
2023-10-12 19:20:35 +08:00
acgnhik
0dda7e7227 fix: fix RuntimeError: cannot join current thread
fix #161
2023-10-09 16:08:20 +08:00
acgnhik
d3579fae90 feat: recording fmp4 streams if no flv streams
resolve #169
2023-10-07 11:52:18 +08:00
acgnhik
79372d23ce fix: fix no auto recording for live streaming that has no flv streams
fix #183
2023-10-07 11:27:45 +08:00
23 changed files with 289 additions and 129 deletions

View File

@@ -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'`

View File

@@ -1,3 +1,3 @@
__prog__ = 'blrec'
__version__ = '1.14.1'
__version__ = '1.15.0'
__github__ = 'https://github.com/acgnhiki/blrec'

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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')

View File

@@ -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

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.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>

View File

@@ -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": [

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 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))})()})();

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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']
)

View File

@@ -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 });
}
}

View File

@@ -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>

View File

@@ -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}`);
}
});
}
}

View File

@@ -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
则不支持。

View File

@@ -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
则不支持。