feat: Add Stash Scene Duplicate Comparator & Plugin Automation Hub to Tab 5
This commit is contained in:
@@ -1973,6 +1973,79 @@
|
||||
</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>
|
||||
</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
|
||||
const btnRefreshStashJobs = document.getElementById('btn-refresh-stash-jobs');
|
||||
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]
|
||||
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)
|
||||
payload = {'query': query_str}
|
||||
if variables:
|
||||
payload['variables'] = variables
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
data=json.dumps({'query': query_str}).encode('utf-8'),
|
||||
data=json.dumps(payload).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as res:
|
||||
@@ -789,11 +792,15 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
||||
self.handle_performer_image()
|
||||
elif self.path.startswith('/api/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'):
|
||||
self.handle_stash_status()
|
||||
elif self.path.startswith('/api/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'):
|
||||
self.handle_stash_missing_images()
|
||||
# SMB API
|
||||
@@ -855,6 +862,10 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
||||
self.handle_stash_task_generate()
|
||||
elif self.path == '/api/stash/tasks/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':
|
||||
self.handle_stash_find_images()
|
||||
elif self.path == '/api/stash/performers/commit-image':
|
||||
@@ -2148,6 +2159,161 @@ class RobustProxyHandler(http.server.SimpleHTTPRequestHandler):
|
||||
except Exception as 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 ====================
|
||||
def handle_smb_scan_cleanup(self):
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
|
||||
Reference in New Issue
Block a user