{ "$schema": "homarr-widget-package-v3", "manifest": { "id": "homarr.reference.downloads", "version": "1.0.0", "sdkVersion": "1", "name": "Unified downloads", "description": "Two independent torrent or Usenet clients with attributable queues, source-specific actions, partial batch results and guest-aware controls.", "entrypoints": { "tile": "widget.tsx", "advanced": "widget.tsx", "server": "server.ts" }, "handlers": { "queue": { "kind": "query", "permission": "view", "inputSchema": { "type": "object", "additionalProperties": false, "required": ["connection"], "properties": { "connection": { "type": "string", "enum": ["client", "secondary"] } } } }, "control": { "kind": "action", "permission": "modify", "inputSchema": { "type": "object", "additionalProperties": false, "required": ["connection", "operation"], "properties": { "connection": { "type": "string", "enum": ["client", "secondary"] }, "operation": { "type": "string", "enum": ["pause", "resume", "pauseItem", "resumeItem", "deleteItem"] }, "itemId": { "type": "string", "minLength": 1 } } }, "guestAccess": true }, "sources": { "kind": "query", "permission": "view" }, "controlBatch": { "kind": "action", "permission": "modify", "guestAccess": true, "inputSchema": { "type": "object", "additionalProperties": false, "required": ["connections", "operation"], "properties": { "connections": { "type": "array", "minItems": 1, "maxItems": 2, "uniqueItems": true, "items": { "type": "string", "enum": ["client", "secondary"] } }, "operation": { "type": "string", "enum": ["pause", "resume"] } } } } } }, "files": { "server.ts": "import type { BatchControl, BatchResult, ControlResult, DownloadConnection, DownloadControl, DownloadQueue, DownloadSource, SourceQueue } from \"./download-types\";\nimport type { WidgetServerContext } from \"@homarr/widget-sdk/server\";\nimport { callWidgetIntegration, defineWidgetServer, getWidgetServerContext } from \"@homarr/widget-sdk/server\";\n\nconst declared:DownloadConnection[] = [\"client\",\"secondary\"];\nconst allowed = new Set([\"pause\",\"resume\",\"pauseItem\",\"resumeItem\",\"deleteItem\"]);\nasync function sources(context:WidgetServerContext):Promise {\n const {bindingNames,options} = await getWidgetServerContext(context);\n return declared.filter((connection) => bindingNames.includes(connection)).map((connection) => {\n let label = options.primaryLabel;\n if (connection === \"secondary\") label = options.secondaryLabel;\n if (typeof label !== \"string\" || !label.trim()) label = connection === \"client\" ? \"Primary client\" : \"Second client\";\n return {connection,label:String(label)};\n });\n}\nasync function source(context:WidgetServerContext,connection:DownloadConnection) {\n const result = (await sources(context)).find((candidate) => candidate.connection === connection);\n if (!result) throw new Error(\"Choose a declared, bound download client\");\n return result;\n}\nasync function queue(context:WidgetServerContext,connection:DownloadConnection):Promise {\n const selected = await source(context,connection);\n const result = await callWidgetIntegration(context,{connection,capability:\"downloads.queue\",input:{limit:100}});\n return {...selected,data:result.data};\n}\nasync function control(context:WidgetServerContext,input:DownloadControl):Promise {\n if (!allowed.has(input?.operation)) throw new Error(\"Unknown download operation\");\n await source(context,input.connection);\n if (input.operation === \"pause\" || input.operation === \"resume\") {\n await callWidgetIntegration(context,{connection:input.connection,capability:`downloads.${input.operation}`,input:{}});\n } else {\n if (typeof input.itemId !== \"string\" || !input.itemId) throw new Error(\"Choose a download from this client\");\n const current = await queue(context,input.connection);\n const item = current.data.items.find((candidate) => candidate.id === input.itemId);\n if (!item) throw new Error(\"The download is no longer in this client's queue; refresh and try again\");\n // Resolve the canonical item in the selected client. Never trust a browser-supplied client index or item object.\n await callWidgetIntegration(context,{connection:input.connection,capability:`downloads.${input.operation}`,input:{item,fromDisk:false}});\n }\n return {connection:input.connection,operation:input.operation,ok:true};\n}\nexport default defineWidgetServer({\n sources: (_input:unknown,context) => sources(context),\n queue: (input:{connection:DownloadConnection},context) => queue(context,input.connection),\n control: (input:DownloadControl,context) => control(context,input),\n controlBatch: async (input:BatchControl,context):Promise => {\n if (![\"pause\",\"resume\"].includes(input?.operation)) throw new Error(\"Batch actions support pause or resume only\");\n if (!Array.isArray(input.connections) || !input.connections.length || input.connections.length > 2 || new Set(input.connections).size !== input.connections.length)\n throw new Error(\"Choose one or two different download clients\");\n const available = await sources(context);\n if (input.connections.some((connection) => !available.some((candidate) => candidate.connection === connection)))\n throw new Error(\"Every batch target must be a declared, bound client\");\n const results = await Promise.all(input.connections.map(async (connection):Promise => {\n try { return await control(context,{connection,operation:input.operation}); }\n catch (error) { return {connection,operation:input.operation,ok:false,error:error instanceof Error ? error.message : String(error)}; }\n }));\n const succeeded = results.filter((result) => result.ok).length;\n return {results,succeeded,failed:results.length-succeeded};\n },\n});\n", "download-source.tsx": "import type { DownloadControl, DownloadItem, DownloadSource, SourceQueue, ControlResult } from \"./download-types\";\nimport { Alert, Badge, Button, Group, Progress, Stack, Text } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetCapabilities, useWidgetHost, useWidgetQuery, useWidgetServices } from \"@homarr/widget-sdk\";\nconst rate = (value:number|undefined) => `${((value ?? 0)/1048576).toFixed(1)} MiB/s`;\nexport function DownloadSourceRegion({source,filter,sort}:{source:DownloadSource;filter:string;sort:string}) {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const capabilities = useWidgetCapabilities();\n const query = useWidgetQuery(\"queue\",{connection:source.connection},{refetchInterval:10000});\n const action = useWidgetAction(\"control\");\n const status = query.data?.data.status;\n const error = query.error ?? action.error;\n const run = (input:DownloadControl) => {void action.mutateAsync(input).catch(() => undefined);};\n const canRun = (input:DownloadControl) => !host.isEditMode && !action.isPending && capabilities.canRun(\"control\",input);\n const remove = async (item:DownloadItem) => {\n const input:DownloadControl = {connection:source.connection,operation:\"deleteItem\",itemId:item.id};\n if (!canRun(input)) return;\n if (await services.confirm({title:\"Remove download\",message:`Remove ${item.name} from ${source.label}? Downloaded files will be kept.`,destructive:true})) run(input);\n };\n const rows = query.data?.data.items ?? [];\n let items = rows.filter((item) => item.name.toLocaleLowerCase().includes(filter.toLocaleLowerCase()));\n if (sort === \"progress\") items = items.toSorted((a,b) => b.progress-a.progress);\n if (sort === \"name\") items = items.toSorted((a,b) => a.name.localeCompare(b.name));\n if (host.displayMode === \"compact\") items = items.slice(0,3);\n const pause:DownloadControl = {connection:source.connection,operation:\"pause\"};\n const resume:DownloadControl = {connection:source.connection,operation:\"resume\"};\n return \n {source.label} \u00b7 {rows.length}{status && {status.paused ? \"Paused\" : rate(status.rates.down)}}\n {error && {error.message}}\n {action.isPending && Updating this client\u2026}\n {query.isPending && Loading this client's queue\u2026}\n {host.displayMode === \"advanced\" && }\n {items.map((item) => {\n const toggle:DownloadControl = {connection:source.connection,operation:item.state === \"paused\" ? \"resumeItem\" : \"pauseItem\",itemId:item.id};\n const deletion:DownloadControl = {connection:source.connection,operation:\"deleteItem\",itemId:item.id};\n return \n {item.name}{item.state}\n \n {Math.round(item.progress*100)}% \u00b7 {rate(item.downSpeed)}{host.displayMode === \"advanced\" && }\n ;\n })}\n {!query.isPending && !query.error && items.length === 0 && No downloads match this client view.}\n {action.data?.ok && {source.label}: {action.data.operation} completed}\n ;\n}\n", "download-types.ts": "export type DownloadConnection = \"client\" | \"secondary\";\nexport type DownloadOperation = \"pause\" | \"resume\" | \"pauseItem\" | \"resumeItem\" | \"deleteItem\";\nexport interface DownloadItem {\n id:string;name:string;state:string;progress:number;downSpeed?:number;\n index:number;type:\"torrent\"|\"usenet\"|\"miscellaneous\";size:number;time:number;\n}\nexport interface DownloadQueue {data:{items:DownloadItem[];status:{paused:boolean;rates:{down:number;up?:number}}}}\nexport interface DownloadSource {connection:DownloadConnection;label:string}\nexport interface SourceQueue extends DownloadSource {data:DownloadQueue[\"data\"]}\nexport interface DownloadControl {connection:DownloadConnection;operation:DownloadOperation;itemId?:string}\nexport interface BatchControl {connections:DownloadConnection[];operation:\"pause\"|\"resume\"}\nexport interface ControlResult {connection:DownloadConnection;operation:DownloadOperation;ok:boolean;error?:string}\nexport interface BatchResult {results:ControlResult[];succeeded:number;failed:number}\nexport interface DownloadOptions {primaryLabel:string;secondaryLabel:string}\n", "widget.tsx": "import type { BatchControl, BatchResult, DownloadConnection, DownloadSource } from \"./download-types\";\nimport { Alert, Button, Group, MultiSelect, Select, Stack, Text, TextInput } from \"@mantine/core\";\nimport { useWidgetAction, useWidgetCapabilities, useWidgetCommands, useWidgetHost, useWidgetQuery, useWidgetState } from \"@homarr/widget-sdk\";\nimport { DownloadSourceRegion } from \"./download-source\";\nexport default function Downloads() {\n const host = useWidgetHost();\n const sources = useWidgetQuery(\"sources\",{}, {staleTime:60000});\n const capabilities = useWidgetCapabilities();\n const batch = useWidgetAction(\"controlBatch\");\n const [filter,setFilter] = useWidgetState(\"filter\",\"\");\n const [sort,setSort] = useWidgetState(\"sort\",\"queue\");\n const [selection,setSelection] = useWidgetState(\"batch-clients\",[]);\n const available = sources.data ?? [];\n const targets = selection.filter((name) => available.some((source) => source.connection === name));\n const all = available.map((source) => source.connection);\n const canRun = (input:BatchControl) => input.connections.length > 0 && !host.isEditMode && !batch.isPending && capabilities.canRun(\"controlBatch\",input);\n const run = (input:BatchControl) => {void batch.mutateAsync(input).catch(() => undefined);};\n const pauseAll:BatchControl = {connections:all,operation:\"pause\"};\n const resumeAll:BatchControl = {connections:all,operation:\"resume\"};\n const pauseSelected:BatchControl = {connections:targets,operation:\"pause\"};\n const resumeSelected:BatchControl = {connections:targets,operation:\"resume\"};\n useWidgetCommands([{id:\"pause-all\",label:\"Pause all download clients\",disabled:!canRun(pauseAll),run:() => run(pauseAll)},{id:\"resume-all\",label:\"Resume all download clients\",disabled:!canRun(resumeAll),run:() => run(resumeAll)}]);\n return \n Downloads \u00b7 {available.length} clients\n {sources.error && {sources.error.message}}\n {host.displayMode === \"advanced\" && <>\n setFilter(event.currentTarget.value)} />