Signed-off-by: mbrucedogs <mbrucedogs@gmail.com>

This commit is contained in:
mbrucedogs 2025-07-26 19:52:31 -05:00
parent 63df6cebaf
commit 7f20ba3ffa
4 changed files with 506 additions and 0 deletions

4
PRD.md
View File

@ -255,6 +255,7 @@ KaraokeMerge/
- **Search & Filter**: Real-time search across artists, titles, and paths - **Search & Filter**: Real-time search across artists, titles, and paths
- **Responsive Design**: Mobile-friendly interface - **Responsive Design**: Mobile-friendly interface
- **Easy Startup**: Automated dependency checking and browser launch - **Easy Startup**: Automated dependency checking and browser launch
- **Remaining Songs View**: Separate page to browse all songs that remain after cleanup
#### **Media Preview & Playback** #### **Media Preview & Playback**
- **Video Playback**: Direct MP4 video playback in modal popup for previewing karaoke videos - **Video Playback**: Direct MP4 video playback in modal popup for previewing karaoke videos
@ -308,6 +309,7 @@ KaraokeMerge/
#### **Key Components** #### **Key Components**
- **`web/app.py`**: Flask application with API endpoints - **`web/app.py`**: Flask application with API endpoints
- **`web/templates/index.html`**: Main web interface template - **`web/templates/index.html`**: Main web interface template
- **`web/templates/remaining_songs.html`**: Remaining songs browsing interface
- **`start_web_ui.py`**: Startup script with dependency management - **`start_web_ui.py`**: Startup script with dependency management
### 7.3 API Endpoints ### 7.3 API Endpoints
@ -317,6 +319,7 @@ KaraokeMerge/
- **`/api/stats`**: Get statistical analysis of the song collection - **`/api/stats`**: Get statistical analysis of the song collection
- **`/api/artists`**: Get list of artists for filtering - **`/api/artists`**: Get list of artists for filtering
- **`/api/mp3-songs`**: Get MP3 songs that remain after cleanup - **`/api/mp3-songs`**: Get MP3 songs that remain after cleanup
- **`/api/remaining-songs`**: Get all remaining songs with pagination and filtering
- **`/api/config`**: Get current configuration settings - **`/api/config`**: Get current configuration settings
#### **Priority Management Endpoints** #### **Priority Management Endpoints**
@ -412,6 +415,7 @@ data/preferences/
- [x] Create responsive design with Bootstrap 5 - [x] Create responsive design with Bootstrap 5
- [x] Add file path normalization for corrupted paths - [x] Add file path normalization for corrupted paths
- [x] Implement change tracking and save button management - [x] Implement change tracking and save button management
- [x] Create remaining songs browsing page with filtering and video preview
#### **Advanced Features** #### **Advanced Features**
- [x] Multi-format support (MP3, CDG, MP4) - [x] Multi-format support (MP3, CDG, MP4)

View File

@ -191,6 +191,11 @@ def index():
"""Main dashboard page.""" """Main dashboard page."""
return render_template('index.html') return render_template('index.html')
@app.route('/remaining-songs')
def remaining_songs():
"""Page showing remaining songs after cleanup."""
return render_template('remaining_songs.html')
@app.route('/api/duplicates') @app.route('/api/duplicates')
def get_duplicates(): def get_duplicates():
"""API endpoint to get duplicate data.""" """API endpoint to get duplicate data."""
@ -435,6 +440,87 @@ def get_mp3_songs():
return jsonify(mp3_song_list) return jsonify(mp3_song_list)
@app.route('/api/remaining-songs')
def get_remaining_songs():
"""Get all remaining songs (MP4 and MP3) after cleanup with pagination."""
try:
all_songs = load_json_file(os.path.join(DATA_DIR, 'allSongs.json'))
skip_songs = load_json_file(os.path.join(DATA_DIR, 'reports', 'skip_songs_detailed.json'))
if not all_songs:
return jsonify({'error': 'No all songs data found'}), 404
if not skip_songs:
skip_songs = []
# Get pagination parameters
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
search = request.args.get('search', '').lower()
file_type_filter = request.args.get('file_type', 'all')
artist_filter = request.args.get('artist', '')
# Create a set of paths that are being skipped
skip_paths = {song['path'] for song in skip_songs}
# Filter for songs that are NOT being skipped
remaining_songs = []
for song in all_songs:
path = song.get('path', '')
if path not in skip_paths:
# Apply file type filter
if file_type_filter != 'all':
if file_type_filter == 'mp4' and not path.lower().endswith('.mp4'):
continue
elif file_type_filter == 'mp3' and not path.lower().endswith(('.mp3', '.cdg')):
continue
# Apply search filter
if search:
title = song.get('title', '').lower()
artist = song.get('artist', '').lower()
if search not in title and search not in artist:
continue
# Apply artist filter
if artist_filter:
artist = song.get('artist', '').lower()
if artist_filter.lower() not in artist:
continue
remaining_songs.append({
'title': song.get('title', 'Unknown'),
'artist': song.get('artist', 'Unknown'),
'path': song.get('path', ''),
'file_type': get_file_type(song.get('path', '')),
'channel': extract_channel(song.get('path', ''))
})
# Sort by artist, then by title
remaining_songs.sort(key=lambda x: (x['artist'].lower(), x['title'].lower()))
# Calculate pagination
total_songs = len(remaining_songs)
total_pages = (total_songs + per_page - 1) // per_page
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
# Get songs for current page
page_songs = remaining_songs[start_idx:end_idx]
return jsonify({
'songs': page_songs,
'pagination': {
'current_page': page,
'per_page': per_page,
'total_songs': total_songs,
'total_pages': total_pages
}
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/download/mp3-songs') @app.route('/api/download/mp3-songs')
def download_mp3_songs(): def download_mp3_songs():

View File

@ -478,6 +478,20 @@
</small> </small>
</div> </div>
</div> </div>
<div class="row mt-3">
<div class="col-md-3">
<a href="/remaining-songs" class="btn btn-outline-success w-100">
<i class="fas fa-eye"></i> View Remaining Songs
</a>
</div>
<div class="col-md-9">
<small class="text-muted">
<i class="fas fa-info-circle"></i>
Browse all remaining songs (MP4 and MP3) after cleanup with filtering,
search, and video preview capabilities.
</small>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,402 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Remaining Songs After Cleanup - Karaoke Library</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">
<style>
.song-card {
border-left: 4px solid #28a745;
margin-bottom: 1rem;
transition: all 0.2s ease;
}
.song-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.file-type-badge {
font-size: 0.75rem;
}
.channel-badge {
font-size: 0.8rem;
}
.stats-card {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
}
.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;
}
.back-button {
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="container-fluid">
<!-- Header -->
<div class="row mt-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<a href="/" class="btn btn-outline-secondary back-button">
<i class="fas fa-arrow-left"></i> Back to Duplicates
</a>
<h1 class="d-inline-block ms-3">Remaining Songs After Cleanup</h1>
</div>
<div class="text-end">
<small class="text-muted">All songs that remain after duplicate removal</small>
</div>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card stats-card">
<div class="card-body text-center">
<h3 id="totalSongs">-</h3>
<p class="mb-0">Total Songs</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card">
<div class="card-body text-center">
<h3 id="mp4Count">-</h3>
<p class="mb-0">MP4 Videos</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card">
<div class="card-body text-center">
<h3 id="mp3Count">-</h3>
<p class="mb-0">MP3/CDG Files</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card">
<div class="card-body text-center">
<h3 id="uniqueArtists">-</h3>
<p class="mb-0">Unique Artists</p>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="filter-section">
<div class="row">
<div class="col-md-4">
<label for="searchInput" class="form-label">Search</label>
<input type="text" class="form-control" id="searchInput" placeholder="Search by artist or title...">
</div>
<div class="col-md-3">
<label for="fileTypeFilter" class="form-label">File Type</label>
<select class="form-select" id="fileTypeFilter">
<option value="all">All Types</option>
<option value="mp4">MP4 Videos</option>
<option value="mp3">MP3/CDG Files</option>
</select>
</div>
<div class="col-md-3">
<label for="artistFilter" class="form-label">Artist Filter</label>
<input type="text" class="form-control" id="artistFilter" placeholder="Filter by artist...">
</div>
<div class="col-md-2">
<label for="perPageSelect" class="form-label">Per Page</label>
<select class="form-select" id="perPageSelect">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</div>
</div>
</div>
<!-- Songs List -->
<div id="songsContainer">
<div class="loading">
<i class="fas fa-spinner fa-spin fa-2x"></i>
<p class="mt-2">Loading remaining songs...</p>
</div>
</div>
<!-- Pagination -->
<div class="row mt-4">
<div class="col-12">
<nav aria-label="Songs pagination">
<div class="d-flex justify-content-between align-items-center">
<div class="pagination-info">
Showing <span id="showingInfo">-</span> of <span id="totalInfo">-</span> songs
</div>
<ul class="pagination mb-0" id="pagination">
<!-- Pagination will be generated here -->
</ul>
</div>
</nav>
</div>
</div>
</div>
<!-- Video Modal -->
<div class="modal fade" id="videoModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="videoModalTitle">Video Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<video id="videoPlayer" class="w-100" controls>
Your browser does not support the video tag.
</video>
</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 currentFilters = {
search: '',
file_type: 'all',
artist: '',
per_page: 50
};
// Initialize the page
document.addEventListener('DOMContentLoaded', function() {
loadSongs();
setupEventListeners();
});
function setupEventListeners() {
// Search input
const searchInput = document.getElementById('searchInput');
let searchTimeout;
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentFilters.search = this.value;
currentPage = 1;
loadSongs();
}, 300);
});
// File type filter
document.getElementById('fileTypeFilter').addEventListener('change', function() {
currentFilters.file_type = this.value;
currentPage = 1;
loadSongs();
});
// Artist filter
const artistFilter = document.getElementById('artistFilter');
let artistTimeout;
artistFilter.addEventListener('input', function() {
clearTimeout(artistTimeout);
artistTimeout = setTimeout(() => {
currentFilters.artist = this.value;
currentPage = 1;
loadSongs();
}, 300);
});
// Per page selector
document.getElementById('perPageSelect').addEventListener('change', function() {
currentFilters.per_page = parseInt(this.value);
currentPage = 1;
loadSongs();
});
}
async function loadSongs() {
const container = document.getElementById('songsContainer');
container.innerHTML = '<div class="loading"><i class="fas fa-spinner fa-spin fa-2x"></i><p class="mt-2">Loading songs...</p></div>';
try {
const params = new URLSearchParams({
page: currentPage,
per_page: currentFilters.per_page,
search: currentFilters.search,
file_type: currentFilters.file_type,
artist: currentFilters.artist
});
const response = await fetch(`/api/remaining-songs?${params}`);
const data = await response.json();
if (response.ok) {
displaySongs(data.songs, data.pagination);
updateStats(data.songs, data.pagination);
} else {
container.innerHTML = `<div class="alert alert-danger">Error: ${data.error}</div>`;
}
} catch (error) {
console.error('Error loading songs:', error);
container.innerHTML = '<div class="alert alert-danger">Error loading songs. Please try again.</div>';
}
}
function displaySongs(songs, pagination) {
const container = document.getElementById('songsContainer');
if (songs.length === 0) {
container.innerHTML = '<div class="alert alert-info">No songs found matching your filters.</div>';
return;
}
const songsHtml = songs.map(song => `
<div class="card song-card">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<h5 class="card-title mb-1">${escapeHtml(song.title)}</h5>
<p class="card-text text-muted mb-2">${escapeHtml(song.artist)}</p>
<div class="path-text">${escapeHtml(song.path)}</div>
</div>
<div class="col-md-4 text-end">
<span class="badge bg-primary file-type-badge me-2">${song.file_type}</span>
${song.channel ? `<span class="badge bg-info channel-badge me-2">${escapeHtml(song.channel)}</span>` : ''}
${song.file_type === 'MP4' ?
`<button class="btn btn-sm btn-outline-primary" onclick="openVideoPlayer('${escapeHtml(song.path)}', '${escapeHtml(song.artist)}', '${escapeHtml(song.title)}')">
<i class="fas fa-play"></i> Play
</button>` :
''
}
</div>
</div>
</div>
</div>
`).join('');
container.innerHTML = songsHtml;
updatePagination(pagination);
}
function updateStats(songs, pagination) {
// Update total songs
document.getElementById('totalSongs').textContent = pagination.total_songs;
// Count file types
const mp4Count = songs.filter(song => song.file_type === 'MP4').length;
const mp3Count = songs.filter(song => song.file_type === 'MP3').length;
// Get unique artists from current page (this is a simplified count)
const uniqueArtists = new Set(songs.map(song => song.artist)).size;
document.getElementById('mp4Count').textContent = mp4Count;
document.getElementById('mp3Count').textContent = mp3Count;
document.getElementById('uniqueArtists').textContent = uniqueArtists;
}
function updatePagination(pagination) {
const paginationContainer = document.getElementById('pagination');
const showingInfo = document.getElementById('showingInfo');
const totalInfo = document.getElementById('totalInfo');
// Update info text
const start = (pagination.current_page - 1) * pagination.per_page + 1;
const end = Math.min(start + pagination.per_page - 1, pagination.total_songs);
showingInfo.textContent = `${start}-${end}`;
totalInfo.textContent = pagination.total_songs;
// Generate pagination buttons
let paginationHtml = '';
// Previous button
paginationHtml += `
<li class="page-item ${pagination.current_page === 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="changePage(${pagination.current_page - 1})">Previous</a>
</li>
`;
// Page numbers
const startPage = Math.max(1, pagination.current_page - 2);
const endPage = Math.min(pagination.total_pages, pagination.current_page + 2);
for (let i = startPage; i <= endPage; i++) {
paginationHtml += `
<li class="page-item ${i === pagination.current_page ? 'active' : ''}">
<a class="page-link" href="#" onclick="changePage(${i})">${i}</a>
</li>
`;
}
// Next button
paginationHtml += `
<li class="page-item ${pagination.current_page === pagination.total_pages ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="changePage(${pagination.current_page + 1})">Next</a>
</li>
`;
paginationContainer.innerHTML = paginationHtml;
}
function changePage(page) {
currentPage = page;
loadSongs();
}
function openVideoPlayer(filePath, artist, title) {
const modal = new bootstrap.Modal(document.getElementById('videoModal'));
const videoPlayer = document.getElementById('videoPlayer');
const modalTitle = document.getElementById('videoModalTitle');
modalTitle.textContent = `${artist} - ${title}`;
// Normalize the path for video playback
const normalizedPath = normalizePath(filePath);
const encodedPath = encodeURIComponent(normalizedPath);
const videoUrl = `/api/video/${encodedPath}`;
videoPlayer.src = videoUrl;
modal.show();
// Handle video errors
videoPlayer.onerror = function() {
console.error('Error loading video:', videoUrl);
alert(`Error loading video: ${filePath}`);
};
}
function normalizePath(filePath) {
// Simple path normalization - just handle :// corruption
if (filePath.includes('://')) {
return filePath.replace('://', ':\\');
}
return filePath;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>