release: 1.5.0

close #23
close #24
This commit is contained in:
acgnhik 2022-02-21 13:50:10 +08:00
parent 34b10a8a2d
commit 93d93888e4
27 changed files with 450 additions and 192 deletions

View File

@ -1,5 +1,10 @@
# 更新日志
## 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.5.0'
__github__ = 'https://github.com/acgnhiki/blrec'

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}')

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

@ -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

@ -249,18 +249,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

@ -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;