Compare commits

..

12 Commits

Author SHA1 Message Date
acgnhiki
47f16f2c4a chore: update workflows 2025-06-03 22:39:22 +08:00
acgnhiki
340972c878 release: 2.0.0-beta.5 2025-06-03 21:59:39 +08:00
acgnhiki
228e5ad46d fix: update web api 2025-06-03 21:50:14 +08:00
mosh
f75d91e7f5 fix: ensure room_id scope 2025-06-03 21:42:34 +08:00
acgnhiki
975fa2794a release: 2.0.0-beta.4 2024-06-21 20:10:28 +08:00
imkero
1206d6e80f fix: live check_connectivity behaviour 2024-06-21 20:02:05 +08:00
acgnhiki
772eb5e3e7 fix: avoid Found duplicated MOOV Atom. Skipped it 2024-06-21 19:56:45 +08:00
acgnhiki
fff5994f0b fix: fix remuxing progress
fix #254
2024-06-20 20:41:17 +08:00
acgnhiki
7fc31e9e11 feat: split file as long as the init section is changed
fix #214
2024-06-20 19:53:49 +08:00
acgnhiki
1d681868f5 perf: stop sync data when ui is not visible 2024-06-20 12:12:19 +08:00
acgnhiki
2cc69db88e fix: failed to add tasks due to long room id
fix #267
fix #271
2024-06-19 22:40:39 +08:00
acgnhiki
da2d4715d1 feat: use ipv4 only 2024-06-19 22:20:05 +08:00
27 changed files with 274 additions and 52 deletions

View File

@@ -18,7 +18,7 @@ jobs:
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

@@ -17,7 +17,7 @@ jobs:
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

@@ -1,5 +1,19 @@
# 更新日志
## 2.0.0-beta.5
- 兼容长 room id
- 更新 web api
## 2.0.0-beta.4
- 添加只使用 ipv4 的命令行选项
- 修复因直播间号较长而添加任务失败
- 检测到 init seciton 改变就分割文件
- 修复 remux 的进度条显示异常
- 修复 remux 出现 `Found duplicated MOOV Atom. Skipped it`
- 修复断网检测
## 2.0.0-beta.3
- 修复 Python 3.8 运行出错

View File

@@ -30,6 +30,6 @@ set api_key=bili2233
set BLREC_DEFAULT_LOG_DIR=日志文件
set BLREC_DEFAULT_OUT_DIR=录播文件
python -m blrec -c settings.toml --open --host %host% --port %port% --api-key %api_key%
python -m blrec -c settings.toml --open --host %host% --port %port% --api-key %api_key% --ipv4
pause

View File

@@ -22,6 +22,6 @@ $env:api_key = "bili2233"
$env:BLREC_DEFAULT_LOG_DIR = "日志文件"
$env:BLREC_DEFAULT_OUT_DIR = "录播文件"
python -m blrec -c settings.toml --open --host $env:host --port $env:port --api-key $env:api_key
python -m blrec -c settings.toml --open --host $env:host --port $env:port --api-key $env:api_key --ipv4
pause

View File

@@ -1,3 +1,3 @@
__prog__ = 'blrec'
__version__ = '2.0.0-beta.3'
__version__ = '2.0.0-beta.5'
__github__ = 'https://github.com/acgnhiki/blrec'

View File

@@ -1,5 +1,6 @@
import asyncio
import hashlib
import time
from abc import ABC
from datetime import datetime
from typing import Any, Dict, Final, List, Mapping, Optional
@@ -10,6 +11,7 @@ from loguru import logger
from tenacity import retry, stop_after_delay, wait_exponential
from .exceptions import ApiRequestError
from . import wbi
from .typing import JsonResponse, QualityNumber, ResponseData
__all__ = 'AppApi', 'WebApi'
@@ -23,7 +25,7 @@ BASE_HEADERS: Final = {
'Connection': 'keep-alive',
'Origin': 'https://live.bilibili.com',
'Pragma': 'no-cache',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', # noqa
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', # noqa
}
@@ -242,6 +244,30 @@ class AppApi(BaseApi):
class WebApi(BaseApi):
_wbi_key = wbi.make_key(
img_key="7cd084941338484aae1ad9425b84077c",
sub_key="4932caff0ff746eab6f01bf08b70ac45",
)
_wbi_key_mtime = 0.0
@retry(reraise=True, stop=stop_after_delay(20), wait=wait_exponential(0.1))
async def _get_json_res(
self, url: str, with_wbi: bool = False, *args: Any, **kwds: Any
) -> JsonResponse:
if with_wbi:
key = self.__class__._wbi_key
ts = int(datetime.now().timestamp())
params = list(kwds.pop("params").items())
query = wbi.build_query(key, ts, params)
url = f'{url}?{query}'
try:
return await super()._get_json_res(url, *args, **kwds)
except ApiRequestError as e:
if e.code == -352 and time.monotonic() - self.__class__._wbi_key_mtime > 60:
await self._update_wbi_key()
raise
async def room_init(self, room_id: int) -> ResponseData:
path = '/room/v1/Room/room_init'
params = {'id': room_id}
@@ -262,14 +288,16 @@ class WebApi(BaseApi):
'ptype': 8,
}
json_responses = await self._get_jsons_concurrently(
self.base_play_info_api_urls, path, params=params
self.base_play_info_api_urls, path, with_wbi=True, params=params
)
return [r['data'] for r in json_responses]
async def get_info_by_room(self, room_id: int) -> ResponseData:
path = '/xlive/web-room/v1/index/getInfoByRoom'
params = {'room_id': room_id}
json_res = await self._get_json(self.base_live_api_urls, path, params=params)
json_res = await self._get_json(
self.base_live_api_urls, path, with_wbi=True, params=params
)
return json_res['data']
async def get_info(self, room_id: int) -> ResponseData:
@@ -285,19 +313,29 @@ class WebApi(BaseApi):
return json_res['data']['timestamp']
async def get_user_info(self, uid: int) -> ResponseData:
# FIXME: "code": -352, "message": "风控校验失败",
path = '/x/space/wbi/acc/info'
params = {'mid': uid}
json_res = await self._get_json(self.base_api_urls, path, params=params)
json_res = await self._get_json(
self.base_api_urls, path, with_wbi=True, params=params
)
return json_res['data']
async def get_danmu_info(self, room_id: int) -> ResponseData:
path = '/xlive/web-room/v1/index/getDanmuInfo'
params = {'id': room_id}
json_res = await self._get_json(self.base_live_api_urls, path, params=params)
json_res = await self._get_json(
self.base_live_api_urls, path, with_wbi=True, params=params
)
return json_res['data']
async def get_nav(self) -> ResponseData:
path = '/x/web-interface/nav'
json_res = await self._get_json(self.base_api_urls, path, check_response=False)
return json_res
async def _update_wbi_key(self) -> None:
nav = await self.get_nav()
img_key = wbi.extract_key(nav['data']['wbi_img']['img_url'])
sub_key = wbi.extract_key(nav['data']['wbi_img']['sub_url'])
self.__class__._wbi_key = wbi.make_key(img_key, sub_key)
self.__class__._wbi_key_mtime = time.monotonic()

View File

@@ -6,13 +6,20 @@ from jsonpath import jsonpath
from ..exception import NotFoundError
from .api import WebApi
from .exceptions import ApiRequestError
from .net import connector, timeout
from .typing import QualityNumber, ResponseData, StreamCodec, StreamFormat
__all__ = 'room_init', 'ensure_room_id'
async def room_init(room_id: int) -> ResponseData:
async with aiohttp.ClientSession(raise_for_status=True) as session:
async with aiohttp.ClientSession(
connector=connector,
connector_owner=False,
raise_for_status=True,
trust_env=True,
timeout=timeout,
) as session:
api = WebApi(session, room_id=room_id)
return await api.room_init(room_id)
@@ -31,7 +38,13 @@ async def ensure_room_id(room_id: int) -> int:
async def get_nav(cookie: str) -> ResponseData:
async with aiohttp.ClientSession(raise_for_status=True) as session:
async with aiohttp.ClientSession(
connector=connector,
connector_owner=False,
raise_for_status=True,
trust_env=True,
timeout=timeout,
) as session:
headers = {
'Origin': 'https://passport.bilibili.com',
'Referer': 'https://passport.bilibili.com/account/security',

View File

@@ -21,6 +21,7 @@ from .exceptions import (
)
from .helpers import extract_codecs, extract_formats, extract_streams
from .models import LiveStatus, RoomInfo, UserInfo
from .net import connector, timeout
from .typing import ApiPlatform, QualityNumber, ResponseData, StreamCodec, StreamFormat
__all__ = ('Live',)
@@ -44,9 +45,11 @@ class Live:
self._html_page_url = f'https://live.bilibili.com/{room_id}'
self._session = aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit=200),
connector=connector,
connector_owner=False,
raise_for_status=True,
trust_env=True,
timeout=timeout,
)
self._appapi = AppApi(self._session, self.headers, room_id=room_id)
self._webapi = WebApi(self._session, self.headers, room_id=room_id)
@@ -172,11 +175,13 @@ class Live:
async def check_connectivity(self) -> bool:
try:
await self._session.head('https://live.bilibili.com/', timeout=3)
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return False
else:
await self._session.head('https://live.bilibili.com/', timeout=3, headers={
'User-Agent': self._user_agent,
})
return True
except Exception as e:
self._logger.warning(f'Check connectivity failed: {repr(e)}')
return False
async def update_info(self, raise_exception: bool = False) -> bool:
return all(

18
src/blrec/bili/net.py Normal file
View File

@@ -0,0 +1,18 @@
import os
import socket
import aiohttp
import requests
__all__ = ('connector', 'timeout')
USE_IPV4_ONLY = bool(os.environ.get('BLREC_IPV4'))
if not USE_IPV4_ONLY:
family = 0
else:
requests.packages.urllib3.util.connection.HAS_IPV6 = False # type: ignore
family = socket.AF_INET
connector = aiohttp.TCPConnector(family=family, limit=200)
timeout = aiohttp.ClientTimeout(total=10)

84
src/blrec/bili/wbi.py Normal file
View File

@@ -0,0 +1,84 @@
import hashlib
from typing import Any, List, Tuple
def extract_key(url: str) -> str:
return url.rsplit("/", 1)[-1].rsplit(".", 1)[0]
def make_key(img_key: str, sub_key: str) -> str:
# fmt: off
MAPPING = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
]
key = (img_key + sub_key).encode()
return bytes([key[n] for n in MAPPING]).decode()
def encode_value(value: str) -> str:
chars = []
for c in value:
if c in "!'()*":
continue
if (c.isascii() and c.isalnum()) or c in "-_.~":
chars.append(c)
else:
for b in c.encode():
chars.append(f"%{b:02X}")
return "".join(chars)
def build_query(key: str, ts: int, params: List[Tuple[str, Any]]) -> str:
params.append(("wts", str(ts)))
params.sort(key=lambda p: p[0])
parts = []
for name, value in params:
parts.append(f"{name}={encode_value(str(value))}")
query = "&".join(parts)
sign = hashlib.md5((query + key).encode()).hexdigest()
query += f"&w_rid={sign}"
return query
def test_extract_key() -> None:
url = "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png"
key = extract_key(url)
assert key == "7cd084941338484aae1ad9425b84077c"
def test_make_key() -> None:
img_key = "7cd084941338484aae1ad9425b84077c"
sub_key = "4932caff0ff746eab6f01bf08b70ac45"
expected = "ea1db124af3c7062474693fa704f4ff8"
key = make_key(img_key, sub_key)
assert key == expected
def test_encode_value() -> None:
expected = "-_-%20F%20%E5%93%94~"
assert encode_value(")-_-( F**' 哔~!") == expected
def test_build_query() -> None:
img_key = "7cd084941338484aae1ad9425b84077c"
sub_key = "4932caff0ff746eab6f01bf08b70ac45"
key = make_key(img_key, sub_key)
ts = 1748867128
params = [("foo", ")-_-( F**' 哔~!"), ("bar", 2333)]
expected = "bar=2333&foo=-_-%20F%20%E5%93%94~&wts=1748867128&w_rid=6ba96e28a3f09b40e704f1e4b4f8e3e3" # noqa
assert build_query(key, ts, params) == expected
if __name__ == "__main__":
test_extract_key()
test_make_key()
test_encode_value()
test_build_query()

View File

@@ -47,6 +47,7 @@ def cli_main(
host: str = typer.Option('localhost', help='webapp host bind'),
port: int = typer.Option(2233, help='webapp port bind'),
open: bool = typer.Option(False, help='open webapp in default browser'),
ipv4: bool = typer.Option(False, help='use IPv4 only'),
root_path: str = typer.Option('', help='ASGI root path'),
key_file: Optional[str] = typer.Option(None, help='SSL key file'),
cert_file: Optional[str] = typer.Option(None, help='SSL certificate file'),
@@ -61,6 +62,8 @@ def cli_main(
os.environ['BLREC_OUT_DIR'] = out_dir
if log_dir is not None:
os.environ['BLREC_LOG_DIR'] = log_dir
if ipv4 is not None:
os.environ['BLREC_IPV4'] = '1'
if not sys.stderr.isatty():
progress = False

View File

@@ -8,6 +8,7 @@ from loguru import logger
from tenacity import retry, stop_after_attempt, wait_fixed
from blrec.bili.live import Live
from blrec.bili.net import connector, timeout
from blrec.event.event_emitter import EventEmitter, EventListener
from blrec.exception import submit_exception
from blrec.path import cover_path
@@ -20,8 +21,7 @@ __all__ = 'CoverDownloader', 'CoverDownloaderEventListener'
class CoverDownloaderEventListener(EventListener):
async def on_cover_image_downloaded(self, path: str) -> None:
...
async def on_cover_image_downloaded(self, path: str) -> None: ...
class CoverSaveStrategy(Enum):
@@ -97,7 +97,13 @@ class CoverDownloader(
@retry(reraise=True, wait=wait_fixed(1), stop=stop_after_attempt(3))
async def _fetch_cover(self, url: str) -> bytes:
async with aiohttp.ClientSession(raise_for_status=True) as session:
async with aiohttp.ClientSession(
connector=connector,
connector_owner=False,
raise_for_status=True,
trust_env=True,
timeout=timeout,
) as session:
async with session.get(url) as response:
return await response.read()

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.187894a5650ad4b5.js" type="module"></script><script src="polyfills.4e5433063877ea34.js" type="module"></script><script src="main.f21b7d831ad9cafb.js" type="module"></script>
<script src="runtime.5566e7902022ba3e.js" type="module"></script><script src="polyfills.4e5433063877ea34.js" type="module"></script><script src="main.f21b7d831ad9cafb.js" type="module"></script>
</body></html>

View File

@@ -1,6 +1,6 @@
{
"configVersion": 1,
"timestamp": 1703317848279,
"timestamp": 1718810136190,
"index": "/index.html",
"assetGroups": [
{
@@ -12,7 +12,7 @@
},
"urls": [
"/103.4a2aea63cc3bf42b.js",
"/287.d162f6c8e5d14fac.js",
"/287.f1c0b0beeb6810b2.js",
"/386.2404f3bc252e1df3.js",
"/503.6553f508f4a9247d.js",
"/548.e2df47ddad764d0b.js",
@@ -22,7 +22,7 @@
"/main.f21b7d831ad9cafb.js",
"/manifest.webmanifest",
"/polyfills.4e5433063877ea34.js",
"/runtime.187894a5650ad4b5.js",
"/runtime.5566e7902022ba3e.js",
"/styles.ae81e04dfa5b2860.css"
],
"patterns": []
@@ -1635,7 +1635,7 @@
"dataGroups": [],
"hashTable": {
"/103.4a2aea63cc3bf42b.js": "2711817f2977bfdc18c34fee4fe9385fe012bb22",
"/287.d162f6c8e5d14fac.js": "87700d76c3cb0fe9890bbe63ad3d388619f95874",
"/287.f1c0b0beeb6810b2.js": "875dca7598179957ed411aa5204ce12871a8e958",
"/386.2404f3bc252e1df3.js": "f937945645579b9651be2666f70cec2c5de4e367",
"/503.6553f508f4a9247d.js": "0878ea0e91bfd5458dd55875561e91060ecb0837",
"/548.e2df47ddad764d0b.js": "0b60f5f001bd127b90d490617bba2091c4c39de3",
@@ -3234,11 +3234,11 @@
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.1fc175bce139f4df.js": "af1775164711ec49e5c3a91ee45bd77509c17c54",
"/index.html": "45319421e7503c2b6728d1d3a0566803571e4d4e",
"/index.html": "abe6df528859e9b6fafa3dda8a4001db74c04dd7",
"/main.f21b7d831ad9cafb.js": "fc51efa446c2ac21ee17e165217dd3faeacc5290",
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/polyfills.4e5433063877ea34.js": "68159ab99e0608976404a17132f60b5ceb6f12d2",
"/runtime.187894a5650ad4b5.js": "a8a395434979ff91b295fe1ce37b258aa55e0121",
"/runtime.5566e7902022ba3e.js": "c7fa8d060497bd9938aca48eba6f523bf0eb85cd",
"/styles.ae81e04dfa5b2860.css": "5933b4f1c4d8fcc1891b68940ee78af4091472b7"
},
"navigationUrls": [

View File

@@ -1 +0,0 @@
(()=>{"use strict";var e,v={},m={};function r(e){var n=m[e];if(void 0!==n)return n.exports;var t=m[e]={exports:{}};return v[e](t,t.exports,r),t.exports}r.m=v,e=[],r.O=(n,t,f,o)=>{if(!t){var a=1/0;for(i=0;i<e.length;i++){for(var[t,f,o]=e[i],d=!0,u=0;u<t.length;u++)(!1&o||a>=o)&&Object.keys(r.O).every(p=>r.O[p](t[u]))?t.splice(u--,1):(d=!1,o<a&&(a=o));if(d){e.splice(i--,1);var c=f();void 0!==c&&(n=c)}}return n}o=o||0;for(var i=e.length;i>0&&e[i-1][2]>o;i--)e[i]=e[i-1];e[i]=[t,f,o]},r.n=e=>{var n=e&&e.__esModule?()=>e.default:()=>e;return r.d(n,{a:n}),n},r.d=(e,n)=>{for(var t in n)r.o(n,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:n[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((n,t)=>(r.f[t](e,n),n),[])),r.u=e=>(592===e?"common":e)+"."+{103:"4a2aea63cc3bf42b",287:"d162f6c8e5d14fac",386:"2404f3bc252e1df3",503:"6553f508f4a9247d",548:"e2df47ddad764d0b",592:"1fc175bce139f4df",688:"7032fddba7983cf6"}[e]+".js",r.miniCssF=e=>{},r.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),(()=>{var e={},n="blrec:";r.l=(t,f,o,i)=>{if(e[t])e[t].push(f);else{var a,d;if(void 0!==o)for(var u=document.getElementsByTagName("script"),c=0;c<u.length;c++){var l=u[c];if(l.getAttribute("src")==t||l.getAttribute("data-webpack")==n+o){a=l;break}}a||(d=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",n+o),a.src=r.tu(t)),e[t]=[f];var s=(g,p)=>{a.onerror=a.onload=null,clearTimeout(b);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(p)),g)return g(p)},b=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),d&&document.head.appendChild(a)}}})(),r.r=e=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:n=>n},typeof trustedTypes<"u"&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="",(()=>{var e={666:0};r.f.j=(f,o)=>{var i=r.o(e,f)?e[f]:void 0;if(0!==i)if(i)o.push(i[2]);else if(666!=f){var a=new Promise((l,s)=>i=e[f]=[l,s]);o.push(i[2]=a);var d=r.p+r.u(f),u=new Error;r.l(d,l=>{if(r.o(e,f)&&(0!==(i=e[f])&&(e[f]=void 0),i)){var s=l&&("load"===l.type?"missing":l.type),b=l&&l.target&&l.target.src;u.message="Loading chunk "+f+" failed.\n("+s+": "+b+")",u.name="ChunkLoadError",u.type=s,u.request=b,i[1](u)}},"chunk-"+f,f)}else e[f]=0},r.O.j=f=>0===e[f];var n=(f,o)=>{var u,c,[i,a,d]=o,l=0;if(i.some(b=>0!==e[b])){for(u in a)r.o(a,u)&&(r.m[u]=a[u]);if(d)var s=d(r)}for(f&&f(o);l<i.length;l++)r.o(e,c=i[l])&&e[c]&&e[c][0](),e[c]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(n.bind(null,0)),t.push=n.bind(null,t.push.bind(t))})()})();

View File

@@ -0,0 +1 @@
(()=>{"use strict";var e,v={},m={};function r(e){var n=m[e];if(void 0!==n)return n.exports;var t=m[e]={exports:{}};return v[e](t,t.exports,r),t.exports}r.m=v,e=[],r.O=(n,t,o,f)=>{if(!t){var a=1/0;for(i=0;i<e.length;i++){for(var[t,o,f]=e[i],c=!0,u=0;u<t.length;u++)(!1&f||a>=f)&&Object.keys(r.O).every(p=>r.O[p](t[u]))?t.splice(u--,1):(c=!1,f<a&&(a=f));if(c){e.splice(i--,1);var d=o();void 0!==d&&(n=d)}}return n}f=f||0;for(var i=e.length;i>0&&e[i-1][2]>f;i--)e[i]=e[i-1];e[i]=[t,o,f]},r.n=e=>{var n=e&&e.__esModule?()=>e.default:()=>e;return r.d(n,{a:n}),n},r.d=(e,n)=>{for(var t in n)r.o(n,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:n[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((n,t)=>(r.f[t](e,n),n),[])),r.u=e=>(592===e?"common":e)+"."+{103:"4a2aea63cc3bf42b",287:"f1c0b0beeb6810b2",386:"2404f3bc252e1df3",503:"6553f508f4a9247d",548:"e2df47ddad764d0b",592:"1fc175bce139f4df",688:"7032fddba7983cf6"}[e]+".js",r.miniCssF=e=>{},r.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),(()=>{var e={},n="blrec:";r.l=(t,o,f,i)=>{if(e[t])e[t].push(o);else{var a,c;if(void 0!==f)for(var u=document.getElementsByTagName("script"),d=0;d<u.length;d++){var l=u[d];if(l.getAttribute("src")==t||l.getAttribute("data-webpack")==n+f){a=l;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",n+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=>{typeof Symbol<"u"&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tt=()=>(void 0===e&&(e={createScriptURL:n=>n},typeof trustedTypes<"u"&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e)})(),r.tu=e=>r.tt().createScriptURL(e),r.p="",(()=>{var e={666:0};r.f.j=(o,f)=>{var i=r.o(e,o)?e[o]:void 0;if(0!==i)if(i)f.push(i[2]);else if(666!=o){var a=new Promise((l,s)=>i=e[o]=[l,s]);f.push(i[2]=a);var c=r.p+r.u(o),u=new Error;r.l(c,l=>{if(r.o(e,o)&&(0!==(i=e[o])&&(e[o]=void 0),i)){var s=l&&("load"===l.type?"missing":l.type),b=l&&l.target&&l.target.src;u.message="Loading chunk "+o+" failed.\n("+s+": "+b+")",u.name="ChunkLoadError",u.type=s,u.request=b,i[1](u)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var n=(o,f)=>{var u,d,[i,a,c]=f,l=0;if(i.some(b=>0!==e[b])){for(u in a)r.o(a,u)&&(r.m[u]=a[u]);if(c)var s=c(r)}for(o&&o(f);l<i.length;l++)r.o(e,d=i[l])&&e[d]&&e[d][0](),e[d]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(n.bind(null,0)),t.push=n.bind(null,t.push.bind(t))})()})();

View File

@@ -4,10 +4,11 @@ import io
from copy import deepcopy
from decimal import Decimal
from pathlib import PurePath
from typing import Optional, Tuple, Union
from typing import Optional, Tuple, Union, cast
import m3u8
from loguru import logger
from m3u8.model import InitializationSection
from reactivex import Observable, Subject, abc
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
@@ -139,7 +140,9 @@ class PlaylistDumper:
) -> m3u8.Segment:
seg = deepcopy(segment)
if init_section := getattr(seg, 'init_section', None):
init_section = cast(InitializationSection, init_section)
init_section.uri = uri
init_section.base_uri = ''
init_section.byterange = init_section_byterange
seg.uri = uri
seg.byterange = segment_byterange

View File

@@ -72,6 +72,14 @@ class SegmentDumper:
def _update_filesize(self, size: int) -> None:
self._filesize += size
def _is_redundant(
self, prev_init_item: Optional[InitSectionData], curr_init_item: InitSectionData
) -> bool:
return (
prev_init_item is not None
and curr_init_item.payload == prev_init_item.payload
)
def _must_split_file(
self, prev_init_item: Optional[InitSectionData], curr_init_item: InitSectionData
) -> bool:
@@ -85,6 +93,10 @@ class SegmentDumper:
curr_profile = ffprobe(curr_init_item.payload)
logger.debug(f'current init section profile: {curr_profile}')
if prev_init_item.payload == curr_init_item.payload:
logger.debug('the current init section is identical to the previous one')
return False
prev_video_profile = prev_profile['streams'][0]
prev_audio_profile = prev_profile['streams'][1]
assert prev_video_profile['codec_type'] == 'video'
@@ -103,7 +115,6 @@ class SegmentDumper:
or prev_video_profile['coded_height'] != curr_video_profile['coded_height']
):
logger.warning('Video parameters changed')
return True
if (
prev_audio_profile['codec_name'] != curr_audio_profile['codec_name']
@@ -112,9 +123,12 @@ class SegmentDumper:
or prev_audio_profile.get('bit_rate') != curr_audio_profile.get('bit_rate')
):
logger.warning('Audio parameters changed')
return True
return False
logger.debug(
'must split the file '
'because the current init section is not identical to the previous one'
)
return True
def _need_split_file(self, item: Union[InitSectionData, SegmentData]) -> bool:
return item.segment.custom_parser_values.get('split', False)
@@ -135,6 +149,8 @@ class SegmentDumper:
split_file = False
if isinstance(item, InitSectionData):
if self._is_redundant(last_init_item, item):
return
split_file = self._must_split_file(last_init_item, item)
last_init_item = item

View File

@@ -92,7 +92,6 @@ class SegmentFetcher:
(
last_segment is None
or seg.init_section != last_segment.init_section
or seg.discontinuity
)
):
url = seg.init_section.absolute_uri

View File

@@ -151,8 +151,11 @@ def remux_video(
else:
continue
if line.startswith('frame='):
try:
size = parse_size(line)
except Exception:
pass
else:
pbar.update(size - pbar.n)
progress = RemuxingProgress(size, total)
observer.on_next(progress)

View File

@@ -131,9 +131,7 @@ class HeaderOptions(BaseModel):
class HeaderSettings(HeaderOptions):
user_agent: str = (
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/89.0.4389.114 Safari/537.36'
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' # noqa
)
cookie: str = ''
@@ -198,9 +196,7 @@ class RecorderSettings(RecorderOptions):
fmp4_stream_timeout: int = 10
read_timeout: int = 3
disconnection_timeout: int = 600
buffer_size: Annotated[
int, Field(ge=4096, le=1024**2 * 512, multiple_of=2)
] = 8192
buffer_size: Annotated[int, Field(ge=4096, le=1024**2 * 512, multiple_of=2)] = 8192
save_cover: bool = False
cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT
@@ -315,7 +311,7 @@ class TaskOptions(BaseModel):
class TaskSettings(TaskOptions):
# must use the real room id rather than the short room id!
room_id: Annotated[int, Field(ge=1, le=99999999)]
room_id: Annotated[int, Field(ge=1, lt=2**100)]
enable_monitor: bool = True
enable_recorder: bool = True

View File

@@ -38,7 +38,15 @@ export class InfoPanelComponent implements OnInit, OnDestroy {
private changeDetector: ChangeDetectorRef,
private notification: NzNotificationService,
private taskService: TaskService
) {}
) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.syncData();
} else {
this.desyncData();
}
});
}
get fps(): string {
const avgFrameRate: string | undefined =

View File

@@ -41,7 +41,15 @@ export class TaskDetailComponent implements OnInit, OnDestroy {
private changeDetector: ChangeDetectorRef,
private notification: NzNotificationService,
private taskService: TaskService
) {}
) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.syncData();
} else {
this.desyncData();
}
});
}
ngOnInit(): void {
this.route.paramMap.subscribe((params: ParamMap) => {

View File

@@ -1,20 +1,20 @@
import { HttpErrorResponse } from '@angular/common/http';
import {
Component,
OnInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
} from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { catchError, concatAll, switchMap } from 'rxjs/operators';
import { interval, of, Subscription } from 'rxjs';
import { NzNotificationService } from 'ng-zorro-antd/notification';
import { Subscription, interval, of } from 'rxjs';
import { catchError, concatAll, switchMap } from 'rxjs/operators';
import { TaskData, DataSelection } from './shared/task.model';
import { retry } from 'src/app/shared/rx-operators';
import { TaskService } from './shared/services/task.service';
import { StorageService } from '../core/services/storage.service';
import { TaskService } from './shared/services/task.service';
import { DataSelection, TaskData } from './shared/task.model';
const SELECTION_STORAGE_KEY = 'app-tasks-selection';
const REVERSE_STORAGE_KEY = 'app-tasks-reverse';
@@ -42,6 +42,14 @@ export class TasksComponent implements OnInit, OnDestroy {
) {
this.selection = this.retrieveSelection();
this.reverse = this.retrieveReverse();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.syncTaskData();
} else {
this.desyncTaskData();
}
});
}
ngOnInit(): void {