feat: improve customization of base api url

This commit is contained in:
acgnhik 2022-08-28 13:58:36 +08:00
parent 7dd894e256
commit 473fef2acc
43 changed files with 424 additions and 420 deletions

View File

@ -1,8 +1,10 @@
import asyncio
import hashlib
import logging
import os
from abc import ABC
from datetime import datetime
from typing import Any, Dict, Mapping, Optional
from typing import Any, Dict, List, Mapping, Optional
from urllib.parse import urlencode
import aiohttp
@ -16,15 +18,17 @@ __all__ = 'AppApi', 'WebApi'
logger = logging.getLogger(__name__)
TRACE_API_REQ = bool(os.environ.get('TRACE_API_REQ'))
class BaseApi(ABC):
base_api_url: str = 'https://api.bilibili.com'
base_live_api_url: str = 'https://api.live.bilibili.com'
base_play_info_api_url: str = base_live_api_url
def __init__(
self, session: aiohttp.ClientSession, headers: Optional[Dict[str, str]] = None
):
self.base_api_urls: List[str] = ['https://api.bilibili.com']
self.base_live_api_urls: List[str] = ['https://api.live.bilibili.com']
self.base_play_info_api_urls: List[str] = ['https://api.live.bilibili.com']
self._session = session
self.headers = headers or {}
self.timeout = 10
@ -45,15 +49,62 @@ class BaseApi(ABC):
)
@retry(reraise=True, stop=stop_after_delay(5), wait=wait_exponential(0.1))
async def _get_json(self, *args: Any, **kwds: Any) -> JsonResponse:
async with self._session.get(
*args, **kwds, timeout=self.timeout, headers=self.headers
) as res:
logger.debug(f'real url: {res.real_url}')
async def _get_json_res(self, *args: Any, **kwds: Any) -> JsonResponse:
kwds = {'timeout': self.timeout, 'headers': self.headers, **kwds}
async with self._session.get(*args, **kwds) as res:
if TRACE_API_REQ:
logger.debug(f'Request info: {res.request_info}')
try:
json_res = await res.json()
except aiohttp.ContentTypeError:
text_res = await res.text()
logger.debug(f'Response text: {text_res[:200]}')
raise
self._check_response(json_res)
return json_res
async def _get_json(
self, base_urls: List[str], path: str, *args: Any, **kwds: Any
) -> JsonResponse:
if not base_urls:
raise ValueError('No base urls')
exception = None
for base_url in base_urls:
url = base_url + path
try:
return await self._get_json_res(url, *args, **kwds)
except Exception as exc:
exception = exc
if TRACE_API_REQ:
logger.debug(f'Failed to get json from {url}', exc_info=exc)
else:
assert exception is not None
raise exception
async def _get_jsons_concurrently(
self, base_urls: List[str], path: str, *args: Any, **kwds: Any
) -> List[JsonResponse]:
if not base_urls:
raise ValueError('No base urls')
urls = [base_url + path for base_url in base_urls]
aws = (self._get_json_res(url, *args, **kwds) for url in urls)
results = await asyncio.gather(*aws, return_exceptions=True)
exceptions = []
json_responses = []
for idx, item in enumerate(results):
if isinstance(item, Exception):
if TRACE_API_REQ:
logger.debug(f'Failed to get json from {urls[idx]}', exc_info=item)
exceptions.append(item)
elif isinstance(item, dict):
json_responses.append(item)
else:
if TRACE_API_REQ:
logger.debug(repr(item))
if not json_responses:
raise exceptions[0]
return json_responses
class AppApi(BaseApi):
# taken from https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/other/API_sign.md # noqa
@ -85,15 +136,15 @@ class AppApi(BaseApi):
params.update(sign=sign)
return params
async def get_room_play_info(
async def get_room_play_infos(
self,
room_id: int,
qn: QualityNumber = 10000,
*,
only_video: bool = False,
only_audio: bool = False,
) -> ResponseData:
url = self.base_play_info_api_url + '/xlive/app-room/v2/index/getRoomPlayInfo'
) -> List[ResponseData]:
path = '/xlive/app-room/v2/index/getRoomPlayInfo'
params = self.signed(
{
'actionKey': 'appkey',
@ -121,11 +172,13 @@ class AppApi(BaseApi):
'ts': int(datetime.utcnow().timestamp()),
}
)
r = await self._get_json(url, params=params, headers=self._headers)
return r['data']
json_responses = await self._get_jsons_concurrently(
self.base_play_info_api_urls, path, params=params
)
return [r['data'] for r in json_responses]
async def get_info_by_room(self, room_id: int) -> ResponseData:
url = self.base_live_api_url + '/xlive/app-room/v1/index/getInfoByRoom'
path = '/xlive/app-room/v1/index/getInfoByRoom'
params = self.signed(
{
'actionKey': 'appkey',
@ -138,11 +191,12 @@ class AppApi(BaseApi):
'ts': int(datetime.utcnow().timestamp()),
}
)
r = await self._get_json(url, params=params)
return r['data']
json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return json_res['data']
async def get_user_info(self, uid: int) -> ResponseData:
url = self.base_api_url + '/x/v2/space'
base_api_urls = ['https://app.bilibili.com']
path = '/x/v2/space'
params = self.signed(
{
'build': '6640400',
@ -153,11 +207,11 @@ class AppApi(BaseApi):
'vmid': uid,
}
)
r = await self._get_json(url, params=params)
return r['data']
json_res = await self._get_json(base_api_urls, path, params=params)
return json_res['data']
async def get_danmu_info(self, room_id: int) -> ResponseData:
url = self.base_live_api_url + '/xlive/app-room/v1/index/getDanmuInfo'
path = '/xlive/app-room/v1/index/getDanmuInfo'
params = self.signed(
{
'actionKey': 'appkey',
@ -170,20 +224,21 @@ class AppApi(BaseApi):
'ts': int(datetime.utcnow().timestamp()),
}
)
r = await self._get_json(url, params=params)
return r['data']
json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return json_res['data']
class WebApi(BaseApi):
async def room_init(self, room_id: int) -> ResponseData:
url = self.base_live_api_url + '/room/v1/Room/room_init'
r = await self._get_json(url, params={'id': room_id})
return r['data']
path = '/room/v1/Room/room_init'
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_room_play_info(
async def get_room_play_infos(
self, room_id: int, qn: QualityNumber = 10000
) -> ResponseData:
url = self.base_play_info_api_url + '/xlive/web-room/v2/index/getRoomPlayInfo'
) -> List[ResponseData]:
path = '/xlive/web-room/v2/index/getRoomPlayInfo'
params = {
'room_id': room_id,
'protocol': '0,1',
@ -193,34 +248,37 @@ class WebApi(BaseApi):
'platform': 'web',
'ptype': 8,
}
r = await self._get_json(url, params=params)
return r['data']
json_responses = await self._get_jsons_concurrently(
self.base_play_info_api_urls, path, params=params
)
return [r['data'] for r in json_responses]
async def get_info_by_room(self, room_id: int) -> ResponseData:
url = self.base_live_api_url + '/xlive/web-room/v1/index/getInfoByRoom'
path = '/xlive/web-room/v1/index/getInfoByRoom'
params = {'room_id': room_id}
r = await self._get_json(url, params=params)
return r['data']
json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return json_res['data']
async def get_info(self, room_id: int) -> ResponseData:
url = self.base_live_api_url + '/room/v1/Room/get_info'
path = '/room/v1/Room/get_info'
params = {'room_id': room_id}
r = await self._get_json(url, params=params)
return r['data']
json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return json_res['data']
async def get_timestamp(self) -> int:
url = self.base_live_api_url + '/av/v1/Time/getTimestamp?platform=pc'
r = await self._get_json(url)
return r['data']['timestamp']
path = '/av/v1/Time/getTimestamp'
params = {'platform': 'pc'}
json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return json_res['data']['timestamp']
async def get_user_info(self, uid: int) -> ResponseData:
url = self.base_api_url + '/x/space/acc/info'
path = '/x/space/acc/info'
params = {'mid': uid}
r = await self._get_json(url, params=params)
return r['data']
json_res = await self._get_json(self.base_api_urls, path, params=params)
return json_res['data']
async def get_danmu_info(self, room_id: int) -> ResponseData:
url = self.base_live_api_url + '/xlive/web-room/v1/index/getDanmuInfo'
path = '/xlive/web-room/v1/index/getDanmuInfo'
params = {'id': room_id}
r = await self._get_json(url, params=params)
return r['data']
json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return json_res['data']

View File

@ -3,7 +3,7 @@ import json
import logging
import re
import time
from typing import Any, Dict, List, cast
from typing import Any, Dict, List
import aiohttp
from jsonpath import jsonpath
@ -52,31 +52,31 @@ class Live:
self._user_info: UserInfo
@property
def base_api_url(self) -> str:
return self._webapi.base_api_url
def base_api_urls(self) -> List[str]:
return self._webapi.base_api_urls
@base_api_url.setter
def base_api_url(self, value: str) -> None:
self._webapi.base_api_url = value
self._appapi.base_api_url = value
@base_api_urls.setter
def base_api_urls(self, value: List[str]) -> None:
self._webapi.base_api_urls = value
self._appapi.base_api_urls = value
@property
def base_live_api_url(self) -> str:
return self._webapi.base_live_api_url
def base_live_api_urls(self) -> List[str]:
return self._webapi.base_live_api_urls
@base_live_api_url.setter
def base_live_api_url(self, value: str) -> None:
self._webapi.base_live_api_url = value
self._appapi.base_live_api_url = value
@base_live_api_urls.setter
def base_live_api_urls(self, value: List[str]) -> None:
self._webapi.base_live_api_urls = value
self._appapi.base_live_api_urls = value
@property
def base_play_info_api_url(self) -> str:
return self._webapi.base_play_info_api_url
def base_play_info_api_urls(self) -> List[str]:
return self._webapi.base_play_info_api_urls
@base_play_info_api_url.setter
def base_play_info_api_url(self, value: str) -> None:
self._webapi.base_play_info_api_url = value
self._appapi.base_play_info_api_url = value
@base_play_info_api_urls.setter
def base_play_info_api_urls(self, value: List[str]) -> None:
self._webapi.base_play_info_api_urls = value
self._appapi.base_play_info_api_urls = value
@property
def user_agent(self) -> str:
@ -102,7 +102,7 @@ class Live:
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',
'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
'Referer': f'https://live.bilibili.com/{self._room_id}',
'Origin': 'https://live.bilibili.com',
'Connection': 'keep-alive',
@ -249,13 +249,14 @@ class Live:
select_alternative: bool = False,
) -> str:
if api_platform == 'web':
info = await self._webapi.get_room_play_info(self._room_id, qn)
paly_infos = await self._webapi.get_room_play_infos(self._room_id, qn)
else:
info = await self._appapi.get_room_play_info(self._room_id, qn)
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(info, '$.playurl_info.playurl.stream[*]')
streams = jsonpath(paly_infos, '$[*].playurl_info.playurl.stream[*]')
if not streams:
raise NoStreamAvailable(stream_format, stream_codec, qn)
formats = jsonpath(
@ -266,10 +267,10 @@ class Live:
codecs = jsonpath(formats, f'$[*].codec[?(@.codec_name == "{stream_codec}")]')
if not codecs:
raise NoStreamCodecAvailable(stream_format, stream_codec, qn)
codec = codecs[0]
accept_qn = cast(List[QualityNumber], codec['accept_qn'])
if qn not in accept_qn or codec['current_qn'] != qn:
accept_qns = jsonpath(codecs, '$[*].accept_qn[*]')
current_qns = jsonpath(codecs, '$[*].current_qn')
if qn not in accept_qns or not all(map(lambda q: q == qn, current_qns)):
raise NoStreamQualityAvailable(stream_format, stream_codec, qn)
def sort_by_host(info: Any) -> int:
@ -282,16 +283,23 @@ class Live:
return 1
if num == '08':
return 2
if num == '05':
return 3
if num == '07':
return 4
return 1000 + int(num)
elif re.search(r'cn-[a-z]+-[a-z]+', host):
return 2000
elif 'mcdn' in host:
return 2000
elif re.search(r'cn-[a-z]+-[a-z]+', host):
return 5000
else:
return 10000
url_info = sorted(codec['url_info'], key=sort_by_host)
urls = [i['host'] + codec['base_url'] + i['extra'] for i in url_info]
url_infos = sorted(
({**i, 'base_url': c['base_url']} for c in codecs for i in c['url_info']),
key=sort_by_host,
)
urls = [i['host'] + i['base_url'] + i['extra'] for i in url_infos]
if not select_alternative:
return urls[0]

View File

@ -141,7 +141,7 @@ class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
self._stream_parser,
self._connection_error_handler,
self._request_exception_handler,
flv_ops.process(sort_tags=True, trace=self._debug),
flv_ops.process(sort_tags=True),
self._cutter,
self._limiter,
self._join_point_extractor,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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.4ae765ab3bddf383.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.27d1fff16f7909f2.js" type="module"></script>
<script src="runtime.c6818dbcd7b06106.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.6da8ea192405b948.js" type="module"></script>
</body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"configVersion": 1,
"timestamp": 1661135687643,
"timestamp": 1661579095139,
"index": "/index.html",
"assetGroups": [
{
@ -13,16 +13,16 @@
"urls": [
"/103.5b5d2a6e5a8a7479.js",
"/146.5a8902910bda9e87.js",
"/170.d0e14a28ee578d1f.js",
"/183.0d3cd9f454be16fb.js",
"/183.ee55fc76717674c3.js",
"/205.cf2caa9b46b14212.js",
"/45.c90c3cea2bf1a66e.js",
"/91.9ff409a090dace5c.js",
"/91.cab8652a2fa56b1a.js",
"/common.858f777e9296e6f2.js",
"/index.html",
"/main.27d1fff16f7909f2.js",
"/main.6da8ea192405b948.js",
"/manifest.webmanifest",
"/polyfills.4b08448aee19bb22.js",
"/runtime.4ae765ab3bddf383.js",
"/runtime.c6818dbcd7b06106.js",
"/styles.2e152d608221c2ee.css"
],
"patterns": []
@ -1636,10 +1636,10 @@
"hashTable": {
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
"/146.5a8902910bda9e87.js": "d9c33c7073662699f00f46f3a384ae5b749fdef9",
"/170.d0e14a28ee578d1f.js": "d6b6208ca442565ed39300b27ab8cbe5501cb46a",
"/183.0d3cd9f454be16fb.js": "e7e6ebc715791102fd09edabe2aa47316208b29c",
"/183.ee55fc76717674c3.js": "2628c996ec80a6c6703d542d34ac95194283bcf8",
"/205.cf2caa9b46b14212.js": "749df896fbbd279dcf49318963f0ce074c5df87f",
"/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764",
"/91.9ff409a090dace5c.js": "d756ffe7cd3f5516e40a7e6d6cf494ea6213a546",
"/91.cab8652a2fa56b1a.js": "c11ebf28472c8a75653f7b27b5cffdec477830fe",
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
@ -3234,11 +3234,11 @@
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
"/index.html": "29167783eb093ffa93369f741a5ce20a534137de",
"/main.27d1fff16f7909f2.js": "22e63726601a31af1a96e7901afc0d2bea7fd414",
"/index.html": "80797fa46f33b7bcf402788a5d0d0516b77f23b1",
"/main.6da8ea192405b948.js": "b8995c7d8ccd465769b90936db5e0a337a827a58",
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
"/runtime.4ae765ab3bddf383.js": "96653fd35d3ad9684e603011436e9d43a1121690",
"/runtime.c6818dbcd7b06106.js": "00160f946c5d007a956f5f61293cbd3bed2756dc",
"/styles.2e152d608221c2ee.css": "9830389a46daa5b4511e0dd343aad23ca9f9690f"
},
"navigationUrls": [

View File

@ -1 +1 @@
(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,o,f)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,o,f]=e[n],c=!0,l=0;l<t.length;l++)(!1&f||a>=f)&&Object.keys(r.O).every(p=>r.O[p](t[l]))?t.splice(l--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var d=o();void 0!==d&&(i=d)}}return i}f=f||0;for(var n=e.length;n>0&&e[n-1][2]>f;n--)e[n]=e[n-1];e[n]=[t,o,f]},r.n=e=>{var i=e&&e.__esModule?()=>e.default:()=>e;return r.d(i,{a:i}),i},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{45:"c90c3cea2bf1a66e",91:"9ff409a090dace5c",103:"5b5d2a6e5a8a7479",146:"5a8902910bda9e87",170:"d0e14a28ee578d1f",183:"0d3cd9f454be16fb",592:"858f777e9296e6f2"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="blrec:";r.l=(t,o,f,n)=>{if(e[t])e[t].push(o);else{var a,c;if(void 0!==f)for(var l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var u=l[d];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==i+f){a=u;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",i+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=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tu=i=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(i))})(),r.p="",(()=>{var e={666:0};r.f.j=(o,f)=>{var n=r.o(e,o)?e[o]:void 0;if(0!==n)if(n)f.push(n[2]);else if(666!=o){var a=new Promise((u,s)=>n=e[o]=[u,s]);f.push(n[2]=a);var c=r.p+r.u(o),l=new Error;r.l(c,u=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=u&&("load"===u.type?"missing":u.type),b=u&&u.target&&u.target.src;l.message="Loading chunk "+o+" failed.\n("+s+": "+b+")",l.name="ChunkLoadError",l.type=s,l.request=b,n[1](l)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var l,d,[n,a,c]=f,u=0;if(n.some(b=>0!==e[b])){for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(c)var s=c(r)}for(o&&o(f);u<n.length;u++)r.o(e,d=n[u])&&e[d]&&e[d][0](),e[n[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();
(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,f,o)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,f,o]=e[n],c=!0,l=0;l<t.length;l++)(!1&o||a>=o)&&Object.keys(r.O).every(p=>r.O[p](t[l]))?t.splice(l--,1):(c=!1,o<a&&(a=o));if(c){e.splice(n--,1);var d=f();void 0!==d&&(i=d)}}return i}o=o||0;for(var n=e.length;n>0&&e[n-1][2]>o;n--)e[n]=e[n-1];e[n]=[t,f,o]},r.n=e=>{var i=e&&e.__esModule?()=>e.default:()=>e;return r.d(i,{a:i}),i},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{45:"c90c3cea2bf1a66e",91:"cab8652a2fa56b1a",103:"5b5d2a6e5a8a7479",146:"5a8902910bda9e87",183:"ee55fc76717674c3",205:"cf2caa9b46b14212",592:"858f777e9296e6f2"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="blrec:";r.l=(t,f,o,n)=>{if(e[t])e[t].push(f);else{var a,c;if(void 0!==o)for(var l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var u=l[d];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==i+o){a=u;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",i+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=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tu=i=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(i))})(),r.p="",(()=>{var e={666:0};r.f.j=(f,o)=>{var n=r.o(e,f)?e[f]:void 0;if(0!==n)if(n)o.push(n[2]);else if(666!=f){var a=new Promise((u,s)=>n=e[f]=[u,s]);o.push(n[2]=a);var c=r.p+r.u(f),l=new Error;r.l(c,u=>{if(r.o(e,f)&&(0!==(n=e[f])&&(e[f]=void 0),n)){var s=u&&("load"===u.type?"missing":u.type),b=u&&u.target&&u.target.src;l.message="Loading chunk "+f+" failed.\n("+s+": "+b+")",l.name="ChunkLoadError",l.type=s,l.request=b,n[1](l)}},"chunk-"+f,f)}else e[f]=0},r.O.j=f=>0===e[f];var i=(f,o)=>{var l,d,[n,a,c]=o,u=0;if(n.some(b=>0!==e[b])){for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(c)var s=c(r)}for(f&&f(o);u<n.length;u++)r.o(e,d=n[u])&&e[d]&&e[d][0](),e[n[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();

View File

@ -17,15 +17,13 @@ __all__ = ('process',)
logger = logging.getLogger(__name__)
def process(
sort_tags: bool = False, trace: bool = False
) -> Callable[[FLVStream], FLVStream]:
def process(sort_tags: bool = False) -> Callable[[FLVStream], FLVStream]:
def _process(source: FLVStream) -> FLVStream:
if sort_tags:
return source.pipe(
defragment(),
split(),
sort(trace=trace),
sort(),
ops.filter(lambda v: not is_avc_end_sequence_tag(v)), # type: ignore
correct(),
fix(),

View File

@ -1,4 +1,5 @@
import logging
import os
from typing import Callable, List, Optional
from reactivex import Observable, abc
@ -21,8 +22,10 @@ __all__ = ('sort',)
logger = logging.getLogger(__name__)
TRACE_OP_SORT = bool(os.environ.get('TRACE_OP_SORT'))
def sort(trace: bool = False) -> Callable[[FLVStream], FLVStream]:
def sort() -> Callable[[FLVStream], FLVStream]:
"Sort tags in GOP by timestamp to ensure subsequent operators work as expected."
def _sort(source: FLVStream) -> FLVStream:
@ -43,7 +46,7 @@ def sort(trace: bool = False) -> Callable[[FLVStream], FLVStream]:
if not gop_tags:
return
if trace:
if TRACE_OP_SORT:
logger.debug(
'Tags in GOP:\n'
f'Number of tags: {len(gop_tags)}\n'

View File

@ -1,26 +1,25 @@
import logging
from io import BytesIO
from typing import Any, BinaryIO, Dict, Mapping, TypedDict
from typing import Any, BinaryIO, Mapping, TypedDict
from .amf import AMFReader, AMFWriter
__all__ = (
'load',
'loads',
'dump',
'dumps',
'ScriptData',
'ScriptDataParser',
'ScriptDataDumper',
)
logger = logging.getLogger(__name__)
class ScriptData(TypedDict):
name: str
value: Dict[str, Any]
value: Any
class ScriptDataParser:
@ -29,7 +28,17 @@ class ScriptDataParser:
def parse(self) -> ScriptData:
name = self._parse_name()
try:
value = self._parse_value()
except EOFError:
logger.debug(f'No script data: {name}')
value = {}
if not isinstance(value, dict):
if name == 'onMetaData':
logger.debug(f'Invalid onMetaData: {value}')
value = {}
else:
logger.debug(f'Unusual script data: {name}, {value}')
return ScriptData(name=name, value=value)
def _parse_name(self) -> str:
@ -37,10 +46,8 @@ class ScriptDataParser:
assert isinstance(value, str)
return value
def _parse_value(self) -> Dict[str, Any]:
value = self._reader.read_value()
assert isinstance(value, dict)
return value
def _parse_value(self) -> Any:
return self._reader.read_value()
class ScriptDataDumper:

View File

@ -1,14 +1,13 @@
from .helpers import shadow_settings, update_settings
from .models import (
DEFAULT_SETTINGS_FILE,
BiliApiSettings,
DanmakuOptions,
DanmakuSettings,
EmailMessageTemplateSettings,
EmailNotificationSettings,
EmailSettings,
EnvSettings,
BiliApiOptions,
BiliApiSettings,
HeaderOptions,
HeaderSettings,
LoggingSettings,
@ -47,7 +46,6 @@ __all__ = (
'Settings',
'SettingsIn',
'SettingsOut',
'BiliApiOptions',
'BiliApiSettings',
'HeaderOptions',
'HeaderSettings',

View File

@ -35,7 +35,6 @@ __all__ = (
'Settings',
'SettingsIn',
'SettingsOut',
'BiliApiOptions',
'BiliApiSettings',
'HeaderOptions',
'HeaderSettings',
@ -112,16 +111,10 @@ class BaseModel(PydanticBaseModel):
)
class BiliApiOptions(BaseModel):
base_api_url: Optional[str]
base_live_api_url: Optional[str]
base_play_info_api_url: Optional[str]
class BiliApiSettings(BiliApiOptions):
base_api_url: str = 'https://api.bilibili.com'
base_live_api_url: str = 'https://api.live.bilibili.com'
base_play_info_api_url: str = base_live_api_url
class BiliApiSettings(BaseModel):
base_api_urls: List[str] = ['https://api.bilibili.com']
base_live_api_urls: List[str] = ['https://api.live.bilibili.com']
base_play_info_api_urls: List[str] = ['https://api.live.bilibili.com']
class HeaderOptions(BaseModel):
@ -299,7 +292,6 @@ class OutputSettings(OutputOptions):
class TaskOptions(BaseModel):
output: OutputOptions = OutputOptions()
bili_api: BiliApiOptions = BiliApiOptions()
header: HeaderOptions = HeaderOptions()
danmaku: DanmakuOptions = DanmakuOptions()
recorder: RecorderOptions = RecorderOptions()
@ -309,14 +301,7 @@ class TaskOptions(BaseModel):
def from_settings(cls, settings: TaskSettings) -> TaskOptions:
return cls(
**settings.dict(
include={
'output',
'bili_api',
'header',
'danmaku',
'recorder',
'postprocessing',
}
include={'output', 'header', 'danmaku', 'recorder', 'postprocessing'}
)
)

View File

@ -16,7 +16,6 @@ from ..notification import (
from ..webhook import WebHook
from .helpers import shadow_settings, update_settings
from .models import (
BiliApiOptions,
DanmakuOptions,
HeaderOptions,
MessageTemplateSettings,
@ -211,13 +210,6 @@ class SettingsManager:
settings.enable_recorder = False
await self.dump_settings()
def apply_task_bili_api_settings(
self, room_id: int, options: BiliApiOptions
) -> None:
final_settings = self._settings.bili_api.copy()
shadow_settings(options, final_settings)
self._app._task_manager.apply_task_bili_api_settings(room_id, final_settings)
async def apply_task_header_settings(
self,
room_id: int,
@ -277,8 +269,10 @@ class SettingsManager:
)
def apply_bili_api_settings(self) -> None:
for settings in self._settings.tasks:
self.apply_task_bili_api_settings(settings.room_id, settings.bili_api)
for task_settings in self._settings.tasks:
self._app._task_manager.apply_task_bili_api_settings(
task_settings.room_id, self._settings.bili_api
)
async def apply_header_settings(self) -> None:
for settings in self._settings.tasks:

View File

@ -1,7 +1,7 @@
from __future__ import annotations
from enum import Enum
from typing import Optional
from typing import List, Optional
import attr
@ -51,9 +51,9 @@ class TaskParam:
filesize_limit: int
duration_limit: int
# BiliApiSettings
base_api_url: str
base_live_api_url: str
base_play_info_api_url: str
base_api_urls: List[str]
base_live_api_urls: List[str]
base_play_info_api_urls: List[str]
# HeaderSettings
user_agent: str
cookie: str

View File

@ -1,7 +1,7 @@
import logging
import os
from pathlib import PurePath
from typing import Iterator, Optional
from typing import Iterator, List, Optional
from blrec.bili.danmaku_client import DanmakuClient
from blrec.bili.live import Live
@ -200,28 +200,28 @@ class RecordTask:
yield DanmakuFileDetail(path=path, size=size, status=status)
@property
def base_api_url(self) -> str:
return self._live.base_api_url
def base_api_urls(self) -> List[str]:
return self._live.base_api_urls
@base_api_url.setter
def base_api_url(self, value: str) -> None:
self._live.base_api_url = value
@base_api_urls.setter
def base_api_urls(self, value: List[str]) -> None:
self._live.base_api_urls = value
@property
def base_live_api_url(self) -> str:
return self._live.base_live_api_url
def base_live_api_urls(self) -> List[str]:
return self._live.base_live_api_urls
@base_live_api_url.setter
def base_live_api_url(self, value: str) -> None:
self._live.base_live_api_url = value
@base_live_api_urls.setter
def base_live_api_urls(self, value: List[str]) -> None:
self._live.base_live_api_urls = value
@property
def base_play_info_api_url(self) -> str:
return self._live.base_play_info_api_url
def base_play_info_api_urls(self) -> List[str]:
return self._live.base_play_info_api_urls
@base_play_info_api_url.setter
def base_play_info_api_url(self, value: str) -> None:
self._live.base_play_info_api_url = value
@base_play_info_api_urls.setter
def base_play_info_api_urls(self, value: List[str]) -> None:
self._live.base_play_info_api_urls = value
@property
def user_agent(self) -> str:

View File

@ -77,9 +77,9 @@ class RecordTaskManager:
self._tasks[settings.room_id] = task
try:
self._settings_manager.apply_task_bili_api_settings(
settings.room_id, settings.bili_api
)
bili_api = self._settings_manager.get_settings({'bili_api'}).bili_api
assert bili_api is not None
self.apply_task_bili_api_settings(settings.room_id, bili_api)
await self._settings_manager.apply_task_header_settings(
settings.room_id, settings.header, restart_danmaku_client=False
)
@ -230,9 +230,9 @@ class RecordTaskManager:
self, room_id: int, settings: BiliApiSettings
) -> None:
task = self._get_task(room_id)
task.base_api_url = settings.base_api_url
task.base_live_api_url = settings.base_live_api_url
task.base_play_info_api_url = settings.base_play_info_api_url
task.base_api_urls = settings.base_api_urls
task.base_live_api_urls = settings.base_live_api_urls
task.base_play_info_api_urls = settings.base_play_info_api_urls
async def apply_task_header_settings(
self,
@ -308,9 +308,9 @@ class RecordTaskManager:
path_template=task.path_template,
filesize_limit=task.filesize_limit,
duration_limit=task.duration_limit,
base_api_url=task.base_api_url,
base_live_api_url=task.base_live_api_url,
base_play_info_api_url=task.base_play_info_api_url,
base_api_urls=task.base_api_urls,
base_live_api_urls=task.base_live_api_urls,
base_play_info_api_urls=task.base_play_info_api_urls,
user_agent=task.user_agent,
cookie=task.cookie,
danmu_uname=task.danmu_uname,

View File

@ -1,5 +1,5 @@
<nz-modal
nzTitle="修改 BASE API URL"
nzTitle="修改主站 API 主机地址"
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
@ -8,13 +8,19 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item>
<nz-form-control [nzErrorTip]="errorTip">
<input type="text" required nz-input formControlName="baseApiUrl" />
<textarea
[rows]="5"
wrap="soft"
nz-input
required
formControlName="baseApiUrls"
></textarea>
<ng-template #errorTip let-control>
<ng-container *ngIf="control.hasError('required')">
不能为空
</ng-container>
<ng-container *ngIf="control.hasError('pattern')">
输入无效
<ng-container *ngIf="control.hasError('baseUrl')">
输入无效: {{ control.getError("baseUrl").value | json }}
</ng-container>
</ng-template>
</nz-form-control>

View File

@ -13,10 +13,8 @@ import {
FormGroup,
Validators,
} from '@angular/forms';
import {
BASE_API_URL_DEFAULT,
BASE_URL_PATTERN,
} from '../../shared/constants/form';
import { BASE_API_URL_DEFAULT } from '../../shared/constants/form';
import { baseUrlValidator } from '../../shared/directives/base-url-validator.directive';
@Component({
selector: 'app-base-api-url-edit-dialog',
@ -25,11 +23,11 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BaseApiUrlEditDialogComponent implements OnChanges {
@Input() value = '';
@Input() value = [];
@Input() visible = false;
@Output() visibleChange = new EventEmitter<boolean>();
@Output() cancel = new EventEmitter<undefined>();
@Output() confirm = new EventEmitter<string>();
@Output() confirm = new EventEmitter<string[]>();
readonly settingsForm: FormGroup;
readonly defaultBaseApiUrl = BASE_API_URL_DEFAULT;
@ -39,15 +37,12 @@ export class BaseApiUrlEditDialogComponent implements OnChanges {
private changeDetector: ChangeDetectorRef
) {
this.settingsForm = formBuilder.group({
baseApiUrl: [
'',
[Validators.required, Validators.pattern(BASE_URL_PATTERN)],
],
baseApiUrls: ['', [Validators.required, baseUrlValidator()]],
});
}
get control() {
return this.settingsForm.get('baseApiUrl') as FormControl;
return this.settingsForm.get('baseApiUrls') as FormControl;
}
ngOnChanges(): void {
@ -70,7 +65,7 @@ export class BaseApiUrlEditDialogComponent implements OnChanges {
}
setValue(): void {
this.control.setValue(this.value);
this.control.setValue(this.value.join('\n'));
this.changeDetector.markForCheck();
}
@ -80,7 +75,12 @@ export class BaseApiUrlEditDialogComponent implements OnChanges {
}
handleConfirm(): void {
this.confirm.emit(this.control.value.trim());
const value = this.control.value as string;
const baseUrls = value
.split('\n')
.map((line) => line.trim())
.filter((line) => !!line);
this.confirm.emit(baseUrls);
this.close();
}

View File

@ -1,5 +1,5 @@
<nz-modal
nzTitle="修改 BASE LIVE API URL"
nzTitle="修改直播 API 主机地址"
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
@ -8,18 +8,19 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item>
<nz-form-control [nzErrorTip]="errorTip">
<input
type="text"
required
<textarea
[rows]="5"
wrap="soft"
nz-input
formControlName="baseLiveApiUrl"
/>
required
formControlName="baseLiveApiUrls"
></textarea>
<ng-template #errorTip let-control>
<ng-container *ngIf="control.hasError('required')">
不能为空
</ng-container>
<ng-container *ngIf="control.hasError('pattern')">
输入无效
<ng-container *ngIf="control.hasError('baseUrl')">
输入无效: {{ control.getError("baseUrl").value | json }}
</ng-container>
</ng-template>
</nz-form-control>

View File

@ -13,10 +13,8 @@ import {
FormGroup,
Validators,
} from '@angular/forms';
import {
BASE_LIVE_API_URL_DEFAULT,
BASE_URL_PATTERN,
} from '../../shared/constants/form';
import { BASE_LIVE_API_URL_DEFAULT } from '../../shared/constants/form';
import { baseUrlValidator } from '../../shared/directives/base-url-validator.directive';
@Component({
selector: 'app-base-live-api-url-edit-dialog',
@ -25,11 +23,11 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BaseLiveApiUrlEditDialogComponent implements OnChanges {
@Input() value = '';
@Input() value = [];
@Input() visible = false;
@Output() visibleChange = new EventEmitter<boolean>();
@Output() cancel = new EventEmitter<undefined>();
@Output() confirm = new EventEmitter<string>();
@Output() confirm = new EventEmitter<string[]>();
readonly settingsForm: FormGroup;
readonly defaultBaseLiveApiUrl = BASE_LIVE_API_URL_DEFAULT;
@ -39,15 +37,12 @@ export class BaseLiveApiUrlEditDialogComponent implements OnChanges {
private changeDetector: ChangeDetectorRef
) {
this.settingsForm = formBuilder.group({
baseLiveApiUrl: [
'',
[Validators.required, Validators.pattern(BASE_URL_PATTERN)],
],
baseLiveApiUrls: ['', [Validators.required, baseUrlValidator()]],
});
}
get control() {
return this.settingsForm.get('baseLiveApiUrl') as FormControl;
return this.settingsForm.get('baseLiveApiUrls') as FormControl;
}
ngOnChanges(): void {
@ -70,7 +65,7 @@ export class BaseLiveApiUrlEditDialogComponent implements OnChanges {
}
setValue(): void {
this.control.setValue(this.value);
this.control.setValue(this.value.join('\n'));
this.changeDetector.markForCheck();
}
@ -80,7 +75,12 @@ export class BaseLiveApiUrlEditDialogComponent implements OnChanges {
}
handleConfirm(): void {
this.confirm.emit(this.control.value.trim());
const value = this.control.value as string;
const baseUrls = value
.split('\n')
.map((line) => line.trim())
.filter((line) => !!line);
this.confirm.emit(baseUrls);
this.close();
}

View File

@ -1,5 +1,5 @@
<nz-modal
nzTitle="修改 BASE PLAY INFO API URL"
nzTitle="修改直播流 API 主机地址"
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
@ -8,18 +8,19 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item>
<nz-form-control [nzErrorTip]="errorTip">
<input
type="text"
required
<textarea
[rows]="5"
wrap="soft"
nz-input
formControlName="basePlayInfoApiUrl"
/>
required
formControlName="basePlayInfoApiUrls"
></textarea>
<ng-template #errorTip let-control>
<ng-container *ngIf="control.hasError('required')">
不能为空
</ng-container>
<ng-container *ngIf="control.hasError('pattern')">
输入无效
<ng-container *ngIf="control.hasError('baseUrl')">
输入无效: {{ control.getError("baseUrl").value | json }}
</ng-container>
</ng-template>
</nz-form-control>

View File

@ -13,10 +13,8 @@ import {
FormGroup,
Validators,
} from '@angular/forms';
import {
BASE_LIVE_API_URL_DEFAULT,
BASE_URL_PATTERN,
} from '../../shared/constants/form';
import { BASE_LIVE_API_URL_DEFAULT } from '../../shared/constants/form';
import { baseUrlValidator } from '../../shared/directives/base-url-validator.directive';
@Component({
selector: 'app-base-play-info-api-url-edit-dialog',
@ -25,11 +23,11 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BasePlayInfoApiUrlEditDialogComponent implements OnChanges {
@Input() value = '';
@Input() value = [];
@Input() visible = false;
@Output() visibleChange = new EventEmitter<boolean>();
@Output() cancel = new EventEmitter<undefined>();
@Output() confirm = new EventEmitter<string>();
@Output() confirm = new EventEmitter<string[]>();
readonly settingsForm: FormGroup;
readonly defaultBasePlayInfoApiUrl = BASE_LIVE_API_URL_DEFAULT;
@ -39,15 +37,12 @@ export class BasePlayInfoApiUrlEditDialogComponent implements OnChanges {
private changeDetector: ChangeDetectorRef
) {
this.settingsForm = formBuilder.group({
basePlayInfoApiUrl: [
'',
[Validators.required, Validators.pattern(BASE_URL_PATTERN)],
],
basePlayInfoApiUrls: ['', [Validators.required, baseUrlValidator()]],
});
}
get control() {
return this.settingsForm.get('basePlayInfoApiUrl') as FormControl;
return this.settingsForm.get('basePlayInfoApiUrls') as FormControl;
}
ngOnChanges(): void {
@ -70,7 +65,7 @@ export class BasePlayInfoApiUrlEditDialogComponent implements OnChanges {
}
setValue(): void {
this.control.setValue(this.value);
this.control.setValue(this.value.join('\n'));
this.changeDetector.markForCheck();
}
@ -80,7 +75,12 @@ export class BasePlayInfoApiUrlEditDialogComponent implements OnChanges {
}
handleConfirm(): void {
this.confirm.emit(this.control.value.trim());
const value = this.control.value as string;
const baseUrls = value
.split('\n')
.map((line) => line.trim())
.filter((line) => !!line);
this.confirm.emit(baseUrls);
this.close();
}

View File

@ -1,79 +1,100 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item
class="setting-item actionable"
(click)="baseApiUrlEditDialog.open()"
(click)="baseApiUrlsEditDialog.open()"
>
<nz-form-label class="setting-label" [nzTooltipTitle]="baseApiUrlTip"
>BASE API URL</nz-form-label
<nz-form-label class="setting-label" [nzTooltipTitle]="baseApiUrlsTip"
>主站 API 主机地址</nz-form-label
>
<ng-template #baseApiUrlTip>
<p>主站 API 的 BASE URL</p>
<ng-template #baseApiUrlsTip>
<p>设置内容:发送主站 API 请求所用的主机的地址,一行一个。</p>
<p>请求方式:先用第一个发送请求,出错就用第二个,以此类推。</p>
<p>主要目的:缓解请求过多被风控</p>
</ng-template>
<nz-form-control
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="syncStatus.baseApiUrl ? baseApiUrlControl : 'warning'"
[nzValidateStatus]="
syncStatus.baseApiUrls ? baseApiUrlsControl : 'warning'
"
>
<nz-form-text class="setting-value"
>{{ baseApiUrlControl.value }}
>{{ baseApiUrlsControl.value }}
</nz-form-text>
<app-base-api-url-edit-dialog
#baseApiUrlEditDialog
[value]="baseApiUrlControl.value"
(confirm)="baseApiUrlControl.setValue($event)"
#baseApiUrlsEditDialog
[value]="baseApiUrlsControl.value"
(confirm)="baseApiUrlsControl.setValue($event)"
></app-base-api-url-edit-dialog>
</nz-form-control>
</nz-form-item>
<nz-form-item
class="setting-item actionable"
(click)="baseLiveApiUrlEditDialog.open()"
(click)="baseLiveApiUrlsEditDialog.open()"
>
<nz-form-label class="setting-label" [nzTooltipTitle]="baseLiveApiUrlTip"
>BASE LIVE API URL</nz-form-label
<nz-form-label class="setting-label" [nzTooltipTitle]="baseLiveApiUrlsTip"
>直播 API 主机地址</nz-form-label
>
<ng-template #baseLiveApiUrlTip>
<p>直播 API (getRoomPlayInfo 除外) 的 BASE URL</p>
<ng-template #baseLiveApiUrlsTip>
<p>
设置内容:发送直播 API (直播流 API getRoomPlayInfo 除外)
请求所用的主机的地址,一行一个。
</p>
<p>请求方式:先用第一个发送请求,出错就用第二个,以此类推。</p>
<p>主要目的:缓解请求过多被风控</p>
</ng-template>
<nz-form-control
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.baseLiveApiUrl ? baseLiveApiUrlControl : 'warning'
syncStatus.baseLiveApiUrls ? baseLiveApiUrlsControl : 'warning'
"
>
<nz-form-text class="setting-value"
>{{ baseLiveApiUrlControl.value }}
>{{ baseLiveApiUrlsControl.value }}
</nz-form-text>
<app-base-live-api-url-edit-dialog
#baseLiveApiUrlEditDialog
[value]="baseLiveApiUrlControl.value"
(confirm)="baseLiveApiUrlControl.setValue($event)"
#baseLiveApiUrlsEditDialog
[value]="baseLiveApiUrlsControl.value"
(confirm)="baseLiveApiUrlsControl.setValue($event)"
></app-base-live-api-url-edit-dialog>
</nz-form-control>
</nz-form-item>
<nz-form-item
class="setting-item actionable"
(click)="basePlayInfoApiUrlEditDialog.open()"
(click)="basePlayInfoApiUrlsEditDialog.open()"
>
<nz-form-label
class="setting-label"
[nzTooltipTitle]="basePalyInfoApiUrlTip"
>BASE PLAY INFO API URL</nz-form-label
>直播流 API 主机地址</nz-form-label
>
<ng-template #basePalyInfoApiUrlTip>
<p>直播 API getRoomPlayInfo 的 BASE URL</p>
<p>
设置内容:发送直播流 API (getRoomPlayInfo)
请求所用的主机的地址,一行一个。
</p>
<p>
请求方式:同时并发向全部 API
主机发送请求(从全部成功的请求结果中提取直播流质量较好的直播流地址)
</p>
<p>主要目的:改变录制的直播流的 CDN</p>
<p>
P.S国外 IP 的请求结果没有 HLS(fmp4) 流,要同时支持 fmp4 和 flv
可以混用国内和国外的 API 主机。
</p>
</ng-template>
<nz-form-control
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.basePlayInfoApiUrl ? basePlayInfoApiUrlControl : 'warning'
syncStatus.basePlayInfoApiUrls ? basePlayInfoApiUrlsControl : 'warning'
"
>
<nz-form-text class="setting-value"
>{{ basePlayInfoApiUrlControl.value }}
>{{ basePlayInfoApiUrlsControl.value }}
</nz-form-text>
<app-base-play-info-api-url-edit-dialog
#basePlayInfoApiUrlEditDialog
[value]="basePlayInfoApiUrlControl.value"
(confirm)="basePlayInfoApiUrlControl.setValue($event)"
#basePlayInfoApiUrlsEditDialog
[value]="basePlayInfoApiUrlsControl.value"
(confirm)="basePlayInfoApiUrlsControl.setValue($event)"
></app-base-play-info-api-url-edit-dialog>
</nz-form-control>
</nz-form-item>

View File

@ -1 +1,6 @@
@use '../shared/styles/setting';
@use "../shared/styles/setting";
@use "src/app/shared/styles/text";
nz-form-control {
@include text.elide-text-overflow;
}

View File

@ -38,22 +38,22 @@ export class BiliApiSettingsComponent implements OnInit, OnChanges {
private settingsSyncService: SettingsSyncService
) {
this.settingsForm = formBuilder.group({
baseApiUrl: [''],
baseLiveApiUrl: [''],
basePlayInfoApiUrl: [''],
baseApiUrls: [[]],
baseLiveApiUrls: [[]],
basePlayInfoApiUrls: [[]],
});
}
get baseApiUrlControl() {
return this.settingsForm.get('baseApiUrl') as FormControl;
get baseApiUrlsControl() {
return this.settingsForm.get('baseApiUrls') as FormControl;
}
get baseLiveApiUrlControl() {
return this.settingsForm.get('baseLiveApiUrl') as FormControl;
get baseLiveApiUrlsControl() {
return this.settingsForm.get('baseLiveApiUrls') as FormControl;
}
get basePlayInfoApiUrlControl() {
return this.settingsForm.get('basePlayInfoApiUrl') as FormControl;
get basePlayInfoApiUrlsControl() {
return this.settingsForm.get('basePlayInfoApiUrls') as FormControl;
}
ngOnChanges(): void {
@ -66,7 +66,8 @@ export class BiliApiSettingsComponent implements OnInit, OnChanges {
.syncSettings(
'biliApi',
this.settings,
this.settingsForm.valueChanges as Observable<BiliApiSettings>
this.settingsForm.valueChanges as Observable<BiliApiSettings>,
false
)
.subscribe((detail) => {
this.syncStatus = { ...this.syncStatus, ...calcSyncStatus(detail) };

View File

@ -34,6 +34,7 @@ import { WebhookSettingsResolver } from './shared/services/webhook-settings.reso
import { SettingsRoutingModule } from './settings-routing.module';
import { SettingsComponent } from './settings.component';
import { SwitchActionableDirective } from './shared/directives/switch-actionable.directive';
import { BaseUrlValidatorDirective } from './shared/directives/base-url-validator.directive';
import { DiskSpaceSettingsComponent } from './disk-space-settings/disk-space-settings.component';
import { NotificationSettingsComponent } from './notification-settings/notification-settings.component';
import { LoggingSettingsComponent } from './logging-settings/logging-settings.component';
@ -74,6 +75,7 @@ import { BasePlayInfoApiUrlEditDialogComponent } from './bili-api-settings/base-
declarations: [
SettingsComponent,
SwitchActionableDirective,
BaseUrlValidatorDirective,
DiskSpaceSettingsComponent,
NotificationSettingsComponent,
LoggingSettingsComponent,

View File

@ -2,7 +2,6 @@ import { CoverSaveStrategy, DeleteStrategy } from '../setting.model';
export const SYNC_FAILED_WARNING_TIP = '设置同步失败!';
export const BASE_URL_PATTERN = /^https?:\/\/.*$/;
export const BASE_API_URL_DEFAULT = 'https://api.bilibili.com';
export const BASE_LIVE_API_URL_DEFAULT = 'https://api.live.bilibili.com';

View File

@ -0,0 +1,8 @@
import { BaseUrlValidatorDirective } from './base-url-validator.directive';
describe('BaseUrlValidatorDirective', () => {
it('should create an instance', () => {
const directive = new BaseUrlValidatorDirective();
expect(directive).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
import { Directive } from '@angular/core';
import {
AbstractControl,
NG_VALIDATORS,
ValidationErrors,
ValidatorFn,
Validators,
} from '@angular/forms';
@Directive({
selector: '[appBaseUrlValidator]',
providers: [
{
provide: NG_VALIDATORS,
useExisting: BaseUrlValidatorDirective,
multi: true,
},
],
})
export class BaseUrlValidatorDirective implements Validators {
validate(control: AbstractControl): ValidationErrors | null {
return baseUrlValidator()(control);
}
}
export function baseUrlValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value as string;
const lines = value
.split('\n')
.map((line) => line.trim())
.filter((line) => !!line);
const invalidValues = lines.filter(
(line) => !/^https?:\/\/\S+$/.test(line)
);
return invalidValues.length > 0
? { baseUrl: { value: invalidValues } }
: null;
};
}

View File

@ -53,14 +53,15 @@ export class SettingsSyncService {
syncSettings<K extends SK, V extends SV>(
key: K,
initialValue: V,
valueChanges: Observable<V>
valueChanges: Observable<V>,
deepDiff: boolean = true
): Observable<DetailWithResult<V> | DetailWithError<V>> {
return valueChanges.pipe(
scan<V, [V, V, Partial<V>]>(
([, prev], curr) => [
prev,
curr,
difference(curr!, prev!) as Partial<V>,
difference(curr!, prev!, deepDiff) as Partial<V>,
],
[initialValue, initialValue, {} as Partial<V>]
),

View File

@ -1,9 +1,9 @@
import type { Nullable, PartialDeep } from 'src/app/shared/utility-types';
export interface BiliApiSettings {
baseApiUrl: string;
baseLiveApiUrl: string;
basePlayInfoApiUrl: string;
baseApiUrls: string[];
baseLiveApiUrls: string[];
basePlayInfoApiUrls: string[];
}
export type BiliApiOptions = Nullable<BiliApiSettings>;

View File

@ -42,7 +42,7 @@ export class InputDurationComponent implements OnInit, ControlValueAccessor {
this.formGroup = formBuilder.group({
duration: [
'',
[Validators.required, Validators.pattern(/^\d{2}:[0~5]\d:[0~5]\d$/)],
[Validators.required, Validators.pattern(/^\d{2}:[0-5]\d:[0-5]\d$/)],
],
});
}

View File

@ -2,7 +2,11 @@ import { transform, isEqual, isObject } from 'lodash-es';
import * as filesize from 'filesize';
// ref: https://gist.github.com/Yimiprod/7ee176597fef230d1451
export function difference(object: object, base: object): object {
export function difference(
object: object,
base: object,
deep: boolean = true
): object {
function diff(object: object, base: object) {
return transform(object, (result: object, value: any, key: string) => {
const baseValue = Reflect.get(base, key);
@ -10,7 +14,7 @@ export function difference(object: object, base: object): object {
Reflect.set(
result,
key,
isObject(value) && isObject(baseValue)
deep && isObject(value) && isObject(baseValue)
? diff(value, baseValue)
: value
);

View File

@ -186,7 +186,6 @@ export class TaskItemComponent implements OnChanges, OnDestroy {
this.settingService.getTaskOptions(this.roomId),
this.settingService.getSettings([
'output',
'biliApi',
'header',
'danmaku',
'recorder',

View File

@ -745,139 +745,6 @@
</nz-form-item>
</div>
<div ngModelGroup="biliApi" class="form-group biliapi">
<h2>BILI API</h2>
<nz-form-item class="setting-item input">
<nz-form-label
class="setting-label"
nzNoColon
[nzTooltipTitle]="baseApiUrlTip"
>BASE API URL</nz-form-label
>
<ng-template #baseApiUrlTip>
<p>主站 API 的 BASE URL</p>
</ng-template>
<nz-form-control
class="setting-control input"
[nzErrorTip]="baseApiUrlErrorTip"
>
<input
type="text"
required
[pattern]="baseUrlPattern"
nz-input
name="baseApiUrl"
[(ngModel)]="model.biliApi.baseApiUrl"
[disabled]="options.biliApi.baseApiUrl === null"
/>
<ng-template #baseApiUrlErrorTip let-control>
<ng-container *ngIf="control.hasError('required')">
不能为空
</ng-container>
<ng-container *ngIf="control.hasError('pattern')">
输入无效
</ng-container>
</ng-template>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.biliApi.baseApiUrl !== null"
(nzCheckedChange)="
options.biliApi.baseApiUrl = $event
? globalSettings.biliApi.baseApiUrl
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item input">
<nz-form-label
class="setting-label"
nzNoColon
[nzTooltipTitle]="baseLiveApiUrlTip"
>BASE LIVE API URL</nz-form-label
>
<ng-template #baseLiveApiUrlTip>
<p>直播 API (getRoomPlayInfo 除外) 的 BASE URL</p>
</ng-template>
<nz-form-control
class="setting-control input"
[nzErrorTip]="baseLiveApiUrlErrorTip"
>
<input
type="text"
required
[pattern]="baseUrlPattern"
nz-input
name="baseLiveApiUrl"
[(ngModel)]="model.biliApi.baseLiveApiUrl"
[disabled]="options.biliApi.baseLiveApiUrl === null"
/>
<ng-template #baseLiveApiUrlErrorTip let-control>
<ng-container *ngIf="control.hasError('required')">
不能为空
</ng-container>
<ng-container *ngIf="control.hasError('pattern')">
输入无效
</ng-container>
</ng-template>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.biliApi.baseLiveApiUrl !== null"
(nzCheckedChange)="
options.biliApi.baseLiveApiUrl = $event
? globalSettings.biliApi.baseLiveApiUrl
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item input">
<nz-form-label
class="setting-label"
nzNoColon
[nzTooltipTitle]="basePalyInfoApiUrlTip"
>BASE PLAY INFO API URL</nz-form-label
>
<ng-template #basePalyInfoApiUrlTip>
<p>直播 API getRoomPlayInfo 的 BASE URL</p>
</ng-template>
<nz-form-control
class="setting-control input"
[nzErrorTip]="basePlayInfoApiUrlErrorTip"
>
<input
type="text"
required
[pattern]="baseUrlPattern"
nz-input
name="basePlayInfoApiUrl"
[(ngModel)]="model.biliApi.basePlayInfoApiUrl"
[disabled]="options.biliApi.basePlayInfoApiUrl === null"
/>
<ng-template #basePlayInfoApiUrlErrorTip let-control>
<ng-container *ngIf="control.hasError('required')">
不能为空
</ng-container>
<ng-container *ngIf="control.hasError('pattern')">
输入无效
</ng-container>
</ng-template>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.biliApi.basePlayInfoApiUrl !== null"
(nzCheckedChange)="
options.biliApi.basePlayInfoApiUrl = $event
? globalSettings.biliApi.basePlayInfoApiUrl
: null
"
>覆盖全局设置</label
>
</nz-form-item>
</div>
<div ngModelGroup="header" class="form-group header">
<h2>网络请求</h2>
<nz-form-item class="setting-item textarea">

View File

@ -29,7 +29,6 @@ import {
DELETE_STRATEGIES,
COVER_SAVE_STRATEGIES,
RECORDING_MODE_OPTIONS,
BASE_URL_PATTERN,
} from '../../settings/shared/constants/form';
type OptionsModel = NonNullable<TaskOptions>;
@ -56,7 +55,6 @@ export class TaskSettingsDialogComponent implements OnChanges {
readonly warningTip =
'需要重启弹幕客户端才能生效,如果任务正在录制可能会丢失弹幕!';
readonly baseUrlPattern = BASE_URL_PATTERN;
readonly pathTemplatePattern = PATH_TEMPLATE_PATTERN;
readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable<
typeof STREAM_FORMAT_OPTIONS