feat: Add Stash Transcode Correlator & Storage Saver with atomic original replacement

This commit is contained in:
david
2026-08-18 11:03:13 -04:00
parent baf3a927a4
commit b175a20af5
2 changed files with 678 additions and 0 deletions
+397
View File
@@ -2155,6 +2155,93 @@
</div> </div>
</div> </div>
<!-- ==================== SECTION D: TRANSCODE CORRELATOR & STORAGE SAVER ==================== -->
<div class="table-container" style="padding: 1.25rem; margin-bottom: 1.5rem; border-color: rgba(16, 185, 129, 0.4);">
<div class="table-header-bar" style="margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.75rem;">
<div>
<div class="table-title" style="color: var(--accent-green);">
🔀 Stash Transcode Correlator & Storage Saver (Replace Originals)
</div>
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 0.2rem;">
Correlate generated Stash web-optimized transcodes with original video files, preview storage savings, and safely replace original files with direct-stream transcodes.
</p>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<button class="btn btn-primary" id="btn-scan-stash-transcodes" style="font-size: 0.8rem;">
🔍 Scan & Correlate Transcodes
</button>
<button class="btn btn-success" id="btn-batch-replace-transcodes" style="font-size: 0.8rem; display: none;">
⚡ Batch Replace Selected (<span id="transcode-selected-count">0</span>)
</button>
</div>
</div>
<!-- Path Mapping & Safety Options Card -->
<div style="background: rgba(0,0,0,0.25); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 0.85rem; margin-bottom: 1rem; display: flex; flex-direction: column; gap: 0.75rem;">
<div class="grid-form" style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));">
<div class="form-group">
<label for="stash-transcode-dir">Transcode Storage Directory</label>
<input type="text" class="form-control font-mono" id="stash-transcode-dir" value="/mnt/isolation/stashapp/generated/transcodes" />
</div>
<div class="form-group">
<label for="stash-container-prefix">Stash DB Path Prefix</label>
<input type="text" class="form-control font-mono" id="stash-container-prefix" value="/data" />
</div>
<div class="form-group">
<label for="stash-local-prefix">Local Mount Path Prefix</label>
<input type="text" class="form-control font-mono" id="stash-local-prefix" value="/mnt/isolation/videos" />
</div>
</div>
<div style="display: flex; gap: 1.25rem; flex-wrap: wrap; padding-top: 0.5rem; border-top: 1px solid var(--border-color);">
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="transcode-opt-atomic-tmp" checked />
<span>🛡️ Atomic Staging Verification (.transcode.tmp)</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="transcode-opt-delete-cache" checked />
<span>🗑️ Delete transcode cache file after replacement</span>
</label>
<label style="font-size: 0.75rem; color: var(--text-main); display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer;">
<input type="checkbox" id="transcode-opt-trigger-rescan" checked />
<span>⚡ Trigger Stash re-scan after replacement</span>
</label>
</div>
</div>
<!-- Transcode Summary & Filter Bar -->
<div id="transcode-summary-bar" style="display: none; background: rgba(30, 41, 59, 0.7); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 0.75rem 1rem; margin-bottom: 1rem; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.75rem;">
<div style="display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap;">
<div>
<span style="font-size: 0.7rem; color: var(--text-muted); display: block;">Correlated Matches</span>
<span id="stat-transcode-matches" style="font-size: 1rem; font-weight: 700; color: #fff;">0</span>
</div>
<div>
<span style="font-size: 0.7rem; color: var(--text-muted); display: block;">Total Potential Savings</span>
<span id="stat-transcode-savings" style="font-size: 1rem; font-weight: 700; color: var(--accent-green);">0 GB</span>
</div>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<select id="transcode-filter-mode" class="form-control" style="width: auto; font-size: 0.75rem; padding: 0.25rem 0.6rem;">
<option value="savers">🟢 Space Savers Only (Orig > Transcode)</option>
<option value="all">📁 Show All Correlated Transcodes</option>
<option value="large">⚡ Large Savings (> 500 MB)</option>
</select>
<input type="text" id="transcode-search-input" class="form-control" placeholder="Search filenames..." style="width: 200px; font-size: 0.75rem; padding: 0.25rem 0.6rem;" />
</div>
</div>
<!-- Transcodes Results Container -->
<div id="stash-transcodes-container">
<div class="empty-state">
<div class="empty-state-icon">🔀</div>
<h3>No transcode scan executed yet</h3>
<p>Click "Scan & Correlate Transcodes" to index all generated Stash transcodes and match them to original video files.</p>
</div>
</div>
</div>
</div> </div>
</main> </main>
@@ -7547,6 +7634,315 @@ Return ONLY a valid JSON object with an array named "results":
const btnScanTagDupes = document.getElementById('btn-scan-tag-dupes'); const btnScanTagDupes = document.getElementById('btn-scan-tag-dupes');
if (btnScanTagDupes) btnScanTagDupes.addEventListener('click', scanTagDuplicates); 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 = `
<div style="padding: 2.5rem; text-align: center; color: var(--text-muted);">
<div class="empty-state-icon">⏳</div>
<p>Scanning Stash library and matching generated transcodes to original video files...</p>
</div>
`;
}
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 = `<div style="color: var(--accent-rose); padding: 1.5rem;">Failed to correlate transcodes: ${err.message}</div>`;
}
} 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 = `
<div class="empty-state">
<div class="empty-state-icon">✨</div>
<h3>No matching transcodes found</h3>
<p>Try changing the filter mode or search term above.</p>
</div>
`;
return;
}
container.innerHTML = `
<div style="overflow-x: auto;">
<table class="data-table" style="width: 100%;">
<thead>
<tr>
<th style="width: 36px; text-align: center;">
<input type="checkbox" id="th-select-all-transcodes" />
</th>
<th>Original Video File & Local Path</th>
<th>Transcode Cache (Stash)</th>
<th>Original Size</th>
<th>Transcoded Size</th>
<th>Storage Saved</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody>
${filtered.map(item => {
const origMb = (item.orig_size / (1024 * 1024)).toFixed(1);
const transMb = (item.transcode_size / (1024 * 1024)).toFixed(1);
const savedMb = (item.saved_bytes / (1024 * 1024)).toFixed(1);
const isPositive = item.saved_bytes > 0;
const isChecked = selectedTranscodeHashes.has(item.oshash);
return `
<tr id="transcode-row-${item.oshash}">
<td style="text-align: center;">
<input type="checkbox" class="transcode-checkbox" data-hash="${item.oshash}" ${isChecked ? 'checked' : ''} />
</td>
<td>
<div style="font-weight: 600; color: #fff; font-size: 0.8rem;">
${escapeHtml(item.basename)}
</div>
<div style="font-size: 0.68rem; color: var(--text-muted); font-family: var(--font-mono); margin-top: 2px;">
📁 ${escapeHtml(item.orig_local_path)}
${!item.orig_exists ? `<span class="badge badge-danger" style="font-size: 0.6rem; margin-left: 4px;">Original Missing on Disk</span>` : ''}
</div>
</td>
<td>
<span class="badge" style="font-family: var(--font-mono); font-size: 0.68rem; background: rgba(255,255,255,0.06);">
${escapeHtml(item.transcode_filename)}
</span>
</td>
<td style="font-family: var(--font-mono); font-size: 0.75rem;">
${origMb} MB
</td>
<td style="font-family: var(--font-mono); font-size: 0.75rem;">
${transMb} MB
</td>
<td>
<span class="badge" style="font-family: var(--font-mono); font-size: 0.72rem; ${isPositive ? 'background: rgba(16,185,129,0.2); color: #6ee7b7; border-color: rgba(16,185,129,0.4);' : 'background: rgba(244,63,94,0.15); color: #fda4af; border-color: rgba(244,63,94,0.3);'}">
${isPositive ? `🟢 -${savedMb} MB (-${item.pct_change}%)` : `🔴 +${Math.abs(savedMb)} MB (+${Math.abs(item.pct_change)}%)`}
</span>
</td>
<td style="text-align: right;">
<div style="display: inline-flex; gap: 0.4rem;">
<button class="btn btn-secondary btn-transcode-preview" data-path="${escapeHtml(item.orig_local_path)}" data-title="${escapeHtml(item.basename)}" style="padding: 0.2rem 0.5rem; font-size: 0.7rem;" title="Preview original video">
🎬 Preview
</button>
<button class="btn btn-success btn-transcode-replace-single" data-hash="${item.oshash}" style="padding: 0.2rem 0.6rem; font-size: 0.72rem; font-weight: 600;" title="Safely replace original with transcoded file">
⚡ Replace
</button>
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
// 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 ==================== // ==================== POST-MOVE PIPELINE AUTOMATION ====================
async function triggerPostMoveAutomation(targetDir) { async function triggerPostMoveAutomation(targetDir) {
const autoPipeline = document.getElementById('stash-auto-pipeline')?.value; 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: Find Performer Duplicates & Merge', icon: '👥', action: () => { switchTab('stash-tasks'); scanPerformerDuplicates(); } },
{ title: 'Stash: Missing Metadata Radar', icon: '📡', action: () => { switchTab('stash-tasks'); loadMissingMetadataRadar(); } }, { title: 'Stash: Missing Metadata Radar', icon: '📡', action: () => { switchTab('stash-tasks'); loadMissingMetadataRadar(); } },
{ title: 'Stash: Tag Normalizer & Deduplication', icon: '🏷️', action: () => { switchTab('stash-tasks'); scanTagDuplicates(); } }, { 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: Clean Database & Generated Files', icon: '🧹', action: () => { switchTab('stash-tasks'); executeStashCleanTask(); } },
{ title: 'Stash: Generate Previews & Sprites', icon: '⚡', action: () => { switchTab('stash-tasks'); executeStashGenerateTask(); } }, { 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(); } }, { title: 'Scan SMB Files (/videodownloader)', icon: '🔍', action: () => { switchTab('smb'); document.getElementById('btn-fetch-smb')?.click(); } },
+281
View File
@@ -809,6 +809,8 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_stash_missing_metadata() self.handle_stash_missing_metadata()
elif self.path.startswith('/api/stash/tags/duplicates'): elif self.path.startswith('/api/stash/tags/duplicates'):
self.handle_stash_tag_duplicates() self.handle_stash_tag_duplicates()
elif self.path.startswith('/api/stash/transcodes/scan'):
self.handle_stash_transcode_scan()
# SMB API # SMB API
elif self.path.startswith('/api/smb/status'): elif self.path.startswith('/api/smb/status'):
self.handle_smb_status() self.handle_smb_status()
@@ -884,6 +886,10 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
self.handle_stash_performer_merge() self.handle_stash_performer_merge()
elif self.path == '/api/stash/tags/merge': elif self.path == '/api/stash/tags/merge':
self.handle_stash_tag_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 # 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()
@@ -2612,6 +2618,281 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
except Exception as e: except Exception as e:
self.send_json_response(500, {"error": str(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 ==================== # ==================== SMB Maintenance & Cleanup Handlers ====================
def handle_smb_scan_cleanup(self): def handle_smb_scan_cleanup(self):
parsed = urllib.parse.urlparse(self.path) parsed = urllib.parse.urlparse(self.path)