From 1a5cbc3e47653f04a0e197b1a65c77935b38061e Mon Sep 17 00:00:00 2001 From: david Date: Mon, 17 Aug 2026 17:44:29 -0400 Subject: [PATCH] feat: Add Relocation History & 1-Click Undo (Tab 4), Library Storage Analytics Heatmap, Zero-Byte & Empty Directory Cleaner, Command Palette (Ctrl+K), In-Player Snapshot/PiP/Loop tools, and CSV Exporters --- qbittorrent_performer_sorter.html | 707 +++++++++++++++++++++++++++++- server.py | 383 +++++++++++++++- 2 files changed, 1081 insertions(+), 9 deletions(-) diff --git a/qbittorrent_performer_sorter.html b/qbittorrent_performer_sorter.html index ed9c553..370708a 100644 --- a/qbittorrent_performer_sorter.html +++ b/qbittorrent_performer_sorter.html @@ -1111,9 +1111,14 @@ + @@ -1368,7 +1373,11 @@ + + @@ -1477,6 +1486,10 @@ + +
@@ -1551,8 +1564,8 @@
-
-
+
+
+ + @@ -1627,8 +1644,142 @@
+ + +
+ +
+
+ Total Organized Moves + 0 +
+
+ Active Organized Files + 0 +
+
+ Undone Relocations + 0 +
+
+ Total Storage Managed + 0 GB +
+
+ + +
+
+ + + + + + + + + +
+ +
+ +
+
+ + +
+
+
+ ๐Ÿ“Š Library Storage Heatmap & Top Performers +
+
Free Space: Calculating...
+
+
+
Loading library disk analytics...
+
+
+ + +
+
+
+ ๐Ÿ“œ Relocation Audit Log & 1-Click Undo History +
+ 0 entries +
+ +
+ + + + + + + + + + + + + + + + + + +
TimeTypeFile / Content NameSource โ†’ DestinationPerformerSizeStatusUndo Action
+
+
๐Ÿ“œ
+

No relocation history yet

+

When files or torrents are moved into performer directories, audit logs and 1-click undo actions will appear here.

+
+
+
+
+
+ + + + + + @@ -2072,20 +2230,31 @@ ] }; - // DOM Elements - Tab 3 Stash Images + // DOM Elements - Tab 3 Stash Images & Tab 4 History const tabBtnStashImages = document.getElementById('tab-btn-stash-images'); const tabPanelStashImages = document.getElementById('tab-panel-stash-images'); const stashImagesTabBadge = document.getElementById('stash-images-tab-badge'); + const tabBtnHistory = document.getElementById('tab-btn-history'); + const tabPanelHistory = document.getElementById('tab-panel-history'); + const historyTabBadge = document.getElementById('history-tab-badge'); + // Tab Switching Logic function switchTab(tabName) { tabBtnQb.classList.toggle('active', tabName === 'qb'); tabBtnSmb.classList.toggle('active', tabName === 'smb'); if (tabBtnStashImages) tabBtnStashImages.classList.toggle('active', tabName === 'stash-images'); + if (tabBtnHistory) tabBtnHistory.classList.toggle('active', tabName === 'history'); tabPanelQb.classList.toggle('active', tabName === 'qb'); tabPanelSmb.classList.toggle('active', tabName === 'smb'); if (tabPanelStashImages) tabPanelStashImages.classList.toggle('active', tabName === 'stash-images'); + if (tabPanelHistory) tabPanelHistory.classList.toggle('active', tabName === 'history'); + + if (tabName === 'history') { + loadRelocationHistory(); + loadStorageAnalytics(); + } localStorage.setItem('qb_active_tab', tabName); } @@ -2093,6 +2262,7 @@ tabBtnQb.addEventListener('click', () => switchTab('qb')); tabBtnSmb.addEventListener('click', () => switchTab('smb')); if (tabBtnStashImages) tabBtnStashImages.addEventListener('click', () => switchTab('stash-images')); + if (tabBtnHistory) tabBtnHistory.addEventListener('click', () => switchTab('history')); function getProviderDisplayName() { const provider = aiProviderSelect ? aiProviderSelect.value : 'local'; @@ -5610,12 +5780,535 @@ Return ONLY a valid JSON object with an array named "results": }); } + const btnStashAutoApproveFirst = document.getElementById('btn-stash-auto-approve-first'); + if (btnStashAutoApproveFirst) { + btnStashAutoApproveFirst.addEventListener('click', () => { + let count = 0; + stashImgState.performers.forEach(p => { + if (stashImgState.selectedIds.has(p.id) && p.candidate_images && p.candidate_images.length > 0) { + stashImgState.selectedImageMap[p.id] = p.candidate_images[0].url; + count++; + } + }); + renderStashPerformerCards(); + showToast(`Auto-selected top photo candidate for ${count} performers!`, 'success'); + }); + } + + // ==================== TAB 4: RELOCATION HISTORY & STORAGE ANALYTICS ==================== + let historyRecords = []; + let cleanupScanData = { files: [], dirs: [] }; + + async function loadRelocationHistory() { + try { + const res = await fetch('/api/history'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + historyRecords = data.history || []; + renderRelocationHistory(historyRecords, data.stats || {}); + } catch (err) { + console.warn('Failed loading relocation history:', err); + } + } + + function renderRelocationHistory(items, stats) { + const tbody = document.getElementById('history-tbody'); + const statTotal = document.getElementById('history-stat-total'); + const statActive = document.getElementById('history-stat-active'); + const statUndone = document.getElementById('history-stat-undone'); + const statSize = document.getElementById('history-stat-size'); + const badge = document.getElementById('history-count-badge'); + + if (statTotal) statTotal.textContent = stats.total || items.length; + if (statActive) statActive.textContent = stats.active || 0; + if (statUndone) statUndone.textContent = stats.undone || 0; + if (historyTabBadge) historyTabBadge.textContent = items.length; + if (badge) badge.textContent = `${items.length} entries`; + + if (statSize) { + const bytes = stats.total_bytes || 0; + statSize.textContent = bytes >= 1024**3 ? `${(bytes / 1024**3).toFixed(2)} GB` : `${(bytes / 1024**2).toFixed(1)} MB`; + } + + if (!tbody) return; + + const q = (document.getElementById('history-search-input')?.value || '').toLowerCase().trim(); + const filtered = items.filter(it => { + if (!q) return true; + return (it.item_name || '').toLowerCase().includes(q) || + (it.performer || '').toLowerCase().includes(q) || + (it.source_path || '').toLowerCase().includes(q) || + (it.target_path || '').toLowerCase().includes(q); + }); + + if (filtered.length === 0) { + tbody.innerHTML = ` + + +
+
๐Ÿ“œ
+

No matching history records

+

${q ? 'Adjust your search query.' : 'Relocation actions will be logged here automatically.'}

+
+ + + `; + return; + } + + tbody.innerHTML = filtered.map(row => { + const isUndone = row.status === 'undone'; + const formattedDate = row.created_at ? new Date(row.created_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'Recent'; + const sizeFormatted = row.file_size ? (row.file_size >= 1024**3 ? `${(row.file_size / 1024**3).toFixed(2)} GB` : `${(row.file_size / 1024**2).toFixed(1)} MB`) : '-'; + + return ` + + ${formattedDate} + + + ${row.source_type || 'smb'} + + + +
${escapeHtml(row.item_name || '')}
+ + +
+
From: ${escapeHtml(row.source_path || '')}
+
To: ${escapeHtml(row.target_path || '')}
+
+ + + ${row.performer ? `๐Ÿ‘ค ${escapeHtml(row.performer)}` : '-'} + + ${sizeFormatted} + + + ${isUndone ? 'โ†ฉ Undone' : 'โœ“ Moved'} + + + + ${!isUndone ? ` + + ` : `Restored`} + + + `; + }).join(''); + + tbody.querySelectorAll('.btn-undo-row').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const hid = parseInt(btn.getAttribute('data-history-id')); + await executeHistoryUndo(hid); + }); + }); + } + + async function executeHistoryUndo(historyId) { + if (!confirm('Are you sure you want to undo this move and restore the file to its original location?')) return; + try { + const res = await fetch('/api/history/undo', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ history_id: historyId }) + }); + const data = await res.json(); + if (data.success) { + showToast('โœ“ Successfully reverted file to original location!', 'success'); + await loadRelocationHistory(); + } else { + showToast(`Undo error: ${data.error}`, 'error'); + } + } catch (err) { + showToast(`Undo request failed: ${err.message}`, 'error'); + } + } + + async function loadStorageAnalytics() { + const container = document.getElementById('storage-analytics-container'); + const diskBadge = document.getElementById('storage-disk-free-badge'); + if (!container) return; + + try { + const res = await fetch('/api/analytics/storage'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + + if (diskBadge && data.disk) { + diskBadge.innerHTML = `๐Ÿ’พ NAS Disk: ${data.disk.free_gb || ''} free of ${data.disk.total_gb || ''}`; + } + + if (!data.performers || data.performers.length === 0) { + container.innerHTML = `
No performer video folders detected in target root.
`; + return; + } + + const maxBytes = data.performers[0]?.total_size || 1; + container.innerHTML = data.performers.slice(0, 16).map((p, idx) => { + const pct = Math.round((p.total_size / maxBytes) * 100); + return ` +
+
+ + ๐Ÿ‘ค ${escapeHtml(p.name)} + + ${p.formatted_size} +
+
+ ${p.video_count} videos + #${idx + 1} +
+
+
+
+
+ `; + }).join(''); + } catch (err) { + container.innerHTML = `
Error loading storage analytics: ${err.message}
`; + } + } + + // Maintenance & Cleanup + async function scanSmbCleanup() { + const modal = document.getElementById('cleanup-modal'); + const sumBox = document.getElementById('cleanup-summary-box'); + const listEl = document.getElementById('cleanup-files-list'); + + try { + showToast('Scanning /videodownloader for 0-byte corrupt files and empty folders...', 'info'); + const res = await fetch('/api/smb/scan-cleanup'); + const data = await res.json(); + cleanupScanData = { files: data.zero_byte_files || [], dirs: data.empty_dirs || [] }; + + sumBox.innerHTML = ` +
Found ${cleanupScanData.files.length} zero-byte/corrupt video files and ${cleanupScanData.dirs.length} empty subdirectories.
+ `; + + if (cleanupScanData.files.length === 0 && cleanupScanData.dirs.length === 0) { + listEl.innerHTML = `
โœ“ Directory is clean! No 0-byte files or empty folders found.
`; + } else { + listEl.innerHTML = ` + ${cleanupScanData.files.map(f => `
๐Ÿ—‘๏ธ 0-byte: ${escapeHtml(f.path)}
`).join('')} + ${cleanupScanData.dirs.map(d => `
๐Ÿ“ Empty Dir: ${escapeHtml(d.path)}
`).join('')} + `; + } + + modal.classList.add('active'); + } catch (err) { + showToast(`Scan error: ${err.message}`, 'error'); + } + } + + async function executeSmbCleanup() { + const modal = document.getElementById('cleanup-modal'); + try { + const filesToDelete = cleanupScanData.files.map(f => f.path); + const dirsToPrune = cleanupScanData.dirs.map(d => d.path); + + const res = await fetch('/api/smb/cleanup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: filesToDelete, dirs: dirsToPrune }) + }); + const data = await res.json(); + modal.classList.remove('active'); + showToast(`โœ“ Pruned ${data.deleted_files || 0} 0-byte files and ${data.pruned_dirs || 0} empty directories!`, 'success'); + } catch (err) { + showToast(`Cleanup error: ${err.message}`, 'error'); + } + } + + async function triggerStashLibraryScan() { + try { + showToast('โšก Triggering Stash metadata scan for /isolation/videos...', 'info'); + const res = await fetch('/api/stash/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: ['/isolation/videos'] }) + }); + const data = await res.json(); + if (data.success) { + showToast('โœ“ Stash library metadata scan triggered successfully!', 'success'); + } else { + showToast(`Stash scan error: ${data.error}`, 'error'); + } + } catch (err) { + showToast(`Stash trigger error: ${err.message}`, 'error'); + } + } + + // CSV Exporter + function exportCSV(filename, rows) { + if (!rows || !rows.length) { + showToast('No data available to export.', 'error'); + return; + } + const keys = Object.keys(rows[0]); + const csvContent = [ + keys.join(','), + ...rows.map(row => keys.map(k => `"${String(row[k] || '').replace(/"/g, '""')}"`).join(',')) + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + showToast(`๐Ÿ“ฅ Exported ${filename} successfully!`, 'success'); + } + + // Export Plan Buttons + const btnExportQbPlan = document.getElementById('btn-export-qb-plan'); + if (btnExportQbPlan) { + btnExportQbPlan.addEventListener('click', () => { + const rows = torrentState.torrents.map(t => ({ + name: t.name, + current_path: t.save_path, + detected_performer: t.selectedPerformer || t.primary_performer || '', + target_path: t.targetPath || '', + confidence: t.confidence || '', + status: t.moveStatus || 'ready', + size_bytes: t.size + })); + exportCSV(`qb_relocation_plan_${new Date().toISOString().slice(0,10)}.csv`, rows); + }); + } + + const btnExportSmbPlan = document.getElementById('btn-export-smb-plan'); + if (btnExportSmbPlan) { + btnExportSmbPlan.addEventListener('click', () => { + const rows = smbState.files.map(f => ({ + file_name: f.name, + source_path: f.path, + detected_performer: f.selectedPerformer || f.primary_performer || '', + target_path: f.targetPath || '', + confidence: f.confidence || '', + status: f.moveStatus || 'ready', + size_bytes: f.size + })); + exportCSV(`smb_relocation_plan_${new Date().toISOString().slice(0,10)}.csv`, rows); + }); + } + + const btnExportHistoryCsv = document.getElementById('btn-export-history-csv'); + if (btnExportHistoryCsv) { + btnExportHistoryCsv.addEventListener('click', () => { + exportCSV(`relocation_history_${new Date().toISOString().slice(0,10)}.csv`, historyRecords); + }); + } + + // Attach Tab 4 Toolbar Buttons + const btnRefreshHistory = document.getElementById('btn-refresh-history'); + if (btnRefreshHistory) { + btnRefreshHistory.addEventListener('click', () => { + loadRelocationHistory(); + loadStorageAnalytics(); + showToast('Relocation history and storage analytics refreshed!', 'info'); + }); + } + + const btnClearHistory = document.getElementById('btn-clear-history'); + if (btnClearHistory) { + btnClearHistory.addEventListener('click', async () => { + if (!confirm('Clear all relocation history logs? (Files will remain in their moved locations).')) return; + try { + await fetch('/api/history/clear', { method: 'POST' }); + historyRecords = []; + renderRelocationHistory([], {}); + showToast('History logs cleared.', 'info'); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } + }); + } + + const btnTriggerStashScan = document.getElementById('btn-trigger-stash-scan'); + if (btnTriggerStashScan) btnTriggerStashScan.addEventListener('click', triggerStashLibraryScan); + + const btnScanSmbCleanup = document.getElementById('btn-scan-smb-cleanup'); + if (btnScanSmbCleanup) btnScanSmbCleanup.addEventListener('click', scanSmbCleanup); + + const btnConfirmCleanup = document.getElementById('btn-confirm-cleanup'); + if (btnConfirmCleanup) btnConfirmCleanup.addEventListener('click', executeSmbCleanup); + + const btnCancelCleanup = document.getElementById('btn-cancel-cleanup'); + const cleanupModalClose = document.getElementById('cleanup-modal-close'); + if (btnCancelCleanup) btnCancelCleanup.addEventListener('click', () => document.getElementById('cleanup-modal').classList.remove('active')); + if (cleanupModalClose) cleanupModalClose.addEventListener('click', () => document.getElementById('cleanup-modal').classList.remove('active')); + + const historySearchInput = document.getElementById('history-search-input'); + if (historySearchInput) { + historySearchInput.addEventListener('input', () => { + renderRelocationHistory(historyRecords, {}); + }); + } + + // ==================== IN-PLAYER VIDEO TOOLS ==================== + const btnVideoSnapshot = document.getElementById('btn-video-snapshot'); + if (btnVideoSnapshot) { + btnVideoSnapshot.addEventListener('click', () => { + const video = document.getElementById('preview-video-element'); + if (!video || video.videoWidth === 0) { + showToast('Video not loaded or playing.', 'error'); + return; + } + try { + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext('2d'); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + canvas.toBlob(blob => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `snapshot_${Date.now()}.jpg`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + showToast('๐Ÿ“ธ High-resolution snapshot captured and downloaded!', 'success'); + }, 'image/jpeg', 0.95); + } catch (err) { + showToast(`Snapshot error: ${err.message}`, 'error'); + } + }); + } + + const btnVideoPip = document.getElementById('btn-video-pip'); + if (btnVideoPip) { + btnVideoPip.addEventListener('click', async () => { + const video = document.getElementById('preview-video-element'); + if (!video) return; + try { + if (document.pictureInPictureElement) { + await document.exitPictureInPicture(); + } else if (document.pictureInPictureEnabled) { + await video.requestPictureInPicture(); + } + } catch (err) { + showToast(`PiP error: ${err.message}`, 'error'); + } + }); + } + + const btnVideoLoop = document.getElementById('btn-video-loop'); + if (btnVideoLoop) { + btnVideoLoop.addEventListener('click', () => { + const video = document.getElementById('preview-video-element'); + if (!video) return; + video.loop = !video.loop; + btnVideoLoop.classList.toggle('active', video.loop); + showToast(video.loop ? '๐Ÿ” Video Repeat: ON' : 'Video Repeat: OFF', 'info'); + }); + } + + // ==================== COMMAND PALETTE (CTRL + K) ==================== + const cmdPaletteModal = document.getElementById('cmd-palette-modal'); + const cmdPaletteInput = document.getElementById('cmd-palette-input'); + const cmdPaletteResults = document.getElementById('cmd-palette-results'); + + const COMMANDS = [ + { title: 'Switch to qBittorrent Sorter', icon: '๐Ÿ“ก', action: () => switchTab('qb') }, + { title: 'Switch to SMB Video Sorter', icon: '๐Ÿ“‚', action: () => switchTab('smb') }, + { title: 'Switch to Stash Image Review', icon: '๐Ÿ–ผ๏ธ', action: () => switchTab('stash-images') }, + { title: 'Switch to Relocation History & Analytics', icon: '๐Ÿ“œ', action: () => switchTab('history') }, + { title: 'Scan SMB Files (/videodownloader)', icon: '๐Ÿ”', action: () => { switchTab('smb'); document.getElementById('btn-fetch-smb')?.click(); } }, + { title: 'Scan Stash Missing Performer Images', icon: 'โšก', action: () => { switchTab('stash-images'); document.getElementById('btn-scan-stash-images')?.click(); } }, + { title: 'Scan Zero-Byte & Empty Folders', icon: '๐Ÿงน', action: () => scanSmbCleanup() }, + { title: 'Trigger Stash Library Metadata Scan', icon: 'โšก', action: () => triggerStashLibraryScan() }, + { title: 'Export qBittorrent Plan to CSV', icon: '๐Ÿ“ฅ', action: () => document.getElementById('btn-export-qb-plan')?.click() }, + { title: 'Export SMB Plan to CSV', icon: '๐Ÿ“ฅ', action: () => document.getElementById('btn-export-smb-plan')?.click() } + ]; + + function renderCommandPalette(query) { + if (!cmdPaletteResults) return; + const q = (query || '').toLowerCase().trim(); + const filtered = COMMANDS.filter(c => !q || c.title.toLowerCase().includes(q)); + + if (filtered.length === 0) { + cmdPaletteResults.innerHTML = `
No commands matching "${escapeHtml(q)}"
`; + return; + } + + cmdPaletteResults.innerHTML = filtered.map((cmd, idx) => ` +
+ ${cmd.icon} + ${escapeHtml(cmd.title)} + Action +
+ `).join(''); + + cmdPaletteResults.querySelectorAll('.cmd-item').forEach((item, i) => { + item.addEventListener('mouseenter', () => item.style.background = 'rgba(6,182,212,0.15)'); + item.addEventListener('mouseleave', () => item.style.background = 'transparent'); + item.addEventListener('click', () => { + cmdPaletteModal.classList.remove('active'); + filtered[i].action(); + }); + }); + } + + if (cmdPaletteInput) { + cmdPaletteInput.addEventListener('input', (e) => renderCommandPalette(e.target.value)); + } + + // Global Keyboard Shortcuts + window.addEventListener('keydown', (e) => { + // Ctrl + K or Cmd + K: Command Palette + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + if (cmdPaletteModal) { + cmdPaletteModal.classList.toggle('active'); + if (cmdPaletteModal.classList.contains('active')) { + renderCommandPalette(''); + if (cmdPaletteInput) { + cmdPaletteInput.value = ''; + setTimeout(() => cmdPaletteInput.focus(), 50); + } + } + } + return; + } + + // Escape closes modals + if (e.key === 'Escape') { + if (cmdPaletteModal) cmdPaletteModal.classList.remove('active'); + const videoModal = document.getElementById('video-preview-modal'); + if (videoModal && videoModal.classList.contains('active')) { + videoModal.classList.remove('active'); + const vid = document.getElementById('preview-video-element'); + if (vid) vid.pause(); + } + const clModal = document.getElementById('cleanup-modal'); + if (clModal) clModal.classList.remove('active'); + } + + // Ctrl + Z: Undo most recent relocation + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && !e.shiftKey) { + if (historyRecords && historyRecords.length > 0) { + const lastActive = historyRecords.find(h => h.status === 'completed'); + if (lastActive) { + e.preventDefault(); + executeHistoryUndo(lastActive.id); + } + } + } + }); + // Init on load window.addEventListener('DOMContentLoaded', async () => { loadConfig(); await loadDatabaseCache(); await checkStashStatus(); await loadExistingPerformers(); + loadRelocationHistory(); if (window.location.protocol.startsWith('http')) { try { await qbLogin(); diff --git a/server.py b/server.py index 017f4dc..ee25f1c 100644 --- a/server.py +++ b/server.py @@ -57,11 +57,45 @@ def init_db(): ''') cursor.execute('CREATE INDEX IF NOT EXISTS idx_item_key ON performer_analysis_cache(item_key)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_item_type ON performer_analysis_cache(item_type)') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS relocation_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_type TEXT NOT NULL, -- 'qb' or 'smb' + item_key TEXT NOT NULL, -- torrent hash or file path + item_name TEXT NOT NULL, + source_path TEXT NOT NULL, + target_path TEXT NOT NULL, + performer TEXT, + file_size INTEGER DEFAULT 0, + status TEXT DEFAULT 'completed', -- 'completed', 'undone', 'failed' + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + undone_at TIMESTAMP + ) + ''') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_history_key ON relocation_history(item_key)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_history_time ON relocation_history(created_at)') conn.commit() conn.close() init_db() +def record_relocation_history(source_type, item_key, item_name, source_path, target_path, performer="", file_size=0): + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO relocation_history (source_type, item_key, item_name, source_path, target_path, performer, file_size, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'completed') + ''', (source_type, item_key, item_name, source_path, target_path, performer, file_size)) + conn.commit() + history_id = cursor.lastrowid + conn.close() + return history_id + except Exception as e: + print(f"Error recording relocation history: {e}") + return None + def get_db_connection(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row @@ -735,6 +769,15 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): # Database Cache API if self.path.startswith('/api/db/cache'): self.handle_db_get_cache() + # Relocation History API + elif self.path.startswith('/api/history'): + self.handle_history_list() + # Storage Analytics & Heatmap API + elif self.path.startswith('/api/analytics/storage'): + self.handle_storage_analytics() + # SMB Maintenance Cleanup Scan API + elif self.path.startswith('/api/smb/scan-cleanup'): + self.handle_smb_scan_cleanup() # Video Stream API (Range 206 Partial Content) elif self.path.startswith('/api/stream'): self.handle_stream_video() @@ -786,24 +829,38 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): self.handle_db_batch_save() elif self.path == '/api/db/delete': self.handle_db_delete() - # Stash Lookup & Image Review APIs + # Relocation History & Undo APIs + elif self.path == '/api/history/record': + self.handle_history_record() + elif self.path == '/api/history/undo': + self.handle_history_undo() + elif self.path == '/api/history/clear': + self.handle_history_clear() + # Stash Lookup, Scan & Image Review APIs elif self.path == '/api/stash/lookup': self.handle_stash_lookup() elif self.path == '/api/stash/batch-lookup': self.handle_stash_batch_lookup() + elif self.path == '/api/stash/scan': + self.handle_stash_metadata_scan() elif self.path == '/api/stash/performers/find-images': self.handle_stash_find_images() elif self.path == '/api/stash/performers/commit-image': self.handle_stash_commit_image() elif self.path == '/api/stash/performers/batch-commit-images': self.handle_stash_batch_commit_images() - # SMB API Moves & Deletes + # SMB API Moves, Deletes & Cleanups elif self.path == '/api/smb/move': self.handle_smb_move() elif self.path == '/api/smb/batch-move': self.handle_smb_batch_move() elif self.path == '/api/smb/delete': self.handle_smb_delete() + elif self.path == '/api/smb/cleanup': + self.handle_smb_execute_cleanup() + # Webhook API + elif self.path == '/api/webhooks/send': + self.handle_send_webhook() elif self.path.startswith('/qb-proxy/'): self.proxy_qb_request('POST') elif self.path.startswith('/anthropic-proxy/'): @@ -1558,6 +1615,7 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): source_path = payload.get('source') target_dir = payload.get('targetDir') target_file = payload.get('targetFile') + performer = payload.get('performer', '') if not source_path or not os.path.exists(source_path): self.send_response(400) @@ -1581,10 +1639,18 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): dest_dir = os.path.dirname(target_file) os.makedirs(dest_dir, exist_ok=True) + f_size = 0 + try: + f_size = os.path.getsize(source_path) + except Exception: + pass # Move file shutil.move(source_path, target_file) + # Record in relocation history + record_relocation_history('smb', source_path, os.path.basename(source_path), source_path, target_file, performer=performer, file_size=f_size) + # Update DB status if entry exists try: conn = get_db_connection() @@ -1620,6 +1686,7 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): source = item.get('source') target_dir = item.get('targetDir') target_file = item.get('targetFile') + performer = item.get('performer', '') if not target_file and target_dir and source: target_file = os.path.join(target_dir, os.path.basename(source)) @@ -1631,7 +1698,16 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): try: dest_dir = os.path.dirname(target_file) os.makedirs(dest_dir, exist_ok=True) + f_size = 0 + try: + f_size = os.path.getsize(source) + except Exception: + pass + shutil.move(source, target_file) + + # Record history + record_relocation_history('smb', source, os.path.basename(source), source, target_file, performer=performer, file_size=f_size) # Update DB try: @@ -1717,6 +1793,309 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): self.send_header('Content-Type', 'application/json') self.send_cors_headers() self.end_headers() + self.wfile.write(json.dumps({"error": f"Delete failed: {str(e)}"}).encode('utf-8')) + + # ==================== Relocation History & Undo Handlers ==================== + def handle_history_list(self): + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM relocation_history ORDER BY id DESC LIMIT 250") + rows = [dict(r) for r in cursor.fetchall()] + + cursor.execute("SELECT COUNT(*) as total, SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) as active, SUM(CASE WHEN status='undone' THEN 1 ELSE 0 END) as undone, SUM(file_size) as total_bytes FROM relocation_history") + raw_stats = cursor.fetchone() + stats = dict(raw_stats) if raw_stats else {} + conn.close() + self.send_json_response(200, {"history": rows, "stats": stats, "count": len(rows)}) + except Exception as e: + self.send_json_response(500, {"error": str(e), "history": []}) + + def handle_history_record(self): + content_length = int(self.headers.get('Content-Length', 0)) + post_data = self.rfile.read(content_length) + try: + p = json.loads(post_data.decode('utf-8')) + hid = record_relocation_history( + p.get('source_type', 'qb'), + p.get('item_key', ''), + p.get('item_name', ''), + p.get('source_path', ''), + p.get('target_path', ''), + performer=p.get('performer', ''), + file_size=p.get('file_size', 0) + ) + self.send_json_response(200, {"success": True, "history_id": hid}) + except Exception as e: + self.send_json_response(500, {"error": str(e)}) + + def handle_history_undo(self): + content_length = int(self.headers.get('Content-Length', 0)) + post_data = self.rfile.read(content_length) + try: + payload = json.loads(post_data.decode('utf-8')) + history_id = payload.get('history_id') + if not history_id: + self.send_json_response(400, {"error": "Missing history_id"}) + return + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM relocation_history WHERE id = ?", (history_id,)) + row = cursor.fetchone() + if not row: + conn.close() + self.send_json_response(404, {"error": "History entry not found"}) + return + + item = dict(row) + source_path = item['source_path'] + target_path = item['target_path'] + source_type = item['source_type'] + item_key = item['item_key'] + + if item['status'] == 'undone': + conn.close() + self.send_json_response(400, {"error": "Entry has already been undone"}) + return + + # Execute Undo + if source_type == 'smb': + if os.path.exists(target_path): + os.makedirs(os.path.dirname(source_path), exist_ok=True) + shutil.move(target_path, source_path) + elif os.path.exists(os.path.join(target_path, os.path.basename(source_path))): + actual_file = os.path.join(target_path, os.path.basename(source_path)) + os.makedirs(os.path.dirname(source_path), exist_ok=True) + shutil.move(actual_file, source_path) + else: + conn.close() + self.send_json_response(404, {"error": f"Target file no longer exists at {target_path}"}) + return + + # Update analysis cache + cursor.execute("UPDATE performer_analysis_cache SET move_status = 'ready', updated_at = CURRENT_TIMESTAMP WHERE item_key = ?", (source_path,)) + + cursor.execute("UPDATE relocation_history SET status = 'undone', undone_at = CURRENT_TIMESTAMP WHERE id = ?", (history_id,)) + conn.commit() + conn.close() + + self.send_json_response(200, {"success": True, "undone_item": item}) + except Exception as e: + self.send_json_response(500, {"error": f"Undo failed: {str(e)}"}) + + def handle_history_clear(self): + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM relocation_history") + conn.commit() + conn.close() + self.send_json_response(200, {"success": True}) + except Exception as e: + self.send_json_response(500, {"error": str(e)}) + + # ==================== Stash Library Metadata Scan Trigger ==================== + def handle_stash_metadata_scan(self): + content_length = int(self.headers.get('Content-Length', 0)) + post_data = self.rfile.read(content_length) + try: + payload = json.loads(post_data.decode('utf-8')) + paths = payload.get('paths', []) + stash_url = payload.get('stash_url') or DEFAULT_STASH_URL + + mutation = ''' + mutation MetadataScan($input: ScanMetadataInput!) { + metadataScan(input: $input) + } + ''' + variables = { + "input": { + "paths": paths if paths else [], + "rescan": False, + "scanGenerateCovers": True + } + } + res = execute_stash_graphql(mutation, stash_url, timeout=20) + self.send_json_response(200, {"success": True, "result": res}) + except Exception as e: + self.send_json_response(500, {"error": str(e)}) + + # ==================== SMB Maintenance & Cleanup Handlers ==================== + def handle_smb_scan_cleanup(self): + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + scan_dir = params.get('path', [DEFAULT_SMB_SOURCE])[0] + + ensure_smb_mounted() + if not os.path.exists(scan_dir): + self.send_json_response(404, {"error": f"Directory does not exist: {scan_dir}", "zero_byte_files": [], "empty_dirs": []}) + return + + zero_byte = [] + empty_dirs = [] + + try: + for root, dirs, files in os.walk(scan_dir, topdown=False): + for f in files: + fp = os.path.join(root, f) + try: + if os.path.isfile(fp) and os.path.getsize(fp) == 0: + zero_byte.append({"name": f, "path": fp, "size": 0}) + except Exception: + pass + + # Check empty directories + if root != scan_dir and not os.listdir(root): + empty_dirs.append({"name": os.path.basename(root), "path": root}) + + self.send_json_response(200, { + "target_dir": scan_dir, + "zero_byte_files": zero_byte, + "empty_dirs": empty_dirs, + "zero_byte_count": len(zero_byte), + "empty_dir_count": len(empty_dirs) + }) + except Exception as e: + self.send_json_response(500, {"error": str(e), "zero_byte_files": [], "empty_dirs": []}) + + def handle_smb_execute_cleanup(self): + content_length = int(self.headers.get('Content-Length', 0)) + post_data = self.rfile.read(content_length) + try: + payload = json.loads(post_data.decode('utf-8')) + files_to_delete = payload.get('files', []) + dirs_to_prune = payload.get('dirs', []) + + deleted_files = 0 + pruned_dirs = 0 + + for fp in files_to_delete: + try: + if os.path.isfile(fp): + os.remove(fp) + deleted_files += 1 + except Exception: + pass + + for dp in dirs_to_prune: + try: + if os.path.isdir(dp) and not os.listdir(dp): + os.rmdir(dp) + pruned_dirs += 1 + except Exception: + pass + + self.send_json_response(200, { + "success": True, + "deleted_files": deleted_files, + "pruned_dirs": pruned_dirs + }) + except Exception as e: + self.send_json_response(500, {"error": str(e)}) + + # ==================== Storage Analytics & Heatmap ==================== + def handle_storage_analytics(self): + parsed = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed.query) + target_root = params.get('path', [DEFAULT_SMB_TARGET_ROOT])[0] + + ensure_smb_mounted() + if not os.path.exists(target_root): + self.send_json_response(404, {"error": f"Target root not accessible: {target_root}", "performers": []}) + return + + performer_stats = [] + total_library_size = 0 + total_video_count = 0 + + try: + entries = os.listdir(target_root) + for entry in entries: + full_p = os.path.join(target_root, entry) + if os.path.isdir(full_p) and not entry.startswith('.'): + f_count = 0 + f_bytes = 0 + for root, _, files in os.walk(full_p): + for f in files: + ext = os.path.splitext(f)[1].lower() + if ext in VIDEO_EXTENSIONS: + f_count += 1 + try: + f_bytes += os.path.getsize(os.path.join(root, f)) + except Exception: + pass + if f_count > 0: + total_video_count += f_count + total_library_size += f_bytes + performer_stats.append({ + "name": entry, + "folder": entry, + "path": full_p, + "video_count": f_count, + "total_size": f_bytes, + "formatted_size": f"{f_bytes / (1024**3):.2f} GB" if f_bytes >= 1024**3 else f"{f_bytes / (1024**2):.1f} MB" + }) + + # Sort largest folders first + performer_stats.sort(key=lambda x: -x['total_size']) + + # Free disk space check + disk_info = {} + try: + st = os.statvfs(target_root) + disk_info = { + "total": st.f_blocks * st.f_frsize, + "free": st.f_bavail * st.f_frsize, + "used": (st.f_blocks - st.f_bavail) * st.f_frsize, + "free_gb": f"{(st.f_bavail * st.f_frsize) / (1024**3):.1f} GB", + "total_gb": f"{(st.f_blocks * st.f_frsize) / (1024**3):.1f} GB" + } + except Exception: + pass + + self.send_json_response(200, { + "target_root": target_root, + "total_performers": len(performer_stats), + "total_videos": total_video_count, + "total_size_bytes": total_library_size, + "total_size_formatted": f"{total_library_size / (1024**3):.2f} GB", + "performers": performer_stats[:100], + "disk": disk_info + }) + except Exception as e: + self.send_json_response(500, {"error": str(e), "performers": []}) + + # ==================== Webhook Dispatcher ==================== + def handle_send_webhook(self): + content_length = int(self.headers.get('Content-Length', 0)) + post_data = self.rfile.read(content_length) + try: + payload = json.loads(post_data.decode('utf-8')) + webhook_url = payload.get('webhook_url') + title = payload.get('title', 'Performer Organizer Notification') + message = payload.get('message', '') + items_count = payload.get('items_count', 0) + + if not webhook_url: + self.send_json_response(400, {"error": "Missing webhook_url"}) + return + + body = { + "content": f"๐Ÿš€ **{title}**\n{message}\n*Organized {items_count} items into library.*", + "username": "qBittorrent Organizer" + } + + req = urllib.request.Request( + webhook_url, + data=json.dumps(body).encode('utf-8'), + headers={'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0'} + ) + with urllib.request.urlopen(req, timeout=10) as res: + self.send_json_response(200, {"success": True, "status": res.status}) + except Exception as e: + self.send_json_response(500, {"error": str(e)}) + self.end_headers() self.wfile.write(json.dumps({"error": f"Delete request failed: {str(e)}"}).encode('utf-8')) # ==================== External API Proxies ====================