feat: custom BASE API URL

This commit is contained in:
acgnhik 2022-08-24 11:57:41 +08:00
parent a2fe0bf01b
commit 91ccd8c595
47 changed files with 1177 additions and 197 deletions

View File

@ -1,47 +1,55 @@
from abc import ABC
import hashlib
from urllib.parse import urlencode
import logging
from abc import ABC
from datetime import datetime
from typing import Mapping, Dict, Any, Final
from typing import Any, Dict, Mapping, Optional
from urllib.parse import urlencode
import aiohttp
from tenacity import (
retry,
wait_exponential,
stop_after_delay,
)
from tenacity import retry, stop_after_delay, wait_exponential
from .typing import QualityNumber, JsonResponse, ResponseData
from .exceptions import ApiRequestError
from .typing import JsonResponse, QualityNumber, ResponseData
__all__ = 'AppApi', 'WebApi'
logger = logging.getLogger(__name__)
class BaseApi(ABC):
def __init__(self, session: aiohttp.ClientSession):
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._session = session
self.headers = headers or {}
self.timeout = 10
@property
def headers(self) -> Dict[str, str]:
return self._headers
@headers.setter
def headers(self, value: Dict[str, str]) -> None:
self._headers = {**value}
@staticmethod
def _check_response(json_res: JsonResponse) -> None:
if json_res['code'] != 0:
raise ApiRequestError(
json_res['code'],
json_res.get('message') or json_res.get('msg') or '',
json_res['code'], json_res.get('message') or json_res.get('msg') or ''
)
@retry(
reraise=True,
stop=stop_after_delay(5),
wait=wait_exponential(0.1),
)
async def _get(self, *args: Any, **kwds: Any) -> JsonResponse:
@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,
*args, **kwds, timeout=self.timeout, headers=self.headers
) as res:
logger.debug(f'real url: {res.real_url}')
json_res = await res.json()
self._check_response(json_res)
return json_res
@ -52,12 +60,20 @@ class AppApi(BaseApi):
_appkey = '1d8b6e7d45233436'
_appsec = '560c52ccd288fed045859ed18bffd973'
_headers = {
_app_headers = {
'User-Agent': 'Mozilla/5.0 BiliDroid/6.64.0 (bbcallen@gmail.com) os/android model/Unknown mobi_app/android build/6640400 channel/bili innerVer/6640400 osVer/6.0.1 network/2', # noqa
'Connection': 'Keep-Alive',
'Accept-Encoding': 'gzip',
}
@property
def headers(self) -> Dict[str, str]:
return self._headers
@headers.setter
def headers(self, value: Dict[str, str]) -> None:
self._headers = {**value, **self._app_headers}
@classmethod
def signed(cls, params: Mapping[str, Any]) -> Dict[str, Any]:
if isinstance(params, Mapping):
@ -77,111 +93,97 @@ class AppApi(BaseApi):
only_video: bool = False,
only_audio: bool = False,
) -> ResponseData:
url = 'https://api.live.bilibili.com/xlive/app-room/v2/index/getRoomPlayInfo' # noqa
params = self.signed({
'actionKey': 'appkey',
'build': '6640400',
'channel': 'bili',
'codec': '0,1', # 0: avc, 1: hevc
'device': 'android',
'device_name': 'Unknown',
'disable_rcmd': '0',
'dolby': '1',
'format': '0,1,2', # 0: flv, 1: ts, 2: fmp4
'free_type': '0',
'http': '1',
'mask': '0',
'mobi_app': 'android',
'need_hdr': '0',
'no_playurl': '0',
'only_audio': '1' if only_audio else '0',
'only_video': '1' if only_video else '0',
'platform': 'android',
'play_type': '0',
'protocol': '0,1',
'qn': qn,
'room_id': room_id,
'ts': int(datetime.utcnow().timestamp()),
})
r = await self._get(url, params=params, headers=self._headers)
url = self.base_play_info_api_url + '/xlive/app-room/v2/index/getRoomPlayInfo'
params = self.signed(
{
'actionKey': 'appkey',
'build': '6640400',
'channel': 'bili',
'codec': '0,1', # 0: avc, 1: hevc
'device': 'android',
'device_name': 'Unknown',
'disable_rcmd': '0',
'dolby': '1',
'format': '0,1,2', # 0: flv, 1: ts, 2: fmp4
'free_type': '0',
'http': '1',
'mask': '0',
'mobi_app': 'android',
'need_hdr': '0',
'no_playurl': '0',
'only_audio': '1' if only_audio else '0',
'only_video': '1' if only_video else '0',
'platform': 'android',
'play_type': '0',
'protocol': '0,1',
'qn': qn,
'room_id': room_id,
'ts': int(datetime.utcnow().timestamp()),
}
)
r = await self._get_json(url, params=params, headers=self._headers)
return r['data']
async def get_info_by_room(self, room_id: int) -> ResponseData:
url = 'https://api.live.bilibili.com/xlive/app-room/v1/index/getInfoByRoom' # noqa
params = self.signed({
'actionKey': 'appkey',
'build': '6640400',
'channel': 'bili',
'device': 'android',
'mobi_app': 'android',
'platform': 'android',
'room_id': room_id,
'ts': int(datetime.utcnow().timestamp()),
})
r = await self._get(url, params=params)
url = self.base_live_api_url + '/xlive/app-room/v1/index/getInfoByRoom'
params = self.signed(
{
'actionKey': 'appkey',
'build': '6640400',
'channel': 'bili',
'device': 'android',
'mobi_app': 'android',
'platform': 'android',
'room_id': room_id,
'ts': int(datetime.utcnow().timestamp()),
}
)
r = await self._get_json(url, params=params)
return r['data']
async def get_user_info(self, uid: int) -> ResponseData:
url = 'https://app.bilibili.com/x/v2/space'
params = self.signed({
'build': '6640400',
'channel': 'bili',
'mobi_app': 'android',
'platform': 'android',
'ts': int(datetime.utcnow().timestamp()),
'vmid': uid,
})
r = await self._get(url, params=params)
url = self.base_api_url + '/x/v2/space'
params = self.signed(
{
'build': '6640400',
'channel': 'bili',
'mobi_app': 'android',
'platform': 'android',
'ts': int(datetime.utcnow().timestamp()),
'vmid': uid,
}
)
r = await self._get_json(url, params=params)
return r['data']
async def get_danmu_info(self, room_id: int) -> ResponseData:
url = 'https://api.live.bilibili.com/xlive/app-room/v1/index/getDanmuInfo' # noqa
params = self.signed({
'actionKey': 'appkey',
'build': '6640400',
'channel': 'bili',
'device': 'android',
'mobi_app': 'android',
'platform': 'android',
'room_id': room_id,
'ts': int(datetime.utcnow().timestamp()),
})
r = await self._get(url, params=params)
url = self.base_live_api_url + '/xlive/app-room/v1/index/getDanmuInfo'
params = self.signed(
{
'actionKey': 'appkey',
'build': '6640400',
'channel': 'bili',
'device': 'android',
'mobi_app': 'android',
'platform': 'android',
'room_id': room_id,
'ts': int(datetime.utcnow().timestamp()),
}
)
r = await self._get_json(url, params=params)
return r['data']
class WebApi(BaseApi):
BASE_API_URL: Final[str] = 'https://api.bilibili.com'
BASE_LIVE_API_URL: Final[str] = 'https://api.live.bilibili.com'
GET_USER_INFO_URL: Final[str] = BASE_API_URL + '/x/space/acc/info'
GET_DANMU_INFO_URL: Final[str] = BASE_LIVE_API_URL + \
'/xlive/web-room/v1/index/getDanmuInfo'
ROOM_INIT_URL: Final[str] = BASE_LIVE_API_URL + '/room/v1/Room/room_init'
GET_INFO_URL: Final[str] = BASE_LIVE_API_URL + '/room/v1/Room/get_info'
GET_INFO_BY_ROOM_URL: Final[str] = BASE_LIVE_API_URL + \
'/xlive/web-room/v1/index/getInfoByRoom'
GET_ROOM_PLAY_INFO_URL: Final[str] = BASE_LIVE_API_URL + \
'/xlive/web-room/v2/index/getRoomPlayInfo'
GET_TIMESTAMP_URL: Final[str] = BASE_LIVE_API_URL + \
'/av/v1/Time/getTimestamp?platform=pc'
async def room_init(self, room_id: int) -> ResponseData:
r = await self._get(self.ROOM_INIT_URL, params={'id': room_id})
url = self.base_live_api_url + '/room/v1/Room/room_init'
r = await self._get_json(url, params={'id': room_id})
return r['data']
async def get_room_play_info(
self, room_id: int, qn: QualityNumber = 10000
) -> ResponseData:
url = self.base_play_info_api_url + '/xlive/web-room/v2/index/getRoomPlayInfo'
params = {
'room_id': room_id,
'protocol': '0,1',
@ -191,37 +193,34 @@ class WebApi(BaseApi):
'platform': 'web',
'ptype': 8,
}
r = await self._get(self.GET_ROOM_PLAY_INFO_URL, params=params)
r = await self._get_json(url, params=params)
return r['data']
async def get_info_by_room(self, room_id: int) -> ResponseData:
params = {
'room_id': room_id,
}
r = await self._get(self.GET_INFO_BY_ROOM_URL, params=params)
url = self.base_live_api_url + '/xlive/web-room/v1/index/getInfoByRoom'
params = {'room_id': room_id}
r = await self._get_json(url, params=params)
return r['data']
async def get_info(self, room_id: int) -> ResponseData:
params = {
'room_id': room_id,
}
r = await self._get(self.GET_INFO_URL, params=params)
url = self.base_live_api_url + '/room/v1/Room/get_info'
params = {'room_id': room_id}
r = await self._get_json(url, params=params)
return r['data']
async def get_timestamp(self) -> int:
r = await self._get(self.GET_TIMESTAMP_URL)
url = self.base_live_api_url + '/av/v1/Time/getTimestamp?platform=pc'
r = await self._get_json(url)
return r['data']['timestamp']
async def get_user_info(self, uid: int) -> ResponseData:
params = {
'mid': uid,
}
r = await self._get(self.GET_USER_INFO_URL, params=params)
url = self.base_api_url + '/x/space/acc/info'
params = {'mid': uid}
r = await self._get_json(url, params=params)
return r['data']
async def get_danmu_info(self, room_id: int) -> ResponseData:
params = {
'id': room_id,
}
r = await self._get(self.GET_DANMU_INFO_URL, params=params)
url = self.base_live_api_url + '/xlive/web-room/v1/index/getDanmuInfo'
params = {'id': room_id}
r = await self._get_json(url, params=params)
return r['data']

View File

@ -53,12 +53,14 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
room_id: int,
*,
max_retries: int = 10,
headers: Optional[Dict[str, str]] = None,
) -> None:
super().__init__()
self.session = session
self.appapi = appapi
self.webapi = webapi
self._room_id = room_id
self.headers = headers or {}
self._api_platform: ApiPlatform = 'web'
self._danmu_info: Dict[str, Any] = COMMON_DANMU_INFO
@ -66,6 +68,14 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
self._retry_delay: int = 0
self._MAX_RETRIES: Final[int] = max_retries
@property
def headers(self) -> Dict[str, str]:
return self._headers
@headers.setter
def headers(self, value: Dict[str, str]) -> None:
self._headers = {**value, 'Connection': 'Upgrade'}
async def _do_start(self) -> None:
await self._update_danmu_info()
await self._connect()
@ -77,6 +87,12 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
await self._disconnect()
logger.debug('Stopped danmaku client')
async def restart(self) -> None:
logger.debug('Restarting danmaku client...')
await self.stop()
await self.start()
logger.debug('Restarted danmaku client')
async def reconnect(self) -> None:
if self.stopped:
return
@ -117,7 +133,9 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
)
logger.debug(f'Connecting WebSocket... {url}')
try:
self._ws = await self.session.ws_connect(url, timeout=5)
self._ws = await self.session.ws_connect(
url, timeout=5, headers=self.headers
)
except Exception as exc:
logger.debug(f'Failed to connect WebSocket: {repr(exc)}')
raise

View File

@ -40,9 +40,44 @@ class Live:
self._cookie = cookie
self._html_page_url = f'https://live.bilibili.com/{room_id}'
self._session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit=200),
raise_for_status=True,
trust_env=True,
)
self._appapi = AppApi(self._session, self.headers)
self._webapi = WebApi(self._session, self.headers)
self._room_info: RoomInfo
self._user_info: UserInfo
@property
def base_api_url(self) -> str:
return self._webapi.base_api_url
@base_api_url.setter
def base_api_url(self, value: str) -> None:
self._webapi.base_api_url = value
self._appapi.base_api_url = value
@property
def base_live_api_url(self) -> str:
return self._webapi.base_live_api_url
@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
@property
def base_play_info_api_url(self) -> str:
return self._webapi.base_play_info_api_url
@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
@property
def user_agent(self) -> str:
return self._user_agent
@ -50,6 +85,8 @@ class Live:
@user_agent.setter
def user_agent(self, value: str) -> None:
self._user_agent = value
self._webapi.headers = self.headers
self._appapi.headers = self.headers
@property
def cookie(self) -> str:
@ -58,15 +95,25 @@ class Live:
@cookie.setter
def cookie(self, value: str) -> None:
self._cookie = value
self._webapi.headers = self.headers
self._appapi.headers = self.headers
@property
def headers(self) -> Dict[str, str]:
return {
'Referer': 'https://live.bilibili.com/',
'Connection': 'Keep-Alive',
'Accept-Encoding': 'gzip',
'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',
'Referer': f'https://live.bilibili.com/{self._room_id}',
'Origin': 'https://live.bilibili.com',
'Connection': 'keep-alive',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-site',
'Pragma': 'no-cache',
'Cache-Control': 'no-cache',
'User-Agent': self._user_agent,
'Cookie': self._cookie,
'Accept-Encoding': 'gzip',
}
@property
@ -94,15 +141,6 @@ class Live:
return self._user_info
async def init(self) -> None:
self._session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit=200),
headers=self.headers,
raise_for_status=True,
trust_env=True,
)
self._appapi = AppApi(self._session)
self._webapi = WebApi(self._session)
self._room_info = await self.get_room_info()
self._user_info = await self.get_user_info(self._room_info.uid)

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.68e08c4d681726f6.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.16a8fc7b1f8a870d.js" type="module"></script>
<script src="runtime.4ae765ab3bddf383.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.27d1fff16f7909f2.js" type="module"></script>
</body></html>

View File

@ -1,6 +1,6 @@
{
"configVersion": 1,
"timestamp": 1659243718041,
"timestamp": 1661135687643,
"index": "/index.html",
"assetGroups": [
{
@ -13,16 +13,16 @@
"urls": [
"/103.5b5d2a6e5a8a7479.js",
"/146.5a8902910bda9e87.js",
"/183.2c7c85597ba82f9e.js",
"/170.d0e14a28ee578d1f.js",
"/183.0d3cd9f454be16fb.js",
"/45.c90c3cea2bf1a66e.js",
"/500.5d39ab52fb714a12.js",
"/91.be3cbd4101dc7500.js",
"/91.9ff409a090dace5c.js",
"/common.858f777e9296e6f2.js",
"/index.html",
"/main.16a8fc7b1f8a870d.js",
"/main.27d1fff16f7909f2.js",
"/manifest.webmanifest",
"/polyfills.4b08448aee19bb22.js",
"/runtime.68e08c4d681726f6.js",
"/runtime.4ae765ab3bddf383.js",
"/styles.2e152d608221c2ee.css"
],
"patterns": []
@ -1636,10 +1636,10 @@
"hashTable": {
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
"/146.5a8902910bda9e87.js": "d9c33c7073662699f00f46f3a384ae5b749fdef9",
"/183.2c7c85597ba82f9e.js": "22a1524d6399d9bde85334a2eba15670f68ccd96",
"/170.d0e14a28ee578d1f.js": "d6b6208ca442565ed39300b27ab8cbe5501cb46a",
"/183.0d3cd9f454be16fb.js": "e7e6ebc715791102fd09edabe2aa47316208b29c",
"/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764",
"/500.5d39ab52fb714a12.js": "646fbfd3af1124519171f1cd9fac4c214b5af60f",
"/91.be3cbd4101dc7500.js": "f0fec71455c96f9a60c4fa671d2ccdba07e9a00a",
"/91.9ff409a090dace5c.js": "d756ffe7cd3f5516e40a7e6d6cf494ea6213a546",
"/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": "3f28dbdfc92c1a0930448a8ff6d5d2ac49648987",
"/main.16a8fc7b1f8a870d.js": "9c680888ae14907d6c20e60c026b49a2331768e9",
"/index.html": "29167783eb093ffa93369f741a5ce20a534137de",
"/main.27d1fff16f7909f2.js": "22e63726601a31af1a96e7901afc0d2bea7fd414",
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
"/runtime.68e08c4d681726f6.js": "04815a3dd35466f647f3707a295bc2c76c9f0375",
"/runtime.4ae765ab3bddf383.js": "96653fd35d3ad9684e603011436e9d43a1121690",
"/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(b=>r.O[b](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:"be3cbd4101dc7500",103:"5b5d2a6e5a8a7479",146:"5a8902910bda9e87",183:"2c7c85597ba82f9e",500:"5d39ab52fb714a12",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,b)=>{a.onerror=a.onload=null,clearTimeout(p);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(b)),g)return g(b)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),c&&document.head.appendChild(a)}}})(),r.r=e=>{"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),p=u&&u.target&&u.target.src;l.message="Loading chunk "+o+" failed.\n("+s+": "+p+")",l.name="ChunkLoadError",l.type=s,l.request=p,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(p=>0!==e[p])){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,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))})()})();

View File

@ -7,6 +7,8 @@ from .models import (
EmailNotificationSettings,
EmailSettings,
EnvSettings,
BiliApiOptions,
BiliApiSettings,
HeaderOptions,
HeaderSettings,
LoggingSettings,
@ -45,6 +47,8 @@ __all__ = (
'Settings',
'SettingsIn',
'SettingsOut',
'BiliApiOptions',
'BiliApiSettings',
'HeaderOptions',
'HeaderSettings',
'DanmakuOptions',

View File

@ -35,6 +35,8 @@ __all__ = (
'Settings',
'SettingsIn',
'SettingsOut',
'BiliApiOptions',
'BiliApiSettings',
'HeaderOptions',
'HeaderSettings',
'DanmakuOptions',
@ -110,6 +112,18 @@ 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 HeaderOptions(BaseModel):
user_agent: Optional[str]
cookie: Optional[str]
@ -285,6 +299,7 @@ class OutputSettings(OutputOptions):
class TaskOptions(BaseModel):
output: OutputOptions = OutputOptions()
bili_api: BiliApiOptions = BiliApiOptions()
header: HeaderOptions = HeaderOptions()
danmaku: DanmakuOptions = DanmakuOptions()
recorder: RecorderOptions = RecorderOptions()
@ -294,7 +309,14 @@ class TaskOptions(BaseModel):
def from_settings(cls, settings: TaskSettings) -> TaskOptions:
return cls(
**settings.dict(
include={'output', 'header', 'danmaku', 'recorder', 'postprocessing'}
include={
'output',
'bili_api',
'header',
'danmaku',
'recorder',
'postprocessing',
}
)
)
@ -587,6 +609,7 @@ class Settings(BaseModel):
tasks: Annotated[List[TaskSettings], Field(max_items=100)] = []
output: OutputSettings = OutputSettings() # type: ignore
logging: LoggingSettings = LoggingSettings() # type: ignore
bili_api: BiliApiSettings = BiliApiSettings()
header: HeaderSettings = HeaderSettings()
danmaku: DanmakuSettings = DanmakuSettings()
recorder: RecorderSettings = RecorderSettings()
@ -636,6 +659,7 @@ class Settings(BaseModel):
class SettingsIn(BaseModel):
output: Optional[OutputSettings] = None
logging: Optional[LoggingSettings] = None
bili_api: Optional[BiliApiSettings] = None
header: Optional[HeaderSettings] = None
danmaku: Optional[DanmakuSettings] = None
recorder: Optional[RecorderSettings] = None

View File

@ -16,6 +16,7 @@ from ..notification import (
from ..webhook import WebHook
from .helpers import shadow_settings, update_settings
from .models import (
BiliApiOptions,
DanmakuOptions,
HeaderOptions,
MessageTemplateSettings,
@ -210,13 +211,24 @@ 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, options: HeaderOptions, *, update_session: bool = True
self,
room_id: int,
options: HeaderOptions,
*,
restart_danmaku_client: bool = True,
) -> None:
final_settings = self._settings.header.copy()
shadow_settings(options, final_settings)
await self._app._task_manager.apply_task_header_settings(
room_id, final_settings, update_session=update_session
room_id, final_settings, restart_danmaku_client=restart_danmaku_client
)
def apply_task_danmaku_settings(
@ -264,6 +276,10 @@ class SettingsManager:
backup_count=self._settings.logging.backup_count,
)
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)
async def apply_header_settings(self) -> None:
for settings in self._settings.tasks:
await self.apply_task_header_settings(settings.room_id, settings.header)

View File

@ -19,6 +19,7 @@ KeyOfSettings = Literal[
'tasks',
'output',
'logging',
'bili_api',
'header',
'danmaku',
'recorder',

View File

@ -50,6 +50,10 @@ class TaskParam:
path_template: str
filesize_limit: int
duration_limit: int
# BiliApiSettings
base_api_url: str
base_live_api_url: str
base_play_info_api_url: str
# HeaderSettings
user_agent: str
cookie: str

View File

@ -190,6 +190,30 @@ class RecordTask:
yield DanmakuFileDetail(path=path, size=size, status=status)
@property
def base_api_url(self) -> str:
return self._live.base_api_url
@base_api_url.setter
def base_api_url(self, value: str) -> None:
self._live.base_api_url = value
@property
def base_live_api_url(self) -> str:
return self._live.base_live_api_url
@base_live_api_url.setter
def base_live_api_url(self, value: str) -> None:
self._live.base_live_api_url = value
@property
def base_play_info_api_url(self) -> str:
return self._live.base_play_info_api_url
@base_play_info_api_url.setter
def base_play_info_api_url(self, value: str) -> None:
self._live.base_play_info_api_url = value
@property
def user_agent(self) -> str:
return self._live.user_agent
@ -460,22 +484,13 @@ class RecordTask:
await self._recorder.stop()
await self._postprocessor.stop()
@aio_task_with_room_id
async def update_info(self, raise_exception: bool = False) -> bool:
return await self._live.update_info(raise_exception=raise_exception)
@aio_task_with_room_id
async def update_session(self) -> None:
if self._monitor_enabled:
await self._danmaku_client.stop()
await self._live.deinit()
await self._live.init()
self._danmaku_client.session = self._live.session
self._danmaku_client.appapi = self._live.appapi
self._danmaku_client.webapi = self._live.webapi
if self._monitor_enabled:
await self._danmaku_client.start()
async def restart_danmaku_client(self) -> None:
await self._danmaku_client.restart()
async def _setup(self) -> None:
self._setup_danmaku_client()
@ -488,7 +503,11 @@ class RecordTask:
def _setup_danmaku_client(self) -> None:
self._danmaku_client = DanmakuClient(
self._live.session, self._live.appapi, self._live.webapi, self._live.room_id
self._live.session,
self._live.appapi,
self._live.webapi,
self._live.room_id,
headers=self._live.headers,
)
def _setup_live_monitor(self) -> None:

View File

@ -18,6 +18,7 @@ if TYPE_CHECKING:
from ..setting import (
DanmakuSettings,
BiliApiSettings,
HeaderSettings,
OutputSettings,
PostprocessingSettings,
@ -76,8 +77,11 @@ class RecordTaskManager:
self._tasks[settings.room_id] = task
try:
self._settings_manager.apply_task_bili_api_settings(
settings.room_id, settings.bili_api
)
await self._settings_manager.apply_task_header_settings(
settings.room_id, settings.header, update_session=False
settings.room_id, settings.header, restart_danmaku_client=False
)
await task.setup()
@ -222,21 +226,29 @@ class RecordTaskManager:
if coros:
await asyncio.wait(coros)
async def apply_task_header_settings(
self, room_id: int, settings: HeaderSettings, *, update_session: bool = True
def apply_task_bili_api_settings(
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
async def apply_task_header_settings(
self,
room_id: int,
settings: HeaderSettings,
*,
restart_danmaku_client: bool = True,
) -> None:
task = self._get_task(room_id)
# avoid unnecessary updates that will interrupt connections
if task.user_agent == settings.user_agent and task.cookie == settings.cookie:
return
task.user_agent = settings.user_agent
task.cookie = settings.cookie
if update_session:
# update task session to take the effect
await task.update_session()
if restart_danmaku_client:
await task.restart_danmaku_client()
def apply_task_output_settings(
self, room_id: int, settings: OutputSettings
@ -296,6 +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,
user_agent=task.user_agent,
cookie=task.cookie,
danmu_uname=task.danmu_uname,

View File

@ -1,9 +1,11 @@
import os
from abc import ABC, abstractmethod
import asyncio
import os
import threading
from abc import ABC, abstractmethod
from typing import Awaitable, TypeVar, final
from blrec.logging.room_id import aio_task_with_room_id
class SwitchableMixin(ABC):
def __init__(self) -> None:
@ -127,21 +129,25 @@ class AsyncCooperationMixin(ABC):
# call submit_exception in a coroutine
# workaround for `RuntimeError: no running event loop`
submit_exception(exc)
self._run_coroutine(wrapper())
def _run_coroutine(self, coro: Awaitable[_T]) -> _T:
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
future = asyncio.run_coroutine_threadsafe(self._with_room_id(coro), self._loop)
return future.result()
@aio_task_with_room_id
async def _with_room_id(self, coro: Awaitable[_T]) -> _T:
return await coro
class SupportDebugMixin(ABC):
def __init__(self) -> None:
super().__init__()
def _init_for_debug(self, room_id: int) -> None:
if (
(value := os.environ.get('DEBUG')) and
(value == '*' or room_id in value.split(','))
if (value := os.environ.get('DEBUG')) and (
value == '*' or room_id in value.split(',')
):
self._debug = True
self._debug_dir = os.path.expanduser(f'~/.blrec/debug/{room_id}')

View File

@ -44,6 +44,7 @@ AliasKeyOfSettings = Literal[
'tasks',
'output',
'logging',
'biliApi',
'header',
'danmaku',
'recorder',

View File

@ -0,0 +1,44 @@
<nz-modal
nzTitle="修改 BASE API URL"
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
>
<ng-container *nzModalContent>
<form nz-form [formGroup]="settingsForm">
<nz-form-item>
<nz-form-control [nzErrorTip]="errorTip">
<input type="text" required nz-input formControlName="baseApiUrl" />
<ng-template #errorTip let-control>
<ng-container *ngIf="control.hasError('required')">
不能为空
</ng-container>
<ng-container *ngIf="control.hasError('pattern')">
输入无效
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
</form>
</ng-container>
<ng-template [nzModalFooter]>
<button
nz-button
nzType="default"
(click)="restoreDefault()"
[disabled]="control.value.trim() === defaultBaseApiUrl"
>
恢复默认
</button>
<button nz-button nzType="default" (click)="handleCancel()">取消</button>
<button
nz-button
nzDanger
nzType="default"
(click)="handleConfirm()"
[disabled]="control.invalid || control.value.trim() === value"
>
确定
</button>
</ng-template>
</nz-modal>

View File

@ -0,0 +1 @@
@use '../../shared/styles/setting';

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BaseApiUrlEditDialogComponent } from './base-api-url-edit-dialog.component';
describe('BaseApiUrlEditDialogComponent', () => {
let component: BaseApiUrlEditDialogComponent;
let fixture: ComponentFixture<BaseApiUrlEditDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ BaseApiUrlEditDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(BaseApiUrlEditDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,90 @@
import {
Component,
ChangeDetectionStrategy,
Input,
Output,
ChangeDetectorRef,
EventEmitter,
OnChanges,
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators,
} from '@angular/forms';
import {
BASE_API_URL_DEFAULT,
BASE_URL_PATTERN,
} from '../../shared/constants/form';
@Component({
selector: 'app-base-api-url-edit-dialog',
templateUrl: './base-api-url-edit-dialog.component.html',
styleUrls: ['./base-api-url-edit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BaseApiUrlEditDialogComponent implements OnChanges {
@Input() value = '';
@Input() visible = false;
@Output() visibleChange = new EventEmitter<boolean>();
@Output() cancel = new EventEmitter<undefined>();
@Output() confirm = new EventEmitter<string>();
readonly settingsForm: FormGroup;
readonly defaultBaseApiUrl = BASE_API_URL_DEFAULT;
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef
) {
this.settingsForm = formBuilder.group({
baseApiUrl: [
'',
[Validators.required, Validators.pattern(BASE_URL_PATTERN)],
],
});
}
get control() {
return this.settingsForm.get('baseApiUrl') as FormControl;
}
ngOnChanges(): void {
this.setValue();
}
open(): void {
this.setValue();
this.setVisible(true);
}
close(): void {
this.setVisible(false);
}
setVisible(visible: boolean): void {
this.visible = visible;
this.visibleChange.emit(visible);
this.changeDetector.markForCheck();
}
setValue(): void {
this.control.setValue(this.value);
this.changeDetector.markForCheck();
}
handleCancel(): void {
this.cancel.emit();
this.close();
}
handleConfirm(): void {
this.confirm.emit(this.control.value.trim());
this.close();
}
restoreDefault(): void {
this.control.setValue(this.defaultBaseApiUrl);
}
}

View File

@ -0,0 +1,49 @@
<nz-modal
nzTitle="修改 BASE LIVE API URL"
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
>
<ng-container *nzModalContent>
<form nz-form [formGroup]="settingsForm">
<nz-form-item>
<nz-form-control [nzErrorTip]="errorTip">
<input
type="text"
required
nz-input
formControlName="baseLiveApiUrl"
/>
<ng-template #errorTip let-control>
<ng-container *ngIf="control.hasError('required')">
不能为空
</ng-container>
<ng-container *ngIf="control.hasError('pattern')">
输入无效
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
</form>
</ng-container>
<ng-template [nzModalFooter]>
<button
nz-button
nzType="default"
(click)="restoreDefault()"
[disabled]="control.value.trim() === defaultBaseLiveApiUrl"
>
恢复默认
</button>
<button nz-button nzType="default" (click)="handleCancel()">取消</button>
<button
nz-button
nzDanger
nzType="default"
(click)="handleConfirm()"
[disabled]="control.invalid || control.value.trim() === value"
>
确定
</button>
</ng-template>
</nz-modal>

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BaseLiveApiUrlEditDialogComponent } from './base-live-api-url-edit-dialog.component';
describe('BaseLiveApiUrlEditDialogComponent', () => {
let component: BaseLiveApiUrlEditDialogComponent;
let fixture: ComponentFixture<BaseLiveApiUrlEditDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ BaseLiveApiUrlEditDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(BaseLiveApiUrlEditDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,90 @@
import {
Component,
ChangeDetectionStrategy,
Input,
Output,
ChangeDetectorRef,
EventEmitter,
OnChanges,
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators,
} from '@angular/forms';
import {
BASE_LIVE_API_URL_DEFAULT,
BASE_URL_PATTERN,
} from '../../shared/constants/form';
@Component({
selector: 'app-base-live-api-url-edit-dialog',
templateUrl: './base-live-api-url-edit-dialog.component.html',
styleUrls: ['./base-live-api-url-edit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BaseLiveApiUrlEditDialogComponent implements OnChanges {
@Input() value = '';
@Input() visible = false;
@Output() visibleChange = new EventEmitter<boolean>();
@Output() cancel = new EventEmitter<undefined>();
@Output() confirm = new EventEmitter<string>();
readonly settingsForm: FormGroup;
readonly defaultBaseLiveApiUrl = BASE_LIVE_API_URL_DEFAULT;
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef
) {
this.settingsForm = formBuilder.group({
baseLiveApiUrl: [
'',
[Validators.required, Validators.pattern(BASE_URL_PATTERN)],
],
});
}
get control() {
return this.settingsForm.get('baseLiveApiUrl') as FormControl;
}
ngOnChanges(): void {
this.setValue();
}
open(): void {
this.setValue();
this.setVisible(true);
}
close(): void {
this.setVisible(false);
}
setVisible(visible: boolean): void {
this.visible = visible;
this.visibleChange.emit(visible);
this.changeDetector.markForCheck();
}
setValue(): void {
this.control.setValue(this.value);
this.changeDetector.markForCheck();
}
handleCancel(): void {
this.cancel.emit();
this.close();
}
handleConfirm(): void {
this.confirm.emit(this.control.value.trim());
this.close();
}
restoreDefault(): void {
this.control.setValue(this.defaultBaseLiveApiUrl);
}
}

View File

@ -0,0 +1,49 @@
<nz-modal
nzTitle="修改 BASE PLAY INFO API URL"
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
>
<ng-container *nzModalContent>
<form nz-form [formGroup]="settingsForm">
<nz-form-item>
<nz-form-control [nzErrorTip]="errorTip">
<input
type="text"
required
nz-input
formControlName="basePlayInfoApiUrl"
/>
<ng-template #errorTip let-control>
<ng-container *ngIf="control.hasError('required')">
不能为空
</ng-container>
<ng-container *ngIf="control.hasError('pattern')">
输入无效
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
</form>
</ng-container>
<ng-template [nzModalFooter]>
<button
nz-button
nzType="default"
(click)="restoreDefault()"
[disabled]="control.value.trim() === defaultBasePlayInfoApiUrl"
>
恢复默认
</button>
<button nz-button nzType="default" (click)="handleCancel()">取消</button>
<button
nz-button
nzDanger
nzType="default"
(click)="handleConfirm()"
[disabled]="control.invalid || control.value.trim() === value"
>
确定
</button>
</ng-template>
</nz-modal>

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BasePlayInfoApiUrlEditDialogComponent } from './base-play-info-api-url-edit-dialog.component';
describe('BasePlayInfoApiUrlEditDialogComponent', () => {
let component: BasePlayInfoApiUrlEditDialogComponent;
let fixture: ComponentFixture<BasePlayInfoApiUrlEditDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ BasePlayInfoApiUrlEditDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(BasePlayInfoApiUrlEditDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,90 @@
import {
Component,
ChangeDetectionStrategy,
Input,
Output,
ChangeDetectorRef,
EventEmitter,
OnChanges,
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators,
} from '@angular/forms';
import {
BASE_LIVE_API_URL_DEFAULT,
BASE_URL_PATTERN,
} from '../../shared/constants/form';
@Component({
selector: 'app-base-play-info-api-url-edit-dialog',
templateUrl: './base-play-info-api-url-edit-dialog.component.html',
styleUrls: ['./base-play-info-api-url-edit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BasePlayInfoApiUrlEditDialogComponent implements OnChanges {
@Input() value = '';
@Input() visible = false;
@Output() visibleChange = new EventEmitter<boolean>();
@Output() cancel = new EventEmitter<undefined>();
@Output() confirm = new EventEmitter<string>();
readonly settingsForm: FormGroup;
readonly defaultBasePlayInfoApiUrl = BASE_LIVE_API_URL_DEFAULT;
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef
) {
this.settingsForm = formBuilder.group({
basePlayInfoApiUrl: [
'',
[Validators.required, Validators.pattern(BASE_URL_PATTERN)],
],
});
}
get control() {
return this.settingsForm.get('basePlayInfoApiUrl') as FormControl;
}
ngOnChanges(): void {
this.setValue();
}
open(): void {
this.setValue();
this.setVisible(true);
}
close(): void {
this.setVisible(false);
}
setVisible(visible: boolean): void {
this.visible = visible;
this.visibleChange.emit(visible);
this.changeDetector.markForCheck();
}
setValue(): void {
this.control.setValue(this.value);
this.changeDetector.markForCheck();
}
handleCancel(): void {
this.cancel.emit();
this.close();
}
handleConfirm(): void {
this.confirm.emit(this.control.value.trim());
this.close();
}
restoreDefault(): void {
this.control.setValue(this.defaultBasePlayInfoApiUrl);
}
}

View File

@ -0,0 +1,80 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item
class="setting-item actionable"
(click)="baseApiUrlEditDialog.open()"
>
<nz-form-label class="setting-label" [nzTooltipTitle]="baseApiUrlTip"
>BASE API URL</nz-form-label
>
<ng-template #baseApiUrlTip>
<p>主站 API 的 BASE URL</p>
</ng-template>
<nz-form-control
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="syncStatus.baseApiUrl ? baseApiUrlControl : 'warning'"
>
<nz-form-text class="setting-value"
>{{ baseApiUrlControl.value }}
</nz-form-text>
<app-base-api-url-edit-dialog
#baseApiUrlEditDialog
[value]="baseApiUrlControl.value"
(confirm)="baseApiUrlControl.setValue($event)"
></app-base-api-url-edit-dialog>
</nz-form-control>
</nz-form-item>
<nz-form-item
class="setting-item actionable"
(click)="baseLiveApiUrlEditDialog.open()"
>
<nz-form-label class="setting-label" [nzTooltipTitle]="baseLiveApiUrlTip"
>BASE LIVE API URL</nz-form-label
>
<ng-template #baseLiveApiUrlTip>
<p>直播 API (getRoomPlayInfo 除外) 的 BASE URL</p>
</ng-template>
<nz-form-control
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.baseLiveApiUrl ? baseLiveApiUrlControl : 'warning'
"
>
<nz-form-text class="setting-value"
>{{ baseLiveApiUrlControl.value }}
</nz-form-text>
<app-base-live-api-url-edit-dialog
#baseLiveApiUrlEditDialog
[value]="baseLiveApiUrlControl.value"
(confirm)="baseLiveApiUrlControl.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()"
>
<nz-form-label
class="setting-label"
[nzTooltipTitle]="basePalyInfoApiUrlTip"
>BASE PLAY INFO API URL</nz-form-label
>
<ng-template #basePalyInfoApiUrlTip>
<p>直播 API getRoomPlayInfo 的 BASE URL</p>
</ng-template>
<nz-form-control
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.basePlayInfoApiUrl ? basePlayInfoApiUrlControl : 'warning'
"
>
<nz-form-text class="setting-value"
>{{ basePlayInfoApiUrlControl.value }}
</nz-form-text>
<app-base-play-info-api-url-edit-dialog
#basePlayInfoApiUrlEditDialog
[value]="basePlayInfoApiUrlControl.value"
(confirm)="basePlayInfoApiUrlControl.setValue($event)"
></app-base-play-info-api-url-edit-dialog>
</nz-form-control>
</nz-form-item>
</form>

View File

@ -0,0 +1 @@
@use '../shared/styles/setting';

View File

@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BiliApiSettingsComponent } from './bili-api-settings.component';
describe('BiliApiSettingsComponent', () => {
let component: BiliApiSettingsComponent;
let fixture: ComponentFixture<BiliApiSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ BiliApiSettingsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(BiliApiSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,76 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
OnChanges,
ChangeDetectorRef,
} from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { Observable } from 'rxjs';
import mapValues from 'lodash-es/mapValues';
import { BiliApiSettings } from '../shared/setting.model';
import {
SettingsSyncService,
SyncStatus,
calcSyncStatus,
} from '../shared/services/settings-sync.service';
import { SYNC_FAILED_WARNING_TIP } from '../shared/constants/form';
@Component({
selector: 'app-bili-api-settings',
templateUrl: './bili-api-settings.component.html',
styleUrls: ['./bili-api-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BiliApiSettingsComponent implements OnInit, OnChanges {
@Input() settings!: BiliApiSettings;
syncStatus!: SyncStatus<BiliApiSettings>;
readonly settingsForm: FormGroup;
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef,
private settingsSyncService: SettingsSyncService
) {
this.settingsForm = formBuilder.group({
baseApiUrl: [''],
baseLiveApiUrl: [''],
basePlayInfoApiUrl: [''],
});
}
get baseApiUrlControl() {
return this.settingsForm.get('baseApiUrl') as FormControl;
}
get baseLiveApiUrlControl() {
return this.settingsForm.get('baseLiveApiUrl') as FormControl;
}
get basePlayInfoApiUrlControl() {
return this.settingsForm.get('basePlayInfoApiUrl') as FormControl;
}
ngOnChanges(): void {
this.syncStatus = mapValues(this.settings, () => true);
this.settingsForm.setValue(this.settings);
}
ngOnInit(): void {
this.settingsSyncService
.syncSettings(
'biliApi',
this.settings,
this.settingsForm.valueChanges as Observable<BiliApiSettings>
)
.subscribe((detail) => {
this.syncStatus = { ...this.syncStatus, ...calcSyncStatus(detail) };
this.changeDetector.markForCheck();
});
}
}

View File

@ -3,8 +3,6 @@
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
(nzOnOk)="handleConfirm()"
(nzOnCancel)="handleCancel()"
>
<ng-container *nzModalContent>
<form nz-form [formGroup]="settingsForm">
@ -59,7 +57,7 @@
</form>
</ng-container>
<ng-template #modalFooter>
<ng-template [nzModalFooter]>
<button
nz-button
nzType="default"

View File

@ -29,6 +29,12 @@
></app-disk-space-settings>
</app-page-section>
<app-page-section name="BILI API">
<app-bili-api-settings
[settings]="settings.biliApi"
></app-bili-api-settings>
</app-page-section>
<app-page-section name="网络请求">
<app-header-settings [settings]="settings.header"></app-header-settings>
</app-page-section>

View File

@ -65,6 +65,10 @@ import { LogdirEditDialogComponent } from './logging-settings/logdir-edit-dialog
import { PathTemplateEditDialogComponent } from './output-settings/path-template-edit-dialog/path-template-edit-dialog.component';
import { MessageTemplateSettingsComponent } from './notification-settings/shared/components/message-template-settings/message-template-settings.component';
import { MessageTemplateEditDialogComponent } from './notification-settings/shared/components/message-template-settings/message-template-edit-dialog/message-template-edit-dialog.component';
import { BiliApiSettingsComponent } from './bili-api-settings/bili-api-settings.component';
import { BaseApiUrlEditDialogComponent } from './bili-api-settings/base-api-url-edit-dialog/base-api-url-edit-dialog.component';
import { BaseLiveApiUrlEditDialogComponent } from './bili-api-settings/base-live-api-url-edit-dialog/base-live-api-url-edit-dialog.component';
import { BasePlayInfoApiUrlEditDialogComponent } from './bili-api-settings/base-play-info-api-url-edit-dialog/base-play-info-api-url-edit-dialog.component';
@NgModule({
declarations: [
@ -101,6 +105,10 @@ import { MessageTemplateEditDialogComponent } from './notification-settings/shar
PathTemplateEditDialogComponent,
MessageTemplateSettingsComponent,
MessageTemplateEditDialogComponent,
BiliApiSettingsComponent,
BaseApiUrlEditDialogComponent,
BaseLiveApiUrlEditDialogComponent,
BasePlayInfoApiUrlEditDialogComponent,
],
imports: [
CommonModule,

View File

@ -2,6 +2,10 @@ 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';
export const PATH_TEMPLATE_PATTERN =
/^(?:[^\\\/:*?"<>|\t\n\r\f\v\{\}]*?\{(?:roomid|uname|title|area|parent_area|year|month|day|hour|minute|second)\}[^\\\/:*?"<>|\t\n\r\f\v\{\}]*?)+?(?:\/(?:[^\\\/:*?"<>|\t\n\r\f\v\{\}]*?\{(?:roomid|uname|title|area|parent_area|year|month|day|hour|minute|second)\}[^\\\/:*?"<>|\t\n\r\f\v\{\}]*?)+?)*$/;

View File

@ -19,6 +19,7 @@ type PrimarySettings = Pick<
Settings,
| 'output'
| 'logging'
| 'biliApi'
| 'header'
| 'danmaku'
| 'recorder'
@ -41,6 +42,7 @@ export class SettingsResolver implements Resolve<PrimarySettings> {
.getSettings([
'output',
'logging',
'biliApi',
'header',
'danmaku',
'recorder',

View File

@ -1,5 +1,13 @@
import type { Nullable, PartialDeep } from 'src/app/shared/utility-types';
export interface BiliApiSettings {
baseApiUrl: string;
baseLiveApiUrl: string;
basePlayInfoApiUrl: string;
}
export type BiliApiOptions = Nullable<BiliApiSettings>;
export interface HeaderSettings {
userAgent: string;
cookie: string;
@ -65,6 +73,7 @@ export type PostprocessingOptions = Nullable<PostprocessingSettings>;
export interface TaskOptions {
output: OutputOptions;
biliApi: BiliApiOptions;
header: HeaderOptions;
danmaku: DanmakuOptions;
recorder: RecorderOptions;
@ -81,7 +90,7 @@ export interface TaskSettings extends TaskOptions {
export type GlobalTaskSettings = Pick<
Settings,
'output' | 'header' | 'danmaku' | 'recorder' | 'postprocessing'
'output' | 'biliApi' | 'header' | 'danmaku' | 'recorder' | 'postprocessing'
>;
export interface OutputSettings {
@ -351,6 +360,7 @@ export interface Settings {
tasks: TaskSettings[];
output: OutputSettings;
logging: LoggingSettings;
biliApi: BiliApiSettings;
header: HeaderSettings;
danmaku: DanmakuSettings;
recorder: RecorderSettings;

View File

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

View File

@ -21,7 +21,7 @@
>
<nz-form-control
class="setting-control input"
[nzErrorTip]="errorTip"
[nzErrorTip]="pathTemplateErrorTip"
>
<input
type="text"
@ -32,7 +32,7 @@
[(ngModel)]="model.output.pathTemplate"
[disabled]="options.output.pathTemplate === null"
/>
<ng-template #errorTip let-control>
<ng-template #pathTemplateErrorTip let-control>
<ng-container *ngIf="control.hasError('required')">
请输入路径模板
</ng-container>
@ -745,6 +745,139 @@
</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">
@ -761,7 +894,7 @@
? 'warning'
: userAgent
"
[nzErrorTip]="errorTip"
[nzErrorTip]="userAgentErrorTip"
>
<textarea
#userAgent="ngModel"
@ -774,7 +907,7 @@
[disabled]="options.header.userAgent === null"
></textarea>
</nz-form-control>
<ng-template #errorTip let-control>
<ng-template #userAgentErrorTip let-control>
<ng-container *ngIf="control.hasError('required')">
请输入 User Agent
</ng-container>

View File

@ -29,6 +29,7 @@ import {
DELETE_STRATEGIES,
COVER_SAVE_STRATEGIES,
RECORDING_MODE_OPTIONS,
BASE_URL_PATTERN,
} from '../../settings/shared/constants/form';
type OptionsModel = NonNullable<TaskOptions>;
@ -55,6 +56,7 @@ 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
@ -111,7 +113,6 @@ export class TaskSettingsDialogComponent implements OnChanges {
}
handleConfirm(): void {
debugger;
this.confirm.emit(difference(this.options, this.taskOptions!));
this.close();
}