first commit

This commit is contained in:
Tomas Dvorak
2025-09-19 08:19:44 +02:00
commit a33c0f0991
13 changed files with 3535 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<title>Plex Playlist Sync</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<style>
.progress { height: 25px; }
.progress-bar { line-height: 25px; }
.track-item { padding: 10px; border-bottom: 1px solid #eee; }
.track-item.success { background-color: #d4edda; }
.track-item.missing { background-color: #fff3cd; }
.track-item.error { background-color: #f8d7da; }
.log-container {
max-height: 400px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
background-color: #f8f9fa;
}
.track-item {
margin-bottom: 5px;
border-radius: 4px;
padding: 8px 12px;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container">
<a class="navbar-brand" href="{{ url_for('index') }}">Plex Playlist Sync</a>
</div>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<footer class="mt-5 mb-4 text-center text-muted">
<div class="container">
<p>Plex Playlist Sync &copy; {{ now.year }}</p>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+906
View File
@@ -0,0 +1,906 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-10">
<div class="card mb-4">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h2 class="h4 mb-0">Playlist Sync Configuration</h2>
<a href="{{ url_for('index') }}" class="btn btn-outline-light btn-sm">
<i class="bi bi-arrow-left me-1"></i> Back
</a>
</div>
<div class="card-body">
<form method="post" class="mb-4">
<div class="row">
<div class="col-md-6">
<h5 class="border-bottom pb-2 mb-3">Connection Settings</h5>
<div class="mb-3">
<label for="plex_url" class="form-label">Plex Server URL</label>
<input type="url" class="form-control" id="plex_url" name="plex_url"
value="{{ config.get('PLEX_BASE_URL', '') }}" required>
</div>
<div class="mb-3">
<label for="plex_token" class="form-label">Plex Token</label>
<div class="input-group">
<input type="password" class="form-control" id="plex_token"
name="plex_token" value="{{ config.get('PLEX_TOKEN', '') }}" required>
<button class="btn btn-outline-secondary" type="button" id="toggleToken">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<div class="col-md-6">
<h5 class="border-bottom pb-2 mb-3">Playlist Settings</h5>
<div class="mb-3">
<label for="library_name" class="form-label">Music Library Name</label>
<input type="text" class="form-control" id="library_name"
name="library_name" value="{{ config.get('MUSIC_LIBRARY_NAME', 'Music') }}" required>
<div class="form-text">The name of your music library in Plex</div>
</div>
<div class="mb-3">
<label for="playlist_name" class="form-label">Playlist Name</label>
<input type="text" class="form-control" id="playlist_name"
name="playlist_name" value="{{ config.get('PLAYLIST_NAME', 'My Playlist') }}" required>
<div class="form-text">Name for the new playlist in Plex</div>
</div>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-3">
<button type="button" class="btn btn-outline-secondary me-2" id="test-connection">
<i class="bi bi-plug me-1"></i> Test Connection
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i> Save Configuration
</button>
</div>
<div id="connection-status" class="mt-3"></div>
</form>
<div class="row mt-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0">File Information</h5>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>CSV File:</span>
<span class="text-end">{{ session.csv_file|basename }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>File Size:</span>
<span>{{ '%0.2f'|format(session.csv_file|filesize / 1024) }} KB</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>Last Modified:</span>
<span>{{ session.csv_file|filemodtime|datetimeformat('%Y-%m-%d %H:%M') }}</span>
</li>
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0">Sync Status</h5>
</div>
<div class="card-body d-flex flex-column">
<div id="sync-status" class="mb-3">
<div class="progress mb-3" style="height: 30px;">
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: 0%; font-weight: 500;"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
</div>
<div id="status-text" class="text-center mb-3">
<i class="bi bi-info-circle me-2"></i> Ready to start sync
</div>
<div id="stats" class="text-center mb-4">
<span id="found" class="badge bg-success rounded-pill me-2 px-3 py-2">
<i class="bi bi-check-circle me-1"></i> <span id="found-count">0</span> found
</span>
<span id="missing" class="badge bg-warning rounded-pill px-3 py-2">
<i class="bi bi-exclamation-triangle me-1"></i> <span id="missing-count">0</span> missing
</span>
</div>
</div>
<div class="mt-auto">
<button id="start-sync" class="btn btn-primary btn-lg w-100">
<i class="bi bi-play-fill me-2"></i> Start Sync
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Missing Tracks Section -->
<div class="card mt-4" id="missing-tracks-card" style="display: none;">
<div class="card-header bg-warning text-dark d-flex justify-content-between align-items-center">
<h3 class="h5 mb-0">
<i class="bi bi-exclamation-triangle me-2"></i> Missing Tracks
</h3>
<span id="missing-count-badge" class="badge bg-dark">0</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Track</th>
<th>Artist</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="missing-tracks-list">
<!-- Filled by JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Manual Search Modal -->
<div class="modal fade" id="searchModal" tabindex="-1" aria-labelledby="searchModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="searchModalLabel">Search for Track in Plex</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="search-query" class="form-label">Search Query</label>
<div class="input-group">
<input type="text" class="form-control" id="search-query" placeholder="Track name - Artist">
<button class="btn btn-primary" type="button" id="search-button">
<i class="bi bi-search me-1"></i> Search
</button>
</div>
</div>
<div id="search-results" class="mt-3">
<div class="text-center text-muted py-4">
<i class="bi bi-search d-block mb-2" style="font-size: 2rem;"></i>
<p class="mb-0">Enter a search query to find tracks in your Plex library</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="add-to-playlist" disabled>
<i class="bi bi-plus-circle me-1"></i> Add to Playlist
</button>
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header bg-secondary text-white d-flex justify-content-between align-items-center">
<h3 class="h5 mb-0">
<i class="bi bi-card-checklist me-2"></i> Sync Log
</h3>
<div class="btn-group" role="group">
<button id="clear-log" class="btn btn-sm btn-outline-light">
<i class="bi bi-trash me-1"></i> Clear
</button>
<button id="copy-log" class="btn btn-sm btn-outline-light">
<i class="bi bi-clipboard me-1"></i> Copy
</button>
</div>
</div>
<div class="card-body p-0">
<div id="log-container" class="log-container">
<div id="sync-log">
<div class="text-muted text-center py-4">
<i class="bi bi-arrow-repeat d-block mb-2" style="font-size: 2rem;"></i>
<p class="mb-0">Waiting to start sync...</p>
</div>
</div>
</div>
</div>
<div class="card-footer text-muted small d-flex justify-content-between align-items-center">
<span id="log-count">0 items</span>
<span id="last-updated">Never synced</span>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Custom filters for template
const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
// Add formatBytes to window for template use
window.formatBytes = formatBytes;
document.addEventListener('DOMContentLoaded', function() {
// UI Elements
const startButton = document.getElementById('start-sync');
const syncLog = document.getElementById('sync-log');
const progressBar = document.getElementById('progress-bar');
const statusText = document.getElementById('status-text');
const foundCount = document.getElementById('found-count');
const missingCount = document.getElementById('missing-count');
const logContainer = document.getElementById('log-container');
const clearLogBtn = document.getElementById('clear-log');
const copyLogBtn = document.getElementById('copy-log');
const logCountEl = document.getElementById('log-count');
const lastUpdatedEl = document.getElementById('last-updated');
const toggleTokenBtn = document.getElementById('toggleToken');
// State
let logEntries = 0;
let syncInProgress = false;
let missingTracks = [];
let currentTrack = null;
let selectedTrack = null;
// Helper functions
const formatTime = (date) => {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
};
const addLogEntry = (message, type = 'info', data = null) => {
const logEntry = document.createElement('div');
logEntry.className = `log-entry d-flex align-items-start mb-2`;
// Add icon based on type
let iconClass = 'bi-info-circle';
if (type === 'success') iconClass = 'bi-check-circle';
else if (type === 'warning') iconClass = 'bi-exclamation-triangle';
else if (type === 'error') iconClass = 'bi-x-circle';
logEntry.innerHTML = `
<div class="flex-shrink-0 me-2 text-${type}">
<i class="bi ${iconClass}"></i>
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between">
<span class="fw-medium">${message}</span>
<small class="text-muted ms-2">${formatTime(new Date())}</small>
</div>
${data ? `<pre class="text-muted small mt-1 mb-0">${JSON.stringify(data, null, 2)}</pre>` : ''}
</div>
`;
// Add to log
syncLog.appendChild(logEntry);
logContainer.scrollTop = logContainer.scrollHeight;
// Update log count
logEntries++;
logCountEl.textContent = `${logEntries} ${logEntries === 1 ? 'entry' : 'entries'}`;
lastUpdatedEl.textContent = `Last updated: ${new Date().toLocaleString()}`;
};
// Update progress
const updateProgress = (percent, message = '') => {
const roundedPercent = Math.round(percent);
progressBar.style.width = `${roundedPercent}%`;
progressBar.setAttribute('aria-valuenow', roundedPercent);
progressBar.textContent = `${roundedPercent}%`;
if (message) {
statusText.innerHTML = `<i class="bi ${syncInProgress ? 'bi-arrow-repeat spin' : 'bi-info-circle'} me-2"></i> ${message}`;
}
// Update progress bar color based on completion
progressBar.classList.remove('bg-success', 'bg-warning', 'bg-danger');
if (roundedPercent >= 90) progressBar.classList.add('bg-success');
else if (roundedPercent >= 50) progressBar.classList.add('bg-primary');
else progressBar.classList.add('bg-info');
};
// Update stats
const updateStats = (found, missing) => {
foundCount.textContent = found;
missingCount.textContent = missing;
// Update badges
document.getElementById('found').classList.toggle('bg-success', found > 0);
document.getElementById('missing').classList.toggle('bg-warning', missing > 0);
};
// Test Plex connection
const testConnection = async () => {
const url = document.getElementById('plex_url').value;
const token = document.getElementById('plex_token').value;
const statusEl = document.getElementById('connection-status');
if (!url || !token) {
statusEl.innerHTML = `
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
Please enter both Plex URL and token
</div>
`;
return;
}
statusEl.innerHTML = `
<div class="alert alert-info mb-0">
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
Testing connection to Plex server...
</div>
`;
try {
const response = await fetch('/test_connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plex_url: url, plex_token: token })
});
const result = await response.json();
if (result.success) {
statusEl.innerHTML = `
<div class="alert alert-success mb-0">
<i class="bi bi-check-circle me-2"></i>
Successfully connected to Plex server! ${result.server_name ? `(${result.server_name})` : ''}
</div>
`;
} else {
throw new Error(result.message || 'Connection failed');
}
} catch (error) {
console.error('Connection test failed:', error);
statusEl.innerHTML = `
<div class="alert alert-danger mb-0">
<i class="bi bi-x-circle me-2"></i>
Connection failed: ${error.message}
</div>
`;
}
};
// Show missing tracks in the UI
const showMissingTracks = (tracks) => {
missingTracks = tracks;
const container = document.getElementById('missing-tracks-list');
const card = document.getElementById('missing-tracks-card');
const countBadge = document.getElementById('missing-count-badge');
if (!tracks || tracks.length === 0) {
card.style.display = 'none';
return;
}
// Update count
countBadge.textContent = tracks.length;
// Clear existing rows
container.innerHTML = '';
// Add each missing track
tracks.forEach((track, index) => {
const row = document.createElement('tr');
row.dataset.index = index;
row.innerHTML = `
<td>${track.title || 'Unknown'}</td>
<td>${track.artist || 'Unknown'}</td>
<td>
<span class="badge bg-warning text-dark">
<i class="bi bi-exclamation-triangle me-1"></i> Missing
</span>
</td>
<td>
<button class="btn btn-sm btn-outline-primary search-track" data-index="${index}">
<i class="bi bi-search me-1"></i> Find in Plex
</button>
</td>
`;
container.appendChild(row);
});
// Show the card
card.style.display = 'block';
// Add event listeners to search buttons
document.querySelectorAll('.search-track').forEach(btn => {
btn.addEventListener('click', (e) => {
const index = e.target.closest('button').dataset.index;
openSearchModal(missingTracks[index]);
});
});
};
// Open search modal for a track
const openSearchModal = (track) => {
currentTrack = track;
const modal = new bootstrap.Modal(document.getElementById('searchModal'));
const query = `${track.title} ${track.artist}`.trim();
// Set initial search query
document.getElementById('search-query').value = query;
// Clear previous results
document.getElementById('search-results').innerHTML = `
<div class="text-center text-muted py-4">
<i class="bi bi-search d-block mb-2" style="font-size: 2rem;"></i>
<p class="mb-0">Enter a search query to find tracks in your Plex library</p>
</div>
`;
// Disable add button until a track is selected
document.getElementById('add-to-playlist').disabled = true;
// Show modal and trigger search if we have a query
modal.show();
if (query) {
searchPlex(query);
}
};
// Search Plex library
const searchPlex = async (query) => {
const resultsEl = document.getElementById('search-results');
resultsEl.innerHTML = `
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Searching...</span>
</div>
<p class="mt-2 mb-0">Searching for "${query}" in your Plex library...</p>
</div>
`;
try {
const response = await fetch('/search_plex', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.message || 'Search failed');
}
if (data.results.length === 0) {
resultsEl.innerHTML = `
<div class="text-center text-muted py-4">
<i class="bi bi-search d-block mb-2" style="font-size: 2rem;"></i>
<p class="mb-0">No results found for "${query}"</p>
<p class="small">Try a different search term or check your library</p>
</div>
`;
return;
}
// Display search results
let html = '<div class="list-group">';
data.results.forEach((track, index) => {
const isSelected = selectedTrack && selectedTrack.ratingKey === track.ratingKey;
html += `
<div class="list-group-item list-group-item-action ${isSelected ? 'active' : ''}"
data-track='${JSON.stringify(track)}'
style="cursor: pointer;">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">${track.title || 'Unknown'}</h6>
<small>${track.duration || ''}</small>
</div>
<p class="mb-1">${track.artist || 'Unknown'}${track.album || 'Unknown'}</p>
<small>${track.year || ''}${track.albumArtist || ''}</small>
</div>
`;
});
html += '</div>';
resultsEl.innerHTML = html;
// Add click handlers to search results
resultsEl.querySelectorAll('.list-group-item').forEach(item => {
item.addEventListener('click', () => {
// Remove active class from all items
resultsEl.querySelectorAll('.list-group-item').forEach(i => {
i.classList.remove('active');
});
// Add active class to selected item
item.classList.add('active');
// Store selected track
selectedTrack = JSON.parse(item.dataset.track);
// Enable add button
document.getElementById('add-to-playlist').disabled = false;
});
});
} catch (error) {
console.error('Search failed:', error);
resultsEl.innerHTML = `
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-2"></i>
Search failed: ${error.message}
</div>
`;
}
};
// Add track to playlist
const addToPlaylist = async () => {
if (!selectedTrack || !currentTrack) return;
const addButton = document.getElementById('add-to-playlist');
const originalText = addButton.innerHTML;
try {
// Update button state
addButton.disabled = true;
addButton.innerHTML = `
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
Adding...
`;
// Call server to add track to playlist
const response = await fetch('/add_to_playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_key: selectedTrack.ratingKey,
original_track: currentTrack
})
});
const result = await response.json();
if (!result.success) {
throw new Error(result.message || 'Failed to add track to playlist');
}
// Show success message
addLogEntry(`✓ Added "${selectedTrack.title}" to playlist`, 'success');
// Update missing tracks list
const index = missingTracks.findIndex(t =>
t.title === currentTrack.title && t.artist === currentTrack.artist
);
if (index !== -1) {
missingTracks.splice(index, 1);
showMissingTracks(missingTracks);
// Update counters
const foundCount = parseInt(document.getElementById('found-count').textContent) + 1;
const missingCount = parseInt(document.getElementById('missing-count').textContent) - 1;
updateStats(foundCount, missingCount);
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('searchModal'));
modal.hide();
} catch (error) {
console.error('Failed to add track:', error);
addLogEntry(`✗ Failed to add track: ${error.message}`, 'error');
// Reset button
addButton.disabled = false;
addButton.innerHTML = originalText;
// Show error in modal
const resultsEl = document.getElementById('search-results');
const errorEl = document.createElement('div');
errorEl.className = 'alert alert-danger mt-3 mb-0';
errorEl.innerHTML = `
<i class="bi bi-exclamation-triangle me-2"></i>
Failed to add track: ${error.message}
`;
resultsEl.prepend(errorEl);
}
};
// Toggle token visibility
if (toggleTokenBtn) {
toggleTokenBtn.addEventListener('click', () => {
const tokenInput = document.getElementById('plex_token');
const icon = toggleTokenBtn.querySelector('i');
if (tokenInput.type === 'password') {
tokenInput.type = 'text';
icon.classList.remove('bi-eye');
icon.classList.add('bi-eye-slash');
} else {
tokenInput.type = 'password';
icon.classList.remove('bi-eye-slash');
icon.classList.add('bi-eye');
}
});
}
// Clear log
if (clearLogBtn) {
clearLogBtn.addEventListener('click', () => {
syncLog.innerHTML = `
<div class="text-muted text-center py-4">
<i class="bi bi-arrow-repeat d-block mb-2" style="font-size: 2rem;"></i>
<p class="mb-0">Log cleared. Ready for next sync...</p>
</div>
`;
logEntries = 0;
logCountEl.textContent = '0 entries';
});
}
// Copy log to clipboard
if (copyLogBtn) {
copyLogBtn.addEventListener('click', async () => {
try {
const logText = Array.from(syncLog.children)
.map(el => el.textContent?.trim())
.filter(Boolean)
.join('\n\n');
await navigator.clipboard.writeText(logText);
// Show success feedback
const originalText = copyLogBtn.innerHTML;
copyLogBtn.innerHTML = '<i class="bi bi-check2 me-1"></i> Copied!';
setTimeout(() => {
copyLogBtn.innerHTML = originalText;
}, 2000);
addLogEntry('Log copied to clipboard', 'success');
} catch (err) {
addLogEntry('Failed to copy log to clipboard', 'error');
console.error('Failed to copy log:', err);
}
});
}
// Start sync process
startButton.addEventListener('click', async function() {
if (syncInProgress) {
addLogEntry('Sync is already in progress', 'warning');
return;
}
syncInProgress = true;
startButton.disabled = true;
startButton.innerHTML = `
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Syncing...
`;
// Clear previous logs if not already populated
if (logEntries === 0) {
syncLog.innerHTML = '';
}
updateProgress(0, 'Initializing sync...');
updateStats(0, 0);
// Add animation to progress bar
progressBar.classList.add('progress-bar-animated');
try {
addLogEntry('Starting playlist sync...', 'info');
const response = await fetch('/run_sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
playlist_name: document.getElementById('playlist_name').value
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Process the chunk of data
buffer += decoder.decode(value, { stream: true });
// Process complete JSON objects from the buffer
let boundary;
while ((boundary = buffer.indexOf('\n')) !== -1) {
const line = buffer.substring(0, boundary).trim();
buffer = buffer.substring(boundary + 1);
if (!line) continue;
try {
const data = JSON.parse(line);
if (data.progress !== undefined) {
updateProgress(data.progress, data.message || 'Syncing...');
}
if (data.status === 'processing') {
addLogEntry(`Processing: ${data.track}`, 'info');
}
else if (data.status === 'found') {
addLogEntry(`✓ Found: ${data.track}${data.match}`, 'success');
updateStats(parseInt(foundCount.textContent) + 1, parseInt(missingCount.textContent));
}
else if (data.status === 'missing') {
addLogEntry(`⚠ Missing: ${data.track}`, 'warning');
updateStats(parseInt(foundCount.textContent), parseInt(missingCount.textContent) + 1);
}
else if (data.status === 'error') {
addLogEntry(`✗ Error: ${data.message}`, 'error', data.details);
}
else if (data.status === 'missing_tracks') {
// Show missing tracks in the UI
showMissingTracks(data.missing_tracks);
// Update progress and stats
updateProgress(100, 'Sync completed with missing tracks');
updateStats(data.found || 0, data.missing_tracks?.length || 0);
// Add summary with warning
const summary = document.createElement('div');
summary.className = 'alert alert-warning mt-3';
summary.innerHTML = `
<h5><i class="bi bi-exclamation-triangle me-2"></i> Sync Completed with Missing Tracks</h5>
<p>Processed ${(data.found || 0) + (data.missing_tracks?.length || 0)} tracks:</p>
<ul class="mb-0">
<li class="text-success">
<i class="bi bi-check-circle-fill me-2"></i>
${data.found || 0} tracks added to playlist
</li>
<li class="text-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
${data.missing_tracks?.length || 0} tracks not found in library
</li>
</ul>
<div class="mt-2">
<button class="btn btn-sm btn-outline-warning" id="show-missing-tracks">
<i class="bi bi-list-ul me-1"></i> Show Missing Tracks
</button>
</div>
`;
syncLog.appendChild(summary);
// Scroll to show summary
summary.scrollIntoView({ behavior: 'smooth' });
// Add click handler for show missing tracks button
document.getElementById('show-missing-tracks')?.addEventListener('click', () => {
const missingTracksCard = document.getElementById('missing-tracks-card');
missingTracksCard.scrollIntoView({ behavior: 'smooth' });
});
addLogEntry(`Sync completed with ${data.missing_tracks?.length || 0} missing tracks`, 'warning');
}
else if (data.status === 'completed') {
updateProgress(100, 'Sync completed!');
updateStats(data.found || 0, 0);
// Add summary
const summary = document.createElement('div');
summary.className = 'alert alert-success mt-3';
summary.innerHTML = `
<h5><i class="bi bi-check-circle me-2"></i> Sync Completed Successfully</h5>
<p>Successfully processed ${data.found || 0} tracks:</p>
<ul class="mb-0">
<li class="text-success">
<i class="bi bi-check-circle-fill me-2"></i>
${data.found || 0} tracks added to playlist
</li>
</ul>
`;
syncLog.appendChild(summary);
// Scroll to show summary
summary.scrollIntoView({ behavior: 'smooth' });
addLogEntry(`Sync completed successfully! All tracks were added to the playlist.`, 'success');
}
} catch (e) {
console.error('Error parsing JSON:', e, 'Line:', line);
}
}
}
} catch (error) {
console.error('Sync error:', error);
updateProgress(0, 'Sync failed');
addLogEntry(`Sync failed: ${error.message}`, 'error', error.stack);
// Show error to user
const errorAlert = document.createElement('div');
errorAlert.className = 'alert alert-danger mt-3';
errorAlert.innerHTML = `
<h5><i class="bi bi-x-circle-fill me-2"></i> Sync Failed</h5>
<p class="mb-1">${error.message}</p>
<button class="btn btn-sm btn-outline-danger mt-2" onclick="this.nextElementSibling.classList.toggle('d-none')">
Show details
</button>
<pre class="d-none small bg-dark text-light p-2 rounded mt-2">${error.stack || 'No stack trace available'}</pre>
`;
syncLog.appendChild(errorAlert);
} finally {
syncInProgress = false;
startButton.disabled = false;
startButton.innerHTML = `
<i class="bi bi-arrow-repeat me-2"></i> Sync Again
`;
progressBar.classList.remove('progress-bar-animated');
// Update last updated time
lastUpdatedEl.textContent = `Last updated: ${new Date().toLocaleString()}`;
}
});
// Add spin animation class
const style = document.createElement('style');
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.spin {
animation: spin 1s linear infinite;
display: inline-block;
}
.log-entry {
border-left: 3px solid transparent;
padding: 0.5rem 1rem;
margin-left: -1rem;
margin-right: -1rem;
}
.log-entry:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.log-entry.text-success {
border-left-color: #198754;
}
.log-entry.text-warning {
border-left-color: #ffc107;
}
.log-entry.text-danger {
border-left-color: #dc3545;
}
.log-entry.text-info {
border-left-color: #0dcaf0;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0.5rem 0 0;
font-size: 0.8em;
line-height: 1.4;
}
`;
document.head.appendChild(style);
});
</script>
{% endblock %}
+280
View File
@@ -0,0 +1,280 @@
{% extends "base.html" %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="text-center mb-5">
<h1 class="display-5 fw-bold text-primary mb-3">Plex Playlist Sync</h1>
<p class="lead">Sync your Spotify playlists with Plex Music Library</p>
</div>
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h2 class="h4 mb-0">Step 1: Connect to Plex</h2>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data" id="plexConfigForm">
<div class="mb-4">
<label for="plex_url" class="form-label fw-bold">Plex Server URL</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-server"></i></span>
<input type="url" class="form-control form-control-lg" id="plex_url"
name="plex_url" value="{{ config.get('PLEX_BASE_URL', '') }}"
placeholder="http://localhost:32400" required>
</div>
<div class="form-text">The URL of your Plex server, usually http://localhost:32400 for local servers</div>
</div>
<div class="mb-4">
<label for="plex_token" class="form-label fw-bold">Plex Authentication Token</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="password" class="form-control form-control-lg" id="plex_token"
name="plex_token" value="{{ config.get('PLEX_TOKEN', '') }}" required>
<button class="btn btn-outline-secondary" type="button" id="toggleToken">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-outline-info" type="button" data-bs-toggle="modal" data-bs-target="#tokenHelpModal">
<i class="bi bi-question-circle"></i>
</button>
</div>
<div class="form-text">
<a href="https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/" target="_blank" rel="noopener noreferrer">
How to find your Plex token
</a>
</div>
</div>
<hr class="my-4">
<div class="mb-4">
<label for="files" class="form-label fw-bold">Spotify Playlist CSV Files</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-file-earmark-music"></i></span>
<input class="form-control form-control-lg" type="file" id="files" name="files" accept=".csv" multiple required>
</div>
<div class="form-text">
Export your Spotify playlists as CSV files. You can select multiple files.
<a href="https://exportify.net/" target="_blank">
How to export from Spotify
</a>
</div>
<div id="file-list" class="mt-2">
<!-- Selected files will be listed here -->
</div>
</div>
<div class="mb-4 form-check form-switch">
<input class="form-check-input" type="checkbox" id="unified-playlist" name="unified_playlist" checked>
<label class="form-check-label" for="unified-playlist">
Create a single unified playlist from all files
</label>
<div class="form-text">
If unchecked, a separate playlist will be created for each file.
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-arrow-right-circle me-2"></i>Continue to Track Matching
</button>
</div>
</form>
</div>
</div>
<div class="card mt-4 shadow-sm">
<div class="card-header bg-light">
<h3 class="h5 mb-0">How It Works</h3>
</div>
<div class="card-body">
<div class="row g-4">
<div class="col-md-4">
<div class="text-center p-3">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
<i class="bi bi-cloud-arrow-up fs-2"></i>
</div>
<h5>1. Upload CSV</h5>
<p class="small text-muted mb-0">Export your Spotify playlist as a CSV file and upload it here</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center p-3">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
<i class="bi bi-search fs-2"></i>
</div>
<h5>2. Match Tracks</h5>
<p class="small text-muted mb-0">We'll find matching tracks in your Plex library</p>
</div>
</div>
<div class="col-md-4">
<div class="text-center p-3">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 80px; height: 80px;">
<i class="bi bi-music-note-list fs-2"></i>
</div>
<h5>3. Create Playlist</h5>
<p class="small text-muted mb-0">Generate a new playlist in Plex with your matched tracks</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Token Help Modal -->
<div class="modal fade" id="tokenHelpModal" tabindex="-1" aria-labelledby="tokenHelpModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="tokenHelpModalLabel">How to Find Your Plex Token</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle-fill me-2"></i>
Your Plex token is required to access your Plex server. Here's how to find it:
</div>
<ol class="mb-4">
<li class="mb-2">Sign in to <a href="https://app.plex.tv/desktop" target="_blank">Plex Web</a></li>
<li class="mb-2">Click on your profile icon in the top-right corner</li>
<li class="mb-2">Select <strong>Account Settings</strong></li>
<li class="mb-2">In the left sidebar, click on <strong>Web</strong> under the <em>Account</em> section</li>
<li class="mb-2">Look for the <code>X-Plex-Token</code> in the URL. It should look something like: <code>X-Plex-Token=xxxxxxxxxxxxxxxxxxxx</code></li>
<li>Copy everything after <code>X-Plex-Token=</code></li>
</ol>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>Important:</strong> Keep your token private and never share it with anyone. This token provides access to your Plex server.
</div>
<p class="mb-0">
<a href="https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/" target="_blank" class="btn btn-outline-primary btn-sm">
<i class="bi bi-question-circle me-1"></i> More detailed instructions
</a>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Got it!</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Toggle token visibility
const toggleTokenBtn = document.getElementById('toggleToken');
if (toggleTokenBtn) {
toggleTokenBtn.addEventListener('click', function() {
const tokenInput = document.getElementById('plex_token');
const icon = this.querySelector('i');
if (tokenInput.type === 'password') {
tokenInput.type = 'text';
icon.classList.remove('bi-eye');
icon.classList.add('bi-eye-slash');
} else {
tokenInput.type = 'password';
icon.classList.remove('bi-eye-slash');
icon.classList.add('bi-eye');
}
});
}
// Handle file selection
const fileInput = document.getElementById('files');
const fileList = document.getElementById('file-list');
if (fileInput && fileList) {
fileInput.addEventListener('change', function() {
const files = Array.from(this.files);
if (files.length === 0) {
fileList.innerHTML = '<div class="text-muted">No files selected</div>';
return;
}
const list = document.createElement('ul');
list.className = 'list-group';
files.forEach((file, index) => {
const listItem = document.createElement('li');
listItem.className = 'list-group-item d-flex justify-content-between align-items-center';
const fileName = document.createElement('span');
fileName.textContent = file.name;
const fileSize = document.createElement('span');
fileSize.className = 'badge bg-secondary rounded-pill';
fileSize.textContent = formatFileSize(file.size);
listItem.appendChild(fileName);
listItem.appendChild(fileSize);
list.appendChild(listItem);
});
fileList.innerHTML = '';
fileList.appendChild(list);
// Update the playlist name to match the first file if unified playlist is enabled
const unifiedCheckbox = document.getElementById('unified-playlist');
if (unifiedCheckbox && unifiedCheckbox.checked && files.length > 0) {
const playlistName = files[0].name.replace(/\.[^/.]+$/, '').replace(/_/g, ' ');
document.querySelector('input[name="playlist_name"]').value = playlistName;
}
});
}
// Handle unified playlist checkbox change
const unifiedCheckbox = document.getElementById('unified-playlist');
if (unifiedCheckbox) {
unifiedCheckbox.addEventListener('change', function() {
const playlistNameInput = document.querySelector('input[name="playlist_name"]');
if (this.checked && fileInput.files.length > 0) {
// If switching to unified, set name to first file's name
const playlistName = fileInput.files[0].name.replace(/\.[^/.]+$/, '').replace(/_/g, ' ');
playlistNameInput.value = playlistName;
}
// If switching to separate playlists, we'll use each file's name later
});
}
// Format file size to human readable format
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Form submission with loading state and validation
const form = document.getElementById('plexConfigForm');
if (form) {
form.addEventListener('submit', function(e) {
const submitBtn = this.querySelector('button[type="submit"]');
const fileInput = document.getElementById('files');
// Validate files
if (fileInput.files.length === 0) {
e.preventDefault();
alert('Please select at least one CSV file');
return;
}
// Show loading state
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Processing...';
}
});
}
});
</script>
{% endblock %}
+646
View File
@@ -0,0 +1,646 @@
{% extends "base.html" %}
{% block extra_head %}
<style>
.progress-container {
height: 1.5rem;
}
.search-result-item {
cursor: pointer;
transition: background-color 0.2s;
}
.search-result-item:hover {
background-color: #f8f9fa;
}
.file-card {
transition: all 0.2s;
}
.file-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.file-icon {
font-size: 2rem;
margin-bottom: 1rem;
}
.playlist-option-card {
cursor: pointer;
transition: all 0.2s;
}
.playlist-option-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
}
.playlist-option-card.active {
border-color: #0d6efd;
background-color: #f8f9ff;
}
.playlist-option-card.active .form-check-input:checked {
background-color: #0d6efd;
border-color: #0d6efd;
}
</style>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="text-center mb-4">
<h1 class="display-5 fw-bold text-primary mb-2">Track Matching</h1>
{% if unified_playlist %}
<p class="lead">We'll create <span class="badge bg-primary">1 unified playlist</span> from <span class="badge bg-secondary">{{ uploaded_files|length }} files</span></p>
{% else %}
<p class="lead">We'll create <span class="badge bg-primary">{{ uploaded_files|length }} playlists</span> (one per file)</p>
{% endif %}
<p class="lead">Found <span class="badge bg-success">{{ found_count }}</span> out of <span class="badge bg-secondary">{{ total_tracks }}</span> tracks in your Plex library</p>
<div class="alert alert-info d-flex align-items-center mt-3" role="alert">
<i class="bi bi-info-circle-fill me-2"></i>
<div>
Need to export your Spotify playlists?
<a href="https://www.exportify.net/" target="_blank" class="alert-link">Exportify</a>
lets you export your playlists as CSV files that work with this tool.
</div>
</div>
{% set total_missing = (total_tracks - found_count) %}
{% if total_missing > 0 %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>{{ total_missing }} tracks not found</strong> in your Plex library.
You can search for them manually below.
</div>
{% else %}
<div class="alert alert-success">
<i class="bi bi-check-circle-fill me-2"></i>
All tracks were found in your Plex library! Click "Create Playlist" to continue.
</div>
{% endif %}
{% set progress_percent = (found_count / total_tracks * 100)|round|int %}
<div class="progress mb-4 progress-container">
<div id="progressBar" class="progress-bar bg-success"
role="progressbar"
aria-valuenow="{{ progress_percent }}"
aria-valuemin="0"
aria-valuemax="100">
{{ progress_percent }}%
</div>
</div>
</div>
{% for group in missing_by_file %}
{% set file_id = (group.filename | replace('.', '_') | replace(' ', '_')) %}
<div class="card shadow mb-4" id="file-card-{{ file_id }}">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">Missing Tracks — {{ group.playlist_name }}</h5>
<span class="badge bg-warning text-dark file-missing-badge" data-file-id="{{ file_id }}">{{ group.missing_count }}</span>
</div>
<div class="card-body p-0">
{% if group.missing_tracks %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Track</th>
<th>Artist</th>
<th>Album</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for track in group.missing_tracks %}
<tr id="track-{{ file_id }}-{{ loop.index }}" class="align-middle">
<td>{{ loop.index }}</td>
<td class="fw-bold">{{ track.get('Track Name', '') }}</td>
<td>{{ track.get('Artist Name(s)', '') }}</td>
<td>{{ track.get('Album Name', '') }}</td>
<td>
<button class="btn btn-sm btn-outline-primary search-track"
data-track-name="{{ track.get('Track Name', '') | e }}"
data-artist-name="{{ track.get('Artist Name(s)', '') | e }}"
data-track-id="{{ loop.index }}"
data-file-id="{{ file_id }}">
<i class="bi bi-search me-1"></i> Search
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center p-4">
<i class="bi bi-check-circle text-success" style="font-size: 2rem;"></i>
<p class="mt-2 mb-0">All tracks were found in this file.</p>
</div>
{% endif %}
</div>
</div>
{% endfor %}
<!-- Uploaded Files Summary -->
<div class="card shadow mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">Uploaded Files</h5>
</div>
<div class="card-body">
<div class="row g-3">
{% for file in uploaded_files %}
<div class="col-md-6 col-lg-4">
<div class="card h-100 file-card">
<div class="card-body text-center">
<div class="file-icon text-primary">
<i class="bi bi-file-earmark-music"></i>
</div>
<h6 class="card-title text-truncate" title="{{ file.filename }}">
{{ file.filename }}
</h6>
<p class="card-text small text-muted mb-1">
{{ file.track_count }} track{% if file.track_count != 1 %}s{% endif %}
</p>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Playlist Creation Options -->
<div class="card shadow mb-4">
<div class="card-header bg-light">
<h5 class="mb-0">Playlist Creation Options</h5>
</div>
<div class="card-body">
<form id="playlistForm" method="post" action="{{ url_for('create_playlist') }}">
{% if file_mode %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<span class="badge bg-secondary">File {{ file_index + 1 }} of {{ total_files }}</span>
</div>
<div class="text-muted small">You'll be taken to the next file after creating this playlist.</div>
</div>
<input type="hidden" name="file_index" value="{{ file_index }}">
<div class="mb-3">
<label for="playlist_name" class="form-label">Playlist Name</label>
<input type="text" class="form-control" id="playlist_name" name="playlist_name"
value="{{ missing_by_file[0].playlist_name }}" required>
<div class="form-text">You can rename this playlist before creating it.</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="onlySelected" name="only_selected">
<label class="form-check-label" for="onlySelected">
Only add selected matches (skip auto-search for remaining tracks)
</label>
</div>
{% elif unified_playlist %}
<div class="mb-3">
<label for="playlist_name" class="form-label">Playlist Name</label>
<input type="text" class="form-control" id="playlist_name" name="playlist_name"
value="{{ config.PLAYLIST_NAME }}" required>
</div>
{% else %}
<p class="text-muted">Each file will be created as a separate playlist with its filename as the playlist name.</p>
{% endif %}
<div class="d-flex justify-content-between mt-4">
<a href="{{ url_for('index') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Back
</a>
<div>
<button type="submit" class="btn btn-primary" id="startSyncBtn">
<i class="bi bi-music-note-list me-1"></i>
{% if file_mode %}
Create This Playlist
{% elif unified_playlist %}
Create Unified Playlist
{% else %}
Create {{ uploaded_files|length }} Playlists
{% endif %}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Search Modal -->
<div class="modal fade" id="searchModal" tabindex="-1" aria-labelledby="searchModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="searchModalLabel">Search for Track</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="searchQuery" class="form-label">Search for track in Plex</label>
<div class="input-group">
<input type="text" class="form-control" id="searchQuery" placeholder="Search by track name, artist, or album">
<button class="btn btn-primary" type="button" id="searchButton">
<i class="bi bi-search"></i> Search
</button>
</div>
<div class="form-text">Searching in: <span id="searchingFor" class="fw-bold"></span></div>
</div>
<div id="searchResults" class="mt-3">
<div class="text-center text-muted py-4">
<i class="bi bi-search" style="font-size: 2rem;"></i>
<p class="mt-2">Enter a search term to find matching tracks in your Plex library</p>
</div>
</div>
<div id="selectedTrackInfo" class="mt-3 d-none">
<hr>
<h6>Selected Track</h6>
<div class="card">
<div class="card-body" id="selectedTrackDetails">
<!-- Selected track will be shown here -->
</div>
</div>
</div>
</div>
<div class="modal-footer">
<input type="hidden" id="currentTrackId">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirmTrackBtn" disabled>Confirm Selection</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const searchModal = new bootstrap.Modal(document.getElementById('searchModal'));
let selectedTrack = null;
let currentFileId = null;
// Handle search track button clicks
document.querySelectorAll('.search-track').forEach(button => {
button.addEventListener('click', function() {
const trackName = this.getAttribute('data-track-name');
const artistName = this.getAttribute('data-artist-name');
const trackId = this.getAttribute('data-track-id');
const fileId = this.getAttribute('data-file-id');
document.getElementById('searchQuery').value = trackName + ' ' + artistName;
document.getElementById('searchingFor').textContent = '\"' + trackName + '\" by ' + artistName;
document.getElementById('currentTrackId').value = trackId;
document.getElementById('currentTrackId').setAttribute('data-file-id', fileId || '');
currentFileId = fileId || '';
document.getElementById('searchResults').innerHTML =
'<div class="text-center py-4">' +
' <div class="spinner-border text-primary" role="status">' +
' <span class="visually-hidden">Loading...</span>' +
' </div>' +
' <p class="mt-2">Searching for \"' + trackName + '\" by ' + artistName + '...</p>' +
'</div>';
// Reset selected track
selectedTrack = null;
document.getElementById('confirmTrackBtn').disabled = true;
document.getElementById('selectedTrackInfo').classList.add('d-none');
// Show modal and trigger search
searchModal.show();
performSearch(trackName, artistName);
});
});
// Handle search button click
document.getElementById('searchButton').addEventListener('click', function() {
const query = document.getElementById('searchQuery').value.trim();
if (query) {
performSearch(query);
}
});
// Handle search on Enter key
document.getElementById('searchQuery').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
const query = this.value.trim();
if (query) {
performSearch(query);
}
}
});
// Handle track selection
document.getElementById('searchResults').addEventListener('click', function(e) {
const resultItem = e.target.closest('.search-result-item');
if (resultItem) {
// Remove active class from all items
document.querySelectorAll('.search-result-item').forEach(item => {
item.classList.remove('active');
});
// Add active class to selected item
resultItem.classList.add('active');
// Store selected track data
selectedTrack = {
title: resultItem.getAttribute('data-title'),
artist: resultItem.getAttribute('data-artist'),
album: resultItem.getAttribute('data-album'),
ratingKey: resultItem.getAttribute('data-rating-key'),
thumb: resultItem.getAttribute('data-thumb')
};
// Update selected track info
const thumbHtml = selectedTrack.thumb ?
'<img src="' + selectedTrack.thumb + '" class="img-thumbnail me-3" style="max-height: 80px;">' : '';
document.getElementById('selectedTrackDetails').innerHTML =
'<div class="d-flex align-items-center">' +
' ' + thumbHtml +
' <div>' +
' <h5 class="mb-1">' + selectedTrack.title + '</h5>' +
' <p class="mb-1 text-muted">' + selectedTrack.artist + '</p>' +
' <p class="mb-0 small">' + (selectedTrack.album || 'No album') + '</p>' +
' </div>' +
'</div>';
document.getElementById('selectedTrackInfo').classList.remove('d-none');
document.getElementById('confirmTrackBtn').disabled = false;
}
});
// Handle confirm track selection
document.getElementById('confirmTrackBtn').addEventListener('click', function() {
if (!selectedTrack) return;
const trackId = document.getElementById('currentTrackId').value;
const fileId = document.getElementById('currentTrackId').getAttribute('data-file-id') || '';
const trackRow = document.getElementById('track-' + fileId + '-' + trackId);
if (trackRow) {
// Update the row with the selected track
trackRow.innerHTML =
'<td>' + trackId + '</td>' +
'<td class="fw-bold">' + selectedTrack.title + '</td>' +
'<td>' + selectedTrack.artist + '</td>' +
'<td>' + (selectedTrack.album || '') + '</td>' +
'<td>' +
' <span class="badge bg-success">' +
' <i class="bi bi-check-circle me-1"></i> Matched' +
' </span>' +
'</td>';
// Update counters and progress
updateMatchCounters(1);
// Add hidden input for this ratingKey to the form, avoid duplicates
const form = document.getElementById('playlistForm');
if (form && selectedTrack.ratingKey) {
const existing = Array.from(form.querySelectorAll('input[name="track_ratingKey[]"]'))
.some(inp => inp.value === selectedTrack.ratingKey);
if (!existing) {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'track_ratingKey[]';
inp.value = selectedTrack.ratingKey;
form.appendChild(inp);
}
}
// Close the modal
searchModal.hide();
}
});
// No AJAX submit; rely on form POST for creation
// Function to perform search
function performSearch(query, originalArtist = '') {
const resultsContainer = document.getElementById('searchResults');
// Show loading state
resultsContainer.innerHTML =
'<div class="text-center py-4">' +
' <div class="spinner-border text-primary" role="status">' +
' <span class="visually-hidden">Loading...</span>' +
' </div>' +
' <p class="mt-2">Searching...</p>' +
'</div>';
fetch('{{ url_for("search_plex") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: query,
original_artist: originalArtist
})
})
.then(async response => {
const contentType = response.headers.get('content-type') || '';
if (!response.ok) {
// If redirected to login/index due to missing session
if (response.redirected || (!contentType.includes('application/json'))) {
throw new Error('Session expired. Please start over.');
}
}
if (!contentType.includes('application/json')) {
throw new Error('Unexpected response.');
}
return response.json();
})
.then(data => {
if (!data.success) {
const msg = data.message || 'Search failed. Please try again.';
resultsContainer.innerHTML =
'<div class="alert alert-danger">' +
' <i class="bi bi-exclamation-triangle-fill me-2"></i>' +
' ' + msg +
'</div>';
return;
}
if (data.results && data.results.length > 0) {
let html = '<div class="list-group list-group-flush">';
data.results.forEach((track, index) => {
const safeTitle = escapeHtml(track.title);
const safeArtist = escapeHtml(track.artist);
const safeAlbum = escapeHtml(track.album || '');
const safeThumb = escapeHtml(track.thumb || '');
const safeDuration = escapeHtml(track.duration || '');
const safeRatingKey = escapeHtml(String(track.ratingKey || ''));
const thumb = safeThumb ?
'<img src="' + safeThumb + '" class="rounded me-3" style="width: 40px; height: 40px; object-fit: cover;">' :
'<div class="bg-light rounded d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;"><i class="bi bi-music-note"></i></div>';
html +=
'<div class="list-group-item list-group-item-action search-result-item" ' +
' data-title="' + safeTitle + '" ' +
' data-artist="' + safeArtist + '" ' +
' data-album="' + safeAlbum + '" ' +
' data-rating-key="' + safeRatingKey + '" ' +
' data-thumb="' + safeThumb + '">' +
' <div class="d-flex align-items-center">' +
' ' + thumb +
' <div class="flex-grow-1">' +
' <div class="d-flex justify-content-between">' +
' <h6 class="mb-1">' + safeTitle + '</h6>' +
' <small class="text-muted">' + safeDuration + '</small>' +
' </div>' +
' <p class="mb-1 small text-muted">' + safeArtist + '</p>' +
' <small class="text-muted">' + safeAlbum + '</small>' +
' </div>' +
' </div>' +
'</div>';
});
html += '</div>';
resultsContainer.innerHTML = html;
} else {
resultsContainer.innerHTML =
'<div class="text-center py-4">' +
' <i class="bi bi-music-note-beamed text-muted" style="font-size: 2rem;"></i>' +
' <p class="mt-2 mb-0">No results found for "' + escapeHtml(query) + '"</p>' +
' <p class="small text-muted">Try a different search term</p>' +
'</div>';
}
})
.catch(error => {
console.error('Error:', error);
const message = (error && error.message) ? error.message : 'An error occurred while searching. Please try again.';
resultsContainer.innerHTML =
'<div class="alert alert-danger">' +
' <i class="bi bi-exclamation-triangle-fill me-2"></i>' +
' ' + message +
'</div>';
if (message.includes('expired')) {
setTimeout(() => { window.location.href = '{{ url_for("index") }}'; }, 1500);
}
});
}
// Function to update match counters and progress
function updateMatchCounters(change) {
const progressBar = document.getElementById('progressBar');
const totalTracks = parseInt('{{ total_tracks|default(0) }}', 10) || 1;
// Update the specific file's missing badge first
if (currentFileId) {
const fileBadge = document.querySelector('.file-missing-badge[data-file-id="' + currentFileId + '"]');
if (fileBadge) {
const cur = parseInt(fileBadge.textContent, 10) || 0;
fileBadge.textContent = Math.max(0, cur - change);
}
}
// Compute global missing as sum of all file badges
let totalMissing = 0;
document.querySelectorAll('.file-missing-badge').forEach(b => {
totalMissing += (parseInt(b.textContent, 10) || 0);
});
// Update progress bar and found count
if (progressBar) {
const currentFound = Math.max(0, totalTracks - totalMissing);
const newPercent = Math.round((currentFound / totalTracks) * 100);
progressBar.style.width = newPercent + '%';
progressBar.setAttribute('aria-valuenow', newPercent);
progressBar.textContent = newPercent + '%';
const foundCountElement = document.querySelector('.lead .badge.bg-success');
if (foundCountElement) {
foundCountElement.textContent = currentFound;
}
}
// Enable create playlist button when no missing remain
if (totalMissing === 0) {
document.getElementById('startSyncBtn').disabled = false;
}
}
// Set progress bar width on page load
document.addEventListener('DOMContentLoaded', function() {
const progressBar = document.getElementById('progressBar');
if (progressBar) {
const progressPercent = '{{ progress_percent }}';
progressBar.style.width = progressPercent + '%';
}
});
// Helper function to escape HTML
function escapeHtml(unsafe) {
if (!unsafe) return '';
return String(unsafe)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
});
</script>
<style>
.search-result-item {
cursor: pointer;
transition: background-color 0.2s;
}
.search-result-item:hover {
background-color: #f8f9fa;
}
.search-result-item.active {
background-color: #e7f1ff;
border-left: 3px solid #0d6efd;
}
.progress {
overflow: visible;
}
.progress-bar {
transition: width 0.6s ease;
position: relative;
}
.progress-bar::after {
content: '';
position: absolute;
top: -5px;
right: -10px;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #198754;
border: 3px solid #fff;
box-shadow: 0 0 0 2px #198754;
display: none;
}
.progress-bar.animate::after {
display: block;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { transform: scale(0.8); opacity: 0.8; }
70% { transform: scale(1.2); opacity: 0.2; }
100% { transform: scale(0.8); opacity: 0.8; }
}
</style>
{% endblock %}
+158
View File
@@ -0,0 +1,158 @@
{% extends "base.html" %}
{% block extra_head %}
<style>
.playlist-card {
transition: transform 0.2s, box-shadow 0.2s;
}
.playlist-card:hover {
transform: translateY(-3px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
}
.playlist-icon {
font-size: 2rem;
margin-bottom: 1rem;
}
.stats-badge {
font-size: 0.9rem;
}
</style>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="card shadow">
<div class="card-body text-center py-5">
<div class="mb-4">
<div class="d-inline-flex align-items-center justify-content-center bg-success bg-opacity-10 rounded-circle p-4 mb-3">
<i class="bi bi-check-circle-fill text-success" style="font-size: 3rem;"></i>
</div>
{% if playlists|length == 1 %}
<h1 class="h3 fw-bold text-success mb-3">Playlist Created Successfully!</h1>
<p class="lead">Your playlist <span class="fw-bold">{{ playlists[0].name }}</span> has been created with <span class="fw-bold">{{ playlists[0].track_count }}</span> tracks.</p>
{% else %}
<h1 class="h3 fw-bold text-success mb-3">{{ playlists|length }} Playlists Created Successfully!</h1>
<p class="lead">All playlists have been created with a total of <span class="fw-bold">{{ playlists|sum(attribute='track_count') }}</span> tracks.</p>
{% endif %}
</div>
<!-- Playlist Cards -->
{% if playlists|length > 1 %}
<div class="row g-4 mt-4">
{% for playlist in playlists %}
<div class="col-md-6 col-lg-4">
<div class="card h-100 playlist-card">
<div class="card-body text-center">
<div class="playlist-icon text-primary">
<i class="bi bi-music-note-list"></i>
</div>
<h5 class="card-title">{{ playlist.name }}</h5>
<p class="text-muted mb-3">
<span class="badge bg-primary stats-badge">
<i class="bi bi-music-note-beamed me-1"></i>{{ playlist.track_count }} track{% if playlist.track_count != 1 %}s{% endif %}
</span>
{% if playlist.source %}
<span class="badge bg-secondary stats-badge ms-1" title="Source file: {{ playlist.source }}">
<i class="bi bi-file-earmark-text me-1"></i>Source
</span>
{% endif %}
</p>
<div class="d-grid gap-2">
<a href="#" class="btn btn-outline-primary btn-sm">
<i class="bi bi-play-circle me-1"></i> Play in Plex
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Action Buttons -->
<div class="d-flex justify-content-center gap-3 mt-5">
<a href="{{ url_for('index') }}" class="btn btn-primary px-4">
<i class="bi bi-house-door me-2"></i>Back to Home
</a>
<a href="{{ url_for('match_tracks') }}" class="btn btn-outline-secondary px-4">
<i class="bi bi-arrow-repeat me-2"></i>Sync Another Playlist
</a>
</div>
<!-- Quick Actions -->
<div class="mt-5 pt-4 border-top">
<h5 class="mb-4">What would you like to do next?</h5>
<div class="row g-3">
<div class="col-md-4">
<a href="#" class="card h-100 text-decoration-none text-dark hover-shadow">
<div class="card-body text-center">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 60px; height: 60px;">
<i class="bi bi-music-note-list fs-4"></i>
</div>
<h6 class="card-title mb-0">View in Plex</h6>
<small class="text-muted">Open your playlists in Plex</small>
</div>
</a>
</div>
<div class="col-md-4">
<a href="#" class="card h-100 text-decoration-none text-dark hover-shadow">
<div class="card-body text-center">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 60px; height: 60px;">
<i class="bi bi-collection-play fs-4"></i>
</div>
<h6 class="card-title mb-0">View All Playlists</h6>
<small class="text-muted">See all your Plex playlists</small>
</div>
</a>
</div>
<div class="col-md-4">
<a href="{{ url_for('index') }}" class="card h-100 text-decoration-none text-dark hover-shadow">
<div class="card-body text-center">
<div class="bg-primary bg-opacity-10 text-primary rounded-circle d-inline-flex align-items-center justify-content-center mb-3" style="width: 60px; height: 60px;">
<i class="bi bi-plus-circle fs-4"></i>
</div>
<h6 class="card-title mb-0">Create Another</h6>
<small class="text-muted">Sync another playlist</small>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Add any client-side interactions if needed
// Example: Add click handler for the "Play in Plex" buttons
document.querySelectorAll('.play-plex-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const playlistName = this.getAttribute('data-playlist-name');
// In a real app, this would open the playlist in Plex
alert(`Opening "${playlistName}" in Plex...`);
});
});
});
</script>
<style>
.hover-shadow {
transition: transform 0.2s, box-shadow 0.2s;
}
.hover-shadow:hover {
transform: translateY(-5px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
</style>
{% endblock %}