Let a misfiled series be moved from the finding that reported it
The triage could say Narcos is filed under anime and offer nothing to do about it. Findings that recorded a destination now carry a Move action, which the AI tab renders as a button on its own — actions are read from the server, so no UI knew this existed. Armed like Fix and Dismiss, because it is the only action in that card that relocates files rather than settings and the undo is another move. Offered only where the triage said misfiled, never for uncertain: three narrowings before a file is touched. Re-reads the series from Sonarr first, so a finding gone stale cannot undo a move made by hand in the meantime.
This commit is contained in:
@@ -66,7 +66,7 @@
|
||||
// GET ?action=chats stored conversations, newest first, metadata only
|
||||
// GET ?action=chat_get&id=<hex32> one stored conversation with its transcript
|
||||
// GET ?action=findings [all=1] repair findings, open only unless all=1
|
||||
// POST action=finding_action id=<hex12> act=fix|ack|dismiss|reopen|cancel [note=…]
|
||||
// POST action=finding_action id=<hex12> act=fix|move|ack|dismiss|reopen|cancel [note=…]
|
||||
// POST action=ask question=… [history=<JSON>] [kind=…] [think=0|1]
|
||||
// POST action=memory_set memory=… replace the memory file
|
||||
// POST action=clear token=<hex32> discard a finished job
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user