diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index e13ec88..39dd52e 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -66,7 +66,7 @@ // GET ?action=chats stored conversations, newest first, metadata only // GET ?action=chat_get&id= one stored conversation with its transcript // GET ?action=findings [all=1] repair findings, open only unless all=1 -// POST action=finding_action id= act=fix|ack|dismiss|reopen|cancel [note=…] +// POST action=finding_action id= act=fix|move|ack|dismiss|reopen|cancel [note=…] // POST action=ask question=… [history=] [kind=…] [think=0|1] // POST action=memory_set memory=… replace the memory file // POST action=clear token= discard a finished job diff --git a/Plugin/unraid/include/ai_repair.php b/Plugin/unraid/include/ai_repair.php index aad9961..8dad561 100644 --- a/Plugin/unraid/include/ai_repair.php +++ b/Plugin/unraid/include/ai_repair.php @@ -90,6 +90,7 @@ const VV_AI_FINDING_KINDS = [ 'arr_health' => 'an arr is reporting a problem about itself', 'system_fault' => 'the kernel reported a hardware or filesystem fault about this machine', 'container_fault' => 'a container is logging a fault about its own environment', + 'media_misfiled' => 'a series is shelved somewhere its own metadata does not support', 'watchdog_strike' => 'a watchdog has counted something far enough to be worth a record', ]; @@ -500,6 +501,14 @@ function vv_ai_finding_actions(array $f): array { : 'Write the proven value to ' . $f['conf_key']; } + // Offered only when a destination was recorded, which is only for a series the triage called + // misfiled. "Uncertain" carries no move_to, so it gets the same buttons every other finding + // has and no way to act on a judgement nobody made. + if (($f['move_to'] ?? '') !== '' && (int)($f['move_id'] ?? 0) > 0) { + $actions['move'] = 'Move ' . ($f['subject'] ?? 'it') . ' to ' . $f['move_to'] + . ' — relocates the files on disk'; + } + $actions['ack'] = ($f['conf_key'] ?? '') !== '' ? 'Known and intended. Stays quiet until ' . $f['conf_key'] . ' changes' : 'Known and intended. Stays quiet until the fault itself changes'; @@ -1070,6 +1079,8 @@ function vv_ai_ask_model(string $prompt, int $timeout = 120): ?string { function vv_ai_classification_findings(): array { if (!vv_ai_assist_discovery_enabled()) return []; + $vars = vv_conf_vars(); + $myId = strtoupper(vv_detect_host()); $rev = vv_ai_classification_review(); $items = $rev['reverse_anime'] ?? []; if (!$items) return []; @@ -1078,8 +1089,12 @@ function vv_ai_classification_findings(): array { foreach (vv_ai_triage_classification($items) as $r) { if (!in_array($r['bucket'], ['misfiled', 'uncertain'], true)) continue; $title = (string)($r['title'] ?? '?'); + // Only a misfiled series gets a destination. "Uncertain" means nobody knows where it + // belongs, and offering a move for it would turn a shrug into a button. + $target = $r['bucket'] === 'misfiled' ? (string)($vars[$myId . '_SONARR_GENERAL_ROOT'] ?? '') : ''; + $found[] = [ - 'kind' => 'watchdog_strike', + 'kind' => 'media_misfiled', 'subject' => $title, 'pin' => 'cls:' . $r['bucket'] . '|' . $title, 'ref' => 'classification', @@ -1088,11 +1103,18 @@ function vv_ai_classification_findings(): array { 'sys_level' => $r['bucket'] === 'misfiled' ? 'warn' : 'info', 'observed' => $r['bucket'], 'evidence' => sprintf('%s sits in %s. The metadata rule found no anime signal, and ' - . 'the triage calls it %s — %s. Network %s, certification %s. ' - . 'Nothing has been moved.', + . 'the triage calls it %s — %s. Network %s, certification %s.%s', $title, $r['root'] ?? 'the anime root', $r['bucket'], $r['why'] ?? 'no reason given', - $r['network'] ?: 'unknown', $r['cert'] ?: 'unknown'), + $r['network'] ?: 'unknown', $r['cert'] ?: 'unknown', + $target !== '' ? ' Moving it would relocate the files to ' . $target . '.' + : ' Nothing has been moved.'), + // What Accept would do. Carried on the finding rather than recomputed at press time, + // so the button acts on the judgement that was shown rather than on a fresh one. + 'move_arr' => 'sonarr', + 'move_id' => (int)($r['id'] ?? 0), + 'move_from' => (string)($r['root'] ?? ''), + 'move_to' => $target, 'source_log' => 'Arrs_Stack/sonarr_classification_scan', 'state' => 'needs_operator', ]; @@ -1100,6 +1122,71 @@ function vv_ai_classification_findings(): array { return $found; } +// Relocates a series to another root folder, files and all, through Sonarr's own API. +// +// The one genuinely destructive thing this file can do, so it is deliberately awkward to reach: +// only from the 'move' action, which is only offered on a finding that recorded a destination, +// which only happens for a series the triage called misfiled. Three narrowings, each of which has +// to hold before a file is touched. +// +// moveFiles=true is the whole point — changing rootFolderPath without it leaves Sonarr pointing at +// a path where nothing lives, which is worse than the misfiling it was correcting. +// +// Re-reads the series from Sonarr rather than trusting the finding's copy. The finding may be +// hours old and the operator may have moved it by hand in the meantime; PUTting a stale record +// back would undo that silently. +function vv_ai_move_series(array $f): array { + $id = (int)($f['move_id'] ?? 0); + $to = trim((string)($f['move_to'] ?? '')); + if ($id <= 0 || $to === '') return ['ok' => false, 'error' => 'no destination recorded']; + + require_once __DIR__ . '/arrs.php'; + $arr = null; + foreach (vv_discover_arrs() as $node) { + foreach ($node['arrs'] as $a) if ($a['type'] === 'sonarr') { $arr = $a; break 2; } + } + if (!$arr) return ['ok' => false, 'error' => 'no sonarr configured']; + + $cur = vv_arr_http($arr['url'], $arr['key'], '/api/v3/series/' . $id, 10); + if (!is_array($cur) || empty($cur['id'])) return ['ok' => false, 'error' => 'series not found']; + + // Already where it should be — a no-op is success, not a failure, and saying so stops the + // finding being reopened forever by a fault that is already fixed. + if (($cur['rootFolderPath'] ?? '') === $to) { + return ['ok' => true, 'note' => 'already there']; + } + + $cur['rootFolderPath'] = $to; + $ctx = stream_context_create(['http' => [ + 'method' => 'PUT', + 'header' => "X-Api-Key: {$arr['key']}\r\nContent-Type: application/json\r\n", + 'content' => json_encode($cur), + 'timeout' => 30, + 'ignore_errors' => true, + ]]); + $res = @file_get_contents(rtrim($arr['url'], '/') . '/api/v3/series/' . $id . '?moveFiles=true', + false, $ctx); + if ($res === false) return ['ok' => false, 'error' => 'sonarr did not answer']; + + $d = json_decode($res, true); + if (!is_array($d) || ($d['rootFolderPath'] ?? '') !== $to) { + return ['ok' => false, 'error' => 'sonarr did not accept the move']; + } + vv_ai_audit_move($f, $to); + return ['ok' => true]; +} + +// A move leaves no trace in conf_changes.log because it is not a conf change, and the arr's own +// history records it as an edit without saying who asked. Written here so "why did this series +// move" has an answer that names the finding. +function vv_ai_audit_move(array $f, string $to): void { + @file_put_contents(LOG_DIR . '/conf_changes.log', + sprintf("%s finding=%s move subject=%s from=%s to=%s ip=%s\n", + date('Y-m-d H:i:s'), $f['id'] ?? '?', $f['subject'] ?? '?', + $f['move_from'] ?? '?', $to, $_SERVER['REMOTE_ADDR'] ?? 'cli'), + FILE_APPEND | LOCK_EX); +} + // ── Reaching the operator ──────────────────────────────────────────────────────────────────── // A finding nobody is told about is a finding nobody has. The card on the AI tab shows them, but // only to someone who opens the tab, and the point of this subsystem is that it works while @@ -1467,6 +1554,17 @@ function vv_ai_finding_apply_action(string $id, string $action, string $note = ' // Back to open, never straight back to needs_operator: whether it still cannot be // repaired here is the next sweep's finding to make, not a state to restore. + // The only action in this file that changes something outside conf. It relocates files on + // disk through Sonarr, which is why it is a button and not something a sweep decides: + // vv_ai_move_series() is reached from here and from nowhere else. + case 'move': + $r = vv_ai_move_series($f); + if (!($r['ok'] ?? false)) return $r + ['action' => 'move']; + // Closed rather than acked. Acked means "known and intended, stay quiet"; this one + // was acted on, and the record should say the fault is gone rather than tolerated. + vv_ai_finding_close($id, 'Moved to ' . ($f['move_to'] ?? '?') . '. ' . $note); + return ['ok' => true, 'action' => 'move', 'moved_to' => $f['move_to'] ?? '']; + case 'reopen': return ['ok' => vv_ai_finding_set_state($id, 'open', $note), 'action' => 'reopen']; diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index 942a0e4..0f62c86 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -768,7 +768,7 @@ vv_ai_chat_markup('vv-ai', [ // Labels are the page's; meanings are not. The title on each button is the server's own // description of that action, so the wording an operator hovers is the same wording the chat // uses for the same row. - const FND_LABEL = { fix: 'Fix', ack: 'I know', dismiss: 'Never a problem', reopen: 'Reopen' }; + const FND_LABEL = { fix: 'Fix', move: 'Move it', ack: 'I know', dismiss: 'Never a problem', reopen: 'Reopen' }; // Which presses take a second one, and what the button says while it waits. Source-aware on // purpose: repair's Fix writes conf and its Dismiss is the one state the sweep will never @@ -777,7 +777,11 @@ vv_ai_chat_markup('vv-ai', [ // ceremony — a single map keyed on the action name alone would have armed it by accident. function qArmLabel(row, act) { if (row.src === 'repair') { + // Move is armed because it is the only action here that touches files rather than settings. + // Sonarr relocates the episodes on disk, and the undo is another move that has to copy + // everything back — so the second press is the cheapest part of the whole operation. return act === 'fix' ? 'Confirm write' + : act === 'move' ? 'Confirm move' : act === 'dismiss' ? 'Confirm — permanent' : null; } // Keeping is armed because it is the only route by which model-written text reaches a future