Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7d3fea92f |
@@ -0,0 +1,10 @@
|
||||
# v0.1.41 · 录像按需缓冲与连续回放
|
||||
|
||||
- 修复浏览器减慢读取时,长录像源 HTTP 连接超时而提前结束的问题。
|
||||
- 回放以最多 5 分钟为一段,沿用自动续播和真实录像空档规则;原始片段下载仍可选择最多一小时。
|
||||
- 每段录像在服务器私有临时目录缓冲,转换结束、失败或离开页面后清理;最多两个转换、每份缓存最大 256 MiB,受磁盘预留空间及下载时限保护。
|
||||
- 转换进程仅访问临时文件,按播放速度读取并限制线程,不携带录像服务认证信息。
|
||||
- 长时间暂停后从当前播放位置重新接入,无需刷新整个页面。
|
||||
- 保留 v0.1.40 家谱全屏抖动、尺寸通知循环与侧栏遮挡修复。
|
||||
|
||||
30 天为录像最长保留时间。实际可保留时长仍受总录像容量上限、磁盘预留、码率和路数限制。
|
||||
@@ -885,7 +885,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
started = False
|
||||
old_timeout = self.connection.gettimeout()
|
||||
try:
|
||||
with playback.stream('http://127.0.0.1:' + str(PLAYBACK_PORT) + path, MEDIA_AUTH, duration) as output:
|
||||
reserve_bytes = int(setting('storage', DEFAULT_STORAGE).get('reserveGB', 8) * 1024**3)
|
||||
with playback.stream('http://127.0.0.1:' + str(PLAYBACK_PORT) + path, MEDIA_AUTH, duration, reserve_bytes=reserve_bytes) as output:
|
||||
first = output.read1(65536)
|
||||
if not first:
|
||||
raise Problem('录像转换失败,请检查录像是否完整及服务器视频驱动', 503)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "zhao-steward-ui",
|
||||
"private": true,
|
||||
"version": "0.1.40",
|
||||
"version": "0.1.41",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
|
||||
@@ -15,6 +15,6 @@ export function enhance(root){
|
||||
export async function loadControllers(){
|
||||
enhance(document.getElementById('app'))
|
||||
new MutationObserver(records=>{for(const record of records)for(const node of record.addedNodes)enhance(node)}).observe(document.getElementById('app'),{childList:true,subtree:true})
|
||||
for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.40';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)})
|
||||
for(const path of [...scripts,'/vue-bridge.js'])await new Promise((resolve,reject)=>{const script=document.createElement('script');script.src=path+'?ui=0.1.41';script.onload=resolve;script.onerror=()=>reject(new Error('Could not load '+path));document.head.append(script)})
|
||||
await window.ZhaoVue.boot()
|
||||
}
|
||||
|
||||
+64
-6
@@ -1,17 +1,60 @@
|
||||
"""Bounded, ephemeral H.264 playback; original recordings remain untouched."""
|
||||
from contextlib import contextmanager
|
||||
import http.client
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
SLOTS = threading.BoundedSemaphore(2)
|
||||
MAX_SOURCE_BYTES = 256 * 1024 * 1024
|
||||
MAX_SESSION_SECONDS = 300
|
||||
|
||||
|
||||
class PlaybackError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def cached_source(source, authorization, reserve_bytes):
|
||||
# A private, bounded scratch file decouples MediaMTX's HTTP write timeout
|
||||
# from browser buffering and pause. Use the data disk, not a small /tmp RAM disk.
|
||||
base = Path(os.environ.get('VISION_DATA', tempfile.gettempdir())) / 'playback-tmp'
|
||||
base.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
for old in base.glob('buffer-*'):
|
||||
try:
|
||||
if old.is_symlink() or not old.is_dir() or time.time() - old.stat().st_mtime < 3600:
|
||||
continue
|
||||
(old / 'source.mp4').unlink(missing_ok=True)
|
||||
old.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
with tempfile.TemporaryDirectory(prefix='buffer-', dir=base) as folder:
|
||||
if shutil.disk_usage(folder).free < reserve_bytes + MAX_SOURCE_BYTES:
|
||||
raise PlaybackError('磁盘剩余空间不足以缓冲回放,请先释放空间')
|
||||
request = urllib.request.Request(source, headers={'Authorization': authorization})
|
||||
target = Path(folder) / 'source.mp4'
|
||||
deadline = time.monotonic() + 30
|
||||
size = 0
|
||||
with urllib.request.urlopen(request, timeout=10) as response, target.open('wb') as output:
|
||||
while chunk := response.read(256 * 1024):
|
||||
size += len(chunk)
|
||||
if size > MAX_SOURCE_BYTES:
|
||||
raise PlaybackError('该录像片段过大,请缩短回放时段')
|
||||
if time.monotonic() > deadline:
|
||||
raise PlaybackError('回放缓冲超时,请稍后重试')
|
||||
if shutil.disk_usage(folder).free < reserve_bytes + len(chunk):
|
||||
raise PlaybackError('磁盘预留空间不足,已停止缓冲回放')
|
||||
output.write(chunk)
|
||||
if not size:
|
||||
raise PlaybackError('该时段没有可读取的录像')
|
||||
yield str(target)
|
||||
|
||||
|
||||
def command(source, authorization, duration):
|
||||
executable = shutil.which('ffmpeg')
|
||||
if not executable:
|
||||
@@ -29,9 +72,10 @@ def command(source, authorization, duration):
|
||||
'-c:v', 'libx264', '-threads', '1', '-preset', 'veryfast',
|
||||
'-tune', 'zerolatency', '-profile:v', 'baseline', '-pix_fmt', 'yuv420p',
|
||||
'-crf', '26', '-g', '20', '-bf', '0']
|
||||
input_options = (['-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n']
|
||||
if authorization else ['-readrate', '1'])
|
||||
return [executable, '-hide_banner', '-loglevel', 'error', '-nostdin',
|
||||
'-threads', '1', '-filter_threads', '1'] + acceleration + [
|
||||
'-rw_timeout', '15000000', '-headers', 'Authorization: ' + authorization + '\r\n',
|
||||
'-threads', '1', '-filter_threads', '1'] + acceleration + input_options + [
|
||||
'-analyzeduration', '1000000', '-probesize', '1000000',
|
||||
# Recordings are finite HTTP downloads. Pacing their input like a
|
||||
# live source stalls upstream writes and truncates long responses.
|
||||
@@ -43,20 +87,34 @@ def command(source, authorization, duration):
|
||||
|
||||
|
||||
@contextmanager
|
||||
def stream(source, authorization, duration):
|
||||
def stream(source, authorization, duration, reserve_bytes=8 * 1024**3):
|
||||
if not 0 < duration <= MAX_SESSION_SECONDS:
|
||||
raise PlaybackError('请刷新页面,使用每段最多 5 分钟的自动连续回放')
|
||||
if not SLOTS.acquire(blocking=False):
|
||||
raise PlaybackError('已有两个回放正在进行,请关闭其他回放后重试')
|
||||
process = timer = None
|
||||
try:
|
||||
# No shell and no diagnostics containing authentication headers in logs.
|
||||
process = subprocess.Popen(command(source, authorization, duration),
|
||||
if not shutil.which('ffmpeg'):
|
||||
raise PlaybackError('回放转换需要服务器安装 FFmpeg')
|
||||
with cached_source(source, authorization, reserve_bytes) as local_source:
|
||||
# Credentials never appear in the converter's process arguments.
|
||||
process = subprocess.Popen(command(local_source, '', duration),
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL)
|
||||
timer = threading.Timer(duration + 45, process.kill)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
try:
|
||||
yield process.stdout
|
||||
except OSError as exc:
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
raise PlaybackError('录像转换服务暂不可用') from exc
|
||||
finally:
|
||||
if timer:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import playback
|
||||
|
||||
|
||||
class PlaybackCacheTests(unittest.TestCase):
|
||||
def test_private_cache_removed_after_use_and_abort(self):
|
||||
for abort in (False, True):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
with patch.dict('os.environ', {'VISION_DATA':folder}), patch('playback.shutil.disk_usage', return_value=SimpleNamespace(free=10**12)), patch('playback.urllib.request.urlopen', return_value=io.BytesIO(b'video')) as opening:
|
||||
try:
|
||||
with playback.cached_source('http://127.0.0.1/source', 'Basic private', 8) as path:
|
||||
self.assertEqual(Path(path).read_bytes(), b'video')
|
||||
self.assertEqual(opening.call_args.args[0].get_header('Authorization'), 'Basic private')
|
||||
if abort:
|
||||
raise RuntimeError('browser left')
|
||||
except RuntimeError:
|
||||
self.assertTrue(abort)
|
||||
self.assertEqual(list((Path(folder)/'playback-tmp').iterdir()), [])
|
||||
|
||||
def test_oversize_and_empty_sources_leave_no_cache(self):
|
||||
for contents in (b'', b'123456789'):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
with patch.dict('os.environ', {'VISION_DATA':folder}), patch('playback.MAX_SOURCE_BYTES', 8), patch('playback.shutil.disk_usage', return_value=SimpleNamespace(free=1000)), patch('playback.urllib.request.urlopen', return_value=io.BytesIO(contents)):
|
||||
with self.assertRaises(playback.PlaybackError):
|
||||
with playback.cached_source('http://127.0.0.1/source', 'private', 8):
|
||||
self.fail('invalid cache accepted')
|
||||
self.assertEqual(list((Path(folder)/'playback-tmp').iterdir()), [])
|
||||
|
||||
def test_disk_reserve_checked_before_download(self):
|
||||
with patch('playback.shutil.disk_usage', return_value=SimpleNamespace(free=1)), patch('playback.urllib.request.urlopen') as opening:
|
||||
with self.assertRaises(playback.PlaybackError):
|
||||
with playback.cached_source('unused', 'private', 8):
|
||||
self.fail('low space accepted')
|
||||
opening.assert_not_called()
|
||||
|
||||
def test_stale_buffers_are_removed_but_current_and_unrelated_files_remain(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
base=Path(folder)/'playback-tmp';base.mkdir()
|
||||
old=base/'buffer-old';old.mkdir();(old/'source.mp4').write_bytes(b'old')
|
||||
os.utime(old,(time.time()-7200,time.time()-7200))
|
||||
fresh=base/'buffer-active';fresh.mkdir();(fresh/'source.mp4').write_bytes(b'active')
|
||||
unrelated=base/'keep.txt';unrelated.write_text('keep')
|
||||
with patch.dict('os.environ',{'VISION_DATA':folder}), patch('playback.shutil.disk_usage',return_value=SimpleNamespace(free=10**12)), patch('playback.urllib.request.urlopen',return_value=io.BytesIO(b'video')):
|
||||
with playback.cached_source('http://127.0.0.1/source','private',8):
|
||||
self.assertFalse(old.exists())
|
||||
self.assertEqual((fresh/'source.mp4').read_bytes(),b'active')
|
||||
self.assertEqual(unrelated.read_text(),'keep')
|
||||
|
||||
def test_local_converter_is_paced_and_has_no_credentials(self):
|
||||
with patch('playback.shutil.which', return_value='ffmpeg'), patch('playback.os.access', return_value=False):
|
||||
cmd=playback.command('/tmp/private/source.mp4', '', 300)
|
||||
self.assertIn('-readrate', cmd)
|
||||
self.assertNotIn('-headers', cmd)
|
||||
self.assertIn('libx264', cmd)
|
||||
self.assertEqual(cmd[cmd.index('-threads')+1], '1')
|
||||
|
||||
def test_sessions_are_bounded_before_consuming_a_slot(self):
|
||||
for duration in (0, 301, float('nan')):
|
||||
with self.assertRaises(playback.PlaybackError):
|
||||
with playback.stream('unused', 'private', duration):
|
||||
self.fail('unbounded playback accepted')
|
||||
self.assertTrue(playback.SLOTS.acquire(blocking=False))
|
||||
playback.SLOTS.release()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+11
-3
@@ -13,9 +13,9 @@ test('microsecond recording starts round inward and never seek into the precedin
|
||||
assert.equal(exact[0].start,Date.parse(raw));
|
||||
assert.deepEqual(Ranges.normalize([{start:raw,duration:0.0001}],date(0),date(1)),[]);
|
||||
});
|
||||
test('long recording continues after each one-hour session',()=>{
|
||||
const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(3600)});
|
||||
assert.deepEqual(Ranges.next(ranges,date(3600)),{start:+date(3600),end:+date(7200)});assert.equal(Ranges.next(ranges,date(8000)),null);
|
||||
test('long recording continues in bounded five-minute sessions',()=>{
|
||||
const ranges=[{start:+date(0),end:+date(8000)}];assert.deepEqual(Ranges.at(ranges,date(0)),{start:+date(0),end:+date(300)});
|
||||
assert.deepEqual(Ranges.next(ranges,date(300)),{start:+date(300),end:+date(600)});assert.equal(Ranges.next(ranges,date(8000)),null);
|
||||
});
|
||||
test('next segment skips the actual gap and does not replay the previous end',()=>{
|
||||
const ranges=[{start:+date(0),end:+date(10)},{start:+date(30),end:+date(40)}];assert.equal(Ranges.at(ranges,date(10)),null);
|
||||
@@ -56,6 +56,14 @@ test('late recording list cannot replace another camera or a logged-out view',as
|
||||
vm.runInContext(section('recording-ui.js','async function queryRecordings(','function drawTimeline('),c);
|
||||
const work=c.queryRecordings();c.playbackGeneration++;p.resolve({items:[]});await work;assert.equal(c.spans,undefined);
|
||||
});
|
||||
test('resuming a long pause reopens the recorded position without a page refresh',()=>{
|
||||
const c=ui({playbackRange:{start:10000,end:300000},playbackPausedAt:Date.now()-20000,playAt:(date,preserve)=>c.played={time:+date,preserve}});
|
||||
c.$('#play-video').currentTime=45;
|
||||
vm.runInContext(section('recording-ui.js','function pausePlayback(','function makeClip('),c);
|
||||
c.resumePlayback();assert.deepEqual(c.played,{time:55000,preserve:true});assert.equal(c.playbackPausedAt,0);
|
||||
c.played=null;c.playbackPausedAt=Date.now();c.resumePlayback();assert.equal(c.played,null);
|
||||
c.playbackPausedAt=Date.now()-20000;c.loggedIn=false;c.resumePlayback();assert.equal(c.played,null);
|
||||
});
|
||||
test('download generation refuses gaps and invalidates old links first',()=>{
|
||||
const c=ui({queryCamera:'camera',spans:[{start:+date(0),end:+date(60)}],playbackParams:()=>new URLSearchParams({start:'test'}),Error});
|
||||
vm.runInContext(section('recording-ui.js','function makeClip(',"$('#query-recordings')"),c);
|
||||
|
||||
@@ -13,7 +13,7 @@ const RecordingRanges=(()=>{
|
||||
.filter(s=>Number.isFinite(s.start)&&Number.isFinite(s.end)&&s.end>s.start).sort((a,b)=>a.start-b.start);
|
||||
const result=[];for(const r of ranges){const last=result.at(-1);if(last&&r.start<=last.end)last.end=Math.max(last.end,r.end);else result.push({...r});}return result;
|
||||
}
|
||||
function at(ranges,time){const t=+time,s=ranges.find(s=>t>=s.start&&t<s.end);return s?{start:t,end:Math.min(s.end,t+3600000)}:null;}
|
||||
function at(ranges,time){const t=+time,s=ranges.find(s=>t>=s.start&&t<s.end);return s?{start:t,end:Math.min(s.end,t+300000)}:null;}
|
||||
function next(ranges,end){const s=ranges.find(s=>s.end>+end);return s?at(ranges,Math.max(s.start,+end)):null;}
|
||||
function clip(ranges,start,end){
|
||||
const a=+start,b=+end;
|
||||
|
||||
+14
-4
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
let playbackRange=null;
|
||||
let playbackRange=null,playbackPausedAt=0;
|
||||
function playbackSourceHint(){renderRecordingStatus();if($('#archive-source').value==='recorder')$('#play-info').textContent='录像机原有录像待接入;查看本系统保存的录像请选择“小电脑本机录像”。';}
|
||||
|
||||
function renderRecordingStatus(){
|
||||
@@ -15,7 +15,7 @@ function renderRecordingStatus(){
|
||||
|
||||
function localDateTime(value){const d=new Date(value);return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${d.toTimeString().slice(0,8)}`;}
|
||||
function resetPlayback(){
|
||||
playbackGeneration++;playbackRange=null;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();
|
||||
playbackGeneration++;playbackRange=null;playbackPausedAt=0;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();
|
||||
$('#play-empty').hidden=false;$('#download').hidden=true;$('#clip-download').hidden=true;$('#clip-status').textContent='';$('#clip-form').reset();
|
||||
spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';$('#play-position').textContent='';drawTimeline();$('#recording-list').innerHTML='';
|
||||
}
|
||||
@@ -42,8 +42,8 @@ function drawTimeline(){
|
||||
function playbackParams(range){return new URLSearchParams({camera:queryCamera,start:new Date(range.start).toISOString(),duration:String((range.end-range.start)/1000)});}
|
||||
function playAt(date,preserveClip=false){
|
||||
const range=RecordingRanges.at(spans,date);if(!range){toast('这个时间没有录像,请选择绿色时段');return;}
|
||||
playbackRange=range;const params=playbackParams(range),v=$('#play-video');v.src='/media/playback?'+params;v.play().catch(()=>{});
|
||||
$('#play-info').textContent='回放起点 '+new Date(range.start).toLocaleString()+' · 每次最多一小时,结束后按续播选项处理';$('#play-empty').hidden=true;
|
||||
playbackPausedAt=0;playbackRange=range;const params=playbackParams(range),v=$('#play-video');v.src='/media/playback?'+params;v.play().catch(()=>{});
|
||||
$('#play-info').textContent='回放起点 '+new Date(range.start).toLocaleString()+' · 每段最多 5 分钟,按续播选项连续播放';$('#play-empty').hidden=true;
|
||||
$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;
|
||||
if(!preserveClip){$('#clip-start').value=localDateTime(range.start);$('#clip-end').value=localDateTime(range.end);$('#clip-download').hidden=true;$('#clip-status').textContent='可修改起止时间后生成下载链接';}
|
||||
}
|
||||
@@ -55,6 +55,15 @@ function continuePlayback(){
|
||||
if(!next){$('#play-info').textContent='所选日期的已查询录像播放完毕';return;}
|
||||
playAt(new Date(next.start),true);if(next.start>end)$('#play-info').textContent=`已跳过无录像时段,续播 ${new Date(next.start).toLocaleString()}`;
|
||||
}
|
||||
function pausePlayback(){
|
||||
const v=$('#play-video');if(playbackRange&&!v.ended)playbackPausedAt=Date.now();
|
||||
}
|
||||
function resumePlayback(){
|
||||
const elapsed=playbackPausedAt?Date.now()-playbackPausedAt:0;playbackPausedAt=0;
|
||||
if(elapsed<12000||!loggedIn||activePage!=='playback'||!playbackRange)return;
|
||||
const time=playbackRange.start+$('#play-video').currentTime*1000;
|
||||
if(time<playbackRange.end)playAt(new Date(time),true);
|
||||
}
|
||||
function makeClip(){
|
||||
$('#clip-download').hidden=true;
|
||||
try{if(!queryCamera)throw Error('请先查询录像');const range=RecordingRanges.clip(spans,new Date($('#clip-start').value),new Date($('#clip-end').value));
|
||||
@@ -67,6 +76,7 @@ $('#seek-button').onclick=()=>{if(!queryCamera){toast('请先查询录像');retu
|
||||
$('#timeline').onclick=e=>{if(!queryStart)return;const r=e.currentTarget.getBoundingClientRect();playAt(new Date(+queryStart+(queryEnd-queryStart)*(e.clientX-r.left)/r.width));};
|
||||
$('#play-video').onerror=()=>{if($('#play-video').getAttribute('src'))$('#play-info').textContent='回放失败:请稍后重试,或下载原始片段;服务器最多同时转换两个回放。';};
|
||||
$('#play-video').onplaying=()=>{$('#play-empty').hidden=true;};$('#play-video').onended=continuePlayback;
|
||||
$('#play-video').onpause=pausePlayback;$('#play-video').onplay=resumePlayback;
|
||||
$('#play-video').ontimeupdate=()=>{if(playbackRange)$('#play-position').textContent='录像时间 '+new Date(Math.min(playbackRange.end,playbackRange.start+$('#play-video').currentTime*1000)).toLocaleString();};
|
||||
$('#clip-form').onsubmit=e=>{e.preventDefault();makeClip();};
|
||||
for(const id of ['clip-start','clip-end'])$('#'+id).oninput=()=>{$('#clip-download').hidden=true;$('#clip-status').textContent='起止时间已修改,请重新生成链接';};
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,3 +1,3 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#244d43"><link rel="icon" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><title>赵府 · 智能数字管家</title> <script type="module" crossorigin src="/ui/assets/index-BN-llATb.js"></script>
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#244d43"><link rel="icon" href="/zhao-icon.png"><link rel="apple-touch-icon" href="/zhao-icon.png"><title>赵府 · 智能数字管家</title> <script type="module" crossorigin src="/ui/assets/index-F3a-FbDz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/ui/assets/index-B2d9n-y0.css">
|
||||
</head><body><div id="app"></div></body></html>
|
||||
|
||||
Reference in New Issue
Block a user