feat: recording fmp4 streams if no flv streams

resolve #169
This commit is contained in:
acgnhik 2023-10-07 11:52:18 +08:00
parent 79372d23ce
commit d3579fae90
12 changed files with 122 additions and 62 deletions

View File

@ -1,11 +1,12 @@
from typing import Any, Dict, List
import aiohttp
from jsonpath import jsonpath
from .api import WebApi
from .typing import ResponseData, QualityNumber
from .exceptions import ApiRequestError
from ..exception import NotFoundError
from .api import WebApi
from .exceptions import ApiRequestError
from .typing import QualityNumber, ResponseData, StreamCodec, StreamFormat
__all__ = 'room_init', 'ensure_room_id'
@ -40,3 +41,18 @@ def get_quality_name(qn: QualityNumber) -> str:
80: '流畅',
}
return QUALITY_MAPPING.get(qn, '')
def extract_streams(play_infos: List[Dict[str, Any]]) -> List[Any]:
streams = jsonpath(play_infos, '$[*].playurl_info.playurl.stream[*]')
return streams
def extract_formats(streams: List[Any], stream_format: StreamFormat) -> List[Any]:
formats = jsonpath(streams, f'$[*].format[?(@.format_name == "{stream_format}")]')
return formats
def extract_codecs(formats: List[Any], stream_codec: StreamCodec) -> List[Any]:
codecs = jsonpath(formats, f'$[*].codec[?(@.codec_name == "{stream_codec}")]')
return codecs

View File

@ -20,6 +20,7 @@ from .exceptions import (
NoStreamFormatAvailable,
NoStreamQualityAvailable,
)
from .helpers import extract_codecs, extract_formats, extract_streams
from .models import LiveStatus, RoomInfo, UserInfo
from .typing import ApiPlatform, QualityNumber, ResponseData, StreamCodec, StreamFormat
@ -50,6 +51,7 @@ class Live:
self._room_info: RoomInfo
self._user_info: UserInfo
self._no_flv_stream: bool
@property
def base_api_urls(self) -> List[str]:
@ -144,9 +146,19 @@ class Live:
self._room_info = await self.get_room_info()
self._user_info = await self.get_user_info(self._room_info.uid)
self._no_flv_stream = False
if self.is_living():
streams = await self.get_live_streams()
if streams:
flv_formats = extract_formats(streams, 'flv')
self._no_flv_stream = not flv_formats
async def deinit(self) -> None:
await self._session.close()
def has_no_flv_streams(self) -> bool:
return self._no_flv_stream
async def get_live_status(self) -> LiveStatus:
try:
# frequent requests will be intercepted by the server's firewall!
@ -239,20 +251,25 @@ class Live:
# the timestamp on the server at the moment in seconds
return await self._webapi.get_timestamp()
async def get_live_streams(
async def get_play_infos(
self, qn: QualityNumber = 10000, api_platform: ApiPlatform = 'web'
) -> List[Any]:
if api_platform == 'web':
paly_infos = await self._webapi.get_room_play_infos(self._room_id, qn)
play_infos = await self._webapi.get_room_play_infos(self._room_id, qn)
else:
paly_infos = await self._appapi.get_room_play_infos(self._room_id, qn)
play_infos = await self._appapi.get_room_play_infos(self._room_id, qn)
for info in paly_infos:
return play_infos
async def get_live_streams(
self, qn: QualityNumber = 10000, api_platform: ApiPlatform = 'web'
) -> List[Any]:
play_infos = await self.get_play_infos(qn, api_platform)
for info in play_infos:
self._check_room_play_info(info)
streams = jsonpath(paly_infos, '$[*].playurl_info.playurl.stream[*]')
return streams
return extract_streams(play_infos)
async def get_live_stream_url(
self,
@ -267,13 +284,11 @@ class Live:
if not streams:
raise NoStreamAvailable(stream_format, stream_codec, qn)
formats = jsonpath(
streams, f'$[*].format[?(@.format_name == "{stream_format}")]'
)
formats = extract_formats(streams, stream_format)
if not formats:
raise NoStreamFormatAvailable(stream_format, stream_codec, qn)
codecs = jsonpath(formats, f'$[*].codec[?(@.codec_name == "{stream_codec}")]')
codecs = extract_codecs(formats, stream_codec)
if not codecs:
raise NoStreamCodecAvailable(stream_format, stream_codec, qn)

View File

@ -9,6 +9,7 @@ from blrec.logging.room_id import aio_task_with_room_id
from ..event.event_emitter import EventEmitter, EventListener
from ..utils.mixins import SwitchableMixin
from .danmaku_client import DanmakuClient, DanmakuCommand, DanmakuListener
from .helpers import extract_formats
from .live import Live
from .models import LiveStatus, RoomInfo
from .typing import Danmaku
@ -71,14 +72,12 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
def _start_polling(self) -> None:
self._polling_task = asyncio.create_task(self._poll_live_status())
self._polling_task.add_done_callback(exception_callback)
logger.debug('Started polling live status')
async def _stop_polling(self) -> None:
self._polling_task.cancel()
with suppress(asyncio.CancelledError):
await self._polling_task
del self._polling_task
logger.debug('Stopped polling live status')
def _start_checking(self) -> None:
self._checking_task = asyncio.create_task(self._check_if_stream_available())
@ -86,7 +85,6 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
asyncio.get_running_loop().call_later(
1800, lambda: asyncio.create_task(self._stop_checking())
)
logger.debug('Started checking if stream available')
async def _stop_checking(self) -> None:
if not hasattr(self, '_checking_task'):
@ -95,7 +93,6 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
with suppress(asyncio.CancelledError):
await self._checking_task
del self._checking_task
logger.debug('Stopped checking if stream available')
async def on_client_reconnected(self) -> None:
# check the live status after the client reconnected and simulate
@ -172,24 +169,43 @@ class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMi
@aio_task_with_room_id
async def _poll_live_status(self) -> None:
logger.debug('Started polling live status')
while True:
await asyncio.sleep(600 + random.randrange(-60, 60))
await self._live.update_room_info()
current_status = self._live.room_info.live_status
if current_status != self._previous_status:
await self._handle_status_change(current_status)
try:
await asyncio.sleep(600 + random.randrange(-60, 60))
await self._live.update_room_info()
current_status = self._live.room_info.live_status
if current_status != self._previous_status:
await self._handle_status_change(current_status)
except asyncio.CancelledError:
logger.debug('Cancelled polling live status')
break
except Exception as e:
logger.warning(f'Failed to poll live status: {repr(e)}')
logger.debug('Stopped polling live status')
@aio_task_with_room_id
async def _check_if_stream_available(self) -> None:
logger.debug('Started checking if stream available')
while True:
try:
streams = await self._live.get_live_streams()
if streams:
logger.debug('live stream available')
self._stream_available = True
flv_formats = extract_formats(streams, 'flv')
self._live._no_flv_stream = not flv_formats
await self._emit('live_stream_available', self._live)
break
except asyncio.CancelledError:
logger.debug('Cancelled checking if stream available')
break
except Exception as e:
logger.warning(f'Failed to get live streams: {repr(e)}')
logger.warning(f'Failed to check if stream available: {repr(e)}')
await asyncio.sleep(1)
logger.debug('Stopped checking if stream available')

View File

@ -238,19 +238,32 @@ class StreamRecorder(
async def _do_start(self) -> None:
self.hls_stream_available_time = None
stream_format = self.stream_format
if stream_format == 'fmp4':
logger.info('Waiting for the fmp4 stream becomes available...')
available = await self._wait_fmp4_stream()
if available:
if self.stream_available_time is not None:
self.hls_stream_available_time = await self._live.get_timestamp()
else:
if self._live.has_no_flv_streams():
if stream_format == 'flv':
logger.warning(
'The specified stream format (fmp4) is not available '
f'in {self.fmp4_stream_timeout} seconcds, '
'falling back to stream format (flv).'
'The specified stream format (flv) is not available, '
'falling back to stream format (fmp4).'
)
stream_format = 'flv'
stream_format = 'fmp4'
self.hls_stream_available_time = self.stream_available_time
else:
if stream_format == 'fmp4':
logger.info('Waiting for the fmp4 stream becomes available...')
available = await self._wait_fmp4_stream()
if available:
if self.stream_available_time is not None:
self.hls_stream_available_time = (
await self._live.get_timestamp()
)
else:
logger.warning(
'The specified stream format (fmp4) is not available '
f'in {self.fmp4_stream_timeout} seconcds, '
'falling back to stream format (flv).'
)
stream_format = 'flv'
self._change_impl(stream_format)
await self._impl.start()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -10,6 +10,6 @@
<body>
<app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
<script src="runtime.34b6285f086501af.js" type="module"></script><script src="polyfills.4e5433063877ea34.js" type="module"></script><script src="main.f21b7d831ad9cafb.js" type="module"></script>
<script src="runtime.42bd9fa8c3bc9be0.js" type="module"></script><script src="polyfills.4e5433063877ea34.js" type="module"></script><script src="main.f21b7d831ad9cafb.js" type="module"></script>
</body></html>

View File

@ -1,6 +1,6 @@
{
"configVersion": 1,
"timestamp": 1695440376157,
"timestamp": 1696650016532,
"index": "/index.html",
"assetGroups": [
{
@ -12,17 +12,17 @@
},
"urls": [
"/103.4a2aea63cc3bf42b.js",
"/287.360829ef4dfc7f0e.js",
"/287.5c768f00dcd24631.js",
"/386.2404f3bc252e1df3.js",
"/503.6553f508f4a9247d.js",
"/548.ea53a087779da599.js",
"/548.b1ac5a3a14214886.js",
"/688.7032fddba7983cf6.js",
"/common.1fc175bce139f4df.js",
"/index.html",
"/main.f21b7d831ad9cafb.js",
"/manifest.webmanifest",
"/polyfills.4e5433063877ea34.js",
"/runtime.34b6285f086501af.js",
"/runtime.42bd9fa8c3bc9be0.js",
"/styles.ae81e04dfa5b2860.css"
],
"patterns": []
@ -1635,10 +1635,10 @@
"dataGroups": [],
"hashTable": {
"/103.4a2aea63cc3bf42b.js": "2711817f2977bfdc18c34fee4fe9385fe012bb22",
"/287.360829ef4dfc7f0e.js": "2f8cb4a318c877840b29d84752e40ed698321e67",
"/287.5c768f00dcd24631.js": "4cd0f85040b1a482bf9796575738afdd2dcda00e",
"/386.2404f3bc252e1df3.js": "f937945645579b9651be2666f70cec2c5de4e367",
"/503.6553f508f4a9247d.js": "0878ea0e91bfd5458dd55875561e91060ecb0837",
"/548.ea53a087779da599.js": "efcdeae60239e68f14b9e410d4bfe64ecb592382",
"/548.b1ac5a3a14214886.js": "2af1216947b79b56b8cf62bda180712219aa89ae",
"/688.7032fddba7983cf6.js": "eae55044529782a51b7e534365255bbfa5522b05",
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
@ -3234,11 +3234,11 @@
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.1fc175bce139f4df.js": "af1775164711ec49e5c3a91ee45bd77509c17c54",
"/index.html": "16375e9b931dcf8a92d56b4a860ab95fb622e08b",
"/index.html": "3aea1e8ace7f41b3206fbf0c431c2988488a6167",
"/main.f21b7d831ad9cafb.js": "fc51efa446c2ac21ee17e165217dd3faeacc5290",
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/polyfills.4e5433063877ea34.js": "68159ab99e0608976404a17132f60b5ceb6f12d2",
"/runtime.34b6285f086501af.js": "9ebaf308e01a4110d64e264057c87060e0d629c7",
"/runtime.42bd9fa8c3bc9be0.js": "14c097816962ec676cdf328954884c9885e562f9",
"/styles.ae81e04dfa5b2860.css": "5933b4f1c4d8fcc1891b68940ee78af4091472b7"
},
"navigationUrls": [

View File

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

View File

@ -10,15 +10,15 @@
<p>
选择要录制的直播流格式
<br />
FLV: 网络不稳定容易中断丢失数据或录制到二压画质
<b>FLV:</b>
flv 流在网络不稳定的情况下容易中断丢失数据或录制到二压画质。没有 flv
流的直播会自动切换录制 fmp4 流。
<br />
HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
<b>HLS (fmp4):</b>
hls 流基本不受网络波动影响,但不是所有直播间都支持。有 flv
流的直播,在设定的等待时间内没有 fmp4 流会自动切换录制 flv 流。
<br />
P.S.
<br />
录制 HLS 流需要 ffmpeg
<br />
在设定时间内没有 fmp4 流会自动切换录制 flv 流
<b>P.S.</b>
<br />
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
则不支持。

View File

@ -157,15 +157,15 @@
<p>
选择要录制的直播流格式
<br />
FLV: 网络不稳定容易中断丢失数据或录制到二压画质
<b>FLV:</b>
flv 流在网络不稳定的情况下容易中断丢失数据或录制到二压画质。没有
flv 流的直播会自动切换录制 fmp4 流。
<br />
HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
<b>HLS (fmp4):</b>
hls 流基本不受网络波动影响,但不是所有直播间都支持。有 flv
流的直播,在设定的等待时间内没有 fmp4 流会自动切换录制 flv 流。
<br />
P.S.
<br />
录制 HLS 流需要 ffmpeg
<br />
在设定时间内没有 fmp4 流会自动切换录制 flv 流
<b>P.S.</b>
<br />
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
则不支持。