feat: Add Stash Scene Duplicate Comparator & Plugin Automation Hub to Tab 5
This commit is contained in:
@@ -1973,6 +1973,79 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== STASH PLUGINS & DUPLICATE SCENE COMPARATOR ==================== -->
|
||||||
|
<div class="table-container" style="padding: 1.25rem; margin-bottom: 1.5rem;">
|
||||||
|
<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-cyan);">
|
||||||
|
🔍 Stash Scene Duplicate Comparator & Plugin Automation Hub
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 0.2rem;">
|
||||||
|
Compare duplicate scene pairs side-by-side (Quality/Bitrate/Resolution), preview streams, and run automated Stash duplicate taggers/cleaners.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge" id="stash-dupe-count-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;">
|
||||||
|
0 duplicate clusters
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plugin Fast Actions Toolbar -->
|
||||||
|
<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.6rem;">
|
||||||
|
<div style="font-size: 0.75rem; font-weight: 700; color: #fff; display: flex; align-items: center; gap: 0.4rem;">
|
||||||
|
<span>⚡ Stash Installed Plugin One-Click Task Triggers:</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
|
||||||
|
<button class="btn btn-secondary btn-plugin-trigger" data-plugin="DupFileManager" data-task="Tag Duplicates" title="DupFileManager: Set tag DuplicateMarkForDeletion to lower quality duplicates" style="font-size: 0.75rem;">
|
||||||
|
🏷️ DupFileManager: Tag Duplicates
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-plugin-trigger" data-plugin="phashDuplicateTagger" data-task="Tag Dupes (EXACT)" title="PHash Tagger: Tag Exact Match (Dist 0) scenes" style="font-size: 0.75rem;">
|
||||||
|
🎯 PHash: Tag Exact (Dist 0)
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-plugin-trigger" data-plugin="phashDuplicateTagger" data-task="Tag Dupes (HIGH)" title="PHash Tagger: Tag High Match (Dist 3) scenes" style="font-size: 0.75rem;">
|
||||||
|
⚡ PHash: Tag High (Dist 3)
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-plugin-trigger" data-plugin="phashDuplicateTagger" data-task="Generate Scene PHASHs" title="Generate perceptual hashes for all scenes where missing" style="font-size: 0.75rem;">
|
||||||
|
🔢 PHash: Generate Scene pHashes
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-plugin-trigger" data-plugin="DupFileManager" data-task="Clear Tags" title="Clear tag DuplicateMarkForDeletion from all files" style="font-size: 0.75rem;">
|
||||||
|
🧹 DupFileManager: Clear Tags
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-plugin-trigger" data-plugin="DupFileManager" data-task="Delete Tagged Scenes" title="Delete scenes having DuplicateMarkForDeletion tag" style="font-size: 0.75rem; color: var(--accent-rose); border-color: rgba(244,63,94,0.4); background: rgba(244,63,94,0.08);">
|
||||||
|
🗑️ DupFileManager: Delete Tagged
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duplicate Filter Controls -->
|
||||||
|
<div class="controls-bar" style="margin-bottom: 1rem; flex-wrap: wrap;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.4rem;">
|
||||||
|
<span style="font-size: 0.75rem; color: var(--text-muted);">Match Distance:</span>
|
||||||
|
<select class="form-control" id="stash-dupe-distance" style="width: auto; padding: 0.3rem 0.6rem; font-size: 0.8rem;">
|
||||||
|
<option value="0">Exact Match (Dist 0)</option>
|
||||||
|
<option value="3">High Match (Dist 3)</option>
|
||||||
|
<option value="6">Medium Match (Dist 6)</option>
|
||||||
|
<option value="10">Loose Match (Dist 10)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" id="btn-scan-stash-dupes" style="font-size: 0.8rem;">
|
||||||
|
🔍 Scan Stash Duplicates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duplicate Clusters Container -->
|
||||||
|
<div id="stash-duplicates-container">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">🔍</div>
|
||||||
|
<h3>No duplicate scan executed yet</h3>
|
||||||
|
<p>Click "Scan Stash Duplicates" to find duplicate scene pairs and compare qualities side-by-side.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -6735,6 +6808,234 @@ Return ONLY a valid JSON object with an array named "results":
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== STASH DUPLICATE COMPARATOR & PLUGIN RUNNERS ====================
|
||||||
|
let duplicateSceneGroups = [];
|
||||||
|
|
||||||
|
async function scanStashDuplicates() {
|
||||||
|
const btn = document.getElementById('btn-scan-stash-dupes');
|
||||||
|
const dist = document.getElementById('stash-dupe-distance')?.value || 0;
|
||||||
|
const container = document.getElementById('stash-duplicates-container');
|
||||||
|
const badge = document.getElementById('stash-dupe-count-badge');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Scanning Duplicates...'; }
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="padding: 2rem; text-align: center; color: var(--text-muted);">
|
||||||
|
<div class="empty-state-icon">⏳</div>
|
||||||
|
<p>Scanning Stash library for duplicate scene clusters (Distance: ${dist})...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/stash/duplicates?distance=${dist}&duration_diff=5`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
duplicateSceneGroups = data.groups || [];
|
||||||
|
|
||||||
|
if (badge) badge.textContent = `${duplicateSceneGroups.length} duplicate clusters (${data.total_duplicate_scenes || 0} scenes)`;
|
||||||
|
|
||||||
|
renderStashDuplicates(duplicateSceneGroups);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Duplicate scan error: ${err.message}`, 'error');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `<div style="color: var(--accent-rose); padding: 1rem;">Failed to scan duplicates: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '🔍 Scan Stash Duplicates'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStashDuplicates(groups) {
|
||||||
|
const container = document.getElementById('stash-duplicates-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">✨</div>
|
||||||
|
<h3>No duplicate scenes found</h3>
|
||||||
|
<p>Your Stash library has 0 duplicate clusters at this match distance.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = groups.map((group, gIdx) => {
|
||||||
|
return `
|
||||||
|
<div style="background: rgba(15, 23, 42, 0.85); border: 1px solid var(--border-color); border-radius: var(--radius-sm); padding: 1rem; margin-bottom: 1.25rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
|
||||||
|
<span style="font-weight: 700; font-size: 0.85rem; color: var(--accent-cyan);">
|
||||||
|
Cluster #${gIdx + 1} (${group.length} Copies)
|
||||||
|
</span>
|
||||||
|
<span style="font-size: 0.75rem; color: var(--text-muted);">
|
||||||
|
${escapeHtml(group[0]?.title || 'Untitled Scene')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem;">
|
||||||
|
${group.map((scene, sIdx) => {
|
||||||
|
const isBest = sIdx === 0;
|
||||||
|
const file = (scene.files && scene.files[0]) || {};
|
||||||
|
const resLabel = file.height ? `${file.height}p` : 'Unknown';
|
||||||
|
const bitrateFormatted = file.bit_rate ? `${(file.bit_rate / 1000000).toFixed(2)} Mbps` : '-';
|
||||||
|
const sizeFormatted = file.size ? (file.size >= 1024**3 ? `${(file.size / 1024**3).toFixed(2)} GB` : `${(file.size / 1024**2).toFixed(1)} MB`) : '-';
|
||||||
|
const durFormatted = file.duration ? `${Math.floor(file.duration / 60)}m ${Math.floor(file.duration % 60)}s` : '-';
|
||||||
|
const performerNames = (scene.performers || []).map(p => p.name).join(', ') || 'Unknown';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div id="dupe-scene-card-${scene.id}" style="background: rgba(30, 41, 59, 0.6); border: 1px solid ${isBest ? 'rgba(16, 185, 129, 0.5)' : 'rgba(244, 63, 94, 0.4)'}; border-radius: var(--radius-sm); padding: 0.85rem; display: flex; flex-direction: column; gap: 0.6rem; position: relative;">
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<span class="badge" style="font-size: 0.7rem; ${isBest ? '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);'}">
|
||||||
|
${isBest ? '🏆 Recommended Keeper' : '⚠️ Lower Quality Duplicate'}
|
||||||
|
</span>
|
||||||
|
<span style="font-size: 0.7rem; color: var(--text-muted); font-family: var(--font-mono);">ID: ${scene.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screenshot Preview -->
|
||||||
|
<div style="width: 100%; height: 140px; border-radius: var(--radius-sm); overflow: hidden; background: #000; display: flex; align-items: center; justify-content: center; position: relative;">
|
||||||
|
${scene.paths?.screenshot ? `<img src="${scene.paths.screenshot}" alt="Scene Cover" style="width: 100%; height: 100%; object-fit: cover;" onerror="this.style.display='none'" />` : ''}
|
||||||
|
<span style="position: absolute; bottom: 6px; right: 6px; background: rgba(0,0,0,0.8); color: #fff; font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; font-family: var(--font-mono);">${durFormatted}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scene Details -->
|
||||||
|
<div style="font-weight: 600; color: #fff; font-size: 0.82rem; line-height: 1.2;">
|
||||||
|
${escapeHtml(scene.title || 'Untitled Scene')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--text-muted);">
|
||||||
|
<span>👤 ${escapeHtml(performerNames)}</span>
|
||||||
|
<span>🏢 ${escapeHtml(scene.studio?.name || '')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Spec Badges -->
|
||||||
|
<div style="display: flex; gap: 0.4rem; flex-wrap: wrap;">
|
||||||
|
<span class="badge badge-primary" style="font-size: 0.68rem; font-family: var(--font-mono);">${resLabel}</span>
|
||||||
|
<span class="badge" style="background: rgba(255,255,255,0.08); font-size: 0.68rem; font-family: var(--font-mono);">${bitrateFormatted}</span>
|
||||||
|
<span class="badge" style="background: rgba(255,255,255,0.08); font-size: 0.68rem; font-family: var(--font-mono);">${sizeFormatted}</span>
|
||||||
|
<span class="badge" style="background: rgba(255,255,255,0.08); font-size: 0.68rem; text-transform: uppercase;">${file.video_codec || 'video'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Path -->
|
||||||
|
<div style="font-size: 0.68rem; color: var(--text-muted); font-family: var(--font-mono); word-break: break-all; line-height: 1.2; background: rgba(0,0,0,0.25); padding: 0.4rem; border-radius: 4px;">
|
||||||
|
📁 ${escapeHtml(file.path || '')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: auto; padding-top: 0.4rem;">
|
||||||
|
<button class="btn btn-secondary btn-dupe-preview" data-path="${escapeHtml(file.path || '')}" data-title="${escapeHtml(scene.title || 'Scene')}" style="flex: 1; justify-content: center; font-size: 0.75rem;">
|
||||||
|
🎬 Preview
|
||||||
|
</button>
|
||||||
|
${!isBest ? `
|
||||||
|
<button class="btn btn-secondary btn-dupe-destroy" data-scene-id="${scene.id}" data-title="${escapeHtml(scene.title || '')}" style="font-size: 0.75rem; color: var(--accent-rose); border-color: rgba(244,63,94,0.4); background: rgba(244,63,94,0.08);" title="Delete duplicate file and remove from Stash">
|
||||||
|
🗑️ Delete Copy
|
||||||
|
</button>
|
||||||
|
` : `
|
||||||
|
<button class="btn btn-secondary btn-dupe-destroy" data-scene-id="${scene.id}" data-title="${escapeHtml(scene.title || '')}" style="font-size: 0.75rem; color: var(--text-muted); opacity: 0.6;" title="Delete this file">
|
||||||
|
🗑️ Delete
|
||||||
|
</button>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Attach Preview & Destroy Listeners
|
||||||
|
container.querySelectorAll('.btn-dupe-preview').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const filePath = btn.getAttribute('data-path');
|
||||||
|
const title = btn.getAttribute('data-title');
|
||||||
|
if (filePath) {
|
||||||
|
openVideoPreview(filePath, title, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-dupe-destroy').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const sid = btn.getAttribute('data-scene-id');
|
||||||
|
const title = btn.getAttribute('data-title');
|
||||||
|
if (confirm(`Permanently delete this duplicate scene and its video file from disk?\n\n"${title}" (ID: ${sid})`)) {
|
||||||
|
await deleteStashDuplicateScene(sid, btn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteStashDuplicateScene(sceneId, btnEl) {
|
||||||
|
try {
|
||||||
|
btnEl.disabled = true;
|
||||||
|
btnEl.textContent = 'Deleting...';
|
||||||
|
const res = await fetch('/api/stash/scenes/destroy', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
scene_id: sceneId,
|
||||||
|
delete_file: true,
|
||||||
|
delete_generated: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast('✓ Duplicate scene & video file deleted successfully!', 'success');
|
||||||
|
const card = document.getElementById(`dupe-scene-card-${sceneId}`);
|
||||||
|
if (card) {
|
||||||
|
card.style.opacity = '0.35';
|
||||||
|
card.innerHTML = `<div style="padding: 2rem; text-align: center; color: var(--accent-rose); font-size: 0.8rem;">🗑️ Deleted from Stash & Disk</div>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(`Delete failed: ${data.error}`, 'error');
|
||||||
|
btnEl.disabled = false;
|
||||||
|
btnEl.textContent = '🗑️ Delete Copy';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Delete error: ${err.message}`, 'error');
|
||||||
|
btnEl.disabled = false;
|
||||||
|
btnEl.textContent = '🗑️ Delete Copy';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger Plugin Task
|
||||||
|
async function triggerPluginTask(pluginId, taskName) {
|
||||||
|
try {
|
||||||
|
showToast(`⚡ Triggering ${pluginId}: ${taskName}...`, 'info');
|
||||||
|
const res = await fetch('/api/stash/plugins/run-task', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ plugin_id: pluginId, task_name: taskName })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
showToast(`✓ Stash task "${taskName}" launched!`, 'success');
|
||||||
|
await pollStashJobs();
|
||||||
|
} else {
|
||||||
|
showToast(`Task error: ${data.error}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Plugin execution error: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach Plugin Triggers & Duplicate Scan Button
|
||||||
|
document.querySelectorAll('.btn-plugin-trigger').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const pId = btn.getAttribute('data-plugin');
|
||||||
|
const tName = btn.getAttribute('data-task');
|
||||||
|
if (pId && tName) {
|
||||||
|
triggerPluginTask(pId, tName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const btnScanStashDupes = document.getElementById('btn-scan-stash-dupes');
|
||||||
|
if (btnScanStashDupes) btnScanStashDupes.addEventListener('click', scanStashDuplicates);
|
||||||
|
|
||||||
// Attach Tab 5 Listeners
|
// Attach Tab 5 Listeners
|
||||||
const btnRefreshStashJobs = document.getElementById('btn-refresh-stash-jobs');
|
const btnRefreshStashJobs = document.getElementById('btn-refresh-stash-jobs');
|
||||||
if (btnRefreshStashJobs) btnRefreshStashJobs.addEventListener('click', pollStashJobs);
|
if (btnRefreshStashJobs) btnRefreshStashJobs.addEventListener('click', pollStashJobs);
|
||||||
|
|||||||
@@ -198,11 +198,14 @@ def format_performer_slug(raw_name):
|
|||||||
parts = [p for p in re.split(r'[\s_\-\.]+', clean) if p]
|
parts = [p for p in re.split(r'[\s_\-\.]+', clean) if p]
|
||||||
return '.'.join(parts)
|
return '.'.join(parts)
|
||||||
|
|
||||||
def execute_stash_graphql(query_str, stash_url=None, timeout=4):
|
def execute_stash_graphql(query_str, stash_url=None, variables=None, timeout=4):
|
||||||
endpoint = normalize_stash_url(stash_url)
|
endpoint = normalize_stash_url(stash_url)
|
||||||
|
payload = {'query': query_str}
|
||||||
|
if variables:
|
||||||
|
payload['variables'] = variables
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
endpoint,
|
endpoint,
|
||||||
data=json.dumps({'query': query_str}).encode('utf-8'),
|
data=json.dumps(payload).encode('utf-8'),
|
||||||
headers={'Content-Type': 'application/json'}
|
headers={'Content-Type': 'application/json'}
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as res:
|
with urllib.request.urlopen(req, timeout=timeout) as res:
|
||||||
@@ -789,11 +792,15 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
self.handle_performer_image()
|
self.handle_performer_image()
|
||||||
elif self.path.startswith('/api/image/proxy'):
|
elif self.path.startswith('/api/image/proxy'):
|
||||||
self.handle_image_proxy()
|
self.handle_image_proxy()
|
||||||
# Stash Status, Tasks & Performer Review APIs
|
# Stash Status, Tasks, Duplicates & Performer Review APIs
|
||||||
elif self.path.startswith('/api/stash/status'):
|
elif self.path.startswith('/api/stash/status'):
|
||||||
self.handle_stash_status()
|
self.handle_stash_status()
|
||||||
elif self.path.startswith('/api/stash/tasks/jobs'):
|
elif self.path.startswith('/api/stash/tasks/jobs'):
|
||||||
self.handle_stash_tasks_jobs()
|
self.handle_stash_tasks_jobs()
|
||||||
|
elif self.path.startswith('/api/stash/duplicates'):
|
||||||
|
self.handle_stash_duplicates()
|
||||||
|
elif self.path.startswith('/api/stash/plugins'):
|
||||||
|
self.handle_stash_plugins_list()
|
||||||
elif self.path.startswith('/api/stash/performers/missing-images'):
|
elif self.path.startswith('/api/stash/performers/missing-images'):
|
||||||
self.handle_stash_missing_images()
|
self.handle_stash_missing_images()
|
||||||
# SMB API
|
# SMB API
|
||||||
@@ -855,6 +862,10 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
self.handle_stash_task_generate()
|
self.handle_stash_task_generate()
|
||||||
elif self.path == '/api/stash/tasks/stop':
|
elif self.path == '/api/stash/tasks/stop':
|
||||||
self.handle_stash_task_stop()
|
self.handle_stash_task_stop()
|
||||||
|
elif self.path == '/api/stash/plugins/run-task':
|
||||||
|
self.handle_stash_run_plugin_task()
|
||||||
|
elif self.path == '/api/stash/scenes/destroy':
|
||||||
|
self.handle_stash_scene_destroy()
|
||||||
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':
|
||||||
@@ -2148,6 +2159,161 @@ 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 Duplicate Checker & Plugin Integration Handlers ====================
|
||||||
|
def handle_stash_duplicates(self):
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
stash_url = params.get('stash_url', [DEFAULT_STASH_URL])[0]
|
||||||
|
distance = int(params.get('distance', [0])[0])
|
||||||
|
duration_diff = float(params.get('duration_diff', [5.0])[0])
|
||||||
|
|
||||||
|
query = '''
|
||||||
|
query FindDuplicateScenes($distance: Int, $duration_diff: Float) {
|
||||||
|
findDuplicateScenes(distance: $distance, duration_diff: $duration_diff) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
date
|
||||||
|
details
|
||||||
|
rating100
|
||||||
|
organized
|
||||||
|
paths {
|
||||||
|
screenshot
|
||||||
|
preview
|
||||||
|
stream
|
||||||
|
}
|
||||||
|
files {
|
||||||
|
id
|
||||||
|
path
|
||||||
|
size
|
||||||
|
duration
|
||||||
|
video_codec
|
||||||
|
width
|
||||||
|
height
|
||||||
|
bit_rate
|
||||||
|
frame_rate
|
||||||
|
}
|
||||||
|
performers {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
image_path
|
||||||
|
}
|
||||||
|
studio {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
image_path
|
||||||
|
}
|
||||||
|
tags {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
res = execute_stash_graphql(query, stash_url, variables={"distance": distance, "duration_diff": duration_diff}, timeout=30)
|
||||||
|
raw_groups = res.get('data', {}).get('findDuplicateScenes') or []
|
||||||
|
|
||||||
|
duplicate_groups = []
|
||||||
|
for group in raw_groups:
|
||||||
|
if isinstance(group, list) and len(group) > 1:
|
||||||
|
sorted_group = sorted(group, key=lambda s: (
|
||||||
|
(s.get('files', [{}])[0].get('height') or 0),
|
||||||
|
(s.get('files', [{}])[0].get('bit_rate') or 0),
|
||||||
|
(s.get('files', [{}])[0].get('size') or 0)
|
||||||
|
), reverse=True)
|
||||||
|
duplicate_groups.append(sorted_group)
|
||||||
|
|
||||||
|
self.send_json_response(200, {
|
||||||
|
"groups": duplicate_groups,
|
||||||
|
"group_count": len(duplicate_groups),
|
||||||
|
"total_duplicate_scenes": sum(len(g) for g in duplicate_groups),
|
||||||
|
"distance": distance,
|
||||||
|
"duration_diff": duration_diff
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json_response(500, {"error": str(e), "groups": []})
|
||||||
|
|
||||||
|
def handle_stash_plugins_list(self):
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
stash_url = params.get('stash_url', [DEFAULT_STASH_URL])[0]
|
||||||
|
|
||||||
|
query = '''
|
||||||
|
{
|
||||||
|
plugins {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
version
|
||||||
|
enabled
|
||||||
|
tasks {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
try:
|
||||||
|
res = execute_stash_graphql(query, stash_url, timeout=10)
|
||||||
|
plugins = res.get('data', {}).get('plugins') or []
|
||||||
|
self.send_json_response(200, {"plugins": plugins, "count": len(plugins)})
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json_response(500, {"error": str(e), "plugins": []})
|
||||||
|
|
||||||
|
def handle_stash_run_plugin_task(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'))
|
||||||
|
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
|
||||||
|
plugin_id = payload.get('plugin_id')
|
||||||
|
task_name = payload.get('task_name')
|
||||||
|
|
||||||
|
if not plugin_id or not task_name:
|
||||||
|
self.send_json_response(400, {"error": "Missing plugin_id or task_name"})
|
||||||
|
return
|
||||||
|
|
||||||
|
mutation = '''
|
||||||
|
mutation RunPluginTask($plugin_id: ID!, $task_name: String!) {
|
||||||
|
runPluginTask(plugin_id: $plugin_id, task_name: $task_name)
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
res = execute_stash_graphql(mutation, stash_url, variables={"plugin_id": plugin_id, "task_name": task_name}, timeout=15)
|
||||||
|
self.send_json_response(200, {"success": True, "job_id": res.get('data', {}).get('runPluginTask')})
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json_response(500, {"error": str(e)})
|
||||||
|
|
||||||
|
def handle_stash_scene_destroy(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'))
|
||||||
|
stash_url = payload.get('stash_url') or DEFAULT_STASH_URL
|
||||||
|
scene_id = payload.get('scene_id')
|
||||||
|
delete_file = bool(payload.get('delete_file', True))
|
||||||
|
delete_generated = bool(payload.get('delete_generated', True))
|
||||||
|
|
||||||
|
if not scene_id:
|
||||||
|
self.send_json_response(400, {"error": "Missing scene_id"})
|
||||||
|
return
|
||||||
|
|
||||||
|
mutation = '''
|
||||||
|
mutation SceneDestroy($input: SceneDestroyInput!) {
|
||||||
|
sceneDestroy(input: $input)
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
variables = {
|
||||||
|
"input": {
|
||||||
|
"id": str(scene_id),
|
||||||
|
"delete_file": delete_file,
|
||||||
|
"delete_generated": delete_generated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res = execute_stash_graphql(mutation, stash_url, variables=variables, timeout=15)
|
||||||
|
self.send_json_response(200, {"success": True, "result": res.get('data', {}).get('sceneDestroy')})
|
||||||
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user