KaraokeMerge/web/templates/index.html

1610 lines
69 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Karaoke Duplicate Review - Web UI</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
// Fallback for Sortable.js if CDN fails
if (typeof Sortable === 'undefined') {
console.warn('Primary Sortable.js CDN failed, trying fallback...');
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js';
script.onload = function() {
console.log('Sortable.js loaded from fallback CDN');
};
script.onerror = function() {
console.error('Both Sortable.js CDNs failed to load');
alert('Warning: Sortable.js library failed to load. Drag and drop functionality will not work.');
};
document.head.appendChild(script);
}
</script>
<style>
.duplicate-card {
border-left: 4px solid #dc3545;
margin-bottom: 1rem;
}
.kept-version {
background-color: #d4edda;
border-left: 4px solid #28a745;
}
.skipped-version {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
}
.file-type-badge {
font-size: 0.75rem;
}
.channel-badge {
font-size: 0.8rem;
}
.stats-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.file-type-card {
transition: transform 0.2s;
}
.file-type-card:hover {
transform: translateY(-2px);
}
.metric-highlight {
font-weight: bold;
color: #28a745;
}
.metric-warning {
font-weight: bold;
color: #dc3545;
}
.filter-section {
background-color: #f8f9fa;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.loading {
text-align: center;
padding: 2rem;
}
.pagination-info {
font-size: 0.9rem;
color: #6c757d;
}
.path-text {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
word-break: break-all;
}
/* Drag and Drop Styles */
.sortable-list {
min-height: 50px;
}
.sortable-item {
cursor: grab;
transition: all 0.2s ease;
}
.sortable-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.sortable-item:active {
cursor: grabbing;
}
.sortable-ghost {
opacity: 0.5;
background-color: #e9ecef;
}
.sortable-chosen {
background-color: #fff3cd;
border: 2px dashed #ffc107;
}
.priority-indicator {
position: absolute;
top: 10px;
right: 10px;
background: #007bff;
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
.priority-1 { background: #28a745; }
.priority-2 { background: #17a2b8; }
.priority-3 { background: #ffc107; color: #212529; }
.priority-4 { background: #fd7e14; }
.priority-5 { background: #dc3545; }
.drag-handle {
cursor: grab;
color: #6c757d;
margin-right: 8px;
padding: 4px;
border-radius: 3px;
transition: all 0.2s ease;
}
.drag-handle:hover {
color: #495057;
background-color: #e9ecef;
}
.drag-handle:active {
cursor: grabbing;
background-color: #dee2e6;
}
.priority-info {
background-color: #e7f3ff;
border: 1px solid #b3d9ff;
border-radius: 4px;
padding: 8px;
margin-bottom: 10px;
font-size: 0.9rem;
}
/* Video Player Modal Styles */
.video-modal {
display: none;
position: fixed;
z-index: 1050;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
}
.video-modal-content {
position: relative;
margin: 2% auto;
padding: 0;
width: 90%;
max-width: 800px;
background-color: #000;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.video-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background-color: #343a40;
color: white;
border-radius: 8px 8px 0 0;
}
.video-modal-title {
margin: 0;
font-size: 1.1rem;
font-weight: 500;
}
.video-modal-close {
color: #aaa;
font-size: 28px;
font-weight: bold;
cursor: pointer;
background: none;
border: none;
padding: 0;
line-height: 1;
}
.video-modal-close:hover {
color: white;
}
.video-container {
position: relative;
width: 100%;
background-color: #000;
}
.video-player {
width: 100%;
height: auto;
max-height: 70vh;
display: block;
}
.play-button {
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.8rem;
cursor: pointer;
margin-left: 8px;
transition: background-color 0.2s;
}
.play-button:hover {
background-color: #0056b3;
}
.play-button:disabled {
background-color: #6c757d;
cursor: not-allowed;
}
.file-path-display {
font-family: 'Courier New', monospace;
font-size: 0.75rem;
color: #6c757d;
margin-top: 4px;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container-fluid">
<!-- Header -->
<div class="row bg-primary text-white p-3 mb-4">
<div class="col">
<h1><i class="fas fa-music"></i> Karaoke Duplicate Review</h1>
<p class="mb-0">Interactive interface for reviewing and understanding your duplicate songs</p>
</div>
</div>
<!-- Statistics Dashboard -->
<div class="row mb-4" id="stats-section">
<!-- Current Totals -->
<div class="col-md-2">
<div class="card stats-card">
<div class="card-body text-center">
<h4 id="total-songs">-</h4>
<p class="mb-0">Total Songs</p>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card stats-card">
<div class="card-body text-center">
<h4 id="total-duplicates">-</h4>
<p class="mb-0">Songs with Duplicates</p>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card stats-card">
<div class="card-body text-center">
<h4 id="total-files">-</h4>
<p class="mb-0">Files to Skip</p>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card stats-card">
<div class="card-body text-center">
<h4 id="total-remaining">-</h4>
<p class="mb-0">Files After Cleanup</p>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card stats-card">
<div class="card-body text-center">
<h4 id="space-savings">-</h4>
<p class="mb-0">Space Savings</p>
</div>
</div>
</div>
<div class="col-md-2">
<div class="card stats-card">
<div class="card-body text-center">
<h4 id="avg-duplicates">-</h4>
<p class="mb-0">Avg Duplicates</p>
</div>
</div>
</div>
</div>
<!-- File Type Breakdown -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card file-type-card">
<div class="card-header bg-primary text-white">
<h6 class="mb-0"><i class="fas fa-list"></i> Current File Types</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-6 text-center">
<h5 id="total-mp4">-</h5>
<small class="text-muted">MP4</small>
</div>
<div class="col-6 text-center">
<h5 id="total-mp3">-</h5>
<small class="text-muted">MP3</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card file-type-card">
<div class="card-header bg-danger text-white">
<h6 class="mb-0"><i class="fas fa-trash"></i> Files to Skip</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-6 text-center">
<h5 id="skip-mp4">-</h5>
<small class="text-muted">MP4</small>
</div>
<div class="col-6 text-center">
<h5 id="skip-mp3">-</h5>
<small class="text-muted">MP3</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card file-type-card">
<div class="card-header bg-success text-white">
<h6 class="mb-0"><i class="fas fa-check"></i> After Cleanup</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-6 text-center">
<h5 id="remaining-mp4">-</h5>
<small class="text-muted">MP4</small>
</div>
<div class="col-6 text-center">
<h5 id="remaining-mp3">-</h5>
<small class="text-muted">MP3</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- View Options -->
<div class="row mb-4">
<div class="col">
<div class="filter-section">
<h5><i class="fas fa-eye"></i> View Options</h5>
<div class="row">
<div class="col-md-3">
<label for="view-mode" class="form-label">View Mode</label>
<select class="form-select" id="view-mode" onchange="changeViewMode()">
<option value="all">All Songs</option>
<option value="artists">Group by Artist</option>
</select>
</div>
<div class="col-md-3">
<label for="sort-by" class="form-label">Sort By</label>
<select class="form-select" id="sort-by" onchange="applyFilters()">
<option value="artist">Artist</option>
<option value="title">Title</option>
<option value="duplicates">Most Duplicates</option>
</select>
</div>
<div class="col-md-3">
<label for="artist-select" class="form-label">Quick Artist Select</label>
<select class="form-select" id="artist-select" onchange="selectArtist()">
<option value="">All Artists</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">&nbsp;</label>
<button class="btn btn-success w-100" onclick="saveChanges()" id="save-btn" disabled>
<i class="fas fa-save"></i> Save Changes
</button>
<small class="text-muted" id="drag-status">
<i class="fas fa-spinner fa-spin"></i> Loading drag-and-drop...
</small>
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<button class="btn btn-primary w-100" onclick="savePriorityPreferences()" id="save-priority-btn" disabled>
<i class="fas fa-sort"></i> Save Priority Preferences
</button>
</div>
<div class="col-md-6">
<button class="btn btn-info w-100" onclick="resetPriorityPreferences()">
<i class="fas fa-undo"></i> Reset Priorities
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col">
<div class="filter-section">
<h5><i class="fas fa-filter"></i> Filters</h5>
<div class="row">
<div class="col-md-2">
<label for="artist-filter" class="form-label">Artist</label>
<input type="text" class="form-control" id="artist-filter" placeholder="Filter by artist...">
</div>
<div class="col-md-2">
<label for="title-filter" class="form-label">Title</label>
<input type="text" class="form-control" id="title-filter" placeholder="Filter by title...">
</div>
<div class="col-md-2">
<label for="channel-filter" class="form-label">Channel</label>
<select class="form-select" id="channel-filter">
<option value="">All Channels</option>
</select>
</div>
<div class="col-md-2">
<label for="file-type-filter" class="form-label">File Type</label>
<select class="form-select" id="file-type-filter">
<option value="">All Types</option>
<option value="mp4">MP4</option>
<option value="mp3">MP3</option>
</select>
</div>
<div class="col-md-2">
<label for="min-duplicates" class="form-label">Min Duplicates</label>
<input type="number" class="form-control" id="min-duplicates" min="0" value="0">
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<button class="btn btn-primary w-100" onclick="applyFilters()">
<i class="fas fa-search"></i> Apply Filters
</button>
</div>
</div>
<div class="row mt-3">
<div class="col-md-3">
<button class="btn btn-success w-100" onclick="downloadMp3Songs()">
<i class="fas fa-download"></i> Download MP3 Song List
</button>
</div>
<div class="col-md-9">
<small class="text-muted">
<i class="fas fa-info-circle"></i>
Download a JSON file containing all MP3 songs that remain after cleanup,
sorted by artist and title. Use this list to find replacement videos.
</small>
</div>
</div>
</div>
</div>
</div>
<!-- Duplicates List -->
<div class="row">
<div class="col">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-list"></i> Duplicate Songs</h5>
<div class="pagination-info" id="pagination-info">
Showing 0 of 0 results
</div>
</div>
<div class="card-body">
<div id="loading" class="loading">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p>Loading duplicates...</p>
</div>
<div id="duplicates-container"></div>
<!-- Pagination -->
<nav aria-label="Duplicates pagination" class="mt-4">
<ul class="pagination justify-content-center" id="pagination">
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
let currentPage = 1;
let totalPages = 1;
let currentFilters = {};
let viewMode = 'all';
let pendingChanges = [];
let allArtists = [];
let priorityChanges = {};
let sortableInstances = [];
// Load data on page load
document.addEventListener('DOMContentLoaded', function() {
// Check if Sortable.js is loaded
if (typeof Sortable === 'undefined') {
console.error('Sortable.js library not loaded!');
alert('Error: Sortable.js library not loaded. Drag and drop functionality will not work.');
} else {
console.log('Sortable.js library loaded successfully');
}
loadStats();
loadArtists();
loadPriorityPreferences();
loadDuplicates();
});
async function loadStats() {
try {
const response = await fetch('/api/stats');
const data = await response.json();
// Main statistics
document.getElementById('total-songs').textContent = data.total_songs.toLocaleString();
document.getElementById('total-duplicates').textContent = data.total_duplicates.toLocaleString();
document.getElementById('total-files').textContent = data.total_files_to_skip.toLocaleString();
document.getElementById('total-remaining').textContent = data.total_remaining.toLocaleString();
document.getElementById('avg-duplicates').textContent = (data.total_files_to_skip / data.total_duplicates).toFixed(1);
// Calculate space savings percentage
const savingsPercent = ((data.total_files_to_skip / data.total_songs) * 100).toFixed(1);
document.getElementById('space-savings').textContent = savingsPercent + '%';
// Current file types
document.getElementById('total-mp4').textContent = data.total_file_types.MP4.toLocaleString();
document.getElementById('total-mp3').textContent = data.total_file_types.MP3.toLocaleString();
// Files to skip
document.getElementById('skip-mp4').textContent = data.skip_file_types.MP4.toLocaleString();
document.getElementById('skip-mp3').textContent = data.skip_file_types.MP3.toLocaleString();
// Files after cleanup
document.getElementById('remaining-mp4').textContent = data.remaining_file_types.MP4.toLocaleString();
document.getElementById('remaining-mp3').textContent = data.remaining_file_types.MP3.toLocaleString();
// Populate channel filter
const channelSelect = document.getElementById('channel-filter');
channelSelect.innerHTML = '<option value="">All Channels</option>';
Object.keys(data.channels).forEach(channel => {
const option = document.createElement('option');
option.value = channel.toLowerCase();
option.textContent = channel + ' (' + data.channels[channel] + ')';
channelSelect.appendChild(option);
});
} catch (error) {
console.error('Error loading stats:', error);
}
}
async function loadDuplicates(page = 1) {
const loading = document.getElementById('loading');
const container = document.getElementById('duplicates-container');
loading.style.display = 'block';
container.innerHTML = '';
try {
const params = new URLSearchParams({
page: page,
per_page: 20,
...currentFilters
});
const response = await fetch('/api/duplicates?' + params);
const data = await response.json();
currentPage = data.page;
totalPages = data.total_pages;
displayDuplicates(data.duplicates);
updatePagination(data.total, data.page, data.per_page, data.total_pages);
} catch (error) {
console.error('Error loading duplicates:', error);
container.innerHTML = '<div class="alert alert-danger">Error loading duplicates</div>';
} finally {
loading.style.display = 'none';
}
}
function toggleDetails(songKey) {
const details = document.getElementById('details-' + songKey);
if (!details) {
console.error('Details element not found for:', songKey);
return;
}
// Find the button that was clicked
const button = document.querySelector('[onclick="toggleDetails(\'' + songKey.replace(/'/g, "\\'") + '\')"]');
if (!button) {
console.error('Button not found for:', songKey);
return;
}
const icon = button.querySelector('i');
if (!icon) {
console.error('Icon not found for:', songKey);
return;
}
if (details.style.display === 'none' || details.style.display === '') {
details.style.display = 'block';
icon.className = 'fas fa-chevron-up';
} else {
details.style.display = 'none';
icon.className = 'fas fa-chevron-down';
}
}
function updatePagination(total, page, perPage, totalPages) {
const info = document.getElementById('pagination-info');
const start = (page - 1) * perPage + 1;
const end = Math.min(page * perPage, total);
info.textContent = 'Showing ' + start + '-' + end + ' of ' + total.toLocaleString() + ' results';
const pagination = document.getElementById('pagination');
pagination.innerHTML = '';
// Previous button
const prevLi = document.createElement('li');
prevLi.className = 'page-item ' + (page === 1 ? 'disabled' : '');
prevLi.innerHTML = '<a class="page-link" href="#" onclick="loadDuplicates(' + (page - 1) + ')">Previous</a>';
pagination.appendChild(prevLi);
// Page numbers
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, page + 2);
for (let i = startPage; i <= endPage; i++) {
const li = document.createElement('li');
li.className = 'page-item ' + (i === page ? 'active' : '');
li.innerHTML = '<a class="page-link" href="#" onclick="loadDuplicates(' + i + ')">' + i + '</a>';
pagination.appendChild(li);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = 'page-item ' + (page === totalPages ? 'disabled' : '');
nextLi.innerHTML = '<a class="page-link" href="#" onclick="loadDuplicates(' + (page + 1) + ')">Next</a>';
pagination.appendChild(nextLi);
}
function applyFilters() {
currentFilters = {
artist: document.getElementById('artist-filter').value,
title: document.getElementById('title-filter').value,
channel: document.getElementById('channel-filter').value,
file_type: document.getElementById('file-type-filter').value,
min_duplicates: document.getElementById('min-duplicates').value
};
loadDuplicates(1);
}
function getFileType(path) {
const lower = path.toLowerCase();
if (lower.endsWith('.mp4')) return 'MP4';
if (lower.endsWith('.mp3')) return 'MP3';
if (lower.endsWith('.cdg')) return 'MP3'; // Treat CDG as MP3 since they're paired
return 'Unknown';
}
function extractChannel(path) {
const lower = path.toLowerCase();
const parts = path.split('\\');
// Look for specific known channels first
const knownChannels = ['Sing King Karaoke', 'KaraFun Karaoke', 'Stingray Karaoke'];
for (const channel of knownChannels) {
if (lower.includes(channel.toLowerCase())) {
return channel;
}
}
// Look for MP4 folder structure: MP4/ChannelName/song.mp4
for (let i = 0; i < parts.length; i++) {
if (parts[i].toLowerCase() === 'mp4' && i < parts.length - 1) {
// If MP4 is found, return the next folder (the actual channel)
if (i + 1 < parts.length) {
const nextPart = parts[i + 1];
// Skip if the next part is the filename (no extension means it's a folder)
if (nextPart.indexOf('.') === -1) {
return nextPart;
} else {
return 'MP4 Root'; // File is directly in MP4 folder
}
} else {
return 'MP4 Root';
}
}
}
// Look for any folder that contains 'karaoke' (fallback)
for (const part of parts) {
if (part.toLowerCase().includes('karaoke')) {
return part;
}
}
// If no specific channel found, return the folder containing the file
if (parts.length >= 2) {
const parentFolder = parts[parts.length - 2]; // Second to last part (folder containing the file)
// If parent folder is MP4, then file is in root
if (parentFolder.toLowerCase() === 'mp4') {
return 'MP4 Root';
}
return parentFolder;
}
return 'Unknown';
}
async function loadArtists() {
try {
const response = await fetch('/api/artists');
const data = await response.json();
allArtists = data.artists;
// Populate artist select dropdown
const artistSelect = document.getElementById('artist-select');
artistSelect.innerHTML = '<option value="">All Artists</option>';
allArtists.forEach(artist => {
const option = document.createElement('option');
option.value = artist.name;
option.textContent = artist.name + ' (' + artist.total_duplicates + ' duplicates)';
artistSelect.appendChild(option);
});
} catch (error) {
console.error('Error loading artists:', error);
}
}
async function loadPriorityPreferences() {
try {
const response = await fetch('/api/load-priority-preferences');
const data = await response.json();
if (data.success) {
priorityChanges = data.preferences;
updatePrioritySaveButton();
}
} catch (error) {
console.error('Error loading priority preferences:', error);
}
}
function changeViewMode() {
viewMode = document.getElementById('view-mode').value;
loadDuplicates(1);
}
function selectArtist() {
const selectedArtist = document.getElementById('artist-select').value;
if (selectedArtist) {
document.getElementById('artist-filter').value = selectedArtist;
applyFilters();
}
}
function toggleKeepFile(songKey, filePath, artist, title, keptVersion) {
const change = {
type: 'keep_file',
song_key: songKey,
file_path: filePath,
artist: artist,
title: title,
kept_version: keptVersion
};
pendingChanges.push(change);
updateSaveButton();
// Visual feedback
const element = document.querySelector('[data-path="' + filePath.replace(/"/g, '\\"') + '"]');
if (element) {
element.style.opacity = '0.5';
element.style.backgroundColor = '#d4edda';
}
}
function updateSaveButton() {
const saveBtn = document.getElementById('save-btn');
if (pendingChanges.length > 0) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Changes (' + pendingChanges.length + ')';
} else {
saveBtn.disabled = true;
saveBtn.textContent = 'Save Changes';
}
}
async function saveChanges() {
if (pendingChanges.length === 0) {
alert('No changes to save');
return;
}
try {
const response = await fetch('/api/save-changes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
changes: pendingChanges
})
});
const result = await response.json();
if (result.success) {
alert('✅ ' + result.message);
pendingChanges = [];
updateSaveButton();
loadDuplicates(); // Refresh the data
} else {
alert('❌ Error: ' + result.error);
}
} catch (error) {
console.error('Error saving changes:', error);
alert('❌ Error saving changes');
}
}
function displayDuplicates(duplicates) {
const container = document.getElementById('duplicates-container');
if (duplicates.length === 0) {
container.innerHTML = '<div class="alert alert-info">No duplicates found matching your filters.</div>';
return;
}
if (viewMode === 'artists') {
displayArtistsView(duplicates);
} else {
displayAllSongsView(duplicates);
}
// Initialize sortable for all duplicate groups
setTimeout(() => {
console.log('Initializing sortable instances...');
updateDragStatus('loading', 'Initializing drag-and-drop...');
initializeSortable();
console.log('Sortable initialization complete');
}, 100);
}
function displayArtistsView(duplicates) {
const container = document.getElementById('duplicates-container');
// Group by artist
const artists = {};
duplicates.forEach(duplicate => {
const artist = duplicate.artist;
if (!artists[artist]) {
artists[artist] = {
name: artist,
songs: [],
totalDuplicates: 0
};
}
artists[artist].songs.push(duplicate);
artists[artist].totalDuplicates += duplicate.total_duplicates;
});
// Sort artists alphabetically
const sortedArtists = Object.values(artists).sort((a, b) => a.name.localeCompare(b.name));
container.innerHTML = sortedArtists.map(artist =>
'<div class="card mb-4">' +
'<div class="card-header bg-primary text-white">' +
'<h5 class="mb-0">' +
'<i class="fas fa-user"></i> ' + artist.name +
'<span class="badge bg-light text-dark ms-2">' + artist.songs.length + ' songs, ' + artist.totalDuplicates + ' duplicates</span>' +
'</h5>' +
'</div>' +
'<div class="card-body">' +
artist.songs.map(duplicate => createSongCard(duplicate)).join('') +
'</div>' +
'</div>'
).join('');
}
function displayAllSongsView(duplicates) {
const container = document.getElementById('duplicates-container');
container.innerHTML = duplicates.map(duplicate => createSongCard(duplicate)).join('');
}
function createSongCard(duplicate) {
// Create a safe ID by replacing special characters
const safeId = (duplicate.artist + ' - ' + duplicate.title).replace(/[^a-zA-Z0-9\s\-]/g, '_');
// Get current priority order for this song
const songKey = duplicate.artist + ' - ' + duplicate.title;
const currentPriorities = priorityChanges[songKey] || [];
// Create all versions array (kept + skipped)
const allVersions = [
{
path: duplicate.kept_version,
file_type: getFileType(duplicate.kept_version),
channel: extractChannel(duplicate.kept_version),
is_kept: true
},
...duplicate.skipped_versions.map(v => ({...v, is_kept: false}))
];
// Apply current priority order if it exists
if (currentPriorities.length > 0) {
allVersions.sort((a, b) => {
const aIndex = currentPriorities.indexOf(a.path);
const bIndex = currentPriorities.indexOf(b.path);
if (aIndex === -1 && bIndex === -1) return 0;
if (aIndex === -1) return 1;
if (bIndex === -1) return 0;
return aIndex - bIndex;
});
}
return '<div class="card duplicate-card">' +
'<div class="card-header">' +
'<div class="d-flex justify-content-between align-items-center">' +
'<h6 class="mb-0">' +
'<strong>' + duplicate.artist + ' - ' + duplicate.title + '</strong>' +
'<span class="badge bg-primary ms-2">' + duplicate.total_duplicates + ' duplicates</span>' +
'</h6>' +
'<div>' +
'<button class="btn btn-sm btn-outline-secondary me-2" onclick="toggleDetails(\'' + safeId.replace(/'/g, "\\'") + '\')">' +
'<i class="fas fa-chevron-down"></i> Details' +
'</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="card-body" id="details-' + safeId + '" style="display: none;">' +
'<div class="priority-info">' +
'<i class="fas fa-info-circle"></i> ' +
'<strong>Drag and drop to reorder file priorities.</strong> ' +
'The top file will be kept, others will be skipped. ' +
'Click "Save Priority Preferences" to apply these changes for future CLI runs.' +
'<br><small class="text-muted"><i class="fas fa-hand-pointer"></i> Use the grip handle (⋮⋮) to drag items</small>' +
'</div>' +
'<!-- Sortable Versions List -->' +
'<h6><i class="fas fa-sort"></i> FILE PRIORITIES (Drag to reorder):</h6>' +
'<div class="sortable-list" id="sortable-' + safeId + '">' +
allVersions.map((version, index) =>
'<div class="card mb-2 sortable-item ' + (version.is_kept ? 'kept-version' : 'skipped-version') + '" ' +
'data-path="' + version.path + '" data-index="' + index + '">' +
'<div class="priority-indicator priority-' + Math.min(index + 1, 5) + '">' + (index + 1) + '</div>' +
'<div class="card-body">' +
'<div class="d-flex align-items-start">' +
'<div class="drag-handle">' +
'<i class="fas fa-grip-vertical"></i>' +
'</div>' +
'<div class="flex-grow-1">' +
'<div class="path-text">' + version.path + '</div>' +
'<span class="badge ' + (version.is_kept ? 'bg-success' : 'bg-danger') + ' file-type-badge">' + version.file_type + '</span>' +
'<span class="badge ' + (version.is_kept ? 'bg-info' : 'bg-warning') + ' channel-badge">' + version.channel + '</span>' +
(version.is_kept ? '<span class="badge bg-success ms-1 status-badge">KEPT</span>' : '<span class="badge bg-danger ms-1 status-badge">SKIPPED</span>') +
(version.file_type === 'MP4' ?
'<button class="play-button" onclick="openVideoPlayer(\'' + version.path.replace(/'/g, "\\'") + '\', \'' + duplicate.artist.replace(/'/g, "\\'") + '\', \'' + duplicate.title.replace(/'/g, "\\'") + '\')">' +
'<i class="fas fa-play"></i> Play' +
'</button>' :
''
) +
'</div>' +
'</div>' +
'</div>' +
'</div>'
).join('') +
'</div>' +
'</div>' +
'</div>';
}
async function downloadMp3Songs() {
try {
// Show loading state
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...';
button.disabled = true;
// Trigger download
const response = await fetch('/api/download/mp3-songs');
if (response.ok) {
// Create a blob from the response
const blob = await response.blob();
// Create download link
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'mp3SongList.json';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Show success message
alert('MP3 Song List downloaded successfully!');
} else {
const errorData = await response.json();
alert('Error downloading MP3 song list: ' + (errorData.error || 'Unknown error'));
}
} catch (error) {
console.error('Error downloading MP3 songs:', error);
alert('Error downloading MP3 song list: ' + error.message);
} finally {
// Restore button state
const button = event.target.closest('button');
button.innerHTML = originalText;
button.disabled = false;
}
}
// Priority Management Functions
function initializeSortable() {
// Destroy existing instances
sortableInstances.forEach(instance => {
if (instance && typeof instance.destroy === 'function') {
instance.destroy();
}
});
sortableInstances = [];
// Check if Sortable.js is available
if (typeof Sortable === 'undefined') {
console.error('Sortable.js not available');
updateDragStatus('error', 'Drag-and-drop not available');
return;
}
let successCount = 0;
let totalLists = document.querySelectorAll('.sortable-list').length;
// Initialize new sortable instances
document.querySelectorAll('.sortable-list').forEach(list => {
try {
console.log('Initializing sortable for list:', list.id);
const sortable = Sortable.create(list, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
onStart: function(evt) {
console.log('Sortable started:', evt);
},
onEnd: function(evt) {
console.log('Sortable ended:', evt);
const songKey = getSongKeyFromSortableList(evt.to);
updatePriorityOrder(songKey, evt.to);
updatePriorityIndicators(evt.to);
}
});
sortableInstances.push(sortable);
successCount++;
console.log('Sortable instance created successfully');
} catch (error) {
console.error('Error creating sortable instance:', error);
}
});
// Update status
if (successCount > 0) {
updateDragStatus('success', `Drag-and-drop ready (${successCount} lists)`);
} else {
updateDragStatus('error', 'No drag-and-drop lists found');
}
}
function updateDragStatus(status, message) {
const statusElement = document.getElementById('drag-status');
if (statusElement) {
let icon = '';
let color = '';
switch (status) {
case 'success':
icon = 'fas fa-check';
color = 'text-success';
break;
case 'error':
icon = 'fas fa-exclamation-triangle';
color = 'text-danger';
break;
case 'loading':
icon = 'fas fa-spinner fa-spin';
color = 'text-muted';
break;
}
statusElement.innerHTML = `<i class="${icon}"></i> ${message}`;
statusElement.className = `text-muted ${color}`;
}
}
function getSongKeyFromSortableList(listElement) {
try {
const detailsElement = listElement.closest('.card-body');
const cardElement = detailsElement.closest('.duplicate-card');
const titleElement = cardElement.querySelector('h6 strong');
const songKey = titleElement.textContent;
console.log('Extracted song key:', songKey);
return songKey;
} catch (error) {
console.error('Error extracting song key:', error);
return '';
}
}
function updatePriorityOrder(songKey, listElement) {
try {
const items = Array.from(listElement.querySelectorAll('.sortable-item'));
const newOrder = items.map(item => item.getAttribute('data-path'));
console.log('Updating priority order for:', songKey);
console.log('New order:', newOrder);
priorityChanges[songKey] = newOrder;
updatePrioritySaveButton();
// Update visual indicators after reordering
updateVisualIndicators(listElement);
} catch (error) {
console.error('Error updating priority order:', error);
}
}
function updateVisualIndicators(listElement) {
try {
const items = Array.from(listElement.querySelectorAll('.sortable-item'));
items.forEach((item, index) => {
// Update the CSS class for the card
item.classList.remove('kept-version', 'skipped-version');
if (index === 0) {
item.classList.add('kept-version');
} else {
item.classList.add('skipped-version');
}
// Update the file type badge color
const fileTypeBadge = item.querySelector('.file-type-badge');
if (fileTypeBadge) {
fileTypeBadge.classList.remove('bg-success', 'bg-danger');
fileTypeBadge.classList.add(index === 0 ? 'bg-success' : 'bg-danger');
}
// Update the channel badge color
const channelBadge = item.querySelector('.channel-badge');
if (channelBadge) {
channelBadge.classList.remove('bg-info', 'bg-warning');
channelBadge.classList.add(index === 0 ? 'bg-info' : 'bg-warning');
}
// Update the KEPT/SKIPPED badge
const statusBadge = item.querySelector('.status-badge');
if (statusBadge) {
statusBadge.classList.remove('bg-success', 'bg-danger');
statusBadge.classList.add(index === 0 ? 'bg-success' : 'bg-danger');
statusBadge.textContent = index === 0 ? 'KEPT' : 'SKIPPED';
}
});
console.log('Visual indicators updated for song list');
} catch (error) {
console.error('Error updating visual indicators:', error);
}
}
function updatePriorityIndicators(listElement) {
const items = Array.from(listElement.querySelectorAll('.sortable-item'));
items.forEach((item, index) => {
const indicator = item.querySelector('.priority-indicator');
if (indicator) {
indicator.className = 'priority-indicator priority-' + Math.min(index + 1, 5);
indicator.textContent = index + 1;
}
});
}
function updatePrioritySaveButton() {
const saveBtn = document.getElementById('save-priority-btn');
const hasChanges = Object.keys(priorityChanges).length > 0;
if (hasChanges) {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Priority Preferences (' + Object.keys(priorityChanges).length + ' songs)';
} else {
saveBtn.disabled = true;
saveBtn.textContent = 'Save Priority Preferences';
}
}
async function savePriorityPreferences() {
if (Object.keys(priorityChanges).length === 0) {
alert('No priority changes to save');
return;
}
try {
const response = await fetch('/api/save-priority-preferences', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
priority_changes: priorityChanges
})
});
const result = await response.json();
if (result.success) {
alert('✅ Priority preferences saved successfully!\n\n' + result.message);
priorityChanges = {};
updatePrioritySaveButton();
} else {
alert('❌ Error: ' + result.error);
}
} catch (error) {
console.error('Error saving priority preferences:', error);
alert('❌ Error saving priority preferences');
}
}
async function resetPriorityPreferences() {
if (confirm('Are you sure you want to reset all priority preferences? This will restore the default priority order.')) {
try {
const response = await fetch('/api/reset-priority-preferences', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
alert('✅ Priority preferences reset successfully!');
priorityChanges = {};
updatePrioritySaveButton();
loadDuplicates(); // Refresh the display
} else {
alert('❌ Error: ' + result.error);
}
} catch (error) {
console.error('Error resetting priority preferences:', error);
alert('❌ Error resetting priority preferences');
}
}
}
// Video Player Functions
function normalizePath(filePath) {
// Fix malformed paths that have been corrupted with ://
// Example: z://MP4KaraFun KaraokeKaraoke I m Not In Love - 10CC.mp4
// Should be: z:\MP4\KaraFun Karaoke\Karaoke I'm Not In Love - 10CC.mp4
if (filePath.includes('://')) {
console.log('DEBUG: Detected malformed path, attempting to fix:', filePath);
// Extract drive letter and rest of path
const match = filePath.match(/^([a-zA-Z]):\/\/(.+)$/);
if (match) {
const driveLetter = match[1];
let restOfPath = match[2];
// Try to reconstruct the proper path structure
// Look for common patterns in the corrupted path
// Pattern 1: Handle the specific case where "Karaoke" appears twice
// Example: "MP4KaraFun KaraokeKaraoke I m Not In Love - 10CC.mp4"
// Should become: "MP4\KaraFun Karaoke\Karaoke I'm Not In Love - 10CC.mp4"
const karaokeDoubleMatch = restOfPath.match(/^MP4([A-Za-z\s]+)KaraokeKaraoke(.+)$/);
if (karaokeDoubleMatch) {
const channelName = karaokeDoubleMatch[1] + "Karaoke";
const fileName = "Karaoke" + karaokeDoubleMatch[2];
const fixedPath = driveLetter + ":\\MP4\\" + channelName + "\\" + fileName;
console.log('DEBUG: Fixed path (pattern 1 - double karaoke):', fixedPath);
return fixedPath;
}
// Pattern 2: MP4 followed by channel name (e.g., MP4KaraFun Karaoke)
const mp4Match = restOfPath.match(/^MP4([A-Za-z\s]+Karaoke)(.+)$/);
if (mp4Match) {
const channelName = mp4Match[1];
const fileName = mp4Match[2];
const fixedPath = driveLetter + ":\\MP4\\" + channelName + "\\" + fileName;
console.log('DEBUG: Fixed path (pattern 2):', fixedPath);
return fixedPath;
}
// Pattern 3: Direct channel name followed by filename
const channelMatch = restOfPath.match(/^([A-Za-z\s]+Karaoke)(.+)$/);
if (channelMatch) {
const channelName = channelMatch[1];
const fileName = channelMatch[2];
const fixedPath = driveLetter + ":\\MP4\\" + channelName + "\\" + fileName;
console.log('DEBUG: Fixed path (pattern 3):', fixedPath);
return fixedPath;
}
// Pattern 4: Look for any known channel names
const knownChannels = ['Sing King Karaoke', 'KaraFun Karaoke', 'Stingray Karaoke'];
for (const channel of knownChannels) {
if (channel.toLowerCase().replace(/\s/g, '') === restOfPath.toLowerCase().replace(/\s/g, '').substring(0, channel.toLowerCase().replace(/\s/g, '').length)) {
// Extract the part before and after the channel name
const channelLower = channel.toLowerCase().replace(/\s/g, '');
const restLower = restOfPath.toLowerCase().replace(/\s/g, '');
const channelIndex = restLower.indexOf(channelLower);
if (channelIndex >= 0) {
// Reconstruct the path
const beforeChannel = restOfPath.substring(0, channelIndex);
const afterChannel = restOfPath.substring(channelIndex + channel.length);
// If there's content before the channel, it might be a folder like "MP4"
let fixedPath;
if (beforeChannel && beforeChannel.toLowerCase() === 'mp4') {
fixedPath = driveLetter + ":\\" + beforeChannel + "\\" + channel + afterChannel;
} else {
fixedPath = driveLetter + ":\\MP4\\" + channel + afterChannel;
}
console.log('DEBUG: Fixed path (pattern 4):', fixedPath);
return fixedPath;
}
}
}
// Pattern 5: Try to split by common separators and reconstruct
// Look for patterns like "MP4KaraFunKaraoke" -> "MP4\KaraFun Karaoke"
if (restOfPath.toLowerCase().includes('karaoke')) {
// Try to find where "karaoke" appears and reconstruct
const karaokeIndex = restOfPath.toLowerCase().indexOf('karaoke');
if (karaokeIndex > 0) {
const beforeKaraoke = restOfPath.substring(0, karaokeIndex);
const afterKaraoke = restOfPath.substring(karaokeIndex + 7); // length of "karaoke"
// If beforeKaraoke starts with "MP4", extract the channel name
if (beforeKaraoke.toLowerCase().startsWith('mp4')) {
const channelPart = beforeKaraoke.substring(3); // Remove "MP4"
if (channelPart) {
const fixedPath = driveLetter + ":\\MP4\\" + channelPart + " Karaoke" + afterKaraoke;
console.log('DEBUG: Fixed path (pattern 5):', fixedPath);
return fixedPath;
}
}
}
}
// Fallback: just replace :// with :\ and hope for the best
const fallbackPath = filePath.replace('://', ':\\');
console.log('DEBUG: Fallback path fix:', fallbackPath);
return fallbackPath;
}
}
return filePath;
}
function openVideoPlayer(filePath, artist, title) {
const modal = document.getElementById('videoModal');
const videoPlayer = document.getElementById('videoPlayer');
const modalTitle = document.getElementById('videoModalTitle');
// Set modal title
modalTitle.textContent = artist + ' - ' + title;
// Normalize the file path to fix any malformed paths
const normalizedPath = normalizePath(filePath);
// Encode the file path for the URL
const encodedPath = encodeURIComponent(normalizedPath);
console.log('DEBUG: Opening video player for:', filePath);
console.log('DEBUG: Normalized path:', normalizedPath);
console.log('DEBUG: Encoded path:', encodedPath);
console.log('DEBUG: Video URL:', '/api/video/' + encodedPath);
// Clear any previous source
videoPlayer.src = '';
// Set video source using Flask route
videoPlayer.src = '/api/video/' + encodedPath;
// Show modal
modal.style.display = 'block';
// Add event listener for video load
videoPlayer.onloadeddata = function() {
console.log('Video loaded successfully');
console.log('Video duration:', videoPlayer.duration);
console.log('Video ready state:', videoPlayer.readyState);
};
// Add error handling
videoPlayer.onerror = function(e) {
console.error('Error loading video:', filePath);
console.error('Normalized path:', normalizedPath);
console.error('Video error details:', e);
console.error('Video error code:', videoPlayer.error ? videoPlayer.error.code : 'unknown');
console.error('Video error message:', videoPlayer.error ? videoPlayer.error.message : 'unknown');
// Show more specific error messages
let errorMessage = 'Error loading video. ';
if (videoPlayer.error) {
switch(videoPlayer.error.code) {
case 1:
errorMessage += 'The video loading was aborted.';
break;
case 2:
errorMessage += 'Network error occurred while loading the video.';
break;
case 3:
errorMessage += 'The video decoding failed. The file may be corrupted or in an unsupported format.';
break;
case 4:
errorMessage += 'The video format is not supported by your browser.';
break;
default:
errorMessage += 'Unknown error occurred.';
}
}
alert(errorMessage + '\n\nCheck console for details.');
};
// Add more detailed event listeners
videoPlayer.onloadstart = function() {
console.log('Video load started');
};
videoPlayer.onprogress = function() {
console.log('Video loading progress');
};
videoPlayer.oncanplay = function() {
console.log('Video can start playing');
console.log('Video dimensions:', videoPlayer.videoWidth, 'x', videoPlayer.videoHeight);
};
videoPlayer.oncanplaythrough = function() {
console.log('Video can play through without buffering');
};
videoPlayer.onabort = function() {
console.log('Video loading aborted');
};
videoPlayer.onstalled = function() {
console.log('Video loading stalled');
};
videoPlayer.onsuspend = function() {
console.log('Video loading suspended');
};
// Try to play the video automatically
videoPlayer.onloadedmetadata = function() {
console.log('Video metadata loaded');
console.log('Video duration:', videoPlayer.duration);
console.log('Video dimensions:', videoPlayer.videoWidth, 'x', videoPlayer.videoHeight);
// Try to play the video
videoPlayer.play().then(function() {
console.log('Video started playing automatically');
}).catch(function(error) {
console.log('Auto-play failed (this is normal):', error);
console.log('User will need to click play manually');
});
};
}
function closeVideoPlayer() {
try {
const modal = document.getElementById('videoModal');
const videoPlayer = document.getElementById('videoPlayer');
if (videoPlayer) {
// Pause video
videoPlayer.pause();
// Clear source and remove all event listeners
videoPlayer.src = '';
videoPlayer.onloadeddata = null;
videoPlayer.onerror = null;
videoPlayer.onloadstart = null;
videoPlayer.onprogress = null;
videoPlayer.oncanplay = null;
videoPlayer.oncanplaythrough = null;
videoPlayer.onabort = null;
videoPlayer.onstalled = null;
videoPlayer.onsuspend = null;
videoPlayer.onloadedmetadata = null;
}
if (modal) {
// Hide modal
modal.style.display = 'none';
}
} catch (error) {
console.error('Error closing video player:', error);
}
}
// Close modal when clicking outside
window.onclick = function(event) {
try {
const modal = document.getElementById('videoModal');
if (event.target === modal) {
closeVideoPlayer();
}
} catch (error) {
console.error('Error in window click handler:', error);
}
}
// Close modal with Escape key
document.addEventListener('keydown', function(event) {
try {
if (event.key === 'Escape') {
closeVideoPlayer();
}
} catch (error) {
console.error('Error in keydown handler:', error);
}
});
// Debug function to test drag and drop
function testDragAndDrop() {
console.log('Testing drag and drop functionality...');
console.log('Sortable.js loaded:', typeof Sortable !== 'undefined');
console.log('Sortable instances:', sortableInstances.length);
console.log('Sortable lists found:', document.querySelectorAll('.sortable-list').length);
// Test if we can find any sortable items
const items = document.querySelectorAll('.sortable-item');
console.log('Sortable items found:', items.length);
if (items.length > 0) {
console.log('First sortable item:', items[0]);
console.log('Drag handle found:', items[0].querySelector('.drag-handle'));
}
// Try to reinitialize sortable
console.log('Reinitializing sortable...');
initializeSortable();
}
// Make test function available globally
window.testDragAndDrop = testDragAndDrop;
</script>
<!-- Video Player Modal -->
<div id="videoModal" class="video-modal">
<div class="video-modal-content">
<div class="video-modal-header">
<h5 class="video-modal-title" id="videoModalTitle">Video Player</h5>
<button class="video-modal-close" onclick="closeVideoPlayer()">&times;</button>
</div>
<div class="video-container">
<video id="videoPlayer" class="video-player" controls>
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
</body>
</html>