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 hashlib
import logging import logging
import os
from abc import ABC from abc import ABC
from datetime import datetime from datetime import datetime
from typing import Any, Dict, Mapping, Optional from typing import Any, Dict, List, Mapping, Optional
from urllib.parse import urlencode from urllib.parse import urlencode
import aiohttp import aiohttp
@ -16,15 +18,17 @@ __all__ = 'AppApi', 'WebApi'
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TRACE_API_REQ = bool(os.environ.get('TRACE_API_REQ'))
class BaseApi(ABC): 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__( def __init__(
self, session: aiohttp.ClientSession, headers: Optional[Dict[str, str]] = None 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._session = session
self.headers = headers or {} self.headers = headers or {}
self.timeout = 10 self.timeout = 10
@ -45,15 +49,62 @@ class BaseApi(ABC):
) )
@retry(reraise=True, stop=stop_after_delay(5), wait=wait_exponential(0.1)) @retry(reraise=True, stop=stop_after_delay(5), wait=wait_exponential(0.1))
async def _get_json(self, *args: Any, **kwds: Any) -> JsonResponse: async def _get_json_res(self, *args: Any, **kwds: Any) -> JsonResponse:
async with self._session.get( kwds = {'timeout': self.timeout, 'headers': self.headers, **kwds}
*args, **kwds, timeout=self.timeout, headers=self.headers async with self._session.get(*args, **kwds) as res:
) as res: if TRACE_API_REQ:
logger.debug(f'real url: {res.real_url}') logger.debug(f'Request info: {res.request_info}')
try:
json_res = await res.json() 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) self._check_response(json_res)
return 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): class AppApi(BaseApi):
# taken from https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/other/API_sign.md # noqa # 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) params.update(sign=sign)
return params return params
async def get_room_play_info( async def get_room_play_infos(
self, self,
room_id: int, room_id: int,
qn: QualityNumber = 10000, qn: QualityNumber = 10000,
*, *,
only_video: bool = False, only_video: bool = False,
only_audio: bool = False, only_audio: bool = False,
) -> ResponseData: ) -> List[ResponseData]:
url = self.base_play_info_api_url + '/xlive/app-room/v2/index/getRoomPlayInfo' path = '/xlive/app-room/v2/index/getRoomPlayInfo'
params = self.signed( params = self.signed(
{ {
'actionKey': 'appkey', 'actionKey': 'appkey',
@ -121,11 +172,13 @@ class AppApi(BaseApi):
'ts': int(datetime.utcnow().timestamp()), 'ts': int(datetime.utcnow().timestamp()),
} }
) )
r = await self._get_json(url, params=params, headers=self._headers) json_responses = await self._get_jsons_concurrently(
return r['data'] 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: 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( params = self.signed(
{ {
'actionKey': 'appkey', 'actionKey': 'appkey',
@ -138,11 +191,12 @@ class AppApi(BaseApi):
'ts': int(datetime.utcnow().timestamp()), 'ts': int(datetime.utcnow().timestamp()),
} }
) )
r = await self._get_json(url, params=params) json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return r['data'] return json_res['data']
async def get_user_info(self, uid: int) -> ResponseData: 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( params = self.signed(
{ {
'build': '6640400', 'build': '6640400',
@ -153,11 +207,11 @@ class AppApi(BaseApi):
'vmid': uid, 'vmid': uid,
} }
) )
r = await self._get_json(url, params=params) json_res = await self._get_json(base_api_urls, path, params=params)
return r['data'] return json_res['data']
async def get_danmu_info(self, room_id: int) -> ResponseData: 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( params = self.signed(
{ {
'actionKey': 'appkey', 'actionKey': 'appkey',
@ -170,20 +224,21 @@ class AppApi(BaseApi):
'ts': int(datetime.utcnow().timestamp()), 'ts': int(datetime.utcnow().timestamp()),
} }
) )
r = await self._get_json(url, params=params) json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return r['data'] return json_res['data']
class WebApi(BaseApi): class WebApi(BaseApi):
async def room_init(self, room_id: int) -> ResponseData: async def room_init(self, room_id: int) -> ResponseData:
url = self.base_live_api_url + '/room/v1/Room/room_init' path = '/room/v1/Room/room_init'
r = await self._get_json(url, params={'id': room_id}) params = {'id': room_id}
return r['data'] 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 self, room_id: int, qn: QualityNumber = 10000
) -> ResponseData: ) -> List[ResponseData]:
url = self.base_play_info_api_url + '/xlive/web-room/v2/index/getRoomPlayInfo' path = '/xlive/web-room/v2/index/getRoomPlayInfo'
params = { params = {
'room_id': room_id, 'room_id': room_id,
'protocol': '0,1', 'protocol': '0,1',
@ -193,34 +248,37 @@ class WebApi(BaseApi):
'platform': 'web', 'platform': 'web',
'ptype': 8, 'ptype': 8,
} }
r = await self._get_json(url, params=params) json_responses = await self._get_jsons_concurrently(
return r['data'] 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: 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} params = {'room_id': room_id}
r = await self._get_json(url, params=params) json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return r['data'] return json_res['data']
async def get_info(self, room_id: int) -> ResponseData: 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} params = {'room_id': room_id}
r = await self._get_json(url, params=params) json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return r['data'] return json_res['data']
async def get_timestamp(self) -> int: async def get_timestamp(self) -> int:
url = self.base_live_api_url + '/av/v1/Time/getTimestamp?platform=pc' path = '/av/v1/Time/getTimestamp'
r = await self._get_json(url) params = {'platform': 'pc'}
return r['data']['timestamp'] 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: 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} params = {'mid': uid}
r = await self._get_json(url, params=params) json_res = await self._get_json(self.base_api_urls, path, params=params)
return r['data'] return json_res['data']
async def get_danmu_info(self, room_id: int) -> ResponseData: 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} params = {'id': room_id}
r = await self._get_json(url, params=params) json_res = await self._get_json(self.base_live_api_urls, path, params=params)
return r['data'] return json_res['data']

View File

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

View File

@ -141,7 +141,7 @@ class FLVStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
self._stream_parser, self._stream_parser,
self._connection_error_handler, self._connection_error_handler,
self._request_exception_handler, self._request_exception_handler,
flv_ops.process(sort_tags=True, trace=self._debug), flv_ops.process(sort_tags=True),
self._cutter, self._cutter,
self._limiter, self._limiter,
self._join_point_extractor, 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> <body>
<app-root></app-root> <app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript> <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> </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, "configVersion": 1,
"timestamp": 1661135687643, "timestamp": 1661579095139,
"index": "/index.html", "index": "/index.html",
"assetGroups": [ "assetGroups": [
{ {
@ -13,16 +13,16 @@
"urls": [ "urls": [
"/103.5b5d2a6e5a8a7479.js", "/103.5b5d2a6e5a8a7479.js",
"/146.5a8902910bda9e87.js", "/146.5a8902910bda9e87.js",
"/170.d0e14a28ee578d1f.js", "/183.ee55fc76717674c3.js",
"/183.0d3cd9f454be16fb.js", "/205.cf2caa9b46b14212.js",
"/45.c90c3cea2bf1a66e.js", "/45.c90c3cea2bf1a66e.js",
"/91.9ff409a090dace5c.js", "/91.cab8652a2fa56b1a.js",
"/common.858f777e9296e6f2.js", "/common.858f777e9296e6f2.js",
"/index.html", "/index.html",
"/main.27d1fff16f7909f2.js", "/main.6da8ea192405b948.js",
"/manifest.webmanifest", "/manifest.webmanifest",
"/polyfills.4b08448aee19bb22.js", "/polyfills.4b08448aee19bb22.js",
"/runtime.4ae765ab3bddf383.js", "/runtime.c6818dbcd7b06106.js",
"/styles.2e152d608221c2ee.css" "/styles.2e152d608221c2ee.css"
], ],
"patterns": [] "patterns": []
@ -1636,10 +1636,10 @@
"hashTable": { "hashTable": {
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a", "/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
"/146.5a8902910bda9e87.js": "d9c33c7073662699f00f46f3a384ae5b749fdef9", "/146.5a8902910bda9e87.js": "d9c33c7073662699f00f46f3a384ae5b749fdef9",
"/170.d0e14a28ee578d1f.js": "d6b6208ca442565ed39300b27ab8cbe5501cb46a", "/183.ee55fc76717674c3.js": "2628c996ec80a6c6703d542d34ac95194283bcf8",
"/183.0d3cd9f454be16fb.js": "e7e6ebc715791102fd09edabe2aa47316208b29c", "/205.cf2caa9b46b14212.js": "749df896fbbd279dcf49318963f0ce074c5df87f",
"/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764", "/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764",
"/91.9ff409a090dace5c.js": "d756ffe7cd3f5516e40a7e6d6cf494ea6213a546", "/91.cab8652a2fa56b1a.js": "c11ebf28472c8a75653f7b27b5cffdec477830fe",
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1", "/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1", "/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709", "/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
@ -3234,11 +3234,11 @@
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01", "/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068", "/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd", "/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
"/index.html": "29167783eb093ffa93369f741a5ce20a534137de", "/index.html": "80797fa46f33b7bcf402788a5d0d0516b77f23b1",
"/main.27d1fff16f7909f2.js": "22e63726601a31af1a96e7901afc0d2bea7fd414", "/main.6da8ea192405b948.js": "b8995c7d8ccd465769b90936db5e0a337a827a58",
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586", "/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d", "/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
"/runtime.4ae765ab3bddf383.js": "96653fd35d3ad9684e603011436e9d43a1121690", "/runtime.c6818dbcd7b06106.js": "00160f946c5d007a956f5f61293cbd3bed2756dc",
"/styles.2e152d608221c2ee.css": "9830389a46daa5b4511e0dd343aad23ca9f9690f" "/styles.2e152d608221c2ee.css": "9830389a46daa5b4511e0dd343aad23ca9f9690f"
}, },
"navigationUrls": [ "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__) logger = logging.getLogger(__name__)
def process( def process(sort_tags: bool = False) -> Callable[[FLVStream], FLVStream]:
sort_tags: bool = False, trace: bool = False
) -> Callable[[FLVStream], FLVStream]:
def _process(source: FLVStream) -> FLVStream: def _process(source: FLVStream) -> FLVStream:
if sort_tags: if sort_tags:
return source.pipe( return source.pipe(
defragment(), defragment(),
split(), split(),
sort(trace=trace), sort(),
ops.filter(lambda v: not is_avc_end_sequence_tag(v)), # type: ignore ops.filter(lambda v: not is_avc_end_sequence_tag(v)), # type: ignore
correct(), correct(),
fix(), fix(),

View File

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

View File

@ -1,26 +1,25 @@
import logging
from io import BytesIO from io import BytesIO
from typing import Any, BinaryIO, Dict, Mapping, TypedDict from typing import Any, BinaryIO, Mapping, TypedDict
from .amf import AMFReader, AMFWriter from .amf import AMFReader, AMFWriter
__all__ = ( __all__ = (
'load', 'load',
'loads', 'loads',
'dump', 'dump',
'dumps', 'dumps',
'ScriptData', 'ScriptData',
'ScriptDataParser', 'ScriptDataParser',
'ScriptDataDumper', 'ScriptDataDumper',
) )
logger = logging.getLogger(__name__)
class ScriptData(TypedDict): class ScriptData(TypedDict):
name: str name: str
value: Dict[str, Any] value: Any
class ScriptDataParser: class ScriptDataParser:
@ -29,7 +28,17 @@ class ScriptDataParser:
def parse(self) -> ScriptData: def parse(self) -> ScriptData:
name = self._parse_name() name = self._parse_name()
try:
value = self._parse_value() 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) return ScriptData(name=name, value=value)
def _parse_name(self) -> str: def _parse_name(self) -> str:
@ -37,10 +46,8 @@ class ScriptDataParser:
assert isinstance(value, str) assert isinstance(value, str)
return value return value
def _parse_value(self) -> Dict[str, Any]: def _parse_value(self) -> Any:
value = self._reader.read_value() return self._reader.read_value()
assert isinstance(value, dict)
return value
class ScriptDataDumper: class ScriptDataDumper:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,79 +1,100 @@
<form nz-form [formGroup]="settingsForm"> <form nz-form [formGroup]="settingsForm">
<nz-form-item <nz-form-item
class="setting-item actionable" class="setting-item actionable"
(click)="baseApiUrlEditDialog.open()" (click)="baseApiUrlsEditDialog.open()"
> >
<nz-form-label class="setting-label" [nzTooltipTitle]="baseApiUrlTip" <nz-form-label class="setting-label" [nzTooltipTitle]="baseApiUrlsTip"
>BASE API URL</nz-form-label >主站 API 主机地址</nz-form-label
> >
<ng-template #baseApiUrlTip> <ng-template #baseApiUrlsTip>
<p>主站 API 的 BASE URL</p> <p>设置内容:发送主站 API 请求所用的主机的地址,一行一个。</p>
<p>请求方式:先用第一个发送请求,出错就用第二个,以此类推。</p>
<p>主要目的:缓解请求过多被风控</p>
</ng-template> </ng-template>
<nz-form-control <nz-form-control
[nzWarningTip]="syncFailedWarningTip" [nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="syncStatus.baseApiUrl ? baseApiUrlControl : 'warning'" [nzValidateStatus]="
syncStatus.baseApiUrls ? baseApiUrlsControl : 'warning'
"
> >
<nz-form-text class="setting-value" <nz-form-text class="setting-value"
>{{ baseApiUrlControl.value }} >{{ baseApiUrlsControl.value }}
</nz-form-text> </nz-form-text>
<app-base-api-url-edit-dialog <app-base-api-url-edit-dialog
#baseApiUrlEditDialog #baseApiUrlsEditDialog
[value]="baseApiUrlControl.value" [value]="baseApiUrlsControl.value"
(confirm)="baseApiUrlControl.setValue($event)" (confirm)="baseApiUrlsControl.setValue($event)"
></app-base-api-url-edit-dialog> ></app-base-api-url-edit-dialog>
</nz-form-control> </nz-form-control>
</nz-form-item> </nz-form-item>
<nz-form-item <nz-form-item
class="setting-item actionable" class="setting-item actionable"
(click)="baseLiveApiUrlEditDialog.open()" (click)="baseLiveApiUrlsEditDialog.open()"
> >
<nz-form-label class="setting-label" [nzTooltipTitle]="baseLiveApiUrlTip" <nz-form-label class="setting-label" [nzTooltipTitle]="baseLiveApiUrlsTip"
>BASE LIVE API URL</nz-form-label >直播 API 主机地址</nz-form-label
> >
<ng-template #baseLiveApiUrlTip> <ng-template #baseLiveApiUrlsTip>
<p>直播 API (getRoomPlayInfo 除外) 的 BASE URL</p> <p>
设置内容:发送直播 API (直播流 API getRoomPlayInfo 除外)
请求所用的主机的地址,一行一个。
</p>
<p>请求方式:先用第一个发送请求,出错就用第二个,以此类推。</p>
<p>主要目的:缓解请求过多被风控</p>
</ng-template> </ng-template>
<nz-form-control <nz-form-control
[nzWarningTip]="syncFailedWarningTip" [nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]=" [nzValidateStatus]="
syncStatus.baseLiveApiUrl ? baseLiveApiUrlControl : 'warning' syncStatus.baseLiveApiUrls ? baseLiveApiUrlsControl : 'warning'
" "
> >
<nz-form-text class="setting-value" <nz-form-text class="setting-value"
>{{ baseLiveApiUrlControl.value }} >{{ baseLiveApiUrlsControl.value }}
</nz-form-text> </nz-form-text>
<app-base-live-api-url-edit-dialog <app-base-live-api-url-edit-dialog
#baseLiveApiUrlEditDialog #baseLiveApiUrlsEditDialog
[value]="baseLiveApiUrlControl.value" [value]="baseLiveApiUrlsControl.value"
(confirm)="baseLiveApiUrlControl.setValue($event)" (confirm)="baseLiveApiUrlsControl.setValue($event)"
></app-base-live-api-url-edit-dialog> ></app-base-live-api-url-edit-dialog>
</nz-form-control> </nz-form-control>
</nz-form-item> </nz-form-item>
<nz-form-item <nz-form-item
class="setting-item actionable" class="setting-item actionable"
(click)="basePlayInfoApiUrlEditDialog.open()" (click)="basePlayInfoApiUrlsEditDialog.open()"
> >
<nz-form-label <nz-form-label
class="setting-label" class="setting-label"
[nzTooltipTitle]="basePalyInfoApiUrlTip" [nzTooltipTitle]="basePalyInfoApiUrlTip"
>BASE PLAY INFO API URL</nz-form-label >直播流 API 主机地址</nz-form-label
> >
<ng-template #basePalyInfoApiUrlTip> <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> </ng-template>
<nz-form-control <nz-form-control
[nzWarningTip]="syncFailedWarningTip" [nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]=" [nzValidateStatus]="
syncStatus.basePlayInfoApiUrl ? basePlayInfoApiUrlControl : 'warning' syncStatus.basePlayInfoApiUrls ? basePlayInfoApiUrlsControl : 'warning'
" "
> >
<nz-form-text class="setting-value" <nz-form-text class="setting-value"
>{{ basePlayInfoApiUrlControl.value }} >{{ basePlayInfoApiUrlsControl.value }}
</nz-form-text> </nz-form-text>
<app-base-play-info-api-url-edit-dialog <app-base-play-info-api-url-edit-dialog
#basePlayInfoApiUrlEditDialog #basePlayInfoApiUrlsEditDialog
[value]="basePlayInfoApiUrlControl.value" [value]="basePlayInfoApiUrlsControl.value"
(confirm)="basePlayInfoApiUrlControl.setValue($event)" (confirm)="basePlayInfoApiUrlsControl.setValue($event)"
></app-base-play-info-api-url-edit-dialog> ></app-base-play-info-api-url-edit-dialog>
</nz-form-control> </nz-form-control>
</nz-form-item> </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 private settingsSyncService: SettingsSyncService
) { ) {
this.settingsForm = formBuilder.group({ this.settingsForm = formBuilder.group({
baseApiUrl: [''], baseApiUrls: [[]],
baseLiveApiUrl: [''], baseLiveApiUrls: [[]],
basePlayInfoApiUrl: [''], basePlayInfoApiUrls: [[]],
}); });
} }
get baseApiUrlControl() { get baseApiUrlsControl() {
return this.settingsForm.get('baseApiUrl') as FormControl; return this.settingsForm.get('baseApiUrls') as FormControl;
} }
get baseLiveApiUrlControl() { get baseLiveApiUrlsControl() {
return this.settingsForm.get('baseLiveApiUrl') as FormControl; return this.settingsForm.get('baseLiveApiUrls') as FormControl;
} }
get basePlayInfoApiUrlControl() { get basePlayInfoApiUrlsControl() {
return this.settingsForm.get('basePlayInfoApiUrl') as FormControl; return this.settingsForm.get('basePlayInfoApiUrls') as FormControl;
} }
ngOnChanges(): void { ngOnChanges(): void {
@ -66,7 +66,8 @@ export class BiliApiSettingsComponent implements OnInit, OnChanges {
.syncSettings( .syncSettings(
'biliApi', 'biliApi',
this.settings, this.settings,
this.settingsForm.valueChanges as Observable<BiliApiSettings> this.settingsForm.valueChanges as Observable<BiliApiSettings>,
false
) )
.subscribe((detail) => { .subscribe((detail) => {
this.syncStatus = { ...this.syncStatus, ...calcSyncStatus(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 { SettingsRoutingModule } from './settings-routing.module';
import { SettingsComponent } from './settings.component'; import { SettingsComponent } from './settings.component';
import { SwitchActionableDirective } from './shared/directives/switch-actionable.directive'; 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 { DiskSpaceSettingsComponent } from './disk-space-settings/disk-space-settings.component';
import { NotificationSettingsComponent } from './notification-settings/notification-settings.component'; import { NotificationSettingsComponent } from './notification-settings/notification-settings.component';
import { LoggingSettingsComponent } from './logging-settings/logging-settings.component'; import { LoggingSettingsComponent } from './logging-settings/logging-settings.component';
@ -74,6 +75,7 @@ import { BasePlayInfoApiUrlEditDialogComponent } from './bili-api-settings/base-
declarations: [ declarations: [
SettingsComponent, SettingsComponent,
SwitchActionableDirective, SwitchActionableDirective,
BaseUrlValidatorDirective,
DiskSpaceSettingsComponent, DiskSpaceSettingsComponent,
NotificationSettingsComponent, NotificationSettingsComponent,
LoggingSettingsComponent, LoggingSettingsComponent,

View File

@ -2,7 +2,6 @@ import { CoverSaveStrategy, DeleteStrategy } from '../setting.model';
export const SYNC_FAILED_WARNING_TIP = '设置同步失败!'; 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_API_URL_DEFAULT = 'https://api.bilibili.com';
export const BASE_LIVE_API_URL_DEFAULT = 'https://api.live.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>( syncSettings<K extends SK, V extends SV>(
key: K, key: K,
initialValue: V, initialValue: V,
valueChanges: Observable<V> valueChanges: Observable<V>,
deepDiff: boolean = true
): Observable<DetailWithResult<V> | DetailWithError<V>> { ): Observable<DetailWithResult<V> | DetailWithError<V>> {
return valueChanges.pipe( return valueChanges.pipe(
scan<V, [V, V, Partial<V>]>( scan<V, [V, V, Partial<V>]>(
([, prev], curr) => [ ([, prev], curr) => [
prev, prev,
curr, curr,
difference(curr!, prev!) as Partial<V>, difference(curr!, prev!, deepDiff) as Partial<V>,
], ],
[initialValue, initialValue, {} as Partial<V>] [initialValue, initialValue, {} as Partial<V>]
), ),

View File

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

View File

@ -42,7 +42,7 @@ export class InputDurationComponent implements OnInit, ControlValueAccessor {
this.formGroup = formBuilder.group({ this.formGroup = formBuilder.group({
duration: [ 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'; import * as filesize from 'filesize';
// ref: https://gist.github.com/Yimiprod/7ee176597fef230d1451 // 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) { function diff(object: object, base: object) {
return transform(object, (result: object, value: any, key: string) => { return transform(object, (result: object, value: any, key: string) => {
const baseValue = Reflect.get(base, key); const baseValue = Reflect.get(base, key);
@ -10,7 +14,7 @@ export function difference(object: object, base: object): object {
Reflect.set( Reflect.set(
result, result,
key, key,
isObject(value) && isObject(baseValue) deep && isObject(value) && isObject(baseValue)
? diff(value, baseValue) ? diff(value, baseValue)
: value : value
); );

View File

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

View File

@ -745,139 +745,6 @@
</nz-form-item> </nz-form-item>
</div> </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"> <div ngModelGroup="header" class="form-group header">
<h2>网络请求</h2> <h2>网络请求</h2>
<nz-form-item class="setting-item textarea"> <nz-form-item class="setting-item textarea">

View File

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