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

This commit is contained in:
david
2026-08-17 17:44:29 -04:00
parent 0524dd4a93
commit 1a5cbc3e47
2 changed files with 1081 additions and 9 deletions
+700 -7
View File
@@ -1111,9 +1111,14 @@
</button>
<button class="tab-btn" id="tab-btn-stash-images">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<span>Stash Performer Image Review</span>
<span>Stash Image Review</span>
<span class="tab-badge" id="stash-images-tab-badge" style="background: rgba(244, 63, 94, 0.2); color: #fda4af; border-color: rgba(244, 63, 94, 0.4);">0</span>
</button>
<button class="tab-btn" id="tab-btn-history">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span>Relocation History & Analytics</span>
<span class="tab-badge" id="history-tab-badge" style="background: rgba(168, 85, 247, 0.2); color: #d8b4fe; border-color: rgba(168, 85, 247, 0.4);">0</span>
</button>
</div>
<!-- Unified Configuration Card (Collapsible - Collapsed by default) -->
@@ -1368,7 +1373,11 @@
</button>
<button class="btn btn-secondary" id="btn-batch-delete-torrents" disabled style="color: var(--accent-rose); border-color: rgba(244, 63, 94, 0.4); background: rgba(244, 63, 94, 0.08);" title="Delete selected torrents and delete their downloaded files from disk">
🗑️ Delete Selected
🗑️ Delete
</button>
<button class="btn btn-secondary" id="btn-export-qb-plan" title="Export current relocation plan to CSV">
📥 Export Plan
</button>
</div>
@@ -1477,6 +1486,10 @@
<button class="btn btn-secondary" id="btn-smb-batch-delete" disabled style="color: var(--accent-rose); border-color: rgba(244, 63, 94, 0.4); background: rgba(244, 63, 94, 0.08);" title="Permanently delete selected files from SMB share">
🗑️ Delete Selected
</button>
<button class="btn btn-secondary" id="btn-export-smb-plan" title="Export current SMB relocation plan to CSV">
📥 Export Plan
</button>
</div>
<div class="search-filter">
@@ -1551,8 +1564,8 @@
</div>
<!-- Controls Bar -->
<div class="controls-bar">
<div class="btn-group">
<div class="controls-bar" style="flex-direction: column; gap: 0.75rem; align-items: stretch;">
<div class="btn-group" style="flex-wrap: wrap;">
<button class="btn btn-primary" id="btn-scan-stash-images">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
Scan Stash Missing Images
@@ -1566,6 +1579,10 @@
⚡ Find Images (All Missing)
</button>
<button class="btn btn-secondary" id="btn-stash-auto-approve-first" disabled title="Auto-select the first candidate photo for all checked performers">
✨ Auto-Select Top Candidate
</button>
<button class="btn btn-secondary" id="btn-stash-stop-images" style="display: none; background: #7f1d1d; border-color: #991b1b; color: #fff;">
⛔ Stop Search
</button>
@@ -1627,8 +1644,142 @@
</div>
</div>
</div>
<!-- ==================== TAB 4: Relocation History & Storage Analytics ==================== -->
<div class="tab-panel" id="tab-panel-history">
<!-- Analytics Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-label">Total Organized Moves</span>
<span class="stat-value" id="history-stat-total" style="color: var(--accent-cyan);">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Active Organized Files</span>
<span class="stat-value" id="history-stat-active" style="color: var(--accent-green);">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Undone Relocations</span>
<span class="stat-value" id="history-stat-undone" style="color: var(--accent-amber);">0</span>
</div>
<div class="stat-card">
<span class="stat-label">Total Storage Managed</span>
<span class="stat-value" id="history-stat-size" style="color: var(--accent-purple);">0 GB</span>
</div>
</div>
<!-- Actions Control Bar -->
<div class="controls-bar">
<div class="btn-group">
<button class="btn btn-primary" id="btn-refresh-history">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Refresh History & Analytics
</button>
<button class="btn btn-secondary" id="btn-trigger-stash-scan" title="Trigger Stash GraphQL metadata scan to index newly relocated files">
⚡ Trigger Stash Library Scan
</button>
<button class="btn btn-secondary" id="btn-scan-smb-cleanup" title="Scan /videodownloader for 0-byte corrupt files and empty folders">
🧹 Scan Zero-Byte & Empty Dirs
</button>
<button class="btn btn-secondary" id="btn-export-history-csv" title="Export full relocation history as CSV">
📥 Export CSV
</button>
<button class="btn btn-secondary" id="btn-clear-history" style="color: var(--accent-rose); border-color: rgba(244, 63, 94, 0.4); background: rgba(244, 63, 94, 0.08);" title="Clear history records">
🗑️ Clear History Logs
</button>
</div>
<div class="search-filter" style="margin-left: auto;">
<input type="text" class="search-input" id="history-search-input" placeholder="🔍 Search history by performer, file..." />
</div>
</div>
<!-- Storage Heatmap & Top Performers Card -->
<div class="table-container" style="padding: 1.25rem; margin-bottom: 1.5rem;">
<div class="table-header-bar" style="margin-bottom: 0.75rem;">
<div class="table-title">
📊 Library Storage Heatmap & Top Performers
</div>
<div style="font-size: 0.75rem; color: var(--text-muted);" id="storage-disk-free-badge">Free Space: Calculating...</div>
</div>
<div id="storage-analytics-container" style="display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center;">
<div style="color: var(--text-muted); font-size: 0.8rem;">Loading library disk analytics...</div>
</div>
</div>
<!-- History Records Table -->
<div class="table-container">
<div class="table-header-bar">
<div class="table-title">
📜 Relocation Audit Log & 1-Click Undo History
</div>
<span style="font-size: 0.75rem; color: var(--text-muted);" id="history-count-badge">0 entries</span>
</div>
<div style="overflow-x: auto;">
<table class="torrent-table" id="history-table">
<thead>
<tr>
<th style="width: 130px;">Time</th>
<th style="width: 70px;">Type</th>
<th>File / Content Name</th>
<th>Source → Destination</th>
<th>Performer</th>
<th style="width: 90px;">Size</th>
<th style="width: 90px; text-align: center;">Status</th>
<th style="width: 100px; text-align: right;">Undo Action</th>
</tr>
</thead>
<tbody id="history-tbody">
<tr>
<td colspan="8">
<div class="empty-state">
<div class="empty-state-icon">📜</div>
<h3>No relocation history yet</h3>
<p>When files or torrents are moved into performer directories, audit logs and 1-click undo actions will appear here.</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</main>
<!-- Command Palette Modal (Ctrl + K) -->
<div class="modal-backdrop" id="cmd-palette-modal">
<div class="modal" style="max-width: 580px; padding: 0.75rem; background: rgba(15, 23, 42, 0.98); border: 1px solid var(--accent-cyan); box-shadow: 0 25px 60px rgba(0,0,0,0.85);">
<div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border-color);">
<span style="font-size: 1.1rem; color: var(--accent-cyan);">⚡</span>
<input type="text" id="cmd-palette-input" placeholder="Type a command or jump to tab (e.g. 'qb', 'smb', 'stash', 'undo', 'scan')..." style="width: 100%; background: transparent; border: none; outline: none; color: #fff; font-size: 0.95rem;" />
<span class="badge" style="font-family: var(--font-mono); font-size: 0.65rem;">ESC</span>
</div>
<div id="cmd-palette-results" style="max-height: 320px; overflow-y: auto; padding: 0.5rem 0;"></div>
</div>
</div>
<!-- Zero-Byte & Cleanup Review Modal -->
<div class="modal-backdrop" id="cleanup-modal">
<div class="modal" style="max-width: 650px;">
<div class="modal-header">
<h3 style="font-size: 0.95rem; font-weight: 600; color: #fff;">🧹 Storage Maintenance & Directory Cleanup</h3>
<button class="modal-close" id="cleanup-modal-close">&times;</button>
</div>
<div class="modal-body" style="display: flex; flex-direction: column; gap: 1rem;">
<div id="cleanup-summary-box" style="font-size: 0.8rem; color: var(--text-main);"></div>
<div id="cleanup-files-list" style="max-height: 240px; overflow-y: auto; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 0.5rem;"></div>
<div style="display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.5rem;">
<button class="btn btn-secondary" id="btn-cancel-cleanup">Cancel</button>
<button class="btn btn-danger" id="btn-confirm-cleanup">🗑️ Execute Cleanup</button>
</div>
</div>
</div>
</div>
<!-- File Modal -->
<div class="modal-backdrop" id="file-modal">
<div class="modal">
@@ -1676,10 +1827,17 @@
<button type="button" class="video-jump-btn video-speed-btn" data-speed="2.0">2x</button>
</div>
<div style="display: flex; gap: 0.4rem; align-items: center;">
<span style="font-size: 0.72rem; color: var(--text-muted); margin-right: 0.2rem;">Tools:</span>
<button type="button" class="video-jump-btn" id="btn-video-snapshot" title="📸 Capture frame snapshot to set as performer image or download">📸 Snap</button>
<button type="button" class="video-jump-btn" id="btn-video-pip" title="🔲 Toggle Picture-in-Picture floating window">🔲 PiP</button>
<button type="button" class="video-jump-btn" id="btn-video-loop" title="🔁 Toggle video repeat">🔁 Loop</button>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button type="button" class="video-jump-btn" id="btn-preview-prev" title="Previous video">◀ Prev</button>
<button type="button" class="video-jump-btn" id="btn-preview-prev" title="Previous video (Alt+Left)">◀ Prev</button>
<span id="preview-counter" style="font-size: 0.72rem; color: var(--text-muted); font-family: var(--font-mono);">0 / 0</span>
<button type="button" class="video-jump-btn" id="btn-preview-next" title="Next video">Next ▶</button>
<button type="button" class="video-jump-btn" id="btn-preview-next" title="Next video (Alt+Right)">Next ▶</button>
</div>
</div>
@@ -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 = `
<tr>
<td colspan="8">
<div class="empty-state">
<div class="empty-state-icon">📜</div>
<h3>No matching history records</h3>
<p>${q ? 'Adjust your search query.' : 'Relocation actions will be logged here automatically.'}</p>
</div>
</td>
</tr>
`;
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 `
<tr style="${isUndone ? 'opacity: 0.55; background: rgba(0,0,0,0.15);' : ''}">
<td style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-muted);">${formattedDate}</td>
<td>
<span class="badge badge-${row.source_type === 'qb' ? 'primary' : 'success'}" style="font-size: 0.68rem; text-transform: uppercase;">
${row.source_type || 'smb'}
</span>
</td>
<td>
<div style="font-weight: 600; color: #fff; font-size: 0.82rem; word-break: break-all;">${escapeHtml(row.item_name || '')}</div>
</td>
<td>
<div style="font-size: 0.7rem; color: var(--text-muted); font-family: var(--font-mono); line-height: 1.3;">
<div><span style="color: var(--accent-rose);">From:</span> ${escapeHtml(row.source_path || '')}</div>
<div><span style="color: var(--accent-green);">To:</span> ${escapeHtml(row.target_path || '')}</div>
</div>
</td>
<td>
${row.performer ? `<span class="badge" style="background: rgba(6,182,212,0.15); color: var(--accent-cyan); border-color: rgba(6,182,212,0.3); font-size: 0.75rem;">👤 ${escapeHtml(row.performer)}</span>` : '<span style="color: var(--text-muted); font-size: 0.75rem;">-</span>'}
</td>
<td style="font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-muted);">${sizeFormatted}</td>
<td style="text-align: center;">
<span class="badge badge-${isUndone ? 'secondary' : 'success'}" style="font-size: 0.7rem;">
${isUndone ? '↩ Undone' : '✓ Moved'}
</span>
</td>
<td style="text-align: right;">
${!isUndone ? `
<button class="btn btn-secondary btn-undo-row" data-history-id="${row.id}" style="padding: 0.25rem 0.6rem; font-size: 0.72rem; color: var(--accent-amber); border-color: rgba(245,158,11,0.4);" title="Restore this file back to original location">
↩ Undo
</button>
` : `<span style="font-size: 0.7rem; color: var(--text-muted);">Restored</span>`}
</td>
</tr>
`;
}).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: <strong>${data.disk.free_gb || ''} free</strong> of ${data.disk.total_gb || ''}`;
}
if (!data.performers || data.performers.length === 0) {
container.innerHTML = `<div style="color: var(--text-muted); font-size: 0.8rem;">No performer video folders detected in target root.</div>`;
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 `
<div style="flex: 1 1 calc(25% - 0.6rem); min-width: 170px; background: rgba(30, 41, 59, 0.6); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 0.6rem; display: flex; flex-direction: column; gap: 0.3rem;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 600; font-size: 0.8rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
👤 ${escapeHtml(p.name)}
</span>
<span style="font-size: 0.72rem; color: var(--accent-cyan); font-family: var(--font-mono);">${p.formatted_size}</span>
</div>
<div style="display: flex; justify-content: space-between; font-size: 0.68rem; color: var(--text-muted);">
<span>${p.video_count} videos</span>
<span>#${idx + 1}</span>
</div>
<div style="background: rgba(0,0,0,0.3); border-radius: 999px; height: 4px; overflow: hidden; width: 100%;">
<div style="background: linear-gradient(90deg, var(--accent-cyan), var(--accent-purple)); width: ${pct}%; height: 100%;"></div>
</div>
</div>
`;
}).join('');
} catch (err) {
container.innerHTML = `<div style="color: var(--accent-rose); font-size: 0.8rem;">Error loading storage analytics: ${err.message}</div>`;
}
}
// 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 = `
<div>Found <strong>${cleanupScanData.files.length}</strong> zero-byte/corrupt video files and <strong>${cleanupScanData.dirs.length}</strong> empty subdirectories.</div>
`;
if (cleanupScanData.files.length === 0 && cleanupScanData.dirs.length === 0) {
listEl.innerHTML = `<div style="color: var(--accent-green); font-size: 0.8rem; padding: 0.5rem;">✓ Directory is clean! No 0-byte files or empty folders found.</div>`;
} else {
listEl.innerHTML = `
${cleanupScanData.files.map(f => `<div style="color: var(--accent-rose); font-size: 0.75rem; font-family: var(--font-mono);">🗑️ 0-byte: ${escapeHtml(f.path)}</div>`).join('')}
${cleanupScanData.dirs.map(d => `<div style="color: var(--accent-amber); font-size: 0.75rem; font-family: var(--font-mono);">📁 Empty Dir: ${escapeHtml(d.path)}</div>`).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 = `<div style="padding: 0.75rem; color: var(--text-muted); font-size: 0.8rem;">No commands matching "${escapeHtml(q)}"</div>`;
return;
}
cmdPaletteResults.innerHTML = filtered.map((cmd, idx) => `
<div class="cmd-item" data-cmd-idx="${idx}" style="padding: 0.6rem 0.8rem; border-radius: var(--radius-sm); cursor: pointer; display: flex; align-items: center; gap: 0.6rem; color: #fff; font-size: 0.82rem; transition: background 0.15s ease;">
<span style="font-size: 1rem;">${cmd.icon}</span>
<span style="flex: 1;">${escapeHtml(cmd.title)}</span>
<span style="font-size: 0.65rem; color: var(--text-muted); font-family: var(--font-mono);">Action</span>
</div>
`).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();
+381 -2
View File
@@ -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,8 +1698,17 @@ 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:
conn = get_db_connection()
@@ -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 ====================