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 `
@@ -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')}
+
${escapeHtml(meta)}
+
+
${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 = {
- Bot Token: Your Telegram bot token (from @BotFather)
- Chat ID: The chat/group ID to send messages to
+ - Thread ID: Thread ID
- Message Template: Custom message with variable placeholders