diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 44f100e..3a3a995 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "importarr-ui", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "importarr-ui", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-slot": "^1.2.3", diff --git a/frontend/package.json b/frontend/package.json index 23a2685..1f08f25 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "importarr-ui", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 31f1106..a3ed86b 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -4,17 +4,23 @@ import { AlertTriangle, Clock, Download, FolderPlus, History, Info, Menu, Moon, import "./globals.css"; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Input } from "./components/ui"; -const groups = ["sab_processing", "ready", "importing", "failed", "ignored_category", "manual_batch", "completed"]; +const groups = ["ready", "importing", "failed", "manual_batch", "completed", "ignored_category", "sab_processing"]; const labels = { sab_processing: "SAB processing", ready: "Ready", importing: "Importing", failed: "Failed", ignored_category: "Ignored category", manual_batch: "Manual batch", completed: "Completed" }; const bytes = value => { let n=Number(value||0), i=0; const units=["B","KB","MB","GB","TB"]; if(!n)return "size unknown"; while(n>=1024&&i<4){n/=1024;i++} return `${n.toFixed(n>=10||!i?0:1)} ${units[i]}`; }; const duration = value => { const s=Math.max(0,Math.floor(Number(value||0))); return s>=60?`${Math.floor(s/60)}m ${String(s%60).padStart(2,"0")}s`:`${s}s`; }; -async function request(url, options={}) { - const response=await fetch(url, options.body ? {...options,headers:{"content-type":"application/json",...options.headers},body:JSON.stringify(options.body)} : options); +export async function request(url, requestOptions={}, retryAuth=true) { + const { auth=false, ...options }=requestOptions; + const protectedRequest=auth||(options.method&&options.method!=="GET"), token=protectedRequest?sessionStorage.getItem("importarr-auth-token"):null; + const headers={...options.headers,...(token?{Authorization:`Bearer ${token}`}:{})}; + const response=await fetch(url, options.body ? {...options,headers:{"content-type":"application/json",...headers},body:JSON.stringify(options.body)} : {...options,headers}); + if(response.status===401&&protectedRequest&&retryAuth){const entered=prompt("Importarr write access token");if(entered){sessionStorage.setItem("importarr-auth-token",entered);return request(url,requestOptions,false)}} if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); throw new Error(typeof error.detail==="string"?error.detail:"Request failed"); } return response.status===204?null:response.json(); } +export const pollDelay = failures => failures ? Math.min(5000,1000*2**(failures-1)) : 300; + function Modal({ trigger, title, children }) { return {trigger}{title}{children}; } function Field({ label, ...props }) { return ; } @@ -34,7 +40,7 @@ export function App(){ const [dark,setDark]=useState(()=>localStorage.getItem("importarr-theme")!=="light" && (localStorage.getItem("importarr-theme")==="dark"||matchMedia("(prefers-color-scheme: dark)").matches)); const refresh=async()=>{ try { const [s,j]=await Promise.all([request("/api/status"),request("/api/jobs")]); setStatus(s);setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})));document.title=`${j.jobs?.length?`${j.jobs.length} jobs`:"Idle"} · Importarr`; } catch(error){ console.error(error); } }; useEffect(()=>{document.documentElement.classList.toggle("dark",dark);localStorage.setItem("importarr-theme",dark?"dark":"light")},[dark]); - useEffect(()=>{refresh();const id=setInterval(refresh,2000);request("/api/control/update-check").then(setUpdate).catch(()=>{});return()=>clearInterval(id)},[]); + useEffect(()=>{let stopped=false,lastCurrent,lastJobs=0,failures=0;const poll=async()=>{try{const s=await request("/api/status");if(stopped)return;setStatus(s);const current=typeof s.current==="object"?(s.current.file||s.current.name):s.current;if(current!==lastCurrent||Date.now()-lastJobs>(current?10000:2000)){lastCurrent=current;lastJobs=Date.now();const j=await request("/api/jobs");setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})))}failures=0}catch(error){failures++;console.error(error)}if(!stopped)setTimeout(poll,pollDelay(failures))};poll();request("/api/control/update-check",{auth:true}).then(setUpdate).catch(()=>{});return()=>{stopped=true}},[]); const post=async(url,body)=>{try{await request(url,{method:"POST",body});await refresh()}catch(error){alert(error.message)}}; const control=action=>{if(action==="cancel-current"&&!confirm("Cancel the current import job?"))return;post(`/api/control/${action}`)}; const current=status?.current, currentName=typeof current==="object"?(current.file||current.name):current; @@ -43,7 +49,7 @@ export function App(){
{update?.update_available&&Version {update.latest_version} is available}
{[["Queue mode",status?.control?.queue_mode],["Current import",currentName||"Idle"],["Imported",status?.imported_total],["Failed",status?.failed_total],["Queue items",status?.queue_total]].map(([label,value])=>{value??"—"}{label})}
-
Current import

{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}

{currentName||"No active copy."}

{currentName&&

{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}

}
+
Current import

{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}

{currentName||"No active copy."}

{currentName&&<>

{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}

}
Queue and history

Grouped by processing state

{jobs.length?groups.map(group=>{const items=jobs.filter(j=>j.group===group);return items.length?

{labels[group]} {items.length}

{items.map(j=>
{j.name}

{j.source_type} · attempts {j.attempt_count||0} · {j.relative_path||j.storage||j.source_id}

{j.state}{j.reason}
{j.can_run_now&&}{j.can_retry&&}{j.can_ignore&&}{j.can_remove&&}
)}
:null}):

No queue items.

}
} diff --git a/frontend/src/main.test.jsx b/frontend/src/main.test.jsx index 187e965..27a4dc4 100644 --- a/frontend/src/main.test.jsx +++ b/frontend/src/main.test.jsx @@ -1,10 +1,11 @@ +// @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SettingsDialog } from "./main"; +import { pollDelay, request, SettingsDialog } from "./main"; describe("Importarr UI behavior", () => { - beforeEach(() => vi.stubGlobal("fetch", vi.fn())); + beforeEach(() => { sessionStorage.clear(); vi.stubGlobal("fetch", vi.fn()); }); afterEach(() => vi.restoreAllMocks()); it("hydrates settings URLs when status arrives asynchronously", async () => { @@ -26,4 +27,22 @@ describe("Importarr UI behavior", () => { await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/import/run-now", expect.objectContaining({ method: "POST", body: JSON.stringify({ force: true }) }))); }); + + it("prompts for write auth and retries with the entered token", async () => { + fetch.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ detail: "auth required" }) }).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ok: true }) }); + vi.stubGlobal("prompt", vi.fn(() => "browser-token")); + await request("/api/queue-items/1/action", { method: "POST", body: { action: "run-now" } }); + expect(fetch).toHaveBeenLastCalledWith("/api/queue-items/1/action", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer browser-token" }) })); + }); + + it("authenticates the protected update-check GET", async () => { + fetch.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ detail: "auth required" }) }).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ update_available: false }) }); + vi.stubGlobal("prompt", vi.fn(() => "browser-token")); + await request("/api/control/update-check", { auth: true }); + expect(fetch).toHaveBeenLastCalledWith("/api/control/update-check", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer browser-token" }) })); + }); + + it("backs polling off after failures and immediately recovers its fast interval", () => { + expect([pollDelay(0),pollDelay(1),pollDelay(2),pollDelay(4)]).toEqual([300,1000,2000,5000]); + }); }); diff --git a/importarr/__init__.py b/importarr/__init__.py index 485f44a..b3f4756 100644 --- a/importarr/__init__.py +++ b/importarr/__init__.py @@ -1 +1 @@ -__version__ = "0.1.1" +__version__ = "0.1.2" diff --git a/importarr/main.py b/importarr/main.py index 5c9a69e..968f344 100644 --- a/importarr/main.py +++ b/importarr/main.py @@ -286,6 +286,7 @@ def _worker_loop() -> None: item = state.claim_next_queue_item(WORKER_ID) if queue_accepting_new_jobs() else None if item is not None: _import_queue_item(item, importer, from_worker=True) + continue except Exception: state.upsert_queue_item(source_type="system", source_id="queue-worker", name="Queue worker", state="failed", reason="worker loop error") _worker_stop.wait(settings.poll_seconds) diff --git a/pyproject.toml b/pyproject.toml index 37d2986..14a9caa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "importarr" -version = "0.1.1" +version = "0.1.2" description = "Arr-style manual SABnzbd import service" readme = "README.md" requires-python = ">=3.12"