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 @@
+
+
-
+
+
+
+
@@ -1627,8 +1644,142 @@
+
+
+
+
+
+
+ Total Organized Moves
+ 0
+
+
+ Active Organized Files
+ 0
+
+
+ Undone Relocations
+ 0
+
+
+ Total Storage Managed
+ 0 GB
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading library disk analytics...
+
+
+
+
+
+
+
+
+
+
+
+ | Time |
+ Type |
+ File / Content Name |
+ Source โ Destination |
+ Performer |
+ Size |
+ Status |
+ Undo Action |
+
+
+
+
+
+
+ ๐
+ No relocation history yet
+ When files or torrents are moved into performer directories, audit logs and 1-click undo actions will appear here.
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1676,10 +1827,17 @@
+
+ Tools:
+
+
+
+
+
-
+
0 / 0
-
+
@@ -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 ====================