From d21170b41d62723775cd4a182f12590cd29be7b5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 17:15:09 +0800 Subject: [PATCH] Handle empty recording days and stale playback queries --- VERSION | 2 +- app.py | 15 ++++++++++++--- test_app.py | 13 +++++++++++++ web/app.js | 8 ++++---- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/VERSION b/VERSION index 1180819..699c6c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.7 +0.1.8 diff --git a/app.py b/app.py index 5d60acf..00be234 100644 --- a/app.py +++ b/app.py @@ -310,6 +310,15 @@ def media_json(path, playback=False): req = urllib.request.Request(target, headers={'Authorization': MEDIA_AUTH}) with urllib.request.urlopen(req, timeout=5) as r: return json.load(r) + except urllib.error.HTTPError as exc: + if playback and path.startswith('/list?') and exc.code == 404: + try: + detail = json.loads(exc.read(4096)) + if detail.get('error') == 'no recording segments found': + return [] + except (ValueError, AttributeError): + pass + raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503) except Exception: raise Problem('视频服务暂不可用,或该时段尚无可回放录像', 503) @@ -402,9 +411,9 @@ def storage_watch(): def discover(network): def probe(ip): try: - with socket.create_connection((str(ip), 554), timeout=0.7) as s: - s.settimeout(1) - s.sendall(('OPTIONS rtsp://' + str(ip) + '/ RTSP/1.0\r\nCSeq: 1\r\n\r\n').encode()) + with socket.create_connection((str(ip), 554), timeout=1.5) as s: + s.settimeout(3) + s.sendall(('OPTIONS rtsp://' + str(ip) + ':554/ RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: ZhaoVision\r\n\r\n').encode()) response = s.recv(1024).decode(errors='replace').split('\r\n')[0] if response.startswith('RTSP/'): return {'host': str(ip), 'port': 554, 'response': response, 'authenticated': False} diff --git a/test_app.py b/test_app.py index 918e312..9061966 100644 --- a/test_app.py +++ b/test_app.py @@ -1,6 +1,7 @@ """Isolated regression tests: python -m unittest -v test_app.""" import hashlib import http.client +import io import json import os import tempfile @@ -128,6 +129,18 @@ class WebTests(unittest.TestCase): self.assertIn('h264_vaapi', command) self.assertIn('pipe:1', command) + def test_empty_recording_day_is_not_service_failure(self): + error = app.urllib.error.HTTPError('http://localhost/list', 404, 'Not Found', {}, + io.BytesIO(b'{"error":"no recording segments found"}')) + with patch('app.urllib.request.urlopen', side_effect=error): + self.assertEqual(app.media_json('/list?path=example', playback=True), []) + + def test_real_media_service_error_is_preserved(self): + error = app.urllib.error.HTTPError('http://localhost/list', 500, 'Error', {}, io.BytesIO(b'{}')) + with patch('app.urllib.request.urlopen', side_effect=error): + with self.assertRaises(app.Problem): + app.media_json('/list?path=example', playback=True) + if __name__ == '__main__': unittest.main() diff --git a/web/app.js b/web/app.js index 33dd049..0be5a07 100644 --- a/web/app.js +++ b/web/app.js @@ -4,7 +4,7 @@ const $$ = (s) => [...document.querySelectorAll(s)]; const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const kind = (v) => ({home:'家庭',company:'公司',other:'其他'}[v] || '其他'); let state = {cameras:[], assets:[], recorders:[], sites:[], storage:{}, scan:{}}, activePage = 'live', loggedIn = false, setup = false; -let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer; +let players = [], liveKey = '', spans = [], queryStart = null, queryEnd = null, queryCamera = '', toastTimer, playbackGeneration = 0; const pageInfo = {live:['实时画面','从一个空间,看到每一个位置。'],playback:['录像回放','沿着时间,找回需要的画面。'],cameras:['镜头通道','为摄像头的每个镜头配置独立通道。'],objects:['摄像头对象','实体设备、镜头通道与空间,明确关联。'],recorders:['录像机','连接现有录像与各个现场通道。'],sites:['空间档案','地区、场所、楼栋与门牌,清楚关联每一个镜头。'],storage:['录像与存储','让记录持续,也让磁盘留有余地。']}; async function api(path, data) { const r = await fetch(path, {method:data === undefined ? 'GET' : 'POST', headers:data === undefined ? {} : {'Content-Type':'application/json'}, body:data === undefined ? undefined : JSON.stringify(data)}); @@ -109,13 +109,13 @@ $('#logout').onclick=async()=>{await api('/api/logout',{});loggedIn=false;stopPl $('#refresh').onclick=()=>{liveKey='';refresh();};$('#site-filter').onchange=()=>{stopPlayers();resetPlayback();render();};$('#quality').onchange=()=>{stopPlayers();renderLive();}; $('#scan-button').onclick=async()=>{try{await api('/api/discover',{network:$('#scan-network').value});await refresh();}catch(e){toast(e.message);}}; $('#path-preset').onchange=e=>{const presets={tplink:['/stream1','/stream2'],hikvision:['/Streaming/Channels/101','/Streaming/Channels/102'],dahua:['/cam/realmonitor?channel=1&subtype=0','/cam/realmonitor?channel=1&subtype=1']};const v=presets[e.target.value];if(v){$('#camera-form').elements.mainPath.value=v[0];$('#camera-form').elements.subPath.value=v[1];}}; -function resetPlayback() {const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();$('#play-empty').hidden=false;$('#download').hidden=true;spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';drawTimeline();$('#recording-list').innerHTML='';} +function resetPlayback() {playbackGeneration++;const v=$('#play-video');v.pause();v.removeAttribute('src');v.load();$('#play-empty').hidden=false;$('#download').hidden=true;spans=[];queryCamera='';queryStart=null;queryEnd=null;$('#play-info').textContent='选择日期,查看实际录像时段';drawTimeline();$('#recording-list').innerHTML='';} async function queryRecordings() { - resetPlayback();const id=$('#play-camera').value,date=$('#play-date').value;if(!id||!date){toast('请选择摄像头和日期');return;} + resetPlayback();const generation=playbackGeneration;const id=$('#play-camera').value,date=$('#play-date').value;if(!id||!date){toast('请选择摄像头和日期');return;} const channel=state.cameras.find(c=>c.id===id);if($('#archive-source').value==='recorder'){$('#play-info').textContent='录像机历史回放接口待接入。请先提供录像机型号,在录像机或原厂 App 中可继续查看已有录像。';return;} queryStart=new Date(date+'T00:00:00');queryEnd=new Date(queryStart);queryEnd.setDate(queryEnd.getDate()+1);queryCamera=id; $('#play-info').textContent='正在查找录像…'; - try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));spans=d.items.map(s=>({...s,date:new Date(s.start)}));$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();$('#recording-list').innerHTML=spans.map(s=>``).join('');if(spans.length)playAt(new Date(Math.max(+spans[0].date,+queryStart)));}catch(e){$('#play-info').textContent=e.message;} + try{const d=await api('/api/recordings?'+new URLSearchParams({camera:id,start:queryStart.toISOString(),end:queryEnd.toISOString()}));if(generation!==playbackGeneration)return;spans=d.items.map(s=>({...s,date:new Date(s.start)}));$('#play-info').textContent=spans.length?`找到 ${spans.length} 段连续录像`:'该日期暂无可回放录像';drawTimeline();$('#recording-list').innerHTML=spans.map(s=>``).join('');if(spans.length)playAt(new Date(Math.max(+spans[0].date,+queryStart)));}catch(e){if(generation===playbackGeneration)$('#play-info').textContent=e.message;} } function drawTimeline() {const c=$('#timeline'),w=c.clientWidth;if(!w)return;const scale=window.devicePixelRatio||1;c.width=w*scale;c.height=76*scale;const ctx=c.getContext('2d');ctx.scale(scale,scale);ctx.fillStyle='#101f25';ctx.fillRect(0,0,w,76);ctx.strokeStyle='#2b454e';for(let i=0;i<=24;i++){const x=w*i/24;ctx.beginPath();ctx.moveTo(x,0);ctx.lineTo(x,i%6===0?76:12);ctx.stroke();}if(!queryStart||!queryEnd)return;ctx.fillStyle='#72cdb5';const total=queryEnd-queryStart;for(const s of spans){const a=Math.max(0,(s.date-queryStart)/total),b=Math.min(1,(+s.date+s.duration*1000-queryStart)/total);if(b>a)ctx.fillRect(a*w,25,Math.max(2,(b-a)*w),25);}} function playAt(date) {const s=spans.find(s=>+date>=+s.date&&+date<+s.date+s.duration*1000);if(!s){toast('这个时间没有录像,请选择绿色时段');return;}const duration=Math.min(3600,(+s.date+s.duration*1000-date)/1000);const params=new URLSearchParams({camera:queryCamera,start:date.toISOString(),duration:String(duration)});$('#play-video').src='/media/playback?'+params;$('#play-video').play().catch(()=>{});$('#play-info').textContent='回放起点 '+date.toLocaleString()+' · 兼容回放,最多一小时;使用下方时间轴定位';$('#play-empty').hidden=true;$('#download').href='/media/playback?'+params+'&download=1';$('#download').setAttribute('download','recording.mp4');$('#download').hidden=false;}