diff --git a/src/stowarr/server.py b/src/stowarr/server.py
index abf0e68..20de8f2 100644
--- a/src/stowarr/server.py
+++ b/src/stowarr/server.py
@@ -176,6 +176,8 @@ def do_GET(self):
self.send_json(200, manager.store.move_queue())
elif path == "/api/reconcile-queue":
self.send_json(200, manager.store.reconcile_queue())
+ elif path == "/api/queue-summary":
+ self.send_json(200, manager.store.queue_summary())
elif path == "/api/recovery":
self.send_json(200, manager.recovery_status())
elif path.startswith("/api/operations/") and path.endswith("/events"):
diff --git a/src/stowarr/store.py b/src/stowarr/store.py
index c421c48..5ea6691 100644
--- a/src/stowarr/store.py
+++ b/src/stowarr/store.py
@@ -510,6 +510,26 @@ def move_queue(self, limit: int = 200) -> list[dict]:
for row in rows
]
+ def queue_summary(self) -> dict[str, dict[str, int]]:
+ terminal = ("COMPLETE", "FAILED", "CANCELLED", "INTERRUPTED")
+ result: dict[str, dict[str, int]] = {}
+ with self.lock:
+ for kind, table in (
+ ("move", "move_queue"),
+ ("reconcile", "reconcile_queue"),
+ ):
+ row = self.db.execute(
+ f"""SELECT COUNT(*) AS total,
+ SUM(CASE WHEN state IN (?,?,?,?) THEN 1 ELSE 0 END) AS terminal
+ FROM {table}""", # nosec
+ terminal,
+ ).fetchone()
+ result[kind] = {
+ "total": int(row["total"]),
+ "terminal": int(row["terminal"] or 0),
+ }
+ return result
+
def _clear_queue(self, table: str) -> int:
if table not in {"move_queue", "reconcile_queue"}:
raise ValueError("Unknown queue")
diff --git a/src/stowarr/web/app.js b/src/stowarr/web/app.js
index 29a4e7a..5fa4faf 100644
--- a/src/stowarr/web/app.js
+++ b/src/stowarr/web/app.js
@@ -1,4 +1,4 @@
-const state={authenticated:false,auth:null,config:null,connections:null,serviceStatus:null,runtime:null,recovery:null,recoveryDiagnoses:new Map(),operations:[],queue:[],reconcileQueue:[],operationEvents:new Map(),selectedHistory:new Set(),securityEvents:[],sessions:[],plan:null,movePlan:null,moveTorrent:null,qbitCatalog:null,routingAudit:null,hiddenMoveColumns:new Set(),syncApp:'radarr',sync:{},safeSyncPlans:{},safeWorkflow:null,syncExpanded:new Set(),syncHiddenStatuses:{radarr:new Set(),sonarr:new Set()},operationSections:{completed:false,remaining:false},operationTracking:false,operationTrackingGeneration:0,operationHidden:false,currentOperation:null};
+const state={authenticated:false,auth:null,config:null,connections:null,serviceStatus:null,runtime:null,recovery:null,recoveryDiagnoses:new Map(),operations:[],queue:[],reconcileQueue:[],queueSummary:{move:{total:0,terminal:0},reconcile:{total:0,terminal:0}},operationEvents:new Map(),selectedHistory:new Set(),securityEvents:[],sessions:[],plan:null,movePlan:null,moveTorrent:null,qbitCatalog:null,routingAudit:null,hiddenMoveColumns:new Set(),syncApp:'radarr',sync:{},safeSyncPlans:{},safeWorkflow:null,syncExpanded:new Set(),syncHiddenStatuses:{radarr:new Set(),sonarr:new Set()},operationSections:{completed:false,remaining:false},operationTracking:false,operationTrackingGeneration:0,operationHidden:false,currentOperation:null};
const $=(s,r=document)=>r.querySelector(s);const $$=(s,r=document)=>[...r.querySelectorAll(s)];
const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]));
const fmtTime=v=>v?new Date(v*1000).toLocaleString(): '—';
@@ -610,13 +610,17 @@ function renderQueue(){
const route=kind==='move'?`${esc(detail.current_pool||'—')} → ${esc(item.target_pool)}`:esc(detail.target_pool||'—');
return `
${esc(item.public_id)}${esc(positionLabel)} | ${esc(detail.torrent_name||item.torrent_hash)}${esc(item.torrent_hash)}${error} | ${route} | ${badge(item.state)} | ${operation} | ${fmtTime(item.updated_at)} | ${action} |
`;
}).join(''):`| The ${kind==='move'?'Move':'Reconcile'} queue is empty |
`;
- const removable=rows.filter(item=>QUEUE_TERMINAL_STATES.has(item.state)).length;
+ const removable=state.queueSummary?.[kind]?.terminal??rows.filter(item=>QUEUE_TERMINAL_STATES.has(item.state)).length;
const clearButton=$(`.clear-queue[data-kind="${kind}"]`);
if(clearButton)clearButton.disabled=!removable;
};
render(state.queue||[],'move','#queue-rows');
render(state.reconcileQueue||[],'reconcile','#reconcile-queue-rows');
- const active=[...(state.queue||[]),...(state.reconcileQueue||[])].filter(item=>['QUEUED','RUNNING'].includes(item.state)).length;
+ const summaryKinds=['move','reconcile'];
+ const hasCompleteSummary=summaryKinds.every(kind=>Number.isInteger(state.queueSummary?.[kind]?.total)&&Number.isInteger(state.queueSummary?.[kind]?.terminal));
+ const active=hasCompleteSummary
+ ?summaryKinds.reduce((count,kind)=>count+state.queueSummary[kind].total-state.queueSummary[kind].terminal,0)
+ :[...(state.queue||[]),...(state.reconcileQueue||[])].filter(item=>['QUEUED','RUNNING'].includes(item.state)).length;
$('#queue-count').textContent=(active+(state.recovery?.count||0))||'';
}
@@ -713,7 +717,7 @@ async function refreshQueue(quiet=false,throwOnError=false){
if(!state.authenticated)return;
const previousActiveReconcile=activeReconcileQueueSignature();
try{
- [state.queue,state.reconcileQueue,state.recovery]=await Promise.all([api('/api/queue'),api('/api/reconcile-queue'),api('/api/recovery')]);
+ [state.queue,state.reconcileQueue,state.queueSummary,state.recovery]=await Promise.all([api('/api/queue'),api('/api/reconcile-queue'),api('/api/queue-summary'),api('/api/recovery')]);
renderQueue();
renderRecovery();
automaticallyTrackRunningQueue();
@@ -730,7 +734,7 @@ async function cancelQueue(id,kind='move'){
async function clearQueue(kind){
const label=kind==='reconcile'?'Reconcile':'Move';
const rows=kind==='reconcile'?state.reconcileQueue:state.queue;
- const finished=rows.filter(item=>QUEUE_TERMINAL_STATES.has(item.state)).length;
+ const finished=state.queueSummary?.[kind]?.terminal??rows.filter(item=>QUEUE_TERMINAL_STATES.has(item.state)).length;
if(!finished)return;
const message=`This removes ${finished} finished queue ${finished===1?'entry':'entries'}. Waiting and running work and Operation History are always kept.`;
if(!await confirmAction({title:`Clear finished ${label} jobs?`,message,details:[['Queue',label],['Finished entries removed',String(finished)]],confirmLabel:'Clear finished',danger:true}))return;
@@ -1097,7 +1101,7 @@ async function viewSyncQueue(publicId){
}
async function inspect(hash){navigate('reconcile');$('#torrent-hash').value=hash;$('#global-hash').value=hash;$('#plan-loading').classList.remove('hidden');$('#plan-result').innerHTML='';try{state.plan=await api(`/api/plan/${encodeURIComponent(hash.trim())}`);renderPlan(state.plan)}catch(e){$('#plan-result').innerHTML=`Request failed
${esc(e.message)}
`}finally{$('#plan-loading').classList.add('hidden')}}
async function refreshServiceStatus(){if(!state.authenticated)return;try{state.serviceStatus=await api('/api/status');renderServiceStatus()}catch(e){state.serviceStatus={version:state.config?.version,services:{stowarr_api:{status:'unavailable',error:e.message},qbittorrent:{status:'unavailable',error:e.message},radarr:{status:'unavailable',error:e.message},sonarr:{status:'unavailable',error:e.message}}};renderServiceStatus()}}
-async function load(){try{const [config,connections,status,runtime,recovery,operations,queue,reconcileQueue,security,sessions]=await Promise.all([api('/api/config'),api('/api/settings/connections'),api('/api/status'),api('/api/settings/runtime'),api('/api/recovery'),api('/api/operations'),api('/api/queue'),api('/api/reconcile-queue'),api('/api/security/events'),api('/api/auth/sessions')]);state.config=config;state.connections=connections;state.serviceStatus=status;state.runtime=runtime;state.recovery=recovery;state.operations=operations;state.queue=queue;state.reconcileQueue=reconcileQueue;state.securityEvents=security.events;state.sessions=sessions.sessions;renderConfig();renderConnections();renderServiceStatus();renderRuntime();renderOperations();renderQueue();renderRecovery();automaticallyTrackRunningQueue();renderSecurity()}catch(e){toast(`Could not load Stowarr: ${e.message}`)}}
+async function load(){try{const [config,connections,status,runtime,recovery,operations,queue,reconcileQueue,queueSummary,security,sessions]=await Promise.all([api('/api/config'),api('/api/settings/connections'),api('/api/status'),api('/api/settings/runtime'),api('/api/recovery'),api('/api/operations'),api('/api/queue'),api('/api/reconcile-queue'),api('/api/queue-summary'),api('/api/security/events'),api('/api/auth/sessions')]);state.config=config;state.connections=connections;state.serviceStatus=status;state.runtime=runtime;state.recovery=recovery;state.operations=operations;state.queue=queue;state.reconcileQueue=reconcileQueue;state.queueSummary=queueSummary;state.securityEvents=security.events;state.sessions=sessions.sessions;renderConfig();renderConnections();renderServiceStatus();renderRuntime();renderOperations();renderQueue();renderRecovery();automaticallyTrackRunningQueue();renderSecurity()}catch(e){toast(`Could not load Stowarr: ${e.message}`)}}
async function revokeSessions(){if(!await confirmAction({title:'Sign out all sessions?',message:'Every active WebUI session, including this one, will be revoked.',confirmLabel:'Sign out all',danger:true}))return;try{await api('/api/auth/sessions/revoke',{method:'POST'});state.authenticated=false;state.config=null;showLogin('All WebUI sessions were signed out.')}catch(e){toast(`Sessions were not revoked: ${e.message}`)}}
async function saveRuntime(event){event.preventDefault();const apply=$('#runtime-apply').checked;if(!await confirmAction({title:`${apply?'Enable':'Disable'} confirmed write operations?`,message:'Move and Reconcile still require an explicit plan-bound confirmation.',details:[['Execution mode',apply?'Write mode':'Dry run']],confirmLabel:apply?'Enable writes':'Enable dry run',danger:apply}))return;const button=event.currentTarget.querySelector('button');button.disabled=true;button.textContent='Validating mounts…';try{state.runtime=await api('/api/settings/runtime',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({apply})});state.config.apply=state.runtime.apply;renderRuntime();renderConfig();toast(`Execution mode: ${apply?'Write mode':'Dry run'}`)}catch(e){$('#runtime-apply').checked=state.runtime.apply;toast(`Execution mode was not changed: ${e.message}`)}finally{button.disabled=false;button.textContent='Save execution mode'}}
async function saveConnections(event){event.preventDefault();const form=event.currentTarget;const button=$('#save-connections');const services={qbittorrent:{url:form.elements['qbittorrent-url'].value.trim(),api_key:form.elements['qbittorrent-api-key'].value.trim(),username:form.elements['qbittorrent-username'].value.trim(),password:form.elements['qbittorrent-password'].value},radarr:{url:form.elements['radarr-url'].value.trim(),api_key:form.elements['radarr-api-key'].value.trim()},sonarr:{url:form.elements['sonarr-url'].value.trim(),api_key:form.elements['sonarr-api-key'].value.trim()}};const selected=Object.entries(services).filter(([,service])=>service.url);if(!selected.length){$('#setup-error').textContent='Enter the URL and credentials for at least one service';$('#setup-error').classList.remove('hidden');return}if(!await confirmAction({title:'Test and save configured services?',message:'Existing credentials are kept for blank secret fields. Only services with a URL are tested.',details:selected.map(([name,service])=>[name==='qbittorrent'?'qBittorrent':name[0].toUpperCase()+name.slice(1),service.url]),confirmLabel:'Test and save'}))return;button.disabled=true;button.textContent='Testing connections…';$('#setup-error').classList.add('hidden');try{const result=await api('/api/settings/connections',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({services})});state.connections=result;renderConnections();form.elements['qbittorrent-api-key'].value='';form.elements['qbittorrent-password'].value='';form.elements['radarr-api-key'].value='';form.elements['sonarr-api-key'].value='';state.qbitCatalog=null;state.routingAudit=null;await refreshServiceStatus();$('#connection-setup').close();toast(`${selected.length} configured service${selected.length===1?'':'s'} tested and saved`)}catch(e){$('#setup-error').textContent=e.message;$('#setup-error').classList.remove('hidden')}finally{button.disabled=false;button.textContent='Test and save configured services'}}
diff --git a/tests/test_store.py b/tests/test_store.py
index 77bc518..05694ce 100644
--- a/tests/test_store.py
+++ b/tests/test_store.py
@@ -310,11 +310,41 @@ def test_reconcile_queue_cleanup_keeps_waiting_work(self):
"waiting", {"auxiliaryFiles": []}, "waiting", {}
)
+ self.assertEqual(
+ store.queue_summary()["reconcile"],
+ {"total": 2, "terminal": 1},
+ )
self.assertEqual(store.clear_reconcile_queue(), 1)
self.assertEqual(
[item["public_id"] for item in store.reconcile_queue()],
[waiting["public_id"]],
)
+ self.assertEqual(
+ store.queue_summary()["reconcile"],
+ {"total": 1, "terminal": 0},
+ )
+
+ def test_queue_summary_counts_finished_work_beyond_list_limit(self):
+ with tempfile.TemporaryDirectory() as directory:
+ store = Store(Path(directory) / "state.db")
+ finished = store.enqueue_move("finished", "p1", {}, "finished", {})
+ store.claim_next_move()
+ store.finish_move(finished["id"], "COMPLETE")
+ for index in range(200):
+ store.enqueue_move(
+ f"waiting-{index}", "p1", {}, f"waiting-{index}", {}
+ )
+
+ visible = store.move_queue()
+ self.assertEqual(len(visible), 200)
+ self.assertFalse(
+ any(item["state"] in {"COMPLETE", "FAILED", "CANCELLED", "INTERRUPTED"}
+ for item in visible)
+ )
+ self.assertEqual(
+ store.queue_summary()["move"],
+ {"total": 201, "terminal": 1},
+ )
def test_restart_marks_queue_and_history_and_pauses_later_work(self):
with tempfile.TemporaryDirectory() as directory: