diff --git a/qbittorrent_performer_sorter.html b/qbittorrent_performer_sorter.html index 7c5124b..604f7fb 100644 --- a/qbittorrent_performer_sorter.html +++ b/qbittorrent_performer_sorter.html @@ -1171,13 +1171,14 @@ - ⛔ Stop Analysis - - - + ⛔ Stop Analy Approve & Move Selected Torrents + + + 🗑️ Delete Selected + @@ -1251,7 +1252,7 @@ - + @@ -1280,6 +1281,11 @@ Approve & Move Selected Files + + + 🗑️ Delete Selected + + @@ -1593,6 +1599,7 @@ const btnAnalyzeAll = document.getElementById('btn-ai-analyze-all'); const btnStopAnalysis = document.getElementById('btn-stop-analysis'); const btnBatchRelocate = document.getElementById('btn-batch-relocate'); + const btnBatchDeleteTorrents = document.getElementById('btn-batch-delete-torrents'); const progressBarContainer = document.getElementById('global-progress-container'); const progressBarFill = document.getElementById('global-progress-fill'); @@ -1610,6 +1617,7 @@ const btnSmbAnalyzeAll = document.getElementById('btn-smb-analyze-all'); const btnSmbStopAnalysis = document.getElementById('btn-smb-stop-analysis'); const btnSmbBatchRelocate = document.getElementById('btn-smb-batch-relocate'); + const btnSmbBatchDelete = document.getElementById('btn-smb-batch-delete'); const smbProgressBarContainer = document.getElementById('smb-progress-container'); const smbProgressBarFill = document.getElementById('smb-progress-fill'); @@ -1845,6 +1853,22 @@ } } + async function deleteItemFromDb(itemKey) { + delete dbCache.all[itemKey]; + dbStatusText.textContent = `SQLite: ${Object.keys(dbCache.all).length} Cached`; + try { + const isServingFromProxy = window.location.protocol.startsWith('http') && window.location.port === '8000'; + const endpoint = isServingFromProxy ? '/api/db/delete' : 'http://localhost:8000/api/db/delete'; + await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ item_key: itemKey }) + }); + } catch (err) { + console.warn('Failed deleting item from SQLite DB:', err); + } + } + // Load saved settings function loadConfig() { const raw = localStorage.getItem(STORAGE_KEY) || localStorage.getItem('qb_performer_sorter_cfg_v3'); @@ -3006,6 +3030,87 @@ Return ONLY valid JSON matching: updateButtonStates(); }); + async function deleteTorrent(torrent, confirmPrompt = true) { + if (confirmPrompt) { + const ok = confirm(`⚠️ PERMANENT DELETE:\n\nAre you sure you want to permanently delete "${torrent.name}" AND ALL ITS DOWNLOADED FILES from disk?`); + if (!ok) return; + } + + torrent.moveStatus = 'moving'; + renderRow(torrent.hash); + + const params = new URLSearchParams(); + params.append('hashes', torrent.hash); + params.append('deleteFiles', 'true'); + + const res = await qbRequest('/api/v2/torrents/delete', { method: 'POST', body: params }); + if (!res.ok) { + const text = await res.text(); + torrent.moveStatus = 'error'; + torrent.errorMessage = 'Failed delete: ' + text; + renderRow(torrent.hash); + throw new Error('Failed to delete torrent: ' + text); + } + + await deleteItemFromDb(torrent.hash); + qbState.torrents = qbState.torrents.filter(t => t.hash !== torrent.hash); + qbState.selectedHashes.delete(torrent.hash); + qbTabBadge.textContent = qbState.torrents.length; + renderTable(); + updateStats(); + showToast(`Deleted ${torrent.name} and downloaded files from disk`, 'success'); + } + + if (btnBatchDeleteTorrents) { + btnBatchDeleteTorrents.addEventListener('click', async () => { + const selected = qbState.torrents.filter(t => qbState.selectedHashes.has(t.hash)); + if (selected.length === 0) { + showToast('Please select at least one torrent checkbox to delete', 'warning'); + return; + } + + const ok = confirm(`⚠️ PERMANENT BATCH DELETE:\n\nAre you sure you want to permanently delete ${selected.length} selected torrent(s) AND ALL THEIR DOWNLOADED FILES from disk?\n\nThis cannot be undone.`); + if (!ok) return; + + btnBatchDeleteTorrents.disabled = true; + btnBatchDeleteTorrents.textContent = 'Deleting...'; + progressBarContainer.style.display = 'block'; + progressBarFill.style.width = '0%'; + + try { + const hashes = selected.map(t => t.hash).join('|'); + const params = new URLSearchParams(); + params.append('hashes', hashes); + params.append('deleteFiles', 'true'); + + const res = await qbRequest('/api/v2/torrents/delete', { method: 'POST', body: params }); + if (!res.ok) { + const text = await res.text(); + throw new Error('Failed deleting torrents from qBittorrent: ' + text); + } + + for (const t of selected) { + await deleteItemFromDb(t.hash); + } + + const deletedHashSet = new Set(selected.map(t => t.hash)); + qbState.torrents = qbState.torrents.filter(t => !deletedHashSet.has(t.hash)); + qbState.selectedHashes.clear(); + qbTabBadge.textContent = qbState.torrents.length; + renderTable(); + updateStats(); + showToast(`Permanently deleted ${selected.length} torrent(s) and files from disk`, 'success'); + } catch (err) { + showToast(err.message, 'error'); + } finally { + btnBatchDeleteTorrents.disabled = false; + btnBatchDeleteTorrents.textContent = '🗑️ Delete Selected'; + setTimeout(() => { progressBarContainer.style.display = 'none'; }, 1000); + updateButtonStates(); + } + }); + } + function updateStats() { statTotal.textContent = qbState.torrents.length; statAnalyzed.textContent = qbState.torrents.filter(t => t.analysis).length; @@ -3024,6 +3129,7 @@ Return ONLY valid JSON matching: btnAnalyzeSelected.disabled = !hasSelection || qbState.isAnalyzing; btnAnalyzeAll.disabled = !hasTorrents || qbState.isAnalyzing; btnBatchRelocate.disabled = !hasReadySelected || qbState.isMoving; + if (btnBatchDeleteTorrents) btnBatchDeleteTorrents.disabled = !hasSelection || qbState.isMoving; } function renderTable() { @@ -3150,6 +3256,7 @@ Return ONLY valid JSON matching: ${t.analysis && t.moveStatus !== 'moved' ? ` Re-run ` : ''} + 🗑️ @@ -3258,6 +3365,14 @@ Return ONLY valid JSON matching: }); }); + root.querySelectorAll('.btn-single-delete-torrent').forEach(btn => { + btn.addEventListener('click', async () => { + const hash = btn.getAttribute('data-hash'); + const t = qbState.torrents.find(item => item.hash === hash); + if (t) await deleteTorrent(t, true); + }); + }); + root.querySelectorAll('.view-files-link').forEach(link => { link.addEventListener('click', async (e) => { e.preventDefault(); @@ -3754,6 +3869,95 @@ Return ONLY a valid JSON object with an array named "results": updateSmbButtonStates(); }); + async function deleteSmbFile(fileItem, confirmPrompt = true) { + if (confirmPrompt) { + const ok = confirm(`⚠️ PERMANENT DELETE:\n\nAre you sure you want to permanently delete "${fileItem.name}" from the SMB share?`); + if (!ok) return; + } + + fileItem.moveStatus = 'moving'; + renderSmbRow(fileItem.path); + + const isServingFromProxy = window.location.protocol.startsWith('http') && window.location.port === '8000'; + const endpoint = isServingFromProxy ? '/api/smb/delete' : 'http://localhost:8000/api/smb/delete'; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: fileItem.path }) + }); + + if (!res.ok) { + const errData = await res.json(); + fileItem.moveStatus = 'error'; + fileItem.errorMessage = 'Failed delete: ' + (errData.error || `HTTP ${res.status}`); + renderSmbRow(fileItem.path); + throw new Error(errData.error || `HTTP ${res.status}`); + } + + await deleteItemFromDb(fileItem.path); + smbState.files = smbState.files.filter(f => f.path !== fileItem.path); + smbState.selectedPaths.delete(fileItem.path); + smbTabBadge.textContent = smbState.files.length; + renderSmbTable(); + updateSmbStats(); + showToast(`Deleted ${fileItem.name} from SMB share`, 'success'); + } + + if (btnSmbBatchDelete) { + btnSmbBatchDelete.addEventListener('click', async () => { + const selected = smbState.files.filter(f => smbState.selectedPaths.has(f.path)); + if (selected.length === 0) { + showToast('Please select at least one SMB file to delete', 'warning'); + return; + } + + const ok = confirm(`⚠️ PERMANENT BATCH DELETE:\n\nAre you sure you want to permanently delete ${selected.length} selected video file(s) from the SMB share?\n\nThis cannot be undone.`); + if (!ok) return; + + btnSmbBatchDelete.disabled = true; + btnSmbBatchDelete.textContent = 'Deleting...'; + smbProgressBarContainer.style.display = 'block'; + smbProgressBarFill.style.width = '0%'; + + try { + const paths = selected.map(f => f.path); + const isServingFromProxy = window.location.protocol.startsWith('http') && window.location.port === '8000'; + const endpoint = isServingFromProxy ? '/api/smb/delete' : 'http://localhost:8000/api/smb/delete'; + + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: paths }) + }); + + if (!res.ok) { + const errData = await res.json(); + throw new Error(errData.error || `HTTP ${res.status}`); + } + + for (const f of selected) { + await deleteItemFromDb(f.path); + } + + const deletedPathSet = new Set(paths); + smbState.files = smbState.files.filter(f => !deletedPathSet.has(f.path)); + smbState.selectedPaths.clear(); + smbTabBadge.textContent = smbState.files.length; + renderSmbTable(); + updateSmbStats(); + showToast(`Permanently deleted ${selected.length} video file(s) from SMB share`, 'success'); + } catch (err) { + showToast(err.message, 'error'); + } finally { + btnSmbBatchDelete.disabled = false; + btnSmbBatchDelete.textContent = '🗑️ Delete Selected'; + setTimeout(() => { smbProgressBarContainer.style.display = 'none'; }, 1000); + updateSmbButtonStates(); + } + }); + } + function updateSmbStats() { smbStatTotal.textContent = smbState.files.length; smbStatAnalyzed.textContent = smbState.files.filter(f => f.analysis).length; @@ -3772,6 +3976,7 @@ Return ONLY a valid JSON object with an array named "results": btnSmbAnalyzeSelected.disabled = !hasSelection || smbState.isAnalyzing; btnSmbAnalyzeAll.disabled = !hasFiles || smbState.isAnalyzing; btnSmbBatchRelocate.disabled = !hasReadySelected || smbState.isMoving; + if (btnSmbBatchDelete) btnSmbBatchDelete.disabled = !hasSelection || smbState.isMoving; } function renderSmbTable() { @@ -3893,6 +4098,7 @@ Return ONLY a valid JSON object with an array named "results": ${f.analysis && f.moveStatus !== 'moved' ? ` Re-run ` : ''} + 🗑️ @@ -3924,6 +4130,14 @@ Return ONLY a valid JSON object with an array named "results": }); }); + root.querySelectorAll('.btn-smb-single-delete').forEach(btn => { + btn.addEventListener('click', async () => { + const path = btn.getAttribute('data-path'); + const f = smbState.files.find(item => item.path === path); + if (f) await deleteSmbFile(f, true); + }); + }); + // Video Preview Button / Title Click root.querySelectorAll('.btn-open-video-preview').forEach(el => { el.addEventListener('click', (e) => { @@ -4153,6 +4367,9 @@ Return ONLY a valid JSON object with an array named "results": ` : ` ✓ Moved Successfully `} + + 🗑️ Delete Video + @@ -4172,6 +4389,21 @@ Return ONLY a valid JSON object with an array named "results": `; // Event listeners inside modal + const btnDeleteNow = videoPerformerBox.querySelector('#btn-modal-delete-now'); + if (btnDeleteNow) { + btnDeleteNow.addEventListener('click', async () => { + try { + if (type === 'smb') { + await deleteSmbFile(item, true); + } else { + await deleteTorrent(item, true); + } + closeVideoPreview(); + } catch (e) { + showToast(`Delete failed: ${e.message}`, 'error'); + } + }); + } videoPerformerBox.querySelectorAll('.modal-performer-pill').forEach(pill => { pill.addEventListener('click', (e) => { e.preventDefault(); diff --git a/server.py b/server.py index 53db802..3a68dd7 100644 --- a/server.py +++ b/server.py @@ -583,22 +583,25 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): else: super().do_GET() - def do_POST(self): - # Database Cache Save APIs + # Database Cache Save/Delete APIs if self.path == '/api/db/save': self.handle_db_save() elif self.path == '/api/db/batch-save': self.handle_db_batch_save() + elif self.path == '/api/db/delete': + self.handle_db_delete() # Stash Lookup APIs elif self.path == '/api/stash/lookup': self.handle_stash_lookup() elif self.path == '/api/stash/batch-lookup': self.handle_stash_batch_lookup() - # SMB API Moves + # SMB API Moves & Deletes 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.startswith('/qb-proxy/'): self.proxy_qb_request('POST') elif self.path.startswith('/anthropic-proxy/'): @@ -783,6 +786,43 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): self.end_headers() self.wfile.write(json.dumps({"error": f"Failed batch saving to DB: {str(e)}"}).encode('utf-8')) + def handle_db_delete(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')) + item_key = payload.get('item_key') + item_keys = payload.get('item_keys', []) + if item_key and item_key not in item_keys: + item_keys.append(item_key) + + if not item_keys: + self.send_response(400) + self.send_header('Content-Type', 'application/json') + self.send_cors_headers() + self.end_headers() + self.wfile.write(json.dumps({"error": "Missing item_key or item_keys"}).encode('utf-8')) + return + + conn = get_db_connection() + cursor = conn.cursor() + for key in item_keys: + cursor.execute("DELETE FROM performer_analysis_cache WHERE item_key = ? OR name = ?", (key, key)) + conn.commit() + conn.close() + + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_cors_headers() + self.end_headers() + self.wfile.write(json.dumps({"success": True, "deleted_keys": item_keys}).encode('utf-8')) + except Exception as e: + self.send_response(500) + self.send_header('Content-Type', 'application/json') + self.send_cors_headers() + self.end_headers() + self.wfile.write(json.dumps({"error": f"Failed deleting from DB: {str(e)}"}).encode('utf-8')) + # ==================== Video Streaming Handlers (Range 206) ==================== def handle_stream_video(self): head_only = False @@ -1217,6 +1257,65 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler): self.end_headers() self.wfile.write(json.dumps({"error": f"Batch move failed: {str(e)}"}).encode('utf-8')) + def handle_smb_delete(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')) + path = payload.get('path') + paths = payload.get('paths', []) + if path and path not in paths: + paths.append(path) + + if not paths: + self.send_response(400) + self.send_header('Content-Type', 'application/json') + self.send_cors_headers() + self.end_headers() + self.wfile.write(json.dumps({"error": "Missing path or paths"}).encode('utf-8')) + return + + deleted = [] + errors = [] + conn = get_db_connection() + cursor = conn.cursor() + + for p in paths: + try: + resolved = resolve_stream_video_path(p) or p + if os.path.exists(resolved): + if os.path.isdir(resolved): + shutil.rmtree(resolved) + else: + os.remove(resolved) + deleted.append(p) + cursor.execute("DELETE FROM performer_analysis_cache WHERE item_key = ? OR name = ?", (p, os.path.basename(p))) + else: + errors.append(f"File not found: {p}") + except Exception as del_err: + errors.append(f"Failed deleting {p}: {str(del_err)}") + + conn.commit() + conn.close() + + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_cors_headers() + self.end_headers() + self.wfile.write(json.dumps({ + "success": len(deleted) > 0, + "deleted": deleted, + "deleted_count": len(deleted), + "errors": errors + }).encode('utf-8')) + + except Exception as e: + self.send_response(500) + self.send_header('Content-Type', 'application/json') + self.send_cors_headers() + self.end_headers() + self.wfile.write(json.dumps({"error": f"Delete request failed: {str(e)}"}).encode('utf-8')) + # ==================== External API Proxies ==================== def proxy_external_api(self, upstream_base, proxy_prefix, method='POST'): upstream_base = upstream_base.rstrip('/')