Compare commits

..

2 Commits

Author SHA1 Message Date
acgnhik
5c9887a2a8 release: 1.6.0-alpha 2022-02-26 15:58:54 +08:00
acgnhik
93d93888e4 release: 1.5.0
close #23
close #24
2022-02-21 13:50:11 +08:00
36 changed files with 515 additions and 196 deletions

View File

@@ -1,5 +1,14 @@
# 更新日志
## 1.6.0-alpha
- REST API 支持获取正在录制的 flv 文件的路径和元数据
## 1.5.0
- 支持设置日志文件存放位置
- Docker 支持修改日志文件和录播文件存放位置
## 1.4.0
- 适应数据有问题的流服务器 gotcha08 (issue #13)

View File

@@ -3,7 +3,7 @@
FROM python:3.10-slim-buster
WORKDIR /app
VOLUME /rec
VOLUME ["/cfg", "/log", "/rec"]
COPY src src/
COPY setup.py setup.cfg .
@@ -15,5 +15,10 @@ RUN apt-get update \
&& apt-get purge -y --auto-remove build-essential python3-dev
# ref: https://github.com/docker-library/python/issues/60#issuecomment-134322383
ENTRYPOINT ["blrec", "-o", "/rec", "--host", "0.0.0.0"]
CMD ["-c", "/rec/settings.toml"]
ENV DEFAULT_SETTINGS_FILE=/cfg/settings.toml
ENV DEFAULT_LOG_DIR=/log
ENV DEFAULT_OUT_DIR=/rec
EXPOSE 2233
ENTRYPOINT ["blrec", "--host", "0.0.0.0"]
CMD []

View File

@@ -3,7 +3,7 @@
FROM python:3.10-slim-buster
WORKDIR /app
VOLUME /rec
VOLUME ["/cfg", "/log", "/rec"]
COPY src src/
COPY setup.py setup.cfg .
@@ -17,5 +17,10 @@ RUN sed -i "s/deb.debian.org/mirrors.aliyun.com/g" /etc/apt/sources.list \
&& apt-get purge -y --auto-remove build-essential python3-dev
# ref: https://github.com/docker-library/python/issues/60#issuecomment-134322383
ENTRYPOINT ["blrec", "-o", "/rec", "--host", "0.0.0.0"]
CMD ["-c", "/rec/settings.toml"]
ENV DEFAULT_SETTINGS_FILE=/cfg/settings.toml
ENV DEFAULT_LOG_DIR=/log
ENV DEFAULT_OUT_DIR=/rec
EXPOSE 2233
ENTRYPOINT ["blrec", "--host", "0.0.0.0"]
CMD []

View File

@@ -75,33 +75,51 @@
## Docker
- 默认参数
### 环境变量
`docker run -v ~/blrec:/rec -dp 2233:2233 acgnhiki/blrec`
- 默认设置文件位置: `ENV DEFAULT_SETTINGS_FILE=/cfg/settings.toml`
- 默认日志存放目录: `ENV DEFAULT_LOG_DIR=/log`
- 默认录播存放目录: `ENV DEFAULT_OUT_DIR=/rec`
- 指定参数
### 默认参数运行
```bash
docker run -v ~/blrec:/rec -dp 2233:2233 acgnhiki/blrec \
-c ~/blrec/settings.toml \
--key-file path/to/key-file \
--cert-file path/to/cert-file \
--api-key bili2233
```
`sudo docker run -v /etc/blrec:/cfg -v /var/log/blrec:/log -v ~/blrec:/rec -dp 2233:2233 acgnhiki/blrec`
### 命令行参数用法
```bash
sudo docker run \
-v /etc/blrec:/cfg -v /var/log/blrec:/log -v ~/blrec:/rec \
-dp 2233:2233 acgnhiki/blrec \
-c /cfg/another_settings.toml \
--key-file path/to/key-file \
--cert-file path/to/cert-file \
--api-key bili2233
```
## 使用方法
### 使用默认设置文件和保存位置
### 命令行参数用法
`blrec --help`
### 默认参数运行
在命令行终端里执行 `blrec` ,然后浏览器访问 `http://localhost:2233`
设置文件为 `toml` 文件,默认位置在 `~/.blrec/settings.toml`。默认录播文件保存位置为当前工作目录 `.`。
默认设置文件位置:`~/.blrec/settings.toml`
### 指定设置文件和保存位置
默认日志文件目录: `~/.blrec/logs`
`blrec -c path/to/settings.toml -o dirpath/to/save/files`
默认录播文件目录: `.`
如果指定设置文件不存在会自动创建。通过命令行参数指定保存位置会覆盖掉设置文件的设置。
### 指定设置文件和录播与日志保存位置
`blrec -c path/to/settings.toml -o path/to/records --log-dir path/to/logs`
如果指定的设置文件不存在会自动创建
**命令行参数会覆盖掉设置文件的对应的设置**
### 绑定主机和端口
@@ -134,7 +152,8 @@
作为 ASGI 应用运行,参数通过环境变量指定。
- `config` 指定设置文件
- `out_dir` 指定保存位置
- `out_dir` 指定录播存放位置
- `log_dir` 指定日志存放位置
- `api_key` 指定 `api key`
### bash

View File

@@ -1,4 +1,4 @@
__prog__ = 'blrec'
__version__ = '1.4.0'
__version__ = '1.6.0-alpha'
__github__ = 'https://github.com/acgnhiki/blrec'

View File

@@ -7,6 +7,7 @@ import attr
import psutil
from . import __prog__, __version__
from .flv.data_analyser import MetaData
from .disk_space import SpaceMonitor, SpaceReclaimer
from .bili.helpers import ensure_room_id
from .task import (
@@ -201,6 +202,9 @@ class Application:
def get_task_param(self, room_id: int) -> TaskParam:
return self._task_manager.get_task_param(room_id)
def get_task_metadata(self, room_id: int) -> Optional[MetaData]:
return self._task_manager.get_task_metadata(room_id)
def get_task_video_file_details(
self, room_id: int
) -> Iterator[VideoFileDetail]:

View File

@@ -9,7 +9,6 @@ import typer
from .. import __prog__, __version__
from ..logging import TqdmOutputStream
from ..setting import DEFAULT_SETTINGS_PATH
logger = logging.getLogger(__name__)
@@ -33,16 +32,21 @@ def cli_main(
help=f"show {__prog__}'s version and exit",
),
config: str = typer.Option(
'~/.blrec/settings.toml',
None,
'--config',
'-c',
help='path of setting file',
help='path of settings.toml file',
),
out_dir: Optional[str] = typer.Option(
None,
'--out-dir',
'-o',
help='path of directory to save files (overwrite setting)'
help='path of directory to store record files (overwrite setting)'
),
log_dir: Optional[str] = typer.Option(
None,
'--log-dir',
help='path of directory to store log files (overwrite setting)'
),
host: str = typer.Option('localhost', help='webapp host bind'),
port: int = typer.Option(2233, help='webapp port bind'),
@@ -52,12 +56,14 @@ def cli_main(
api_key: Optional[str] = typer.Option(None, help='web api key'),
) -> None:
"""Bilibili live streaming recorder"""
if config != DEFAULT_SETTINGS_PATH:
if config is not None:
os.environ['config'] = config
if api_key is not None:
os.environ['api_key'] = api_key
if out_dir is not None:
os.environ['out_dir'] = out_dir
if log_dir is not None:
os.environ['log_dir'] = log_dir
if open:
typer.launch(f'http://localhost:{port}')

View File

@@ -14,6 +14,7 @@ from .raw_danmaku_receiver import RawDanmakuReceiver
from .raw_danmaku_dumper import RawDanmakuDumper, RawDanmakuDumperEventListener
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
from ..event.event_emitter import EventListener, EventEmitter
from ..flv.data_analyser import MetaData
from ..bili.live import Live
from ..bili.models import RoomInfo
from ..bili.danmaku_client import DanmakuClient
@@ -274,6 +275,14 @@ class Recorder(
def duration_limit(self, value: int) -> None:
self._stream_recorder.duration_limit = value
@property
def recording_path(self) -> Optional[str]:
return self._stream_recorder.recording_path
@property
def metadata(self) -> Optional[MetaData]:
return self._stream_recorder.metadata
async def _do_start(self) -> None:
self._live_monitor.add_listener(self)
self._danmaku_dumper.add_listener(self)

View File

@@ -38,6 +38,7 @@ from .statistics import StatisticsCalculator
from ..event.event_emitter import EventListener, EventEmitter
from ..bili.live import Live
from ..bili.typing import QualityNumber
from ..flv.data_analyser import MetaData
from ..flv.stream_processor import StreamProcessor, BaseOutputFileManager
from ..utils.mixins import AsyncCooperationMix, AsyncStoppableMixin
from ..path import escape_path
@@ -186,6 +187,13 @@ class StreamRecorder(
def recording_path(self) -> Optional[str]:
return self._file_manager.curr_path
@property
def metadata(self) -> Optional[MetaData]:
if self._stream_processor is not None:
return self._stream_processor.metadata
else:
return None
def has_file(self) -> bool:
return self._file_manager.has_file()

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.0c70df55750c11ae.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.5e1124f1a8c47971.js" type="module"></script>
<script src="runtime.dbc624475730f362.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.042620305008901b.js" type="module"></script>
</body></html>

View File

@@ -1,6 +1,6 @@
{
"configVersion": 1,
"timestamp": 1644635235127,
"timestamp": 1645421972060,
"index": "/index.html",
"assetGroups": [
{
@@ -13,15 +13,15 @@
"urls": [
"/103.5b5d2a6e5a8a7479.js",
"/146.92e3b29c4c754544.js",
"/622.03823b1714105423.js",
"/66.97582e026891bf70.js",
"/694.d4844204c9f8d279.js",
"/853.84ee7e1d7cff8913.js",
"/common.858f777e9296e6f2.js",
"/index.html",
"/main.5e1124f1a8c47971.js",
"/main.042620305008901b.js",
"/manifest.webmanifest",
"/polyfills.4b08448aee19bb22.js",
"/runtime.0c70df55750c11ae.js",
"/runtime.dbc624475730f362.js",
"/styles.1f581691b230dc4d.css"
],
"patterns": []
@@ -1635,8 +1635,8 @@
"hashTable": {
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
"/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d",
"/622.03823b1714105423.js": "86c61c37b53c951370ef2a16eb187cda666d7562",
"/66.97582e026891bf70.js": "11cfd8acd3399fef42f0cf77d64aafc62c7e6994",
"/694.d4844204c9f8d279.js": "513c6b68a84ad47494a7397a06194c5136da3adc",
"/853.84ee7e1d7cff8913.js": "6281853ef474fc543ac39fb47ec4a0a61ca875fa",
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
@@ -3232,11 +3232,11 @@
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
"/index.html": "9e332051ad11197ce464047d2a13bc15016a3d70",
"/main.5e1124f1a8c47971.js": "325ed4fbaa0160c18e06bb646431d1fa86f826f1",
"/index.html": "4a8198a30590a4863ef700f1c541a0fce551e8c1",
"/main.042620305008901b.js": "03d1b5d12f588193841fdc44913ec20625404c7c",
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
"/runtime.0c70df55750c11ae.js": "8e2f23cde1c56d8859adf4cd948753c1f8736d86",
"/runtime.dbc624475730f362.js": "030dff9e7d735e03d87257f33e8167d467d99adb",
"/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150"
},
"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)+"."+{66:"97582e026891bf70",103:"5b5d2a6e5a8a7479",146:"92e3b29c4c754544",592:"858f777e9296e6f2",622:"03823b1714105423",853:"84ee7e1d7cff8913"}[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,d=0;d<t.length;d++)(!1&f||a>=f)&&Object.keys(r.O).every(p=>r.O[p](t[d]))?t.splice(d--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var u=o();void 0!==u&&(i=u)}}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)+"."+{66:"97582e026891bf70",103:"5b5d2a6e5a8a7479",146:"92e3b29c4c754544",592:"858f777e9296e6f2",694:"d4844204c9f8d279",853:"84ee7e1d7cff8913"}[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 d=document.getElementsByTagName("script"),u=0;u<d.length;u++){var l=d[u];if(l.getAttribute("src")==t||l.getAttribute("data-webpack")==i+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",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((l,s)=>n=e[o]=[l,s]);f.push(n[2]=a);var c=r.p+r.u(o),d=new Error;r.l(c,l=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=l&&("load"===l.type?"missing":l.type),b=l&&l.target&&l.target.src;d.message="Loading chunk "+o+" failed.\n("+s+": "+b+")",d.name="ChunkLoadError",d.type=s,d.request=b,n[1](d)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var d,u,[n,a,c]=f,l=0;if(n.some(b=>0!==e[b])){for(d in a)r.o(a,d)&&(r.m[d]=a[d]);if(c)var s=c(r)}for(o&&o(f);l<n.length;l++)r.o(e,u=n[l])&&e[u]&&e[u][0](),e[n[l]]=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

@@ -14,7 +14,7 @@ from .models import (
from .common import Resolution, is_audio_tag, is_script_tag, is_video_tag
__all__ = 'DataAnalyser',
__all__ = 'DataAnalyser', 'MetaData', 'KeyFrames'
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)

View File

@@ -14,7 +14,7 @@ from rx.subject import Subject
from rx.core import Observable
from .models import FlvHeader, FlvTag, ScriptTag, VideoTag, AudioTag
from .data_analyser import DataAnalyser
from .data_analyser import DataAnalyser, MetaData
from .stream_cutter import StreamCutter
from .limit_checker import LimitChecker
from .parameters_checker import ParametersChecker
@@ -117,6 +117,16 @@ class StreamProcessor:
for point in self._join_points:
yield point
@property
def metadata(self) -> Optional[MetaData]:
if not self._analyse_data:
return None
try:
return self._data_analyser.make_metadata()
except Exception as e:
logger.debug(f'Failed to make metadata data, due to: {repr(e)}')
return None
@property
def size_updates(self) -> Observable:
return self._size_updates

View File

@@ -1,4 +1,4 @@
from .configure import configure_logger, TqdmOutputStream, ConsoleHandler
from .configure_logging import configure_logger, TqdmOutputStream, ConsoleHandler
__all__ = (

View File

@@ -1,133 +1,135 @@
import os
import logging
from logging import LogRecord, Handler
from logging.handlers import RotatingFileHandler
from datetime import datetime
import asyncio
import threading
import atexit
from typing import Any, List, Optional
from colorama import init, deinit, Fore, Back, Style
from tqdm import tqdm
from .typing import LOG_LEVEL
__all__ = 'configure_logger', 'ConsoleHandler', 'TqdmOutputStream'
class TqdmOutputStream:
def write(self, string: str = '') -> None:
tqdm.write(string, end='')
class ConsoleHandler(logging.StreamHandler):
def __init__(self, stream=None) -> None: # type: ignore
super().__init__(stream)
def format(self, record: LogRecord) -> str:
msg = super().format(record)
level = record.levelno
if level == logging.DEBUG:
style = Fore.GREEN
elif level == logging.WARNING:
style = Fore.YELLOW
elif level == logging.ERROR:
style = Fore.RED
elif level == logging.CRITICAL:
style = Fore.WHITE + Back.RED + Style.BRIGHT
else:
style = ''
return style + msg + Style.RESET_ALL if style else msg
_old_factory = logging.getLogRecordFactory()
def obtain_room_id() -> str:
try:
task = asyncio.current_task()
assert task is not None
except Exception:
name = threading.current_thread().getName()
else:
name = task.get_name()
if '::' in name:
if (room_id := name.split('::')[-1]):
return room_id
return ''
def record_factory(*args: Any, **kwargs: Any) -> LogRecord:
record = _old_factory(*args, **kwargs)
if (room_id := obtain_room_id()):
record.roomid = '[' + room_id + '] ' # type: ignore
else:
record.roomid = '' # type: ignore
return record
logging.setLogRecordFactory(record_factory)
_old_handlers: List[Handler] = []
def configure_logger(
out_dir: str,
*,
console_log_level: LOG_LEVEL = 'INFO',
max_bytes: Optional[int] = None,
backup_count: Optional[int] = None,
) -> None:
# config root logger
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
# config formatter
formatter = logging.Formatter(
'[%(asctime)s] [%(levelname)s] [%(module)s] %(roomid)s%(message)s'
)
# logging to console
console_handler = ConsoleHandler(TqdmOutputStream())
console_handler.setLevel(logging.getLevelName(console_log_level))
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# logging to file
file_handler = RotatingFileHandler(
make_log_file_path(out_dir),
maxBytes=max_bytes or 1024 ** 2 * 10,
backupCount=backup_count or 1,
encoding='utf-8',
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# remove old handlers after re-configured
for handler in _old_handlers:
logger.removeHandler(handler)
# retain old handlers for the removing
_old_handlers.append(console_handler)
_old_handlers.append(file_handler)
def make_log_file_path(out_dir: str) -> str:
data_time_string = datetime.now().strftime("%Y-%m-%d-%H%M%S")
filename = f'blrec_{data_time_string}.log'
path = os.path.join(out_dir, filename)
return path
init()
atexit.register(deinit)
import os
import logging
from logging import LogRecord, Handler
from logging.handlers import RotatingFileHandler
from datetime import datetime
import asyncio
import threading
import atexit
from typing import Any, List, Optional
from colorama import init, deinit, Fore, Back, Style
from tqdm import tqdm
from .typing import LOG_LEVEL
__all__ = 'configure_logger', 'ConsoleHandler', 'TqdmOutputStream'
class TqdmOutputStream:
def write(self, string: str = '') -> None:
tqdm.write(string, end='')
class ConsoleHandler(logging.StreamHandler):
def __init__(self, stream=None) -> None: # type: ignore
super().__init__(stream)
def format(self, record: LogRecord) -> str:
msg = super().format(record)
level = record.levelno
if level == logging.DEBUG:
style = Fore.GREEN
elif level == logging.WARNING:
style = Fore.YELLOW
elif level == logging.ERROR:
style = Fore.RED
elif level == logging.CRITICAL:
style = Fore.WHITE + Back.RED + Style.BRIGHT
else:
style = ''
return style + msg + Style.RESET_ALL if style else msg
_old_factory = logging.getLogRecordFactory()
def obtain_room_id() -> str:
try:
task = asyncio.current_task()
assert task is not None
except Exception:
name = threading.current_thread().getName()
else:
name = task.get_name()
if '::' in name:
if (room_id := name.split('::')[-1]):
return room_id
return ''
def record_factory(*args: Any, **kwargs: Any) -> LogRecord:
record = _old_factory(*args, **kwargs)
if (room_id := obtain_room_id()):
record.roomid = '[' + room_id + '] ' # type: ignore
else:
record.roomid = '' # type: ignore
return record
logging.setLogRecordFactory(record_factory)
_old_handlers: List[Handler] = []
def configure_logger(
log_dir: str,
*,
console_log_level: LOG_LEVEL = 'INFO',
max_bytes: Optional[int] = None,
backup_count: Optional[int] = None,
) -> None:
# config root logger
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
# config formatter
formatter = logging.Formatter(
'[%(asctime)s] [%(levelname)s] [%(module)s] %(roomid)s%(message)s'
)
# logging to console
console_handler = ConsoleHandler(TqdmOutputStream())
console_handler.setLevel(logging.getLevelName(console_log_level))
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# logging to file
log_file_path = make_log_file_path(log_dir)
file_handler = RotatingFileHandler(
log_file_path,
maxBytes=max_bytes or 1024 ** 2 * 10,
backupCount=backup_count or 1,
encoding='utf-8',
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
# remove old handlers after re-configured
for handler in _old_handlers:
logger.removeHandler(handler)
# retain old handlers for the removing
_old_handlers.append(console_handler)
_old_handlers.append(file_handler)
logger.info(f'log file: {log_file_path}')
def make_log_file_path(log_dir: str) -> str:
data_time_string = datetime.now().strftime("%Y-%m-%d-%H%M%S-%f")
filename = f'blrec_{data_time_string}.log'
return os.path.abspath(os.path.join(log_dir, filename))
init()
atexit.register(deinit)

View File

@@ -1,5 +1,5 @@
from .models import (
DEFAULT_SETTINGS_PATH,
DEFAULT_SETTINGS_FILE,
EnvSettings,
Settings,
@@ -36,7 +36,7 @@ from .setting_manager import SettingsManager
__all__ = (
'DEFAULT_SETTINGS_PATH',
'DEFAULT_SETTINGS_FILE',
'EnvSettings',
'Settings',

View File

@@ -15,7 +15,7 @@ from typing import (
import toml
from pydantic import BaseModel as PydanticBaseModel
from pydantic import Field, BaseSettings, validator, PrivateAttr, DirectoryPath
from pydantic import Field, BaseSettings, validator, PrivateAttr
from pydantic.networks import HttpUrl, EmailStr
from ..bili.typing import QualityNumber
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
__all__ = (
'DEFAULT_SETTINGS_PATH',
'DEFAULT_SETTINGS_FILE',
'EnvSettings',
'Settings',
@@ -61,12 +61,19 @@ __all__ = (
)
DEFAULT_SETTINGS_PATH: Final[str] = '~/.blrec/settings.toml'
DEFAULT_OUT_DIR: Final[str] = os.environ.get('DEFAULT_OUT_DIR', '.')
DEFAULT_LOG_DIR: Final[str] = os.environ.get(
'DEFAULT_LOG_DIR', '~/.blrec/logs/'
)
DEFAULT_SETTINGS_FILE: Final[str] = os.environ.get(
'DEFAULT_SETTINGS_FILE', '~/.blrec/settings.toml'
)
class EnvSettings(BaseSettings):
settings_file: Annotated[str, Field(env='config')] = DEFAULT_SETTINGS_PATH
settings_file: Annotated[str, Field(env='config')] = DEFAULT_SETTINGS_FILE
out_dir: Optional[str] = None
log_dir: Optional[str] = None
api_key: Annotated[
Optional[str],
Field(min_length=8, max_length=80, regex=r'[a-zA-Z\d\-]{8,80}'),
@@ -232,8 +239,14 @@ class OutputOptions(BaseModel):
return value
def out_dir_factory() -> str:
path = os.path.expanduser(DEFAULT_OUT_DIR)
os.makedirs(path, exist_ok=True)
return path
class OutputSettings(OutputOptions):
out_dir: Annotated[str, DirectoryPath] = '.'
out_dir: Annotated[str, Field(default_factory=out_dir_factory)]
path_template: str = (
'{roomid} - {uname}/'
'blive_{roomid}_{year}-{month}-{day}-{hour}{minute}{second}'
@@ -271,13 +284,26 @@ class TaskSettings(TaskOptions):
enable_recorder: bool = True
def log_dir_factory() -> str:
path = os.path.expanduser(DEFAULT_LOG_DIR)
os.makedirs(path, exist_ok=True)
return path
class LoggingSettings(BaseModel):
log_dir: Annotated[str, Field(default_factory=log_dir_factory)]
console_log_level: LOG_LEVEL = 'INFO'
max_bytes: Annotated[
int, Field(ge=1024 ** 2, le=1024 ** 2 * 10, multiple_of=1024 ** 2)
] = 1024 ** 2 * 10 # allowed 1 ~ 10 MB
backup_count: Annotated[int, Field(ge=1, le=30)] = 30
@validator('log_dir')
def _validate_dir(cls, path: str) -> str:
if not os.path.isdir(os.path.expanduser(path)):
raise ValueError(f"'{path}' not a directory")
return path
class SpaceSettings(BaseModel):
check_interval: int = 60 # 1 minutes
@@ -408,6 +434,8 @@ class Settings(BaseModel):
def update_from_env_settings(self, env_settings: EnvSettings) -> None:
if (out_dir := env_settings.out_dir) is not None:
self.output.out_dir = out_dir
if (log_dir := env_settings.log_dir) is not None:
self.logging.log_dir = log_dir
def dump(self) -> None:
assert self._path

View File

@@ -1,5 +1,4 @@
from __future__ import annotations
import os
import asyncio
from typing import Optional, TYPE_CHECKING, cast
@@ -249,18 +248,13 @@ class SettingsManager:
self.apply_task_output_settings(settings.room_id, settings.output)
out_dir = self._settings.output.out_dir
self._app._out_dir = out_dir
self._app._space_monitor.path = out_dir
self._app._space_reclaimer.path = out_dir
prev_out_dir = self._app._out_dir
self._app._out_dir = out_dir
if not os.path.samefile(out_dir, prev_out_dir):
self.apply_logging_settings()
def apply_logging_settings(self) -> None:
configure_logger(
self._app._out_dir,
log_dir=self._settings.logging.log_dir,
console_log_level=self._settings.logging.console_log_level,
max_bytes=self._settings.logging.max_bytes,
backup_count=self._settings.logging.backup_count,

View File

@@ -29,6 +29,7 @@ class TaskStatus:
danmu_count: int # Number of Danmu in total
danmu_rate: float # Number of Danmu per minutes
real_quality_number: QualityNumber
recording_path: Optional[str] = None
postprocessor_status: PostprocessorStatus = PostprocessorStatus.WAITING
postprocessing_path: Optional[str] = None
postprocessing_progress: Optional[Progress] = None

View File

@@ -21,6 +21,7 @@ from ..core import Recorder
from ..postprocess import Postprocessor, PostprocessorStatus, DeleteStrategy
from ..postprocess.remuxer import RemuxProgress
from ..flv.metadata_injector import InjectProgress
from ..flv.data_analyser import MetaData
from ..event.event_submitters import (
LiveEventSubmitter, RecorderEventSubmitter, PostprocessorEventSubmitter
)
@@ -132,6 +133,7 @@ class RecordTask:
danmu_count=self._recorder.danmu_count,
danmu_rate=self._recorder.danmu_rate,
real_quality_number=self._recorder.real_quality_number,
recording_path=self.recording_path,
postprocessor_status=self._postprocessor.status,
postprocessing_path=self._postprocessor.postprocessing_path,
postprocessing_progress=(
@@ -349,6 +351,14 @@ class RecordTask:
def duration_limit(self, value: int) -> None:
self._recorder.duration_limit = value
@property
def recording_path(self) -> Optional[str]:
return self._recorder.recording_path
@property
def metadata(self) -> Optional[MetaData]:
return self._recorder.metadata
@property
def remux_to_mp4(self) -> bool:
return self._postprocessor.remux_to_mp4

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
import asyncio
from typing import Dict, Iterator, TYPE_CHECKING
from typing import Dict, Iterator, Optional, TYPE_CHECKING
from .task import RecordTask
from .models import TaskData, TaskParam, VideoFileDetail, DanmakuFileDetail
from ..flv.data_analyser import MetaData
from ..exception import NotFoundError
if TYPE_CHECKING:
from ..setting import SettingsManager
@@ -154,6 +155,10 @@ class RecordTaskManager:
task = self._get_task(room_id)
return self._make_task_param(task)
def get_task_metadata(self, room_id: int) -> Optional[MetaData]:
task = self._get_task(room_id)
return task.metadata
def get_task_video_file_details(
self, room_id: int
) -> Iterator[VideoFileDetail]:

View File

@@ -53,6 +53,17 @@ async def get_task_param(room_id: int) -> Dict[str, Any]:
return attr.asdict(app.get_task_param(room_id))
@router.get(
'/{room_id}/metadata',
responses={**not_found_responses},
)
async def get_task_metadata(room_id: int) -> Dict[str, Any]:
metadata = app.get_task_metadata(room_id)
if not metadata:
return {}
return attr.asdict(metadata)
@router.get(
'/{room_id}/videos',
responses={**not_found_responses},

View File

@@ -0,0 +1,36 @@
<nz-modal
nzTitle="修改日志文件存放目录"
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
(nzOnOk)="handleConfirm()"
(nzOnCancel)="handleCancel()"
>
<ng-container *nzModalContent>
<form nz-form [formGroup]="settingsForm">
<nz-form-item>
<nz-form-control
nzHasFeedback
nzValidatingTip="正在验证..."
[nzErrorTip]="errorTip"
>
<input type="text" required nz-input formControlName="logDir" />
<ng-template #errorTip let-control>
<ng-container *ngIf="control.hasError('required')">
请输入保存位置
</ng-container>
<ng-container *ngIf="control.hasError('notADirectory')">
不是一个目录
</ng-container>
<ng-container *ngIf="control.hasError('noPermissions')">
没有读写权限
</ng-container>
<ng-container *ngIf="control.hasError('failedToValidate')">
未能进行验证
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
</form>
</ng-container>
</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 { LogdirEditDialogComponent } from './logdir-edit-dialog.component';
describe('LogdirEditDialogComponent', () => {
let component: LogdirEditDialogComponent;
let fixture: ComponentFixture<LogdirEditDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ LogdirEditDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(LogdirEditDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,104 @@
import {
Component,
OnChanges,
ChangeDetectionStrategy,
Input,
Output,
EventEmitter,
ChangeDetectorRef,
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
ValidationErrors,
Validators,
} from '@angular/forms';
import { Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ERRNO } from 'src/app/core/models/validation.model';
import { ValidationService } from 'src/app/core/services/validation.service';
@Component({
selector: 'app-logdir-edit-dialog',
templateUrl: './logdir-edit-dialog.component.html',
styleUrls: ['./logdir-edit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LogdirEditDialogComponent 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;
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef,
private validationService: ValidationService
) {
this.settingsForm = formBuilder.group({
logDir: ['', [Validators.required], [this.logDirAsyncValidator]],
});
}
get control() {
return this.settingsForm.get('logDir') 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();
}
private logDirAsyncValidator = (
control: FormControl
): Observable<ValidationErrors | null> => {
return this.validationService.validateDir(control.value).pipe(
map((result) => {
switch (result.code) {
case ERRNO.ENOTDIR:
return { error: true, notADirectory: true };
case ERRNO.EACCES:
return { error: true, noPermissions: true };
default:
return null;
}
}),
catchError(() => of({ error: true, failedToValidate: true }))
);
};
}

View File

@@ -1,4 +1,23 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item
class="setting-item actionable"
(click)="logDirEditDialog.open()"
>
<nz-form-label class="setting-label">日志文件存放目录</nz-form-label>
<nz-form-control
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="syncStatus.logDir ? logDirControl : 'warning'"
>
<nz-form-text class="setting-value"
>{{ logDirControl.value }}
</nz-form-text>
<app-logdir-edit-dialog
#logDirEditDialog
[value]="logDirControl.value"
(confirm)="logDirControl.setValue($event)"
></app-logdir-edit-dialog>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label class="setting-label" nzNoColon
>终端日志输出级别</nz-form-label

View File

@@ -58,12 +58,17 @@ export class LoggingSettingsComponent implements OnInit, OnChanges {
private settingsSyncService: SettingsSyncService
) {
this.settingsForm = formBuilder.group({
logDir: [''],
consoleLogLevel: [''],
maxBytes: [''],
backupCount: [''],
});
}
get logDirControl() {
return this.settingsForm.get('logDir') as FormControl;
}
get consoleLogLevelControl() {
return this.settingsForm.get('consoleLogLevel') as FormControl;
}

View File

@@ -1,5 +1,5 @@
<nz-modal
nzTitle="修改文件保存位置"
nzTitle="修改文件存放目录"
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
@@ -11,7 +11,7 @@
<nz-form-item>
<nz-form-control
nzHasFeedback
nzValidatingTip="正在验..."
nzValidatingTip="正在验..."
[nzErrorTip]="errorTip"
>
<input type="text" required nz-input formControlName="outDir" />
@@ -26,7 +26,7 @@
没有读写权限
</ng-container>
<ng-container *ngIf="control.hasError('failedToValidate')">
未能进行
未能进行验
</ng-container>
</ng-template>
</nz-form-control>

View File

@@ -3,7 +3,7 @@
class="setting-item actionable"
(click)="outDirEditDialog.open()"
>
<nz-form-label class="setting-label">保存位置</nz-form-label>
<nz-form-label class="setting-label">存放目录</nz-form-label>
<nz-form-control
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="syncStatus.outDir ? outDirControl : 'warning'"

View File

@@ -55,6 +55,7 @@ import { WebhookManagerComponent } from './webhook-settings/webhook-manager/webh
import { WebhookEditDialogComponent } from './webhook-settings/webhook-edit-dialog/webhook-edit-dialog.component';
import { WebhookListComponent } from './webhook-settings/webhook-list/webhook-list.component';
import { OutdirEditDialogComponent } from './output-settings/outdir-edit-dialog/outdir-edit-dialog.component';
import { LogdirEditDialogComponent } from './logging-settings/logdir-edit-dialog/logdir-edit-dialog.component';
import { PathTemplateEditDialogComponent } from './output-settings/path-template-edit-dialog/path-template-edit-dialog.component';
@NgModule({
@@ -84,6 +85,7 @@ import { PathTemplateEditDialogComponent } from './output-settings/path-template
WebhookEditDialogComponent,
WebhookListComponent,
OutdirEditDialogComponent,
LogdirEditDialogComponent,
PathTemplateEditDialogComponent,
],
imports: [

View File

@@ -89,6 +89,7 @@ export type LogLevel =
| 'NOTSET';
export interface LoggingSettings {
logDir: string;
consoleLogLevel: LogLevel;
maxBytes: number;
backupCount: number;