feat: implemented unified downloading

This commit is contained in:
2026-03-08 22:39:22 +01:00
parent f4dee850f3
commit 4609112d07
8 changed files with 465 additions and 49 deletions

View File

@@ -145,7 +145,7 @@
.card { padding: 16px; }
body { padding-bottom: 64px; }
textarea, select, input { font-size: 1rem; }
#btn-download, #btn-monochrome { width: 100%; border-radius: var(--radius); }
#btn-download, #btn-monochrome, #btn-unified { width: 100%; border-radius: var(--radius); }
.job-preview { max-width: calc(100vw - 120px); }
#toast { left: 16px; right: 16px; bottom: 72px; max-width: none; }
#bottom-nav { display: flex; }
@@ -154,10 +154,9 @@
</head>
<body>
<div class="header">
<h1 style="cursor:pointer" onclick="showPage('download')"><span>Votify</span> Web</h1>
<h1 style="cursor:pointer" onclick="showPage('unified')"><span>Votify</span> Web</h1>
<div class="tabs">
<button class="tab active" onclick="showPage('download')">Votify</button>
<button class="tab" onclick="showPage('monochrome')">Monochrome</button>
<button class="tab active" onclick="showPage('unified')">Download</button>
<button class="tab" onclick="showPage('jobs')">Jobs</button>
<button class="tab" onclick="showPage('files')">Files</button>
<button class="tab" onclick="showPage('settings')">Settings</button>
@@ -165,9 +164,26 @@
</div>
<div class="container">
<!-- DOWNLOAD PAGE -->
<div id="page-download" class="page active">
<!-- UNIFIED DOWNLOAD PAGE -->
<div id="page-unified" class="page active">
<div class="card">
<h2>Download</h2>
<div class="form-group">
<label for="unified-urls">Spotify URLs (one per line)</label>
<textarea id="unified-urls" placeholder="https://open.spotify.com/track/...&#10;https://open.spotify.com/album/..."></textarea>
</div>
<button class="btn" id="btn-unified" onclick="startUnifiedDownload()">Download</button>
<div style="margin-top: 16px; display: flex; gap: 8px;">
<button class="btn btn-sm btn-secondary" onclick="showPage('download')">Votify (Spotify)</button>
<button class="btn btn-sm btn-secondary" onclick="showPage('monochrome')">Monochrome (Tidal)</button>
</div>
</div>
</div>
<!-- VOTIFY PAGE (hidden, accessible via button) -->
<div id="page-download" class="page">
<div class="card">
<button class="btn btn-sm btn-secondary" onclick="showPage('unified')" style="margin-bottom:12px">&larr; Back</button>
<h2>Votify Download</h2>
<div class="form-group">
<label for="urls">Spotify URLs (one per line)</label>
@@ -239,6 +255,7 @@
<!-- MONOCHROME PAGE -->
<div id="page-monochrome" class="page">
<div class="card">
<button class="btn btn-sm btn-secondary" onclick="showPage('unified')" style="margin-bottom:12px">&larr; Back</button>
<h2>Monochrome Download</h2>
<div class="form-group">
<label for="mono-url">Spotify URL (track, album, or playlist)</label>
@@ -285,6 +302,14 @@
<a href="/logout" class="btn btn-danger btn-sm" style="text-decoration:none">Logout</a>
</div>
{% endif %}
<div class="card">
<h2>Fallback Quality</h2>
<p style="font-size:0.85rem; color:var(--text2); margin-bottom:12px">Quality for Votify fallback when Monochrome can't find a track</p>
<select id="fallback-quality">
<option value="aac-medium">AAC 128kbps</option>
<option value="aac-high">AAC 256kbps (Premium)</option>
</select>
</div>
<div class="card">
<h2>Cookies</h2>
<div class="cookie-status" id="cookie-status">
@@ -313,13 +338,9 @@
</div>
<nav id="bottom-nav">
<button class="bottom-tab active" data-page="download" onclick="showPage('download')">
<button class="bottom-tab active" data-page="unified" onclick="showPage('unified')">
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg></span>
<span>Votify</span>
</button>
<button class="bottom-tab" data-page="monochrome" onclick="showPage('monochrome')">
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5.5" cy="17.5" r="2.5"/><circle cx="17.5" cy="15.5" r="2.5"/><path d="M8 17V5l12-2v12"/></svg></span>
<span>Mono</span>
<span>Download</span>
</button>
<button class="bottom-tab" data-page="jobs" onclick="showPage('jobs')">
<span class="btab-icon"><svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg></span>
@@ -346,13 +367,16 @@
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active');
const matchingTab = document.querySelector(`.tab[onclick="showPage('${name}')"]`);
// For hidden pages (download/monochrome), highlight the "Download" tab
const tabName = (name === 'download' || name === 'monochrome') ? 'unified' : name;
const matchingTab = document.querySelector(`.tab[onclick="showPage('${tabName}')"]`);
if (matchingTab) matchingTab.classList.add('active');
document.querySelectorAll('.bottom-tab').forEach(t => t.classList.toggle('active', t.dataset.page === name));
document.querySelectorAll('.bottom-tab').forEach(t => t.classList.toggle('active', t.dataset.page === tabName));
if (name === 'jobs') loadJobs();
if (name === 'files') loadFiles("");
if (name === 'settings') { checkCookies(); checkWvd(); }
if (name === 'settings') { checkCookies(); checkWvd(); loadFallbackQuality(); }
if (jobPollInterval) clearInterval(jobPollInterval);
if (name === 'jobs') jobPollInterval = setInterval(loadJobs, 3000);
@@ -387,7 +411,7 @@
const result = await res.json();
if (res.ok) {
document.getElementById('urls').value = '';
document.querySelector('[onclick="showPage(\'jobs\')"]').click();
showPage('jobs');
} else {
showToast(result.error || 'Failed to start download', 'error');
}
@@ -425,6 +449,51 @@
btn.disabled = false;
}
async function startUnifiedDownload() {
const btn = document.getElementById('btn-unified');
btn.disabled = true;
const url = document.getElementById('unified-urls').value.trim();
if (!url) { showToast('Enter a Spotify URL', 'error'); btn.disabled = false; return; }
try {
const res = await fetch('/api/unified/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const result = await res.json();
if (res.ok) {
document.getElementById('unified-urls').value = '';
showPage('jobs');
} else {
showToast(result.error || 'Failed to start download', 'error');
}
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
btn.disabled = false;
}
async function loadFallbackQuality() {
try {
const res = await fetch('/api/settings');
const data = await res.json();
document.getElementById('fallback-quality').value = data.fallback_quality || 'aac-medium';
} catch (_) {}
}
document.getElementById('fallback-quality').addEventListener('change', async function() {
try {
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fallback_quality: this.value })
});
showToast('Fallback quality saved');
} catch (e) {
showToast('Error saving setting', 'error');
}
});
function parseProgress(output) {
if (!output || output.length === 0) return null;
let current = 0, total = 0, dlPct = 0;
@@ -724,22 +793,23 @@
if (!lines.length) return;
// Drop onto active page's textarea
const monoActive = document.getElementById('page-monochrome').classList.contains('active');
const ta = document.getElementById(monoActive ? 'mono-url' : 'urls');
const votifyActive = document.getElementById('page-download').classList.contains('active');
let taId = 'unified-urls';
if (monoActive) taId = 'mono-url';
else if (votifyActive) taId = 'urls';
const ta = document.getElementById(taId);
ta.value = (ta.value.trim() ? ta.value.trim() + '\n' : '') + lines.join('\n');
ta.classList.remove('drop-flash');
void ta.offsetWidth;
ta.classList.add('drop-flash');
setTimeout(() => ta.classList.remove('drop-flash'), 650);
if (!monoActive) showPage('download');
});
const urlTextarea = document.getElementById('urls');
urlTextarea.addEventListener('dragover', () => urlTextarea.classList.add('drag-over'));
urlTextarea.addEventListener('dragleave', () => urlTextarea.classList.remove('drag-over'));
urlTextarea.addEventListener('drop', () => urlTextarea.classList.remove('drag-over'));
const monoTextarea = document.getElementById('mono-url');
monoTextarea.addEventListener('dragover', () => monoTextarea.classList.add('drag-over'));
monoTextarea.addEventListener('dragleave', () => monoTextarea.classList.remove('drag-over'));
monoTextarea.addEventListener('drop', () => monoTextarea.classList.remove('drag-over'));
for (const id of ['urls', 'mono-url', 'unified-urls']) {
const ta = document.getElementById(id);
ta.addEventListener('dragover', () => ta.classList.add('drag-over'));
ta.addEventListener('dragleave', () => ta.classList.remove('drag-over'));
ta.addEventListener('drop', () => ta.classList.remove('drag-over'));
}
// Remember settings
const SETTINGS_KEY = 'votify-settings';