@ouro.bot/cli 0.1.0-alpha.825 → 0.1.0-alpha.826
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.826",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Sanctuary: a download sitting at zero bytes is reported as stalled rather than as progress, with a blocklist-and-research repair."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
4
10
|
{
|
|
5
11
|
"version": "0.1.0-alpha.825",
|
|
6
12
|
"changes": [
|
|
@@ -73,6 +73,10 @@ class ServiceError extends Error {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
const seerr = (path, opts = {}) => req(SEERR.url, `/api/v1${path}`, { key: SEERR.apiKey, ...opts })
|
|
76
|
+
// Long enough that an ordinary slow start is not called dead, short enough that
|
|
77
|
+
// nobody waits a day for a release that was never going to arrive.
|
|
78
|
+
const STALL_AFTER_HOURS = 6
|
|
79
|
+
|
|
76
80
|
const sonarr = (path, opts = {}) => req(SONARR.url, `/api/v3${path}`, { key: SONARR.apiKey, ...opts })
|
|
77
81
|
const radarr = (path, opts = {}) => req(RADARR.url, `/api/v3${path}`, { key: RADARR.apiKey, ...opts })
|
|
78
82
|
const prowlarr = (path, opts = {}) => req(PROWLARR.url, `/api/v1${path}`, { key: PROWLARR.apiKey, ...opts })
|
|
@@ -427,14 +431,35 @@ async function mediaRequestStatus(a) {
|
|
|
427
431
|
const byDownload = new Map()
|
|
428
432
|
for (const r of queueRecs) byDownload.set(r.downloadId ?? `row:${r.id}`, r)
|
|
429
433
|
const downloads = [...byDownload.values()]
|
|
434
|
+
const sizeLeft = downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0)
|
|
435
|
+
const sizeTotal = downloads.reduce((s, r) => s + (r.size ?? 0), 0)
|
|
436
|
+
// A torrent that has not moved a byte since it was grabbed is not slow, it is
|
|
437
|
+
// dead: no seeders, or a release the client cannot fetch. Radarr goes on
|
|
438
|
+
// reporting trackedDownloadStatus "ok" for these indefinitely, so a queue row
|
|
439
|
+
// on its own reads as healthy and any answer built from it reassures instead
|
|
440
|
+
// of acting. Measuring progress against age is what separates the two.
|
|
441
|
+
const now = Date.now()
|
|
442
|
+
const stalledItems = downloads
|
|
443
|
+
.filter((r) => (r.size ?? 0) > 0 && (r.sizeleft ?? 0) >= (r.size ?? 0) && r.added
|
|
444
|
+
&& (now - Date.parse(r.added)) / 3_600_000 >= STALL_AFTER_HOURS)
|
|
445
|
+
.map((r) => ({
|
|
446
|
+
title: r.title ?? null,
|
|
447
|
+
added_at: r.added ?? null,
|
|
448
|
+
age_hours: Math.round((now - Date.parse(r.added)) / 3_600_000),
|
|
449
|
+
size_gb: Number(((r.size ?? 0) / 1073741824).toFixed(2)),
|
|
450
|
+
queue_id: r.id ?? null,
|
|
451
|
+
}))
|
|
430
452
|
result.download = {
|
|
431
453
|
active_downloads: downloads.length,
|
|
432
454
|
active_items: queueRecs.length,
|
|
433
455
|
states: [...new Set(downloads.map((r) => r.status))],
|
|
434
456
|
tracked_states: [...new Set(downloads.map((r) => r.trackedDownloadState).filter(Boolean))],
|
|
435
457
|
errors: [...new Set(downloads.map((r) => r.errorMessage).filter(Boolean))],
|
|
436
|
-
size_left_bytes:
|
|
437
|
-
size_total_bytes:
|
|
458
|
+
size_left_bytes: sizeLeft,
|
|
459
|
+
size_total_bytes: sizeTotal,
|
|
460
|
+
percent_complete: sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : null,
|
|
461
|
+
stalled: stalledItems.length > 0,
|
|
462
|
+
stalled_items: stalledItems,
|
|
438
463
|
}
|
|
439
464
|
|
|
440
465
|
const chain = await chainHealth()
|
|
@@ -453,6 +478,15 @@ function diagnose({ shelf, result, chain, entity, kind }) {
|
|
|
453
478
|
return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
|
|
454
479
|
summary: "On the shelf and complete. Nothing is stuck." }
|
|
455
480
|
}
|
|
481
|
+
if (result.download.stalled) {
|
|
482
|
+
const items = result.download.stalled_items
|
|
483
|
+
const oldest = Math.max(...items.map((i) => i.age_hours))
|
|
484
|
+
return { stuck_stage: "download", stuck_reason: "stalled_no_progress",
|
|
485
|
+
likely_fix: "blocklist_and_research", human_action_required: false,
|
|
486
|
+
percent_complete: result.download.percent_complete,
|
|
487
|
+
detail: items,
|
|
488
|
+
summary: `Stalled, not slow. ${items.length === 1 ? "The release" : `${items.length} releases`} ${items.length === 1 ? "has" : "have"} not downloaded a single byte in ${oldest} hours, which means no seeders rather than a quiet queue. Waiting will not fix it; blocklist the release and search again for a different one.` }
|
|
489
|
+
}
|
|
456
490
|
if (result.download.active_downloads > 0) {
|
|
457
491
|
const left = result.download.size_left_bytes
|
|
458
492
|
const total = result.download.size_total_bytes
|
|
@@ -528,9 +562,23 @@ async function mediaDiagnoseAndFix(a) {
|
|
|
528
562
|
? (action === "force_import_scan" ? { name: "RescanSeries", seriesId: svcId } : { name: "SeriesSearch", seriesId: svcId })
|
|
529
563
|
: (action === "force_import_scan" ? { name: "RescanMovie", movieIds: [svcId] } : { name: "MoviesSearch", movieIds: [svcId] })
|
|
530
564
|
commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
|
|
565
|
+
} else if (action === "blocklist_and_research") {
|
|
566
|
+
// Removing with blocklist=true is what stops the same dead release being
|
|
567
|
+
// grabbed straight back. The search that follows is then free to pick a
|
|
568
|
+
// different one.
|
|
569
|
+
const stalled = before.download.stalled_items ?? []
|
|
570
|
+
if (!stalled.length) return { action, result: "nothing_to_blocklist", before, after: null, human_action_required: false,
|
|
571
|
+
human_action_reason: "No download has been sitting at zero long enough to call it stalled." }
|
|
572
|
+
for (const item of stalled) {
|
|
573
|
+
if (item.queue_id === null) continue
|
|
574
|
+
const client = kind === "series" ? sonarr : radarr
|
|
575
|
+
await client(`/queue/${item.queue_id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } })
|
|
576
|
+
}
|
|
577
|
+
const cmd = kind === "series" ? { name: "SeriesSearch", seriesId: svcId } : { name: "MoviesSearch", movieIds: [svcId] }
|
|
578
|
+
commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
|
|
531
579
|
} else {
|
|
532
580
|
return { action, result: "unsupported_action", before, after: null, human_action_required: true,
|
|
533
|
-
human_action_reason: `Action "${action}" is not implemented. Supported: rescan, enable_monitoring, force_import_scan, report_only.` }
|
|
581
|
+
human_action_reason: `Action "${action}" is not implemented. Supported: rescan, enable_monitoring, force_import_scan, blocklist_and_research, report_only.` }
|
|
534
582
|
}
|
|
535
583
|
|
|
536
584
|
await new Promise((r) => setTimeout(r, 12_000))
|
|
@@ -553,6 +601,7 @@ function describeAction(action) {
|
|
|
553
601
|
rescan: "Trigger a fresh indexer search and grab the best acceptable release.",
|
|
554
602
|
enable_monitoring: "Mark it monitored, then search.",
|
|
555
603
|
force_import_scan: "Re-scan the disk so an already-downloaded file gets imported.",
|
|
604
|
+
blocklist_and_research: "Blocklist the stalled release so it cannot be grabbed again, then search for a different one.",
|
|
556
605
|
report_only: "Report only; change nothing.",
|
|
557
606
|
}[action] ?? "Unknown action."
|
|
558
607
|
}
|
|
@@ -625,7 +674,7 @@ const TOOLS = [
|
|
|
625
674
|
},
|
|
626
675
|
{
|
|
627
676
|
name: "media_request_status",
|
|
628
|
-
description: "One call, every stage: request, indexer search, download, file on disk, plus acquisition-chain health and a deterministic `diagnosis` block naming what is stuck, why, and the fix. Use this for any 'is it here yet?' or 'why hasn't X downloaded?' question. Never infer the cause yourself — read diagnosis.summary.",
|
|
677
|
+
description: "One call, every stage: request, indexer search, download, file on disk, plus acquisition-chain health and a deterministic `diagnosis` block naming what is stuck, why, and the fix. Use this for any 'is it here yet?' or 'why hasn't X downloaded?' question. A queue row is not proof of progress: a release sitting at zero bytes comes back as stalled, not as downloading. Never infer the cause yourself — read diagnosis.summary.",
|
|
629
678
|
inputSchema: { type: "object", properties: {
|
|
630
679
|
request_id: { type: "string", description: "e.g. jellyseerr:42" },
|
|
631
680
|
tmdb_id: { type: "number" },
|
|
@@ -634,10 +683,10 @@ const TOOLS = [
|
|
|
634
683
|
},
|
|
635
684
|
{
|
|
636
685
|
name: "media_diagnose_and_fix",
|
|
637
|
-
description: "Act on a stuck request. Omit `action` to apply the fix that media_request_status already identified. Refuses to act (result 'rejected_fix_unsafe') when the acquisition chain itself is down, because re-searching cannot help then. Use dry_run to preview.",
|
|
686
|
+
description: "Act on a stuck request. Omit `action` to apply the fix that media_request_status already identified, including 'blocklist_and_research' for a release that has stalled at zero bytes. Refuses to act (result 'rejected_fix_unsafe') when the acquisition chain itself is down, because re-searching cannot help then. Use dry_run to preview.",
|
|
638
687
|
inputSchema: { type: "object", properties: {
|
|
639
688
|
request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
|
|
640
|
-
action: { type: "string", enum: ["rescan", "enable_monitoring", "force_import_scan", "report_only"] },
|
|
689
|
+
action: { type: "string", enum: ["rescan", "enable_monitoring", "force_import_scan", "blocklist_and_research", "report_only"] },
|
|
641
690
|
dry_run: { type: "boolean" },
|
|
642
691
|
} },
|
|
643
692
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.826</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.826",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.826",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|