mirror of
https://github.com/acgnhiki/blrec.git
synced 2025-01-14 04:10:06 +08:00
parent
34b10a8a2d
commit
93d93888e4
@ -1,5 +1,10 @@
|
||||
# 更新日志
|
||||
|
||||
## 1.5.0
|
||||
|
||||
- 支持设置日志文件存放位置
|
||||
- Docker 支持修改日志文件和录播文件存放位置
|
||||
|
||||
## 1.4.0
|
||||
|
||||
- 适应数据有问题的流服务器 gotcha08 (issue #13)
|
||||
|
11
Dockerfile
11
Dockerfile
@ -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 []
|
||||
|
@ -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 []
|
||||
|
51
README.md
51
README.md
@ -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
|
||||
|
@ -1,4 +1,4 @@
|
||||
|
||||
__prog__ = 'blrec'
|
||||
__version__ = '1.4.0'
|
||||
__version__ = '1.5.0'
|
||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
||||
|
@ -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
1
src/blrec/data/webapp/694.d4844204c9f8d279.js
Normal file
1
src/blrec/data/webapp/694.d4844204c9f8d279.js
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
File diff suppressed because one or more lines are too long
@ -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": [
|
||||
|
@ -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))})()})();
|
@ -1,4 +1,4 @@
|
||||
from .configure import configure_logger, TqdmOutputStream, ConsoleHandler
|
||||
from .configure_logging import configure_logger, TqdmOutputStream, ConsoleHandler
|
||||
|
||||
|
||||
__all__ = (
|
||||
|
@ -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)
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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>
|
@ -0,0 +1 @@
|
||||
@use '../../shared/styles/setting';
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 }))
|
||||
);
|
||||
};
|
||||
}
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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'"
|
||||
|
@ -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: [
|
||||
|
@ -89,6 +89,7 @@ export type LogLevel =
|
||||
| 'NOTSET';
|
||||
|
||||
export interface LoggingSettings {
|
||||
logDir: string;
|
||||
consoleLogLevel: LogLevel;
|
||||
maxBytes: number;
|
||||
backupCount: number;
|
||||
|
Loading…
Reference in New Issue
Block a user