diff --git a/qbittorrent_performer_sorter.html b/qbittorrent_performer_sorter.html
index cebeff2..b935ed6 100644
--- a/qbittorrent_performer_sorter.html
+++ b/qbittorrent_performer_sorter.html
@@ -2155,6 +2155,93 @@
+
+
+
+
+
+
+
+
+
+
+
+ Correlated Matches
+ 0
+
+
+ Total Potential Savings
+ 0 GB
+
+
+
+
+
+ ๐ข Space Savers Only (Orig > Transcode)
+ ๐ Show All Correlated Transcodes
+ โก Large Savings (> 500 MB)
+
+
+
+
+
+
+
+
+
๐
+
No transcode scan executed yet
+
Click "Scan & Correlate Transcodes" to index all generated Stash transcodes and match them to original video files.
+
+
+
+
@@ -7547,6 +7634,315 @@ Return ONLY a valid JSON object with an array named "results":
const btnScanTagDupes = document.getElementById('btn-scan-tag-dupes');
if (btnScanTagDupes) btnScanTagDupes.addEventListener('click', scanTagDuplicates);
+ // ==================== SECTION D: TRANSCODE CORRELATOR & STORAGE SAVER ====================
+ let transcodeItems = [];
+ let selectedTranscodeHashes = new Set();
+
+ async function scanStashTranscodes() {
+ const btn = document.getElementById('btn-scan-stash-transcodes');
+ const container = document.getElementById('stash-transcodes-container');
+ const summaryBar = document.getElementById('transcode-summary-bar');
+ const batchBtn = document.getElementById('btn-batch-replace-transcodes');
+
+ const transcodeDir = document.getElementById('stash-transcode-dir')?.value.trim() || '/mnt/isolation/stashapp/generated/transcodes';
+ const stashPrefix = document.getElementById('stash-container-prefix')?.value.trim() || '/data';
+ const localPrefix = document.getElementById('stash-local-prefix')?.value.trim() || '/mnt/isolation/videos';
+
+ try {
+ if (btn) { btn.disabled = true; btn.textContent = 'Correlating Transcodes...'; }
+ if (container) {
+ container.innerHTML = `
+
+
โณ
+
Scanning Stash library and matching generated transcodes to original video files...
+
+ `;
+ }
+
+ const queryParams = new URLSearchParams({
+ transcode_dir: transcodeDir,
+ stash_prefix: stashPrefix,
+ local_prefix: localPrefix
+ });
+
+ const res = await fetch(`/api/stash/transcodes/scan?${queryParams.toString()}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ transcodeItems = data.items || [];
+ selectedTranscodeHashes.clear();
+
+ if (summaryBar) summaryBar.style.display = 'flex';
+ if (batchBtn) batchBtn.style.display = 'inline-flex';
+
+ const statMatches = document.getElementById('stat-transcode-matches');
+ if (statMatches) statMatches.textContent = `${transcodeItems.length} matched (${data.total_transcodes_on_disk || 0} on disk)`;
+
+ const statSavings = document.getElementById('stat-transcode-savings');
+ if (statSavings) {
+ const gb = (data.total_potential_savings_bytes || 0) / (1024**3);
+ statSavings.textContent = `${gb.toFixed(2)} GB`;
+ }
+
+ renderStashTranscodes();
+ } catch (err) {
+ showToast(`Transcode scan error: ${err.message}`, 'error');
+ if (container) {
+ container.innerHTML = `Failed to correlate transcodes: ${err.message}
`;
+ }
+ } finally {
+ if (btn) { btn.disabled = false; btn.textContent = '๐ Scan & Correlate Transcodes'; }
+ }
+ }
+
+ function renderStashTranscodes() {
+ const container = document.getElementById('stash-transcodes-container');
+ const filterMode = document.getElementById('transcode-filter-mode')?.value || 'savers';
+ const searchQ = (document.getElementById('transcode-search-input')?.value || '').toLowerCase().trim();
+
+ if (!container) return;
+
+ let filtered = transcodeItems.filter(item => {
+ if (searchQ && !item.basename.toLowerCase().includes(searchQ) && !item.orig_local_path.toLowerCase().includes(searchQ)) {
+ return false;
+ }
+ if (filterMode === 'savers') return item.saved_bytes > 0;
+ if (filterMode === 'large') return item.saved_bytes >= 500 * 1024 * 1024;
+ return true;
+ });
+
+ if (filtered.length === 0) {
+ container.innerHTML = `
+
+
โจ
+
No matching transcodes found
+
Try changing the filter mode or search term above.
+
+ `;
+ return;
+ }
+
+ container.innerHTML = `
+
+ `;
+
+ // Select all listener
+ const thSelectAll = document.getElementById('th-select-all-transcodes');
+ if (thSelectAll) {
+ thSelectAll.addEventListener('change', () => {
+ filtered.forEach(item => {
+ if (thSelectAll.checked) {
+ selectedTranscodeHashes.add(item.oshash);
+ } else {
+ selectedTranscodeHashes.delete(item.oshash);
+ }
+ });
+ document.querySelectorAll('.transcode-checkbox').forEach(cb => cb.checked = thSelectAll.checked);
+ updateSelectedTranscodeCount();
+ });
+ }
+
+ // Individual checkbox listeners
+ container.querySelectorAll('.transcode-checkbox').forEach(cb => {
+ cb.addEventListener('change', () => {
+ const h = cb.getAttribute('data-hash');
+ if (cb.checked) selectedTranscodeHashes.add(h);
+ else selectedTranscodeHashes.delete(h);
+ updateSelectedTranscodeCount();
+ });
+ });
+
+ // Preview listener
+ container.querySelectorAll('.btn-transcode-preview').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const p = btn.getAttribute('data-path');
+ const t = btn.getAttribute('data-title');
+ if (p) openVideoPreview(p, t, null);
+ });
+ });
+
+ // Single Replace listener
+ container.querySelectorAll('.btn-transcode-replace-single').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const h = btn.getAttribute('data-hash');
+ const item = transcodeItems.find(x => x.oshash === h);
+ if (!item) return;
+
+ if (confirm(`Safely replace original video file with Stash transcode?\n\nFile: ${item.basename}\nOriginal Size: ${(item.orig_size/(1024*1024)).toFixed(1)} MB\nTranscode Size: ${(item.transcode_size/(1024*1024)).toFixed(1)} MB\nSpace Saved: ${(item.saved_bytes/(1024*1024)).toFixed(1)} MB`)) {
+ await executeSingleTranscodeReplace(item, btn);
+ }
+ });
+ });
+ }
+
+ function updateSelectedTranscodeCount() {
+ const countEl = document.getElementById('transcode-selected-count');
+ if (countEl) countEl.textContent = selectedTranscodeHashes.size;
+ }
+
+ async function executeSingleTranscodeReplace(item, btnEl) {
+ const deleteCache = document.getElementById('transcode-opt-delete-cache')?.checked;
+ const triggerRescan = document.getElementById('transcode-opt-trigger-rescan')?.checked;
+
+ try {
+ btnEl.disabled = true;
+ btnEl.textContent = 'Replacing...';
+ const res = await fetch('/api/stash/transcodes/replace', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ orig_local_path: item.orig_local_path,
+ transcode_path: item.transcode_path,
+ delete_transcode: deleteCache,
+ trigger_rescan: triggerRescan
+ })
+ });
+ const data = await res.json();
+ if (data.success) {
+ showToast(`โ Replaced "${item.basename}" (Saved ${(data.saved_bytes/(1024*1024)).toFixed(1)} MB)!`, 'success');
+ const row = document.getElementById(`transcode-row-${item.oshash}`);
+ if (row) {
+ row.style.opacity = '0.4';
+ row.style.background = 'rgba(16, 185, 129, 0.1)';
+ btnEl.textContent = 'โ
Replaced';
+ }
+ } else {
+ showToast(`Replacement failed: ${data.error}`, 'error');
+ btnEl.disabled = false;
+ btnEl.textContent = 'โก Replace';
+ }
+ } catch (err) {
+ showToast(`Replace error: ${err.message}`, 'error');
+ btnEl.disabled = false;
+ btnEl.textContent = 'โก Replace';
+ }
+ }
+
+ // Batch Replace Action
+ const btnBatchReplaceTranscodes = document.getElementById('btn-batch-replace-transcodes');
+ if (btnBatchReplaceTranscodes) {
+ btnBatchReplaceTranscodes.addEventListener('click', async () => {
+ if (selectedTranscodeHashes.size === 0) {
+ showToast('No transcodes selected for replacement', 'error');
+ return;
+ }
+
+ const selectedItems = transcodeItems.filter(item => selectedTranscodeHashes.has(item.oshash));
+ const totalSavedMb = (selectedItems.reduce((acc, x) => acc + x.saved_bytes, 0) / (1024*1024)).toFixed(1);
+
+ if (!confirm(`Batch replace ${selectedItems.length} original video files with their Stash transcoded files?\n\nTotal Estimated Space Saved: ${totalSavedMb} MB\nOriginal files will be atomically overwritten with the transcoded MP4s.`)) {
+ return;
+ }
+
+ try {
+ btnBatchReplaceTranscodes.disabled = true;
+ btnBatchReplaceTranscodes.textContent = `Replacing ${selectedItems.length}...`;
+
+ const deleteCache = document.getElementById('transcode-opt-delete-cache')?.checked;
+ const triggerRescan = document.getElementById('transcode-opt-trigger-rescan')?.checked;
+
+ const res = await fetch('/api/stash/transcodes/batch-replace', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ items: selectedItems,
+ delete_transcode: deleteCache,
+ trigger_rescan: triggerRescan
+ })
+ });
+ const data = await res.json();
+ if (data.success) {
+ showToast(`โ Batch replaced ${data.replaced_count} files (Saved ${(data.total_saved_bytes/(1024*1024)).toFixed(1)} MB)!`, 'success');
+ await scanStashTranscodes();
+ } else {
+ showToast(`Batch error: ${data.error}`, 'error');
+ }
+ } catch (err) {
+ showToast(`Batch execution error: ${err.message}`, 'error');
+ } finally {
+ btnBatchReplaceTranscodes.disabled = false;
+ btnBatchReplaceTranscodes.textContent = `โก Batch Replace Selected (${selectedTranscodeHashes.size})`;
+ }
+ });
+ }
+
+ // Filter & Search Event Listeners
+ const transcodeFilterMode = document.getElementById('transcode-filter-mode');
+ if (transcodeFilterMode) transcodeFilterMode.addEventListener('change', renderStashTranscodes);
+
+ const transcodeSearchInput = document.getElementById('transcode-search-input');
+ if (transcodeSearchInput) transcodeSearchInput.addEventListener('input', renderStashTranscodes);
+
+ const btnScanStashTranscodes = document.getElementById('btn-scan-stash-transcodes');
+ if (btnScanStashTranscodes) btnScanStashTranscodes.addEventListener('click', scanStashTranscodes);
+
// ==================== POST-MOVE PIPELINE AUTOMATION ====================
async function triggerPostMoveAutomation(targetDir) {
const autoPipeline = document.getElementById('stash-auto-pipeline')?.value;
@@ -7613,6 +8009,7 @@ Return ONLY a valid JSON object with an array named "results":
{ title: 'Stash: Find Performer Duplicates & Merge', icon: '๐ฅ', action: () => { switchTab('stash-tasks'); scanPerformerDuplicates(); } },
{ title: 'Stash: Missing Metadata Radar', icon: '๐ก', action: () => { switchTab('stash-tasks'); loadMissingMetadataRadar(); } },
{ title: 'Stash: Tag Normalizer & Deduplication', icon: '๐ท๏ธ', action: () => { switchTab('stash-tasks'); scanTagDuplicates(); } },
+ { title: 'Stash: Correlate Transcodes & Replace Originals', icon: '๐', action: () => { switchTab('stash-tasks'); scanStashTranscodes(); } },
{ title: 'Stash: Clean Database & Generated Files', icon: '๐งน', action: () => { switchTab('stash-tasks'); executeStashCleanTask(); } },
{ title: 'Stash: Generate Previews & Sprites', icon: 'โก', action: () => { switchTab('stash-tasks'); executeStashGenerateTask(); } },
{ title: 'Scan SMB Files (/videodownloader)', icon: '๐', action: () => { switchTab('smb'); document.getElementById('btn-fetch-smb')?.click(); } },
diff --git a/server.py b/server.py
index 6bae6e1..9f8dab9 100644
--- a/server.py
+++ b/server.py
@@ -809,6 +809,8 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_stash_missing_metadata()
elif self.path.startswith('/api/stash/tags/duplicates'):
self.handle_stash_tag_duplicates()
+ elif self.path.startswith('/api/stash/transcodes/scan'):
+ self.handle_stash_transcode_scan()
# SMB API
elif self.path.startswith('/api/smb/status'):
self.handle_smb_status()
@@ -884,6 +886,10 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_stash_performer_merge()
elif self.path == '/api/stash/tags/merge':
self.handle_stash_tag_merge()
+ elif self.path == '/api/stash/transcodes/replace':
+ self.handle_stash_transcode_replace()
+ elif self.path == '/api/stash/transcodes/batch-replace':
+ self.handle_stash_transcode_batch_replace()
# SMB API Moves, Deletes & Cleanups
elif self.path == '/api/smb/move':
self.handle_smb_move()
@@ -2612,6 +2618,281 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
except Exception as e:
self.send_json_response(500, {"error": str(e)})
+ # ==================== Stash Transcode Correlator & Storage Saver Handlers ====================
+ def handle_stash_transcode_scan(self):
+ parsed = urllib.parse.urlparse(self.path)
+ params = urllib.parse.parse_qs(parsed.query)
+ stash_url = params.get('stash_url', [DEFAULT_STASH_URL])[0]
+ transcode_dir = params.get('transcode_dir', ['/mnt/isolation/stashapp/generated/transcodes'])[0]
+ stash_prefix = params.get('stash_prefix', ['/data'])[0]
+ local_prefix = params.get('local_prefix', ['/mnt/isolation/videos'])[0]
+
+ if not os.path.exists(transcode_dir):
+ self.send_json_response(400, {"error": f"Transcode directory not found: {transcode_dir}", "items": []})
+ return
+
+ # Build dictionary of all transcode files on disk
+ transcode_files = {}
+ for f in os.listdir(transcode_dir):
+ if f.endswith('.mp4'):
+ h = f[:-4]
+ full_p = os.path.join(transcode_dir, f)
+ try:
+ transcode_files[h] = {
+ "filename": f,
+ "path": full_p,
+ "size": os.path.getsize(full_p)
+ }
+ except OSError:
+ pass
+
+ # Scan Stash findFiles in batches of 5000 until all matched or end
+ matched_items = []
+ page = 1
+ page_size = 5000
+ unmatched_transcodes = set(transcode_files.keys())
+
+ while unmatched_transcodes:
+ q = f'''
+ query GetFilesBatch {{
+ findFiles(filter: {{ per_page: {page_size}, page: {page} }}) {{
+ files {{
+ id
+ path
+ basename
+ size
+ fingerprints {{
+ type
+ value
+ }}
+ ... on VideoFile {{
+ width
+ height
+ duration
+ video_codec
+ }}
+ }}
+ }}
+ }}
+ '''
+ try:
+ res = execute_stash_graphql(q, stash_url, timeout=30)
+ files = res.get('data', {}).get('findFiles', {}).get('files', [])
+ if not files:
+ break
+
+ for f in files:
+ fps = {fp['type']: fp['value'] for fp in f.get('fingerprints', [])}
+ oshash = fps.get('oshash')
+ if oshash and oshash in transcode_files:
+ tinfo = transcode_files[oshash]
+ orig_stash_path = f.get('path', '')
+ orig_local_path = orig_stash_path.replace(stash_prefix, local_prefix) if stash_prefix and orig_stash_path.startswith(stash_prefix) else orig_stash_path
+
+ orig_size = f.get('size') or 0
+ transcode_size = tinfo['size']
+ saved_bytes = orig_size - transcode_size
+ pct_change = ((saved_bytes / orig_size) * 100) if orig_size > 0 else 0
+
+ matched_items.append({
+ "file_id": f['id'],
+ "oshash": oshash,
+ "basename": f.get('basename', ''),
+ "orig_stash_path": orig_stash_path,
+ "orig_local_path": orig_local_path,
+ "orig_exists": os.path.exists(orig_local_path),
+ "orig_size": orig_size,
+ "orig_resolution": f"{f.get('width', '')}x{f.get('height', '')}",
+ "orig_codec": f.get('video_codec', ''),
+ "transcode_filename": tinfo['filename'],
+ "transcode_path": tinfo['path'],
+ "transcode_size": transcode_size,
+ "saved_bytes": saved_bytes,
+ "pct_change": round(pct_change, 1)
+ })
+ unmatched_transcodes.discard(oshash)
+
+ if len(files) < page_size:
+ break
+ page += 1
+ except Exception as e:
+ print(f"Error querying Stash findFiles page {page}: {e}")
+ break
+
+ # Sort by space saved descending
+ matched_items.sort(key=lambda x: x['saved_bytes'], reverse=True)
+ total_saved_bytes = sum(x['saved_bytes'] for x in matched_items if x['saved_bytes'] > 0)
+
+ self.send_json_response(200, {
+ "items": matched_items,
+ "total_matched": len(matched_items),
+ "total_transcodes_on_disk": len(transcode_files),
+ "total_potential_savings_bytes": total_saved_bytes
+ })
+
+ def handle_stash_transcode_replace(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'))
+ orig_local_path = payload.get('orig_local_path')
+ transcode_path = payload.get('transcode_path')
+ delete_transcode = bool(payload.get('delete_transcode', True))
+ trigger_rescan = bool(payload.get('trigger_rescan', True))
+ stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
+
+ if not orig_local_path or not transcode_path:
+ self.send_json_response(400, {"error": "Missing orig_local_path or transcode_path"})
+ return
+
+ if not os.path.exists(transcode_path):
+ self.send_json_response(400, {"error": f"Transcode file does not exist: {transcode_path}"})
+ return
+
+ if not os.path.exists(orig_local_path):
+ self.send_json_response(400, {"error": f"Original file does not exist at local path: {orig_local_path}"})
+ return
+
+ orig_size = os.path.getsize(orig_local_path)
+ transcode_size = os.path.getsize(transcode_path)
+ if transcode_size == 0:
+ self.send_json_response(400, {"error": "Transcode file is 0 bytes (corrupted), aborting replacement"})
+ return
+
+ # Step 1: Copy transcode to temporary staging file next to original
+ tmp_staging_path = f"{orig_local_path}.transcode.tmp"
+ if os.path.exists(tmp_staging_path):
+ try:
+ os.remove(tmp_staging_path)
+ except OSError:
+ pass
+
+ shutil.copy2(transcode_path, tmp_staging_path)
+
+ # Step 2: Verify size of staging file
+ staged_size = os.path.getsize(tmp_staging_path)
+ if staged_size != transcode_size:
+ if os.path.exists(tmp_staging_path):
+ os.remove(tmp_staging_path)
+ self.send_json_response(500, {"error": f"Size mismatch during staging: expected {transcode_size}, got {staged_size}"})
+ return
+
+ # Step 3: Atomically overwrite original file with staged transcode
+ os.replace(tmp_staging_path, orig_local_path)
+
+ # Step 4: If delete_transcode is requested, remove original transcode cache
+ if delete_transcode:
+ try:
+ os.remove(transcode_path)
+ except OSError as e:
+ print(f"Warning: Could not remove transcode cache file {transcode_path}: {e}")
+
+ saved_bytes = orig_size - transcode_size
+
+ # Step 5: Record to Relocation History audit log
+ record_relocation_history(
+ source_path=transcode_path,
+ target_path=orig_local_path,
+ performer_name="Transcode Optimization",
+ status="completed",
+ size_bytes=transcode_size,
+ action_type="transcode_replace"
+ )
+
+ # Step 6: Trigger Stash metadata scan on parent folder if requested
+ if trigger_rescan:
+ try:
+ parent_dir = os.path.dirname(orig_local_path)
+ stash_parent = parent_dir.replace('/mnt/isolation/videos', '/data')
+ mutation = '''
+ mutation MetadataScan($input: ScanMetadataInput!) {
+ metadataScan(input: $input)
+ }
+ '''
+ execute_stash_graphql(mutation, stash_url, variables={"input": {"paths": [stash_parent], "rescan": True}}, timeout=10)
+ except Exception as e:
+ print(f"Background Stash rescan trigger error: {e}")
+
+ self.send_json_response(200, {
+ "success": True,
+ "orig_local_path": orig_local_path,
+ "orig_size": orig_size,
+ "new_size": transcode_size,
+ "saved_bytes": saved_bytes
+ })
+ except Exception as e:
+ self.send_json_response(500, {"error": str(e)})
+
+ def handle_stash_transcode_batch_replace(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'))
+ items = payload.get('items', [])
+ delete_transcode = bool(payload.get('delete_transcode', True))
+ trigger_rescan = bool(payload.get('trigger_rescan', True))
+ stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
+
+ replaced_count = 0
+ total_saved_bytes = 0
+ errors = []
+ rescanned_dirs = set()
+
+ for item in items:
+ orig_local_path = item.get('orig_local_path')
+ transcode_path = item.get('transcode_path')
+ if not orig_local_path or not transcode_path or not os.path.exists(transcode_path) or not os.path.exists(orig_local_path):
+ continue
+
+ try:
+ orig_size = os.path.getsize(orig_local_path)
+ transcode_size = os.path.getsize(transcode_path)
+ if transcode_size == 0:
+ continue
+
+ tmp_staging_path = f"{orig_local_path}.transcode.tmp"
+ shutil.copy2(transcode_path, tmp_staging_path)
+ if os.path.getsize(tmp_staging_path) != transcode_size:
+ if os.path.exists(tmp_staging_path):
+ os.remove(tmp_staging_path)
+ continue
+
+ os.replace(tmp_staging_path, orig_local_path)
+ if delete_transcode:
+ try:
+ os.remove(transcode_path)
+ except OSError:
+ pass
+
+ replaced_count += 1
+ saved = orig_size - transcode_size
+ total_saved_bytes += saved
+ rescanned_dirs.add(os.path.dirname(orig_local_path))
+ except Exception as e:
+ errors.append({"path": orig_local_path, "error": str(e)})
+
+ # Trigger Stash scan for all touched directories
+ if trigger_rescan and rescanned_dirs:
+ try:
+ stash_dirs = [d.replace('/mnt/isolation/videos', '/data') for d in rescanned_dirs]
+ mutation = '''
+ mutation MetadataScan($input: ScanMetadataInput!) {
+ metadataScan(input: $input)
+ }
+ '''
+ execute_stash_graphql(mutation, stash_url, variables={"input": {"paths": stash_dirs, "rescan": True}}, timeout=15)
+ except Exception as e:
+ print(f"Batch Stash rescan trigger error: {e}")
+
+ self.send_json_response(200, {
+ "success": True,
+ "replaced_count": replaced_count,
+ "total_saved_bytes": total_saved_bytes,
+ "errors": errors
+ })
+ 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)