added track visibility
This commit is contained in:
5
app.py
5
app.py
@@ -64,6 +64,11 @@ def create_app():
|
||||
# The id parameter is captured, but you can use it as needed.
|
||||
return render_template('album.html')
|
||||
|
||||
@app.route('/track/<id>')
|
||||
def serve_track(id):
|
||||
# The id parameter is captured, but you can use it as needed.
|
||||
return render_template('track.html')
|
||||
|
||||
@app.route('/static/<path:path>')
|
||||
def serve_static(path):
|
||||
return send_from_directory('static', path)
|
||||
|
||||
130
static/css/track/track.css
Normal file
130
static/css/track/track.css
Normal file
@@ -0,0 +1,130 @@
|
||||
/* Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
min-height: 100vh;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Track Header */
|
||||
#track-header {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 2rem;
|
||||
align-items: center;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#track-album-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#track-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#track-name {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#track-artist,
|
||||
#track-album,
|
||||
#track-duration,
|
||||
#track-explicit {
|
||||
font-size: 1rem;
|
||||
color: #b3b3b3;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
#loading,
|
||||
#error {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
#error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* Back Button Styling */
|
||||
.back-btn {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.back-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Download Button Base Style (same as in the playlist project) */
|
||||
.download-btn {
|
||||
background-color: #1db954;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background-color: #17a44b;
|
||||
}
|
||||
|
||||
.download-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Queue Toggle Button (if used elsewhere) */
|
||||
.queue-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1,315 +1,342 @@
|
||||
// queue.js
|
||||
class DownloadQueue {
|
||||
constructor() {
|
||||
this.downloadQueue = {};
|
||||
this.prgInterval = null;
|
||||
this.initDOM();
|
||||
this.initEventListeners();
|
||||
this.loadExistingPrgFiles();
|
||||
}
|
||||
|
||||
/* DOM Management */
|
||||
initDOM() {
|
||||
const queueHTML = `
|
||||
<div id="downloadQueue" class="sidebar right" hidden>
|
||||
<div class="sidebar-header">
|
||||
<h2>Download Queue</h2>
|
||||
<button class="close-btn" aria-label="Close queue">×</button>
|
||||
</div>
|
||||
<div id="queueItems" aria-live="polite"></div>
|
||||
constructor() {
|
||||
this.downloadQueue = {};
|
||||
this.prgInterval = null;
|
||||
this.initDOM();
|
||||
this.initEventListeners();
|
||||
this.loadExistingPrgFiles();
|
||||
}
|
||||
|
||||
/* DOM Management */
|
||||
initDOM() {
|
||||
const queueHTML = `
|
||||
<div id="downloadQueue" class="sidebar right" hidden>
|
||||
<div class="sidebar-header">
|
||||
<h2>Download Queue</h2>
|
||||
<button class="close-btn" aria-label="Close queue">×</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.insertAdjacentHTML('beforeend', queueHTML);
|
||||
}
|
||||
|
||||
/* Event Handling */
|
||||
initEventListeners() {
|
||||
// Escape key handler
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const queueSidebar = document.getElementById('downloadQueue');
|
||||
if (e.key === 'Escape' && queueSidebar.classList.contains('active')) {
|
||||
this.toggleVisibility();
|
||||
}
|
||||
});
|
||||
|
||||
// Close button handler
|
||||
document.getElementById('downloadQueue').addEventListener('click', (e) => {
|
||||
if (e.target.closest('.close-btn')) {
|
||||
this.toggleVisibility();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* Public API */
|
||||
toggleVisibility() {
|
||||
<div id="queueItems" aria-live="polite"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.insertAdjacentHTML('beforeend', queueHTML);
|
||||
}
|
||||
|
||||
/* Event Handling */
|
||||
initEventListeners() {
|
||||
// Escape key handler
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const queueSidebar = document.getElementById('downloadQueue');
|
||||
queueSidebar.classList.toggle('active');
|
||||
queueSidebar.hidden = !queueSidebar.classList.contains('active');
|
||||
this.dispatchEvent('queueVisibilityChanged', { visible: queueSidebar.classList.contains('active') });
|
||||
}
|
||||
|
||||
addDownload(item, type, prgFile) {
|
||||
const queueId = this.generateQueueId();
|
||||
const entry = this.createQueueEntry(item, type, prgFile, queueId);
|
||||
|
||||
this.downloadQueue[queueId] = entry;
|
||||
document.getElementById('queueItems').appendChild(entry.element);
|
||||
this.startEntryMonitoring(queueId);
|
||||
this.dispatchEvent('downloadAdded', { queueId, item, type });
|
||||
}
|
||||
|
||||
/* Core Functionality */
|
||||
async startEntryMonitoring(queueId) {
|
||||
const entry = this.downloadQueue[queueId];
|
||||
if (!entry || entry.hasEnded) return;
|
||||
|
||||
entry.intervalId = setInterval(async () => {
|
||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`);
|
||||
if (entry.hasEnded) {
|
||||
clearInterval(entry.intervalId);
|
||||
if (e.key === 'Escape' && queueSidebar.classList.contains('active')) {
|
||||
this.toggleVisibility();
|
||||
}
|
||||
});
|
||||
|
||||
// Close button handler
|
||||
document.getElementById('downloadQueue').addEventListener('click', (e) => {
|
||||
if (e.target.closest('.close-btn')) {
|
||||
this.toggleVisibility();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* Public API */
|
||||
toggleVisibility() {
|
||||
const queueSidebar = document.getElementById('downloadQueue');
|
||||
queueSidebar.classList.toggle('active');
|
||||
queueSidebar.hidden = !queueSidebar.classList.contains('active');
|
||||
this.dispatchEvent('queueVisibilityChanged', { visible: queueSidebar.classList.contains('active') });
|
||||
}
|
||||
|
||||
addDownload(item, type, prgFile) {
|
||||
const queueId = this.generateQueueId();
|
||||
const entry = this.createQueueEntry(item, type, prgFile, queueId);
|
||||
|
||||
this.downloadQueue[queueId] = entry;
|
||||
document.getElementById('queueItems').appendChild(entry.element);
|
||||
this.startEntryMonitoring(queueId);
|
||||
this.dispatchEvent('downloadAdded', { queueId, item, type });
|
||||
}
|
||||
|
||||
/* Core Functionality */
|
||||
async startEntryMonitoring(queueId) {
|
||||
const entry = this.downloadQueue[queueId];
|
||||
if (!entry || entry.hasEnded) return;
|
||||
|
||||
entry.intervalId = setInterval(async () => {
|
||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`);
|
||||
if (entry.hasEnded) {
|
||||
clearInterval(entry.intervalId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/prgs/${entry.prgFile}`);
|
||||
const data = await response.json();
|
||||
const progress = data.last_line;
|
||||
|
||||
if (!progress) {
|
||||
this.handleInactivity(entry, queueId, logElement);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/prgs/${entry.prgFile}`);
|
||||
const data = await response.json();
|
||||
const progress = data.last_line;
|
||||
|
||||
if (!progress) {
|
||||
this.handleInactivity(entry, queueId, logElement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (JSON.stringify(entry.lastStatus) === JSON.stringify(progress)) {
|
||||
this.handleInactivity(entry, queueId, logElement);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.lastStatus = progress;
|
||||
entry.lastUpdated = Date.now();
|
||||
entry.status = progress.status;
|
||||
logElement.textContent = this.getStatusMessage(progress);
|
||||
|
||||
if (['error', 'complete', 'cancel'].includes(progress.status)) {
|
||||
this.handleTerminalState(entry, queueId, progress);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Status check failed:', error);
|
||||
this.handleTerminalState(entry, queueId, {
|
||||
status: 'error',
|
||||
message: 'Status check error'
|
||||
});
|
||||
|
||||
if (JSON.stringify(entry.lastStatus) === JSON.stringify(progress)) {
|
||||
this.handleInactivity(entry, queueId, logElement);
|
||||
return;
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/* Helper Methods */
|
||||
generateQueueId() {
|
||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
createQueueEntry(item, type, prgFile, queueId) {
|
||||
return {
|
||||
item,
|
||||
type,
|
||||
prgFile,
|
||||
element: this.createQueueItem(item, type, prgFile, queueId),
|
||||
lastStatus: null,
|
||||
lastUpdated: Date.now(),
|
||||
hasEnded: false,
|
||||
intervalId: null,
|
||||
uniqueId: queueId
|
||||
};
|
||||
}
|
||||
|
||||
createQueueItem(item, type, prgFile, queueId) {
|
||||
const div = document.createElement('article');
|
||||
div.className = 'queue-item';
|
||||
div.setAttribute('aria-live', 'polite');
|
||||
div.setAttribute('aria-atomic', 'true');
|
||||
div.innerHTML = `
|
||||
<div class="title">${item.name}</div>
|
||||
<div class="type">${type.charAt(0).toUpperCase() + type.slice(1)}</div>
|
||||
<div class="log" id="log-${queueId}-${prgFile}">Initializing download...</div>
|
||||
<button class="cancel-btn" data-prg="${prgFile}" data-type="${type}" data-queueid="${queueId}" title="Cancel Download">
|
||||
<img src="https://www.svgrepo.com/show/488384/skull-head.svg" alt="Cancel Download">
|
||||
</button>
|
||||
`;
|
||||
|
||||
div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e));
|
||||
return div;
|
||||
}
|
||||
|
||||
async handleCancelDownload(e) {
|
||||
const btn = e.target.closest('button');
|
||||
btn.style.display = 'none';
|
||||
|
||||
const { prg, type, queueid } = btn.dataset;
|
||||
try {
|
||||
const response = await fetch(`/api/${type}/download/cancel?prg_file=${encodeURIComponent(prg)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "cancel") {
|
||||
const logElement = document.getElementById(`log-${queueid}-${prg}`);
|
||||
logElement.textContent = "Download cancelled";
|
||||
const entry = this.downloadQueue[queueid];
|
||||
if (entry) {
|
||||
entry.hasEnded = true;
|
||||
clearInterval(entry.intervalId);
|
||||
}
|
||||
setTimeout(() => this.cleanupEntry(queueid), 5000);
|
||||
|
||||
entry.lastStatus = progress;
|
||||
entry.lastUpdated = Date.now();
|
||||
entry.status = progress.status;
|
||||
logElement.textContent = this.getStatusMessage(progress);
|
||||
|
||||
if (['error', 'complete', 'cancel'].includes(progress.status)) {
|
||||
this.handleTerminalState(entry, queueId, progress);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Cancel error:', error);
|
||||
console.error('Status check failed:', error);
|
||||
this.handleTerminalState(entry, queueId, {
|
||||
status: 'error',
|
||||
message: 'Status check error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* State Management */
|
||||
async loadExistingPrgFiles() {
|
||||
try {
|
||||
const response = await fetch('/api/prgs/list');
|
||||
const prgFiles = await response.json();
|
||||
|
||||
for (const prgFile of prgFiles) {
|
||||
const prgResponse = await fetch(`/api/prgs/${prgFile}`);
|
||||
const prgData = await prgResponse.json();
|
||||
const dummyItem = { name: prgData.name || prgFile, external_urls: {} };
|
||||
this.addDownload(dummyItem, prgData.type || "unknown", prgFile);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/* Helper Methods */
|
||||
generateQueueId() {
|
||||
return Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
createQueueEntry(item, type, prgFile, queueId) {
|
||||
return {
|
||||
item,
|
||||
type,
|
||||
prgFile,
|
||||
element: this.createQueueItem(item, type, prgFile, queueId),
|
||||
lastStatus: null,
|
||||
lastUpdated: Date.now(),
|
||||
hasEnded: false,
|
||||
intervalId: null,
|
||||
uniqueId: queueId
|
||||
};
|
||||
}
|
||||
|
||||
createQueueItem(item, type, prgFile, queueId) {
|
||||
const div = document.createElement('article');
|
||||
div.className = 'queue-item';
|
||||
div.setAttribute('aria-live', 'polite');
|
||||
div.setAttribute('aria-atomic', 'true');
|
||||
div.innerHTML = `
|
||||
<div class="title">${item.name}</div>
|
||||
<div class="type">${type.charAt(0).toUpperCase() + type.slice(1)}</div>
|
||||
<div class="log" id="log-${queueId}-${prgFile}">Initializing download...</div>
|
||||
<button class="cancel-btn" data-prg="${prgFile}" data-type="${type}" data-queueid="${queueId}" title="Cancel Download">
|
||||
<img src="https://www.svgrepo.com/show/488384/skull-head.svg" alt="Cancel Download">
|
||||
</button>
|
||||
`;
|
||||
|
||||
div.querySelector('.cancel-btn').addEventListener('click', (e) => this.handleCancelDownload(e));
|
||||
return div;
|
||||
}
|
||||
|
||||
async handleCancelDownload(e) {
|
||||
const btn = e.target.closest('button');
|
||||
btn.style.display = 'none';
|
||||
|
||||
const { prg, type, queueid } = btn.dataset;
|
||||
try {
|
||||
const response = await fetch(`/api/${type}/download/cancel?prg_file=${encodeURIComponent(prg)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "cancel") {
|
||||
const logElement = document.getElementById(`log-${queueid}-${prg}`);
|
||||
logElement.textContent = "Download cancelled";
|
||||
const entry = this.downloadQueue[queueid];
|
||||
if (entry) {
|
||||
entry.hasEnded = true;
|
||||
clearInterval(entry.intervalId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading existing PRG files:', error);
|
||||
setTimeout(() => this.cleanupEntry(queueid), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
cleanupEntry(queueId) {
|
||||
const entry = this.downloadQueue[queueId];
|
||||
if (entry) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.element.remove();
|
||||
delete this.downloadQueue[queueId];
|
||||
fetch(`/api/prgs/delete/${encodeURIComponent(entry.prgFile)}`, { method: 'DELETE' })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
/* Event Dispatching */
|
||||
dispatchEvent(name, detail) {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail }));
|
||||
}
|
||||
|
||||
/* Status Message Handling */
|
||||
getStatusMessage(data) {
|
||||
// Helper function to format an array into a human-readable list without a comma before "and".
|
||||
function formatList(items) {
|
||||
if (!items || items.length === 0) return '';
|
||||
if (items.length === 1) return items[0];
|
||||
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
||||
// For three or more items: join all but the last with commas, then " and " the last item.
|
||||
return items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1];
|
||||
}
|
||||
|
||||
// Helper function for a simple pluralization:
|
||||
function pluralize(word) {
|
||||
// If the word already ends with an "s", assume it's plural.
|
||||
return word.endsWith('s') ? word : word + 's';
|
||||
}
|
||||
|
||||
switch (data.status) {
|
||||
case 'downloading':
|
||||
// For track downloads only.
|
||||
if (data.type === 'track') {
|
||||
return `Downloading track "${data.song}" by ${data.artist}...`;
|
||||
}
|
||||
return `Downloading ${data.type}...`;
|
||||
|
||||
case 'initializing':
|
||||
if (data.type === 'playlist') {
|
||||
return `Initializing playlist download "${data.name}" with ${data.total_tracks} tracks...`;
|
||||
} else if (data.type === 'album') {
|
||||
return `Initializing album download "${data.album}" by ${data.artist}...`;
|
||||
} else if (data.type === 'artist') {
|
||||
let subsets = [];
|
||||
// Prefer an explicit subsets array if available.
|
||||
if (data.subsets && Array.isArray(data.subsets) && data.subsets.length > 0) {
|
||||
subsets = data.subsets;
|
||||
}
|
||||
// Otherwise, if album_type is provided, split it into an array.
|
||||
else if (data.album_type) {
|
||||
subsets = data.album_type
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.map(item => pluralize(item));
|
||||
}
|
||||
if (subsets.length > 0) {
|
||||
const subsetsMessage = formatList(subsets);
|
||||
return `Initializing download for ${data.artist}'s ${subsetsMessage}`;
|
||||
}
|
||||
// Fallback message if neither subsets nor album_type are provided.
|
||||
return `Initializing download for ${data.artist} with ${data.total_albums} album(s) [${data.album_type}]...`;
|
||||
}
|
||||
return `Initializing ${data.type} download...`;
|
||||
|
||||
case 'progress':
|
||||
// Expect progress messages for playlists, albums (or artist’s albums) to include a "track" and "current_track".
|
||||
if (data.track && data.current_track) {
|
||||
// current_track is a string in the format "current/total"
|
||||
const parts = data.current_track.split('/');
|
||||
const current = parts[0];
|
||||
const total = parts[1] || '?';
|
||||
|
||||
if (data.type === 'playlist') {
|
||||
return `Downloading playlist: Track ${current} of ${total} - ${data.track}`;
|
||||
} else if (data.type === 'album') {
|
||||
// For album progress, the "album" and "artist" fields may be available on a done message.
|
||||
// In some cases (like artist downloads) only track info is passed.
|
||||
if (data.album && data.artist) {
|
||||
return `Downloading album "${data.album}" by ${data.artist}: track ${current} of ${total} - ${data.track}`;
|
||||
} else {
|
||||
return `Downloading track ${current} of ${total}: ${data.track} from ${data.album}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback if fields are missing:
|
||||
return `Progress: ${data.status}...`;
|
||||
|
||||
case 'done':
|
||||
if (data.type === 'track') {
|
||||
return `Finished track "${data.song}" by ${data.artist}`;
|
||||
} else if (data.type === 'playlist') {
|
||||
return `Finished playlist "${data.name}" with ${data.total_tracks} tracks`;
|
||||
} else if (data.type === 'album') {
|
||||
return `Finished album "${data.album}" by ${data.artist}`;
|
||||
} else if (data.type === 'artist') {
|
||||
return `Finished artist "${data.artist}" (${data.album_type})`;
|
||||
}
|
||||
return `Finished ${data.type}`;
|
||||
|
||||
case 'retrying':
|
||||
return `Track "${data.song}" by ${data.artist}" failed, retrying (${data.retry_count}/10) in ${data.seconds_left}s`;
|
||||
|
||||
case 'error':
|
||||
return `Error: ${data.message || 'Unknown error'}`;
|
||||
|
||||
case 'complete':
|
||||
return 'Download completed successfully';
|
||||
|
||||
case 'skipped':
|
||||
return `Track "${data.song}" skipped, it already exists!`;
|
||||
|
||||
case 'real_time': {
|
||||
// Convert milliseconds to minutes and seconds.
|
||||
const totalMs = data.time_elapsed;
|
||||
const minutes = Math.floor(totalMs / 60000);
|
||||
const seconds = Math.floor((totalMs % 60000) / 1000);
|
||||
const paddedSeconds = seconds < 10 ? '0' + seconds : seconds;
|
||||
return `Real-time downloading track "${data.song}" by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
default:
|
||||
return data.status;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Cancel error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const downloadQueue = new DownloadQueue();
|
||||
|
||||
/* State Management */
|
||||
async loadExistingPrgFiles() {
|
||||
try {
|
||||
const response = await fetch('/api/prgs/list');
|
||||
const prgFiles = await response.json();
|
||||
|
||||
for (const prgFile of prgFiles) {
|
||||
const prgResponse = await fetch(`/api/prgs/${prgFile}`);
|
||||
const prgData = await prgResponse.json();
|
||||
const dummyItem = { name: prgData.name || prgFile, external_urls: {} };
|
||||
this.addDownload(dummyItem, prgData.type || "unknown", prgFile);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading existing PRG files:', error);
|
||||
}
|
||||
}
|
||||
|
||||
cleanupEntry(queueId) {
|
||||
const entry = this.downloadQueue[queueId];
|
||||
if (entry) {
|
||||
clearInterval(entry.intervalId);
|
||||
entry.element.remove();
|
||||
delete this.downloadQueue[queueId];
|
||||
fetch(`/api/prgs/delete/${encodeURIComponent(entry.prgFile)}`, { method: 'DELETE' })
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
/* Event Dispatching */
|
||||
dispatchEvent(name, detail) {
|
||||
document.dispatchEvent(new CustomEvent(name, { detail }));
|
||||
}
|
||||
|
||||
/* Status Message Handling */
|
||||
getStatusMessage(data) {
|
||||
// Helper function to format an array into a human-readable list without a comma before "and".
|
||||
function formatList(items) {
|
||||
if (!items || items.length === 0) return '';
|
||||
if (items.length === 1) return items[0];
|
||||
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
||||
// For three or more items: join all but the last with commas, then " and " the last item.
|
||||
return items.slice(0, -1).join(', ') + ' and ' + items[items.length - 1];
|
||||
}
|
||||
|
||||
// Helper function for a simple pluralization:
|
||||
function pluralize(word) {
|
||||
// If the word already ends with an "s", assume it's plural.
|
||||
return word.endsWith('s') ? word : word + 's';
|
||||
}
|
||||
|
||||
switch (data.status) {
|
||||
case 'downloading':
|
||||
// For track downloads only.
|
||||
if (data.type === 'track') {
|
||||
return `Downloading track "${data.song}" by ${data.artist}...`;
|
||||
}
|
||||
return `Downloading ${data.type}...`;
|
||||
|
||||
case 'initializing':
|
||||
if (data.type === 'playlist') {
|
||||
return `Initializing playlist download "${data.name}" with ${data.total_tracks} tracks...`;
|
||||
} else if (data.type === 'album') {
|
||||
return `Initializing album download "${data.album}" by ${data.artist}...`;
|
||||
} else if (data.type === 'artist') {
|
||||
let subsets = [];
|
||||
// Prefer an explicit subsets array if available.
|
||||
if (data.subsets && Array.isArray(data.subsets) && data.subsets.length > 0) {
|
||||
subsets = data.subsets;
|
||||
}
|
||||
// Otherwise, if album_type is provided, split it into an array.
|
||||
else if (data.album_type) {
|
||||
subsets = data.album_type
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.map(item => pluralize(item));
|
||||
}
|
||||
if (subsets.length > 0) {
|
||||
const subsetsMessage = formatList(subsets);
|
||||
return `Initializing download for ${data.artist}'s ${subsetsMessage}`;
|
||||
}
|
||||
// Fallback message if neither subsets nor album_type are provided.
|
||||
return `Initializing download for ${data.artist} with ${data.total_albums} album(s) [${data.album_type}]...`;
|
||||
}
|
||||
return `Initializing ${data.type} download...`;
|
||||
|
||||
case 'progress':
|
||||
// Expect progress messages for playlists, albums (or artist’s albums) to include a "track" and "current_track".
|
||||
if (data.track && data.current_track) {
|
||||
// current_track is a string in the format "current/total"
|
||||
const parts = data.current_track.split('/');
|
||||
const current = parts[0];
|
||||
const total = parts[1] || '?';
|
||||
|
||||
if (data.type === 'playlist') {
|
||||
return `Downloading playlist: Track ${current} of ${total} - ${data.track}`;
|
||||
} else if (data.type === 'album') {
|
||||
// For album progress, the "album" and "artist" fields may be available on a done message.
|
||||
// In some cases (like artist downloads) only track info is passed.
|
||||
if (data.album && data.artist) {
|
||||
return `Downloading album "${data.album}" by ${data.artist}: track ${current} of ${total} - ${data.track}`;
|
||||
} else {
|
||||
return `Downloading track ${current} of ${total}: ${data.track} from ${data.album}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback if fields are missing:
|
||||
return `Progress: ${data.status}...`;
|
||||
|
||||
case 'done':
|
||||
if (data.type === 'track') {
|
||||
return `Finished track "${data.song}" by ${data.artist}`;
|
||||
} else if (data.type === 'playlist') {
|
||||
return `Finished playlist "${data.name}" with ${data.total_tracks} tracks`;
|
||||
} else if (data.type === 'album') {
|
||||
return `Finished album "${data.album}" by ${data.artist}`;
|
||||
} else if (data.type === 'artist') {
|
||||
return `Finished artist "${data.artist}" (${data.album_type})`;
|
||||
}
|
||||
return `Finished ${data.type}`;
|
||||
|
||||
case 'retrying':
|
||||
return `Track "${data.song}" by ${data.artist}" failed, retrying (${data.retry_count}/10) in ${data.seconds_left}s`;
|
||||
|
||||
case 'error':
|
||||
return `Error: ${data.message || 'Unknown error'}`;
|
||||
|
||||
case 'complete':
|
||||
return 'Download completed successfully';
|
||||
|
||||
case 'skipped':
|
||||
return `Track "${data.song}" skipped, it already exists!`;
|
||||
|
||||
case 'real_time': {
|
||||
// Convert milliseconds to minutes and seconds.
|
||||
const totalMs = data.time_elapsed;
|
||||
const minutes = Math.floor(totalMs / 60000);
|
||||
const seconds = Math.floor((totalMs % 60000) / 1000);
|
||||
const paddedSeconds = seconds < 10 ? '0' + seconds : seconds;
|
||||
return `Real-time downloading track "${data.song}" by ${data.artist} (${(data.percentage * 100).toFixed(1)}%). Time elapsed: ${minutes}:${paddedSeconds}`;
|
||||
}
|
||||
|
||||
default:
|
||||
return data.status;
|
||||
}
|
||||
}
|
||||
|
||||
/* New Methods to Handle Terminal State and Inactivity */
|
||||
|
||||
handleTerminalState(entry, queueId, progress) {
|
||||
// Mark the entry as ended and clear its monitoring interval
|
||||
entry.hasEnded = true;
|
||||
clearInterval(entry.intervalId);
|
||||
const logElement = document.getElementById(`log-${entry.uniqueId}-${entry.prgFile}`);
|
||||
if (logElement) {
|
||||
logElement.textContent = this.getStatusMessage(progress);
|
||||
}
|
||||
// Optionally, perform cleanup after a delay
|
||||
setTimeout(() => this.cleanupEntry(queueId), 5000);
|
||||
}
|
||||
|
||||
handleInactivity(entry, queueId, logElement) {
|
||||
// Check if a significant time has elapsed since the last update (e.g., 10 seconds)
|
||||
const now = Date.now();
|
||||
if (now - entry.lastUpdated > 10000) {
|
||||
const progress = { status: 'error', message: 'Inactivity timeout' };
|
||||
this.handleTerminalState(entry, queueId, progress);
|
||||
} else {
|
||||
if (logElement) {
|
||||
logElement.textContent = 'Waiting for progress update...';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const downloadQueue = new DownloadQueue();
|
||||
|
||||
171
static/js/track.js
Normal file
171
static/js/track.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// Import the downloadQueue singleton from your working queue.js implementation.
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Parse track ID from URL. Expecting URL in the form /track/{id}
|
||||
const pathSegments = window.location.pathname.split('/');
|
||||
const trackId = pathSegments[pathSegments.indexOf('track') + 1];
|
||||
|
||||
if (!trackId) {
|
||||
showError('No track ID provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch track info and render it
|
||||
fetch(`/api/track/info?id=${encodeURIComponent(trackId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => renderTrack(data))
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('Failed to load track.');
|
||||
});
|
||||
|
||||
// Attach event listener to the queue icon to toggle download queue
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders the track header information.
|
||||
* The API response structure is assumed to be similar to:
|
||||
* {
|
||||
* "album": { ... },
|
||||
* "artists": [ ... ],
|
||||
* "duration_ms": 149693,
|
||||
* "explicit": false,
|
||||
* "external_urls": { "spotify": "https://open.spotify.com/track/..." },
|
||||
* "name": "Track Name",
|
||||
* ... other track info
|
||||
* }
|
||||
*/
|
||||
function renderTrack(track) {
|
||||
// Hide loading and error messages
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
document.getElementById('error').classList.add('hidden');
|
||||
|
||||
// Update header info
|
||||
document.getElementById('track-name').textContent = track.name;
|
||||
// Display the first artist’s name (or join multiple if needed)
|
||||
document.getElementById('track-artist').textContent = `By ${track.artists.map(a => a.name).join(', ')}`;
|
||||
// Display album name and type
|
||||
document.getElementById('track-album').textContent = `Album: ${track.album.name} (${track.album.album_type})`;
|
||||
// Display track duration converted from milliseconds
|
||||
document.getElementById('track-duration').textContent = `Duration: ${msToTime(track.duration_ms)}`;
|
||||
// Show if the track is explicit
|
||||
document.getElementById('track-explicit').textContent = track.explicit ? 'Explicit' : 'Clean';
|
||||
|
||||
// Use the album cover image if available; otherwise, fall back to a placeholder
|
||||
const imageUrl = track.album.images && track.album.images[0] ? track.album.images[0].url : 'placeholder.jpg';
|
||||
document.getElementById('track-album-image').src = imageUrl;
|
||||
|
||||
// --- Add Back Button (if not already added) ---
|
||||
let backButton = document.getElementById('backButton');
|
||||
if (!backButton) {
|
||||
backButton = document.createElement('button');
|
||||
backButton.id = 'backButton';
|
||||
backButton.textContent = 'Back';
|
||||
backButton.className = 'back-btn';
|
||||
// Insert the back button at the beginning of the header container.
|
||||
const headerContainer = document.getElementById('track-header');
|
||||
headerContainer.insertBefore(backButton, headerContainer.firstChild);
|
||||
}
|
||||
backButton.addEventListener('click', () => {
|
||||
// Navigate to the site's base URL.
|
||||
window.location.href = window.location.origin;
|
||||
});
|
||||
|
||||
// --- Attach Download Button Listener ---
|
||||
const downloadBtn = document.getElementById('downloadTrackBtn');
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
// Disable the button to prevent repeated clicks.
|
||||
downloadBtn.disabled = true;
|
||||
downloadBtn.textContent = 'Queueing...';
|
||||
|
||||
// Start the download for the track.
|
||||
startDownload(track.external_urls.spotify, 'track', { name: track.name })
|
||||
.then(() => {
|
||||
downloadBtn.textContent = 'Queued!';
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to queue track download: ' + err.message);
|
||||
downloadBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reveal the header and actions container
|
||||
document.getElementById('track-header').classList.remove('hidden');
|
||||
document.getElementById('actions').classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts milliseconds to minutes:seconds.
|
||||
*/
|
||||
function msToTime(duration) {
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = Math.floor((duration % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an error message in the UI.
|
||||
*/
|
||||
function showError(message) {
|
||||
const errorEl = document.getElementById('error');
|
||||
errorEl.textContent = message;
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the download process by building the API URL,
|
||||
* fetching download details, and then adding the download to the queue.
|
||||
*/
|
||||
async function startDownload(url, type, item) {
|
||||
// Retrieve configuration (if any) from localStorage
|
||||
const config = JSON.parse(localStorage.getItem('activeConfig')) || {};
|
||||
const {
|
||||
fallback = false,
|
||||
spotify = '',
|
||||
deezer = '',
|
||||
spotifyQuality = 'NORMAL',
|
||||
deezerQuality = 'MP3_128',
|
||||
realTime = false
|
||||
} = config;
|
||||
|
||||
const service = url.includes('open.spotify.com') ? 'spotify' : 'deezer';
|
||||
let apiUrl = '';
|
||||
|
||||
// For a track, we use the default track download endpoint.
|
||||
apiUrl = `/api/${type}/download?service=${service}&url=${encodeURIComponent(url)}`;
|
||||
|
||||
// Append account and quality details.
|
||||
if (fallback && service === 'spotify') {
|
||||
apiUrl += `&main=${deezer}&fallback=${spotify}`;
|
||||
apiUrl += `&quality=${deezerQuality}&fall_quality=${spotifyQuality}`;
|
||||
} else {
|
||||
const mainAccount = service === 'spotify' ? spotify : deezer;
|
||||
apiUrl += `&main=${mainAccount}&quality=${service === 'spotify' ? spotifyQuality : deezerQuality}`;
|
||||
}
|
||||
|
||||
if (realTime) {
|
||||
apiUrl += '&real_time=true';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl);
|
||||
const data = await response.json();
|
||||
// Add the download to the queue using the working queue implementation.
|
||||
downloadQueue.addDownload(item, type, data.prg_file);
|
||||
} catch (error) {
|
||||
showError('Download failed: ' + error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
44
templates/track.html
Normal file
44
templates/track.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Track Viewer</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/queue/queue.css') }}">
|
||||
<!-- Optionally include the icons CSS if not already merged into your track.css -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main/icons.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/track/track.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="track-header" class="hidden">
|
||||
<!-- Back Button will be inserted here via JavaScript -->
|
||||
<img id="track-album-image" alt="Album cover">
|
||||
<div id="track-info">
|
||||
<h1 id="track-name"></h1>
|
||||
<p id="track-artist"></p>
|
||||
<p id="track-album"></p>
|
||||
<p id="track-duration"></p>
|
||||
<p id="track-explicit"></p>
|
||||
</div>
|
||||
<!-- Queue Icon Button -->
|
||||
<button id="queueIcon" class="queue-icon" aria-label="Download queue" aria-controls="downloadQueue" aria-expanded="false">
|
||||
<img src="{{ url_for('static', filename='images/queue.svg') }}" alt="Queue Icon">
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="actions" class="hidden">
|
||||
<!-- Download Button for this track -->
|
||||
<button id="downloadTrackBtn" class="download-btn download-btn--main">
|
||||
Download Track
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="loading">Loading...</div>
|
||||
<div id="error" class="hidden">Error loading track</div>
|
||||
</div>
|
||||
|
||||
<!-- The download queue container will be inserted by queue.js -->
|
||||
<script type="module" src="{{ url_for('static', filename='js/track.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user