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:
@@ -1111,9 +1111,14 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" id="tab-btn-stash-images">
|
<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>
|
<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>
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Unified Configuration Card (Collapsible - Collapsed by default) -->
|
<!-- Unified Configuration Card (Collapsible - Collapsed by default) -->
|
||||||
@@ -1368,7 +1373,11 @@
|
|||||||
</button>
|
</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">
|
<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>
|
</button>
|
||||||
</div>
|
</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">
|
<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
|
🗑️ Delete Selected
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" id="btn-export-smb-plan" title="Export current SMB relocation plan to CSV">
|
||||||
|
📥 Export Plan
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search-filter">
|
<div class="search-filter">
|
||||||
@@ -1551,8 +1564,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls Bar -->
|
<!-- Controls Bar -->
|
||||||
<div class="controls-bar">
|
<div class="controls-bar" style="flex-direction: column; gap: 0.75rem; align-items: stretch;">
|
||||||
<div class="btn-group">
|
<div class="btn-group" style="flex-wrap: wrap;">
|
||||||
<button class="btn btn-primary" id="btn-scan-stash-images">
|
<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>
|
<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
|
Scan Stash Missing Images
|
||||||
@@ -1566,6 +1579,10 @@
|
|||||||
⚡ Find Images (All Missing)
|
⚡ Find Images (All Missing)
|
||||||
</button>
|
</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;">
|
<button class="btn btn-secondary" id="btn-stash-stop-images" style="display: none; background: #7f1d1d; border-color: #991b1b; color: #fff;">
|
||||||
⛔ Stop Search
|
⛔ Stop Search
|
||||||
</button>
|
</button>
|
||||||
@@ -1627,8 +1644,142 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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">×</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 -->
|
<!-- File Modal -->
|
||||||
<div class="modal-backdrop" id="file-modal">
|
<div class="modal-backdrop" id="file-modal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -1676,10 +1827,17 @@
|
|||||||
<button type="button" class="video-jump-btn video-speed-btn" data-speed="2.0">2x</button>
|
<button type="button" class="video-jump-btn video-speed-btn" data-speed="2.0">2x</button>
|
||||||
</div>
|
</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;">
|
<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>
|
<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>
|
||||||
</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 tabBtnStashImages = document.getElementById('tab-btn-stash-images');
|
||||||
const tabPanelStashImages = document.getElementById('tab-panel-stash-images');
|
const tabPanelStashImages = document.getElementById('tab-panel-stash-images');
|
||||||
const stashImagesTabBadge = document.getElementById('stash-images-tab-badge');
|
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
|
// Tab Switching Logic
|
||||||
function switchTab(tabName) {
|
function switchTab(tabName) {
|
||||||
tabBtnQb.classList.toggle('active', tabName === 'qb');
|
tabBtnQb.classList.toggle('active', tabName === 'qb');
|
||||||
tabBtnSmb.classList.toggle('active', tabName === 'smb');
|
tabBtnSmb.classList.toggle('active', tabName === 'smb');
|
||||||
if (tabBtnStashImages) tabBtnStashImages.classList.toggle('active', tabName === 'stash-images');
|
if (tabBtnStashImages) tabBtnStashImages.classList.toggle('active', tabName === 'stash-images');
|
||||||
|
if (tabBtnHistory) tabBtnHistory.classList.toggle('active', tabName === 'history');
|
||||||
|
|
||||||
tabPanelQb.classList.toggle('active', tabName === 'qb');
|
tabPanelQb.classList.toggle('active', tabName === 'qb');
|
||||||
tabPanelSmb.classList.toggle('active', tabName === 'smb');
|
tabPanelSmb.classList.toggle('active', tabName === 'smb');
|
||||||
if (tabPanelStashImages) tabPanelStashImages.classList.toggle('active', tabName === 'stash-images');
|
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);
|
localStorage.setItem('qb_active_tab', tabName);
|
||||||
}
|
}
|
||||||
@@ -2093,6 +2262,7 @@
|
|||||||
tabBtnQb.addEventListener('click', () => switchTab('qb'));
|
tabBtnQb.addEventListener('click', () => switchTab('qb'));
|
||||||
tabBtnSmb.addEventListener('click', () => switchTab('smb'));
|
tabBtnSmb.addEventListener('click', () => switchTab('smb'));
|
||||||
if (tabBtnStashImages) tabBtnStashImages.addEventListener('click', () => switchTab('stash-images'));
|
if (tabBtnStashImages) tabBtnStashImages.addEventListener('click', () => switchTab('stash-images'));
|
||||||
|
if (tabBtnHistory) tabBtnHistory.addEventListener('click', () => switchTab('history'));
|
||||||
|
|
||||||
function getProviderDisplayName() {
|
function getProviderDisplayName() {
|
||||||
const provider = aiProviderSelect ? aiProviderSelect.value : 'local';
|
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
|
// Init on load
|
||||||
window.addEventListener('DOMContentLoaded', async () => {
|
window.addEventListener('DOMContentLoaded', async () => {
|
||||||
loadConfig();
|
loadConfig();
|
||||||
await loadDatabaseCache();
|
await loadDatabaseCache();
|
||||||
await checkStashStatus();
|
await checkStashStatus();
|
||||||
await loadExistingPerformers();
|
await loadExistingPerformers();
|
||||||
|
loadRelocationHistory();
|
||||||
if (window.location.protocol.startsWith('http')) {
|
if (window.location.protocol.startsWith('http')) {
|
||||||
try {
|
try {
|
||||||
await qbLogin();
|
await qbLogin();
|
||||||
|
|||||||
@@ -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_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 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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
init_db()
|
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():
|
def get_db_connection():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
@@ -735,6 +769,15 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
# Database Cache API
|
# Database Cache API
|
||||||
if self.path.startswith('/api/db/cache'):
|
if self.path.startswith('/api/db/cache'):
|
||||||
self.handle_db_get_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)
|
# Video Stream API (Range 206 Partial Content)
|
||||||
elif self.path.startswith('/api/stream'):
|
elif self.path.startswith('/api/stream'):
|
||||||
self.handle_stream_video()
|
self.handle_stream_video()
|
||||||
@@ -786,24 +829,38 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
self.handle_db_batch_save()
|
self.handle_db_batch_save()
|
||||||
elif self.path == '/api/db/delete':
|
elif self.path == '/api/db/delete':
|
||||||
self.handle_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':
|
elif self.path == '/api/stash/lookup':
|
||||||
self.handle_stash_lookup()
|
self.handle_stash_lookup()
|
||||||
elif self.path == '/api/stash/batch-lookup':
|
elif self.path == '/api/stash/batch-lookup':
|
||||||
self.handle_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':
|
elif self.path == '/api/stash/performers/find-images':
|
||||||
self.handle_stash_find_images()
|
self.handle_stash_find_images()
|
||||||
elif self.path == '/api/stash/performers/commit-image':
|
elif self.path == '/api/stash/performers/commit-image':
|
||||||
self.handle_stash_commit_image()
|
self.handle_stash_commit_image()
|
||||||
elif self.path == '/api/stash/performers/batch-commit-images':
|
elif self.path == '/api/stash/performers/batch-commit-images':
|
||||||
self.handle_stash_batch_commit_images()
|
self.handle_stash_batch_commit_images()
|
||||||
# SMB API Moves & Deletes
|
# SMB API Moves, Deletes & Cleanups
|
||||||
elif self.path == '/api/smb/move':
|
elif self.path == '/api/smb/move':
|
||||||
self.handle_smb_move()
|
self.handle_smb_move()
|
||||||
elif self.path == '/api/smb/batch-move':
|
elif self.path == '/api/smb/batch-move':
|
||||||
self.handle_smb_batch_move()
|
self.handle_smb_batch_move()
|
||||||
elif self.path == '/api/smb/delete':
|
elif self.path == '/api/smb/delete':
|
||||||
self.handle_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/'):
|
elif self.path.startswith('/qb-proxy/'):
|
||||||
self.proxy_qb_request('POST')
|
self.proxy_qb_request('POST')
|
||||||
elif self.path.startswith('/anthropic-proxy/'):
|
elif self.path.startswith('/anthropic-proxy/'):
|
||||||
@@ -1558,6 +1615,7 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
source_path = payload.get('source')
|
source_path = payload.get('source')
|
||||||
target_dir = payload.get('targetDir')
|
target_dir = payload.get('targetDir')
|
||||||
target_file = payload.get('targetFile')
|
target_file = payload.get('targetFile')
|
||||||
|
performer = payload.get('performer', '')
|
||||||
|
|
||||||
if not source_path or not os.path.exists(source_path):
|
if not source_path or not os.path.exists(source_path):
|
||||||
self.send_response(400)
|
self.send_response(400)
|
||||||
@@ -1581,10 +1639,18 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
|
|
||||||
dest_dir = os.path.dirname(target_file)
|
dest_dir = os.path.dirname(target_file)
|
||||||
os.makedirs(dest_dir, exist_ok=True)
|
os.makedirs(dest_dir, exist_ok=True)
|
||||||
|
f_size = 0
|
||||||
|
try:
|
||||||
|
f_size = os.path.getsize(source_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Move file
|
# Move file
|
||||||
shutil.move(source_path, target_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
|
# Update DB status if entry exists
|
||||||
try:
|
try:
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -1620,6 +1686,7 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
source = item.get('source')
|
source = item.get('source')
|
||||||
target_dir = item.get('targetDir')
|
target_dir = item.get('targetDir')
|
||||||
target_file = item.get('targetFile')
|
target_file = item.get('targetFile')
|
||||||
|
performer = item.get('performer', '')
|
||||||
|
|
||||||
if not target_file and target_dir and source:
|
if not target_file and target_dir and source:
|
||||||
target_file = os.path.join(target_dir, os.path.basename(source))
|
target_file = os.path.join(target_dir, os.path.basename(source))
|
||||||
@@ -1631,8 +1698,17 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
dest_dir = os.path.dirname(target_file)
|
dest_dir = os.path.dirname(target_file)
|
||||||
os.makedirs(dest_dir, exist_ok=True)
|
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)
|
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
|
# Update DB
|
||||||
try:
|
try:
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
@@ -1717,6 +1793,309 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
self.send_header('Content-Type', 'application/json')
|
self.send_header('Content-Type', 'application/json')
|
||||||
self.send_cors_headers()
|
self.send_cors_headers()
|
||||||
self.end_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'))
|
self.wfile.write(json.dumps({"error": f"Delete request failed: {str(e)}"}).encode('utf-8'))
|
||||||
|
|
||||||
# ==================== External API Proxies ====================
|
# ==================== External API Proxies ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user