diff --git a/core/automation_engine.py b/core/automation_engine.py index d19cbb58..82329279 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -997,6 +997,7 @@ class AutomationEngine: """Send message via Telegram Bot API.""" bot_token = config.get('bot_token', '').strip() chat_id = config.get('chat_id', '').strip() + thread_id = config.get('thread_id', '').strip() if not bot_token or not chat_id: raise ValueError("Bot token and chat ID are required for Telegram") @@ -1007,7 +1008,7 @@ class AutomationEngine: resp = requests.post( f'https://api.telegram.org/bot{bot_token}/sendMessage', - json={"chat_id": chat_id, "text": message, "parse_mode": "HTML"}, + json={"chat_id": chat_id, "message_thread_id": thread_id, "text": message, "parse_mode": "HTML"}, timeout=10, ) data = resp.json() if resp.status_code == 200 else {} diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 7df4ab4b..72f5fe5f 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -4890,7 +4890,7 @@ function _promptNotifyConfig(groupName) { if (type === 'discord_webhook') { fieldsDiv.innerHTML = ''; } else if (type === 'telegram') { - fieldsDiv.innerHTML = ''; + fieldsDiv.innerHTML = ''; } else if (type === 'pushbullet') { fieldsDiv.innerHTML = ''; } else { @@ -4911,7 +4911,7 @@ function _promptNotifyConfig(groupName) { if (type === 'discord_webhook') { config = { webhook_url: (overlay.querySelector('#deploy-notify-url')?.value || '').trim() }; } else if (type === 'telegram') { - config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim() }; + config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim(), thread_id: (overlay.querySelector('#deploy-notify-thread')?.value || '').trim() }; } else if (type === 'pushbullet') { config = { access_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim() }; } else { @@ -6004,6 +6004,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) { if (blockType === 'telegram') { const botToken = _escAttr(config.bot_token || ''); const chatId = _escAttr(config.chat_id || ''); + const threadID = _escAttr(config.thread_id || ''); return `
@@ -6012,6 +6013,10 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
+
+ + +
@@ -6291,6 +6296,7 @@ function _readPlacedConfig(slotKey) { return { bot_token: document.getElementById('cfg-' + slotKey + '-bot_token')?.value?.trim() || '', chat_id: document.getElementById('cfg-' + slotKey + '-chat_id')?.value?.trim() || '', + thread_id: document.getElementById('cfg-' + slotKey + '-thread_id')?.value?.trim() || '', message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', }; } diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index f59ea4d0..c3a4d7fd 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -3094,6 +3094,150 @@ function openLibraryHistoryModal() { } } +// ────────────────────────────────────────────────────────────────────── +// Quarantine tab — rendered inside the Library History modal as a third +// tab next to Downloads + Server Imports. Reuses the existing list + +// pagination chrome; provides per-row Approve / Recover / Delete actions. +// ────────────────────────────────────────────────────────────────────── + +async function loadQuarantineList() { + const list = document.getElementById('library-history-list'); + const pagination = document.getElementById('library-history-pagination'); + const sourceBar = document.getElementById('history-source-bar'); + if (!list) return; + list.innerHTML = '
Loading...
'; + if (pagination) pagination.innerHTML = ''; + if (sourceBar) sourceBar.style.display = 'none'; + + try { + const resp = await fetch('/api/quarantine/list'); + const data = await resp.json(); + const entries = data.entries || []; + const countEl = document.getElementById('history-quarantine-count'); + if (countEl) countEl.textContent = entries.length; + + if (!data.success) { + list.innerHTML = `
Error: ${escapeHtml(data.error || 'Failed to load')}
`; + return; + } + if (entries.length === 0) { + list.innerHTML = '
🛡️

No quarantined files. Nice and clean.
'; + return; + } + list.innerHTML = entries.map(renderQuarantineEntry).join(''); + } catch (err) { + console.error('Error loading quarantine entries:', err); + list.innerHTML = '
Error loading quarantine
'; + } +} + +function renderQuarantineEntry(entry) { + const triggerLabels = { integrity: 'Duration / Integrity', acoustid: 'AcoustID Mismatch', bit_depth: 'Bit Depth Filter', unknown: 'Unknown' }; + const triggerColors = { integrity: '#facc15', acoustid: '#ef5350', bit_depth: '#fb923c', unknown: '#888' }; + const triggerLabel = triggerLabels[entry.trigger] || entry.trigger || 'Unknown'; + const triggerColor = triggerColors[entry.trigger] || '#888'; + + const id = escapeHtml(entry.id); + const approveLabel = entry.has_full_context ? 'Approve' : 'Recover'; + const approveTitle = entry.has_full_context + ? 'Re-run post-processing with only the failing check skipped' + : 'Legacy entry — move to Staging, finish via Import flow'; + const approveCall = entry.has_full_context + ? `approveQuarantineEntry('${id}')` + : `recoverQuarantineEntry('${id}')`; + + const meta = [entry.expected_artist, entry.original_filename].filter(Boolean).join(' — '); + const triggerBadge = `${escapeHtml(triggerLabel)}`; + const reasonDetail = `
Reason: ${escapeHtml(entry.reason || 'Unknown')}
`; + + return `
+
🛡️
+
+
+
+
${escapeHtml(entry.expected_track || entry.original_filename || 'Unknown')}
+ +
+
${triggerBadge}
+
${formatHistoryTime(entry.timestamp)}
+ + + +
+
+ ${reasonDetail} +
+
+
`; +} + +async function approveQuarantineEntry(entryId) { + const ok = await showConfirmDialog({ + title: 'Approve Quarantined File', + message: 'Re-run post-processing for this file with only the failing check skipped. The file will be tagged, lyrics generated, and moved into your library. Other quality gates (AcoustID + bit-depth) still run.', + confirmText: 'Approve & Import', + cancelText: 'Cancel', + }); + if (!ok) return; + try { + const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { method: 'POST' }); + const data = await r.json(); + if (!data.success) { + showToast(`Approve failed: ${data.error}`, 'error'); + } else { + showToast(`Approved — skipped ${data.trigger_bypassed} check, re-running pipeline.`, 'success'); + } + } catch (err) { + showToast(`Approve failed: ${err.message}`, 'error'); + } + loadQuarantineList(); +} + +async function recoverQuarantineEntry(entryId) { + const ok = await showConfirmDialog({ + title: 'Recover To Staging', + message: 'Legacy entry — no embedded context. The file will be moved to your Staging folder so you can finish via the Import page (manual match).', + confirmText: 'Move To Staging', + cancelText: 'Cancel', + }); + if (!ok) return; + try { + const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/recover`, { method: 'POST' }); + const data = await r.json(); + if (!data.success) { + showToast(`Recover failed: ${data.error}`, 'error'); + } else { + showToast('Moved to Staging — finish via the Import page.', 'success'); + } + } catch (err) { + showToast(`Recover failed: ${err.message}`, 'error'); + } + loadQuarantineList(); +} + +async function deleteQuarantineEntry(entryId) { + const ok = await showConfirmDialog({ + title: 'Delete Quarantined File', + message: 'This permanently removes the file and its metadata sidecar. Cannot be undone.', + confirmText: 'Delete', + cancelText: 'Cancel', + destructive: true, + }); + if (!ok) return; + try { + const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}`, { method: 'DELETE' }); + const data = await r.json(); + if (!data.success) { + showToast(`Delete failed: ${data.error}`, 'error'); + } else { + showToast('Quarantined file deleted.', 'success'); + } + } catch (err) { + showToast(`Delete failed: ${err.message}`, 'error'); + } + loadQuarantineList(); +} + function closeLibraryHistoryModal() { const overlay = document.getElementById('library-history-overlay'); if (overlay) overlay.classList.add('hidden'); @@ -3110,6 +3254,12 @@ function switchHistoryTab(tab) { async function loadLibraryHistory() { const { tab, page, limit } = _libraryHistoryState; + if (tab === 'quarantine') { + // Refresh the count for the other two tabs in the background so + // the badge stays accurate when the user switches over. + loadQuarantineList(); + return; + } const list = document.getElementById('library-history-list'); const pagination = document.getElementById('library-history-pagination'); if (!list) return; @@ -6199,6 +6349,7 @@ const TOOL_HELP_CONTENT = {