Major Features: - 🖥️ Standalone desktop app (VideoMatcher.app) - double-click to run - 🎨 Black & gold branded UI (Montserrat font, #FFC407 accent) - 📁 Local file browser for master/adaptation folders - ⚡ Fast mode processing (10-20x faster, disables AKAZE/AI Vision) - 🤖 Smart AI Vision fallback (auto-retry when no matches found) - 📊 Real-time progress bars (fingerprinting & matching) - 💾 Local processing (no cloud, no authentication) - 📤 CSV export with master filenames Web Application (Enterprise): - 🌐 Flask web app with Azure AD authentication - 📦 Box.com integration for cloud storage - 🐳 Docker support for deployment - 🔐 JWT validation with httpOnly cookies - 🎯 REST API endpoints Enhancements: - Fixed master filename lookup (was showing "Unknown") - Automatic fingerprint recovery (detects missing files) - Improved CSV format (master file next to adaptation) - Port conflict handling (auto-finds available port) - Environment variable fixes for standalone mode Documentation: - Updated README with standalone app section - Added 10+ guide documents (UI improvements, fingerprint recovery, etc.) - Build instructions with PyInstaller - Comprehensive troubleshooting guide Technical: - PyInstaller build configuration (video_matcher.spec) - Launcher with environment setup (launcher.py) - Mock authentication for standalone mode - Video matcher service layer - Metadata parser and AKAZE video matching 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
865 lines
34 KiB
HTML
865 lines
34 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Video Matcher - Standalone</title>
|
||
|
||
<!-- Bootstrap CSS -->
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
|
||
<!-- Montserrat Font -->
|
||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" rel="stylesheet">
|
||
|
||
<style>
|
||
body {
|
||
background: #000000;
|
||
min-height: 100vh;
|
||
padding: 20px;
|
||
font-family: 'Montserrat', sans-serif;
|
||
}
|
||
|
||
.main-container {
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.card {
|
||
border: none;
|
||
border-radius: 15px;
|
||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.card-header {
|
||
background: #FFC407;
|
||
color: #000000;
|
||
border-radius: 15px 15px 0 0 !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.folder-path {
|
||
background: #f8f9fa;
|
||
padding: 10px 15px;
|
||
border-radius: 8px;
|
||
font-family: monospace;
|
||
font-size: 13px;
|
||
margin: 10px 0;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.folder-item {
|
||
padding: 12px;
|
||
border: 2px solid transparent;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
margin-bottom: 8px;
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
.folder-item:hover {
|
||
background: #e9ecef;
|
||
border-color: #FFC407;
|
||
}
|
||
|
||
.folder-item.selected {
|
||
background: #fff9e6;
|
||
border-color: #FFC407;
|
||
}
|
||
|
||
.video-file {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 10px;
|
||
border-bottom: 1px solid #dee2e6;
|
||
}
|
||
|
||
.video-file:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.badge-size {
|
||
background-color: #6c757d;
|
||
padding: 4px 10px;
|
||
border-radius: 12px;
|
||
font-size: 11px;
|
||
color: white;
|
||
}
|
||
|
||
.step-indicator {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.step {
|
||
flex: 1;
|
||
text-align: center;
|
||
position: relative;
|
||
padding: 10px;
|
||
}
|
||
|
||
.step::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 25px;
|
||
left: 50%;
|
||
right: -50%;
|
||
height: 2px;
|
||
background: #dee2e6;
|
||
z-index: 0;
|
||
}
|
||
|
||
.step:last-child::before {
|
||
display: none;
|
||
}
|
||
|
||
.step-number {
|
||
width: 50px;
|
||
height: 50px;
|
||
border-radius: 50%;
|
||
background: #dee2e6;
|
||
color: #6c757d;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: bold;
|
||
font-size: 20px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.step.active .step-number {
|
||
background: #FFC407;
|
||
color: #000000;
|
||
}
|
||
|
||
.step.completed .step-number {
|
||
background: #28a745;
|
||
color: white;
|
||
}
|
||
|
||
.step-label {
|
||
margin-top: 10px;
|
||
font-size: 14px;
|
||
color: #6c757d;
|
||
}
|
||
|
||
.step.active .step-label {
|
||
color: #FFC407;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.progress-section {
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.result-item {
|
||
padding: 15px;
|
||
border: 1px solid #dee2e6;
|
||
border-radius: 8px;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.result-item.matched {
|
||
background: #d4edda;
|
||
border-color: #28a745;
|
||
}
|
||
|
||
.result-item.unmatched {
|
||
background: #f8d7da;
|
||
border-color: #dc3545;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: #FFC407;
|
||
border: none;
|
||
color: #000000;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: #e6b006;
|
||
color: #000000;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="main-container">
|
||
<div class="card">
|
||
<div class="card-header text-center py-4">
|
||
<h2 class="mb-0">🎬 Video Master-Adaptation Matcher</h2>
|
||
<small>Standalone Application</small>
|
||
</div>
|
||
<div class="card-body">
|
||
<!-- Step Indicator -->
|
||
<div class="step-indicator">
|
||
<div class="step active" id="step1">
|
||
<div class="step-number">1</div>
|
||
<div class="step-label">Select Masters</div>
|
||
</div>
|
||
<div class="step" id="step2">
|
||
<div class="step-number">2</div>
|
||
<div class="step-label">Select Adaptations</div>
|
||
</div>
|
||
<div class="step" id="step3">
|
||
<div class="step-number">3</div>
|
||
<div class="step-label">Process & View Results</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 1: Select Master Folder -->
|
||
<div id="masterSelection" class="step-content">
|
||
<h4 class="mb-3">Step 1: Select Master Videos Folder</h4>
|
||
<p class="text-muted">Choose the folder containing your master video files</p>
|
||
|
||
<div class="d-flex gap-2 mb-3">
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="browseHome()">🏠 Home</button>
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="browseDesktop()">🖥️ Desktop</button>
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="browseDocuments()">📁 Documents</button>
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="browseUp()" id="btnUp" disabled>⬆️ Parent Folder</button>
|
||
</div>
|
||
|
||
<div id="currentMasterPath" class="folder-path">No folder selected</div>
|
||
|
||
<div id="masterBrowser" class="mt-3" style="max-height: 400px; overflow-y: auto;">
|
||
<!-- Folder browser will be populated here -->
|
||
</div>
|
||
|
||
<!-- Fingerprinting Progress -->
|
||
<div id="fingerprintProgress" style="display: none;" class="mt-4">
|
||
<div class="progress" style="height: 30px;">
|
||
<div id="fingerprintProgressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success"
|
||
role="progressbar" style="width: 0%">
|
||
<span id="fingerprintProgressText">0 / 0</span>
|
||
</div>
|
||
</div>
|
||
<div id="fingerprintCurrentVideo" class="mt-2 text-muted" style="font-size: 14px;">
|
||
Processing...
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-3">
|
||
<button class="btn btn-primary" id="btnSelectMasterFolder" onclick="selectMasterFolder()" disabled>
|
||
Use This Folder →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 2: Select Adaptation Folders -->
|
||
<div id="adaptationSelection" class="step-content" style="display: none;">
|
||
<h4 class="mb-3">Step 2: Select Adaptation Videos Folder(s)</h4>
|
||
<p class="text-muted">Choose one or more folders containing adaptation videos</p>
|
||
|
||
<div class="d-flex gap-2 mb-3">
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="browseAdaptHome()">🏠 Home</button>
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="browseAdaptDesktop()">🖥️ Desktop</button>
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="browseAdaptDocuments()">📁 Documents</button>
|
||
<button class="btn btn-outline-secondary btn-sm" onclick="browseAdaptUp()" id="btnAdaptUp" disabled>⬆️ Parent Folder</button>
|
||
</div>
|
||
|
||
<div id="currentAdaptPath" class="folder-path">No folder selected</div>
|
||
|
||
<div id="adaptationBrowser" class="mt-3" style="max-height: 400px; overflow-y: auto;">
|
||
<!-- Folder browser will be populated here -->
|
||
</div>
|
||
|
||
<div class="mt-3">
|
||
<button class="btn btn-outline-primary" onclick="addAdaptationFolder()" id="btnAddFolder" disabled>
|
||
➕ Add This Folder
|
||
</button>
|
||
</div>
|
||
|
||
<div class="mt-4" id="selectedAdaptations" style="display: none;">
|
||
<h5>Selected Folders:</h5>
|
||
<div id="adaptationList"></div>
|
||
</div>
|
||
|
||
<div class="mt-3">
|
||
<button class="btn btn-secondary" onclick="backToMasters()">← Back</button>
|
||
<button class="btn btn-primary" id="btnProceed" onclick="proceedToMatching()" disabled>
|
||
Start Matching →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 3: Processing & Results -->
|
||
<div id="processingResults" class="step-content" style="display: none;">
|
||
<h4 class="mb-3">Step 3: Processing Videos</h4>
|
||
|
||
<div id="processingStatus" class="progress-section">
|
||
<div class="text-center">
|
||
<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
|
||
<span class="visually-hidden">Processing...</span>
|
||
</div>
|
||
<p class="mt-3">Analyzing videos and finding matches...</p>
|
||
|
||
<!-- Progress Bar -->
|
||
<div class="mt-4" style="max-width: 500px; margin: 0 auto;">
|
||
<div class="progress" style="height: 30px;">
|
||
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated"
|
||
role="progressbar" style="width: 0%">
|
||
<span id="progressText">0 / 0</span>
|
||
</div>
|
||
</div>
|
||
<div id="currentVideoText" class="mt-2 text-muted" style="font-size: 14px;">
|
||
Starting...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="resultsSection" style="display: none;">
|
||
<h4 class="mb-3">Matching Results</h4>
|
||
<div class="alert alert-info">
|
||
<strong>Summary:</strong>
|
||
<span id="resultsSummary"></span>
|
||
</div>
|
||
|
||
<div id="resultsList" class="mt-4">
|
||
<!-- Results will be populated here -->
|
||
</div>
|
||
|
||
<div class="mt-4">
|
||
<button class="btn btn-primary" onclick="exportResults()">📊 Export Results</button>
|
||
<button class="btn btn-outline-secondary" onclick="startOver()">🔄 Start Over</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bootstrap JS -->
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||
|
||
<script>
|
||
// Application State
|
||
let currentMasterPath = null;
|
||
let selectedMasterFolder = null;
|
||
let currentAdaptPath = null;
|
||
let selectedAdaptationFolders = [];
|
||
let roots = {};
|
||
let matchResults = null;
|
||
|
||
// Initialize
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
browseHome();
|
||
});
|
||
|
||
// Master Folder Selection
|
||
async function browseMasterPath(path) {
|
||
try {
|
||
const response = await fetch('/local/browse', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: path })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
alert('Error: ' + (data.error || 'Failed to browse folder'));
|
||
return;
|
||
}
|
||
|
||
currentMasterPath = data.current_path;
|
||
roots = data.roots || {};
|
||
|
||
// Update UI
|
||
document.getElementById('currentMasterPath').textContent = currentMasterPath || 'Select a starting location';
|
||
document.getElementById('btnUp').disabled = !data.parent_path;
|
||
document.getElementById('btnSelectMasterFolder').disabled = !currentMasterPath || data.video_count === 0;
|
||
|
||
// Render folders
|
||
const browser = document.getElementById('masterBrowser');
|
||
browser.innerHTML = '';
|
||
|
||
// Show roots if at root level
|
||
if (!path && data.roots) {
|
||
for (const [key, value] of Object.entries(data.roots)) {
|
||
if (value) {
|
||
const item = createFolderItem(key, value, () => browseMasterPath(value));
|
||
browser.appendChild(item);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Show folders
|
||
data.folders.forEach(folder => {
|
||
const item = createFolderItem('📁 ' + folder.name, folder.path, () => browseMasterPath(folder.path));
|
||
browser.appendChild(item);
|
||
});
|
||
|
||
// Show video count
|
||
if (data.video_count > 0) {
|
||
const info = document.createElement('div');
|
||
info.className = 'alert alert-success mt-3';
|
||
info.textContent = `✓ Found ${data.video_count} video file(s) in this folder`;
|
||
browser.appendChild(info);
|
||
}
|
||
|
||
} catch (error) {
|
||
alert('Error browsing folder: ' + error.message);
|
||
}
|
||
}
|
||
|
||
function browseHome() { browseMasterPath(null); }
|
||
function browseDesktop() { browseMasterPath(roots.desktop || null); }
|
||
function browseDocuments() { browseMasterPath(roots.documents || null); }
|
||
function browseUp() {
|
||
if (currentMasterPath) {
|
||
const parent = currentMasterPath.split('/').slice(0, -1).join('/') || '/';
|
||
browseMasterPath(parent);
|
||
}
|
||
}
|
||
|
||
async function selectMasterFolder() {
|
||
if (!currentMasterPath) return;
|
||
|
||
// Show loading state
|
||
const btnSelect = document.getElementById('btnSelectMasterFolder');
|
||
const originalText = btnSelect.innerHTML;
|
||
btnSelect.disabled = true;
|
||
btnSelect.innerHTML = '⏳ Scanning folder...';
|
||
|
||
try {
|
||
// Scan the folder
|
||
const response = await fetch('/local/scan-masters', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ folder_path: currentMasterPath })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
alert('Error: ' + (data.error || 'Failed to scan folder'));
|
||
btnSelect.disabled = false;
|
||
btnSelect.innerHTML = originalText;
|
||
return;
|
||
}
|
||
|
||
if (data.new_videos === 0 && data.already_added === 0) {
|
||
alert('No video files found in this folder');
|
||
btnSelect.disabled = false;
|
||
btnSelect.innerHTML = originalText;
|
||
return;
|
||
}
|
||
|
||
selectedMasterFolder = {
|
||
path: currentMasterPath,
|
||
...data
|
||
};
|
||
|
||
// Process masters if needed (new videos or missing fingerprints)
|
||
if (data.new_videos > 0) {
|
||
// Check if any need re-fingerprinting
|
||
const needsRefp = data.scanned.filter(v => v.reason && v.reason.includes('Missing')).length;
|
||
const isNew = data.scanned.filter(v => v.reason === 'New video').length;
|
||
|
||
let statusMsg = `⏳ Processing ${data.new_videos} video(s)...`;
|
||
btnSelect.innerHTML = statusMsg;
|
||
btnSelect.disabled = true;
|
||
|
||
// Start progress polling
|
||
fingerprintProgressInterval = setInterval(pollFingerprintProgress, 1000);
|
||
|
||
const videoPaths = data.scanned.map(v => v.path);
|
||
const addResponse = await fetch('/local/add-masters', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ video_paths: videoPaths })
|
||
});
|
||
|
||
// Stop polling
|
||
if (fingerprintProgressInterval) {
|
||
clearInterval(fingerprintProgressInterval);
|
||
fingerprintProgressInterval = null;
|
||
}
|
||
|
||
const addData = await addResponse.json();
|
||
|
||
if (!addResponse.ok) {
|
||
alert('Error adding masters: ' + (addData.error || 'Failed'));
|
||
btnSelect.disabled = false;
|
||
btnSelect.innerHTML = originalText;
|
||
return;
|
||
}
|
||
|
||
// Build detailed message
|
||
let message = '';
|
||
const newCount = addData.results.filter(r => r.status === 'new').length;
|
||
const refpCount = addData.results.filter(r => r.status === 're-fingerprinted').length;
|
||
|
||
if (newCount > 0 && refpCount > 0) {
|
||
message = `✓ Added ${newCount} new master(s) and re-fingerprinted ${refpCount} existing master(s)`;
|
||
} else if (refpCount > 0) {
|
||
message = `✓ Re-fingerprinted ${refpCount} master(s) with missing fingerprints`;
|
||
} else {
|
||
message = `✓ Successfully added ${newCount} new master video(s)`;
|
||
}
|
||
|
||
if (data.already_added > 0) {
|
||
message += `\n\n${data.already_added} master(s) already had valid fingerprints`;
|
||
}
|
||
|
||
if (addData.failed > 0) {
|
||
message += `\n\n⚠ ${addData.failed} video(s) failed to process. Check terminal for details.`;
|
||
}
|
||
|
||
alert(message);
|
||
} else {
|
||
alert(`✓ All ${data.already_added} master video(s) already have valid fingerprints`);
|
||
}
|
||
|
||
// Move to step 2
|
||
goToStep2();
|
||
|
||
} catch (error) {
|
||
alert('Error: ' + error.message);
|
||
btnSelect.disabled = false;
|
||
btnSelect.innerHTML = originalText;
|
||
}
|
||
}
|
||
|
||
// Adaptation Folder Selection
|
||
async function browseAdaptPath(path) {
|
||
try {
|
||
const response = await fetch('/local/browse', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ path: path })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
alert('Error: ' + (data.error || 'Failed to browse folder'));
|
||
return;
|
||
}
|
||
|
||
currentAdaptPath = data.current_path;
|
||
|
||
// Update UI
|
||
document.getElementById('currentAdaptPath').textContent = currentAdaptPath || 'Select a starting location';
|
||
document.getElementById('btnAdaptUp').disabled = !data.parent_path;
|
||
document.getElementById('btnAddFolder').disabled = !currentAdaptPath || data.video_count === 0;
|
||
|
||
// Render folders
|
||
const browser = document.getElementById('adaptationBrowser');
|
||
browser.innerHTML = '';
|
||
|
||
// Show roots if at root level
|
||
if (!path && data.roots) {
|
||
for (const [key, value] of Object.entries(data.roots)) {
|
||
if (value) {
|
||
const item = createFolderItem(key, value, () => browseAdaptPath(value));
|
||
browser.appendChild(item);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Show folders
|
||
data.folders.forEach(folder => {
|
||
const item = createFolderItem('📁 ' + folder.name, folder.path, () => browseAdaptPath(folder.path));
|
||
browser.appendChild(item);
|
||
});
|
||
|
||
// Show video count
|
||
if (data.video_count > 0) {
|
||
const info = document.createElement('div');
|
||
info.className = 'alert alert-success mt-3';
|
||
info.textContent = `✓ Found ${data.video_count} video file(s) in this folder`;
|
||
browser.appendChild(info);
|
||
}
|
||
|
||
} catch (error) {
|
||
alert('Error browsing folder: ' + error.message);
|
||
}
|
||
}
|
||
|
||
function browseAdaptHome() { browseAdaptPath(null); }
|
||
function browseAdaptDesktop() { browseAdaptPath(roots.desktop || null); }
|
||
function browseAdaptDocuments() { browseAdaptPath(roots.documents || null); }
|
||
function browseAdaptUp() {
|
||
if (currentAdaptPath) {
|
||
const parent = currentAdaptPath.split('/').slice(0, -1).join('/') || '/';
|
||
browseAdaptPath(parent);
|
||
}
|
||
}
|
||
|
||
function addAdaptationFolder() {
|
||
if (!currentAdaptPath) return;
|
||
|
||
// Check if already added
|
||
if (selectedAdaptationFolders.includes(currentAdaptPath)) {
|
||
alert('This folder is already added');
|
||
return;
|
||
}
|
||
|
||
selectedAdaptationFolders.push(currentAdaptPath);
|
||
updateAdaptationList();
|
||
}
|
||
|
||
function updateAdaptationList() {
|
||
const list = document.getElementById('adaptationList');
|
||
const section = document.getElementById('selectedAdaptations');
|
||
|
||
if (selectedAdaptationFolders.length === 0) {
|
||
section.style.display = 'none';
|
||
document.getElementById('btnProceed').disabled = true;
|
||
return;
|
||
}
|
||
|
||
section.style.display = 'block';
|
||
document.getElementById('btnProceed').disabled = false;
|
||
|
||
list.innerHTML = selectedAdaptationFolders.map((folder, index) => `
|
||
<div class="alert alert-info d-flex justify-content-between align-items-center">
|
||
<span>📁 ${folder}</span>
|
||
<button class="btn btn-sm btn-danger" onclick="removeAdaptationFolder(${index})">Remove</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function removeAdaptationFolder(index) {
|
||
selectedAdaptationFolders.splice(index, 1);
|
||
updateAdaptationList();
|
||
}
|
||
|
||
let progressInterval = null;
|
||
let fingerprintProgressInterval = null;
|
||
|
||
async function pollProgress() {
|
||
try {
|
||
const response = await fetch('/local/match-progress');
|
||
const progress = await response.json();
|
||
|
||
if (progress.active) {
|
||
// Update progress bar
|
||
const percent = progress.total > 0 ? (progress.current / progress.total * 100) : 0;
|
||
document.getElementById('progressBar').style.width = percent + '%';
|
||
document.getElementById('progressText').textContent = `${progress.current} / ${progress.total}`;
|
||
document.getElementById('currentVideoText').textContent =
|
||
progress.current_video ? `Processing: ${progress.current_video}` : 'Starting...';
|
||
} else {
|
||
// Processing complete, stop polling
|
||
if (progressInterval) {
|
||
clearInterval(progressInterval);
|
||
progressInterval = null;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error polling progress:', error);
|
||
}
|
||
}
|
||
|
||
async function pollFingerprintProgress() {
|
||
try {
|
||
const response = await fetch('/local/add-masters-progress');
|
||
const progress = await response.json();
|
||
|
||
if (progress.active) {
|
||
// Show progress bar
|
||
document.getElementById('fingerprintProgress').style.display = 'block';
|
||
|
||
// Update progress bar
|
||
const percent = progress.total > 0 ? (progress.current / progress.total * 100) : 0;
|
||
document.getElementById('fingerprintProgressBar').style.width = percent + '%';
|
||
document.getElementById('fingerprintProgressText').textContent = `${progress.current} / ${progress.total}`;
|
||
document.getElementById('fingerprintCurrentVideo').textContent =
|
||
progress.current_video ? `Fingerprinting: ${progress.current_video}` : 'Starting...';
|
||
} else {
|
||
// Processing complete, stop polling
|
||
if (fingerprintProgressInterval) {
|
||
clearInterval(fingerprintProgressInterval);
|
||
fingerprintProgressInterval = null;
|
||
}
|
||
// Hide progress bar after a moment
|
||
setTimeout(() => {
|
||
document.getElementById('fingerprintProgress').style.display = 'none';
|
||
}, 1000);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error polling fingerprint progress:', error);
|
||
}
|
||
}
|
||
|
||
async function proceedToMatching() {
|
||
if (selectedAdaptationFolders.length === 0) {
|
||
alert('Please select at least one adaptation folder');
|
||
return;
|
||
}
|
||
|
||
goToStep3();
|
||
|
||
try {
|
||
// Scan adaptation folders
|
||
document.getElementById('currentVideoText').textContent = 'Scanning adaptation folders...';
|
||
|
||
const scanResponse = await fetch('/local/scan-adaptations', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ folder_paths: selectedAdaptationFolders })
|
||
});
|
||
|
||
const scanData = await scanResponse.json();
|
||
|
||
if (!scanResponse.ok) {
|
||
throw new Error(scanData.error || 'Failed to scan adaptations');
|
||
}
|
||
|
||
if (scanData.total_videos === 0) {
|
||
alert('No video files found in selected folders');
|
||
return;
|
||
}
|
||
|
||
// Start progress polling
|
||
progressInterval = setInterval(pollProgress, 1000);
|
||
|
||
// Start matching
|
||
const videoPaths = scanData.videos.map(v => v.path);
|
||
|
||
const matchResponse = await fetch('/local/match', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ adaptation_paths: videoPaths })
|
||
});
|
||
|
||
const matchData = await matchResponse.json();
|
||
|
||
// Stop polling
|
||
if (progressInterval) {
|
||
clearInterval(progressInterval);
|
||
progressInterval = null;
|
||
}
|
||
|
||
if (!matchResponse.ok) {
|
||
throw new Error(matchData.error || 'Matching failed');
|
||
}
|
||
|
||
matchResults = matchData;
|
||
displayResults(matchData);
|
||
|
||
} catch (error) {
|
||
if (progressInterval) {
|
||
clearInterval(progressInterval);
|
||
progressInterval = null;
|
||
}
|
||
alert('Error during processing: ' + error.message);
|
||
document.getElementById('processingStatus').innerHTML =
|
||
`<div class="alert alert-danger">Error: ${error.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function displayResults(data) {
|
||
document.getElementById('processingStatus').style.display = 'none';
|
||
document.getElementById('resultsSection').style.display = 'block';
|
||
|
||
// Build summary with AI fallback info
|
||
let summaryText = `${data.matched} matched, ${data.unmatched} unmatched out of ${data.total} total videos`;
|
||
if (data.ai_fallback_used > 0) {
|
||
summaryText += `<br><span class="badge bg-info mt-2">🤖 ${data.ai_fallback_used} matched using AI Vision fallback (cross-aspect ratio)</span>`;
|
||
}
|
||
|
||
document.getElementById('resultsSummary').innerHTML = summaryText;
|
||
|
||
const list = document.getElementById('resultsList');
|
||
list.innerHTML = data.results.map(result => {
|
||
const matched = result.matched;
|
||
const itemClass = matched ? 'matched' : 'unmatched';
|
||
const icon = matched ? '✅' : '❌';
|
||
const matchMethod = result.match_method || 'fast';
|
||
const isAiFallback = matchMethod === 'ai_vision_fallback';
|
||
|
||
return `
|
||
<div class="result-item ${itemClass}">
|
||
<h6>
|
||
${icon} ${result.adaptation_filename || 'Unknown'}
|
||
${isAiFallback ? '<span class="badge bg-info ms-2" style="font-size: 11px;">🤖 AI Vision</span>' : ''}
|
||
</h6>
|
||
${matched ? `
|
||
<p class="mb-1"><strong>Matched Master:</strong> ${result.master_filename || result.master_id || 'Unknown'}</p>
|
||
<p class="mb-1"><strong>Confidence:</strong> ${result.confidence ? (result.confidence * 100).toFixed(1) : '0.0'}%</p>
|
||
<p class="mb-1"><strong>Audio Score:</strong> ${result.audio_score ? (result.audio_score * 100).toFixed(1) : '0.0'}%</p>
|
||
${isAiFallback ? '<p class="mb-0 text-info"><small><em>Matched using AI Vision (likely cross-aspect ratio)</em></small></p>' : ''}
|
||
` : `
|
||
<p class="mb-0 text-muted">No matching master found</p>
|
||
`}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function exportResults() {
|
||
if (!matchResults) return;
|
||
|
||
const csv = generateCSV(matchResults);
|
||
const blob = new Blob([csv], { type: 'text/csv' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `match_results_${new Date().toISOString().split('T')[0]}.csv`;
|
||
a.click();
|
||
}
|
||
|
||
function generateCSV(data) {
|
||
const headers = ['Adaptation File', 'Master File', 'Matched', 'Confidence', 'Audio Score', 'Match Method'];
|
||
const rows = data.results.map(r => [
|
||
r.adaptation_filename || r.adaptation_path,
|
||
r.matched ? (r.master_filename || 'Unknown') : '',
|
||
r.matched ? 'Yes' : 'No',
|
||
r.confidence ? (r.confidence * 100).toFixed(1) + '%' : '0%',
|
||
r.audio_score ? (r.audio_score * 100).toFixed(1) + '%' : '0%',
|
||
r.match_method === 'ai_vision_fallback' ? 'AI Vision' :
|
||
r.match_method === 'fast' ? 'Fast' : 'No Match'
|
||
]);
|
||
|
||
return [headers, ...rows].map(row =>
|
||
row.map(cell => `"${cell}"`).join(',')
|
||
).join('\n');
|
||
}
|
||
|
||
function startOver() {
|
||
location.reload();
|
||
}
|
||
|
||
// Helper Functions
|
||
function createFolderItem(label, path, onClick) {
|
||
const div = document.createElement('div');
|
||
div.className = 'folder-item';
|
||
div.textContent = label;
|
||
div.onclick = onClick;
|
||
return div;
|
||
}
|
||
|
||
function goToStep2() {
|
||
document.getElementById('masterSelection').style.display = 'none';
|
||
document.getElementById('adaptationSelection').style.display = 'block';
|
||
document.getElementById('step1').classList.remove('active');
|
||
document.getElementById('step1').classList.add('completed');
|
||
document.getElementById('step2').classList.add('active');
|
||
browseAdaptHome();
|
||
}
|
||
|
||
function goToStep3() {
|
||
document.getElementById('adaptationSelection').style.display = 'none';
|
||
document.getElementById('processingResults').style.display = 'block';
|
||
document.getElementById('step2').classList.remove('active');
|
||
document.getElementById('step2').classList.add('completed');
|
||
document.getElementById('step3').classList.add('active');
|
||
}
|
||
|
||
function backToMasters() {
|
||
document.getElementById('adaptationSelection').style.display = 'none';
|
||
document.getElementById('masterSelection').style.display = 'block';
|
||
document.getElementById('step2').classList.remove('active');
|
||
document.getElementById('step1').classList.remove('completed');
|
||
document.getElementById('step1').classList.add('active');
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|