858 lines
45 KiB
HTML
858 lines
45 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Trackpull</title>
|
|
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
|
|
<link rel="manifest" href="/static/manifest.json">
|
|
<meta name="theme-color" content="#1db954">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="apple-mobile-web-app-title" content="Trackpull">
|
|
<link rel="apple-touch-icon" href="/static/icons/icon-192x192.png">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
:root {
|
|
--bg: #121212; --surface: #1e1e1e; --surface2: #2a2a2a;
|
|
--accent: #1db954; --accent-hover: #1ed760;
|
|
--text: #ffffff; --text2: #b3b3b3;
|
|
--danger: #e74c3c; --warning: #f39c12;
|
|
--radius: 8px;
|
|
}
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
|
|
.header { background: var(--surface); padding: 16px 24px; display: flex; align-items: center; gap: 16px; border-bottom: 1px solid var(--surface2); }
|
|
.header h1 { font-size: 1.4rem; font-weight: 700; }
|
|
.header h1 span { color: var(--accent); }
|
|
|
|
.tabs { display: flex; gap: 4px; margin-left: auto; }
|
|
.tab { background: none; border: none; color: var(--text2); padding: 8px 16px; cursor: pointer; border-radius: var(--radius); font-size: 0.9rem; transition: all 0.2s; }
|
|
.tab:hover { color: var(--text); background: var(--surface2); }
|
|
.tab.active { color: var(--accent); background: var(--surface2); }
|
|
|
|
.container { max-width: 960px; margin: 0 auto; padding: 24px; }
|
|
.page { display: none; }
|
|
.page.active { display: block; }
|
|
|
|
.card { background: var(--surface); border-radius: var(--radius); padding: 20px; margin-bottom: 16px; }
|
|
.card h2 { font-size: 1.1rem; margin-bottom: 16px; color: var(--text2); }
|
|
|
|
label { display: block; font-size: 0.85rem; color: var(--text2); margin-bottom: 6px; }
|
|
textarea, select, input[type="number"] {
|
|
width: 100%; background: var(--surface2); border: 1px solid #333; color: var(--text);
|
|
border-radius: var(--radius); padding: 10px 12px; font-size: 0.9rem; font-family: inherit;
|
|
transition: border-color 0.2s;
|
|
}
|
|
textarea:focus, select:focus, input:focus { outline: none; border-color: var(--accent); }
|
|
textarea { resize: vertical; min-height: 80px; }
|
|
|
|
.form-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 12px; }
|
|
.form-group { margin-bottom: 12px; }
|
|
|
|
.checkbox-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 12px; }
|
|
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 0.9rem; color: var(--text2); }
|
|
.checkbox-label input { accent-color: var(--accent); width: 16px; height: 16px; }
|
|
|
|
.btn { background: var(--accent); color: #000; border: none; padding: 10px 24px; border-radius: 20px; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s; }
|
|
.btn:hover { background: var(--accent-hover); transform: scale(1.02); }
|
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
|
.btn-sm { padding: 6px 14px; font-size: 0.8rem; }
|
|
.btn-danger { background: var(--danger); color: #fff; }
|
|
.btn-secondary { background: var(--surface2); color: var(--text); }
|
|
|
|
.status-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
|
|
.status-queued { background: #333; color: var(--text2); }
|
|
.status-running { background: #1a3a2a; color: var(--accent); }
|
|
.status-completed { background: #1a3a1a; color: #4caf50; }
|
|
.status-failed { background: #3a1a1a; color: var(--danger); }
|
|
.status-cancelled { background: #3a3a1a; color: var(--warning); }
|
|
|
|
.job-card { background: var(--surface); border-radius: var(--radius); padding: 16px; margin-bottom: 12px; }
|
|
.job-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
|
.job-urls { font-size: 0.8rem; color: var(--text2); word-break: break-all; margin-bottom: 8px; }
|
|
.job-progress { height: 4px; background: var(--surface2); border-radius: 2px; margin-bottom: 8px; overflow: hidden; }
|
|
.job-progress-bar { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s ease; }
|
|
.job-toggle { background: none; border: none; color: var(--text2); cursor: pointer; font-size: 0.78rem; padding: 4px 0; display: flex; align-items: center; gap: 6px; font-family: inherit; transition: color 0.15s; }
|
|
.job-toggle:hover { color: var(--text); }
|
|
.job-toggle .arrow { display: inline-block; transition: transform 0.2s; font-size: 0.7rem; }
|
|
.job-toggle .arrow.open { transform: rotate(90deg); }
|
|
.job-preview { color: var(--text2); opacity: 0.6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 500px; font-family: 'Cascadia Code', 'Fira Code', monospace; }
|
|
.job-output-wrapper { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
|
|
.job-output-wrapper.open { max-height: 300px; overflow-y: auto; }
|
|
.job-output { background: #0d0d0d; border-radius: 4px; padding: 12px; font-family: 'Cascadia Code', 'Fira Code', monospace; font-size: 0.78rem; line-height: 1.5; color: var(--text2); white-space: pre-wrap; word-break: break-all; }
|
|
.job-actions { display: flex; gap: 8px; margin-top: 8px; }
|
|
|
|
.file-list { list-style: none; }
|
|
.file-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: var(--radius); transition: background 0.15s; }
|
|
.file-item:hover { background: var(--surface2); }
|
|
.file-icon { font-size: 1.2rem; width: 24px; text-align: center; }
|
|
.file-name { flex: 1; font-size: 0.9rem; cursor: pointer; }
|
|
.file-size { font-size: 0.8rem; color: var(--text2); }
|
|
.file-actions { display: flex; gap: 4px; }
|
|
.file-actions .btn-icon { background: none; border: none; color: var(--text2); cursor: pointer; padding: 4px 8px; border-radius: 4px; font-size: 0.85rem; transition: all 0.15s; }
|
|
.file-actions .btn-icon:hover { background: var(--surface2); color: var(--text); }
|
|
.file-actions .btn-icon.delete:hover { color: var(--danger); }
|
|
.breadcrumb { display: flex; gap: 4px; align-items: center; margin-bottom: 16px; font-size: 0.85rem; flex-wrap: wrap; }
|
|
.breadcrumb a { color: var(--accent); text-decoration: none; }
|
|
.breadcrumb span { color: var(--text2); }
|
|
|
|
.cookie-status { display: flex; align-items: center; gap: 12px; }
|
|
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
|
.dot-green { background: var(--accent); }
|
|
.dot-red { background: var(--danger); }
|
|
|
|
.empty { text-align: center; padding: 40px; color: var(--text2); }
|
|
|
|
/* Advanced options toggle */
|
|
.advanced-toggle { background: none; border: none; color: var(--text2); cursor: pointer; font-size: 0.85rem; padding: 4px 0; display: flex; align-items: center; gap: 6px; font-family: inherit; margin-bottom: 8px; transition: color 0.15s; }
|
|
.advanced-toggle:hover { color: var(--text); }
|
|
.advanced-toggle .chevron { display: inline-block; transition: transform 0.2s; font-size: 0.7rem; }
|
|
.advanced-toggle.open .chevron { transform: rotate(90deg); }
|
|
.advanced-section { max-height: 0; overflow: hidden; transition: max-height 0.35s ease; }
|
|
.advanced-section.open { max-height: 600px; }
|
|
|
|
/* Drag-and-drop feedback */
|
|
textarea.drag-over { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(29,185,84,0.25); }
|
|
textarea.drop-flash { animation: dropFlash 0.6s ease; }
|
|
@keyframes dropFlash { 0%,100% { box-shadow: none; } 30%,70% { box-shadow: 0 0 0 3px var(--accent); border-color: var(--accent); } }
|
|
|
|
/* Running job pulse */
|
|
.status-running { animation: statusPulse 1.8s ease-in-out infinite; }
|
|
@keyframes statusPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
|
|
/* Toast */
|
|
#toast { position: fixed; bottom: 24px; right: 24px; background: var(--surface); border-radius: var(--radius); padding: 12px 18px; font-size: 0.88rem; box-shadow: 0 4px 20px rgba(0,0,0,0.5); opacity: 0; transform: translateY(8px); transition: opacity 0.25s, transform 0.25s; pointer-events: none; max-width: 320px; z-index: 999; border-left: 3px solid var(--accent); }
|
|
#toast.show { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
|
#toast.error { border-left-color: var(--danger); }
|
|
|
|
/* Bottom navigation bar (mobile only) */
|
|
#bottom-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; height: 56px; background: var(--surface); border-top: 1px solid var(--surface2); z-index: 100; }
|
|
.bottom-tab { flex: 1; background: none; border: none; color: var(--text2); cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; font-size: 0.62rem; font-family: inherit; padding: 0; transition: color 0.15s; -webkit-tap-highlight-color: transparent; }
|
|
.bottom-tab .btab-icon { display: flex; }
|
|
.bottom-tab.active { color: var(--accent); }
|
|
|
|
/* Touch targets */
|
|
.file-actions .btn-icon { padding: 8px 12px; min-width: 36px; min-height: 36px; display: inline-flex; align-items: center; justify-content: center; }
|
|
.file-item { min-height: 48px; }
|
|
.checkbox-label { padding: 6px 0; }
|
|
|
|
@media (max-width: 600px) {
|
|
.form-row { grid-template-columns: 1fr; }
|
|
.header { flex-wrap: wrap; }
|
|
.tabs { display: none; }
|
|
.container { padding: 16px; }
|
|
.card { padding: 16px; }
|
|
body { padding-bottom: 64px; }
|
|
textarea, select, input { font-size: 1rem; }
|
|
#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; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1 style="cursor:pointer" onclick="showPage('unified')"><span>Track</span>pull</h1>
|
|
<div class="tabs">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<!-- 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/... 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">← Back</button>
|
|
<h2>Votify Direct</h2>
|
|
<div class="form-group">
|
|
<label for="urls">Spotify URLs (one per line)</label>
|
|
<textarea id="urls" placeholder="https://open.spotify.com/track/... https://open.spotify.com/album/..."></textarea>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div>
|
|
<label for="audio_quality">Audio Quality</label>
|
|
<select id="audio_quality">
|
|
<option value="aac-medium">AAC 128kbps</option>
|
|
<option value="aac-high">AAC 256kbps (Premium)</option>
|
|
<option value="vorbis-low">Vorbis 96kbps</option>
|
|
<option value="vorbis-medium">Vorbis 160kbps</option>
|
|
<option value="vorbis-high">Vorbis 320kbps (Premium)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="output_format">Output Format</label>
|
|
<select id="output_format">
|
|
<option value="original">Keep Original</option>
|
|
<option value="mp3" selected>MP3</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="advanced-toggle" id="advanced-toggle" onclick="toggleAdvanced()">
|
|
<span class="chevron">▶</span> Advanced options
|
|
</button>
|
|
<div class="advanced-section" id="advanced-section">
|
|
<div class="form-row">
|
|
<div>
|
|
<label for="video_format">Video Format</label>
|
|
<select id="video_format">
|
|
<option value="mp4">MP4</option>
|
|
<option value="webm">WebM</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="cover_size">Cover Size</label>
|
|
<select id="cover_size">
|
|
<option value="small">Small</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="large" selected>Large</option>
|
|
<option value="extra-large">Extra Large</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="download_mode">Download Mode</label>
|
|
<select id="download_mode">
|
|
<option value="ytdlp">yt-dlp</option>
|
|
<option value="aria2c">aria2c</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="checkbox-row">
|
|
<label class="checkbox-label"><input type="checkbox" id="save_cover"> Save Cover Art</label>
|
|
<label class="checkbox-label"><input type="checkbox" id="save_playlist"> Save Playlist File</label>
|
|
<label class="checkbox-label"><input type="checkbox" id="overwrite"> Overwrite Existing</label>
|
|
<label class="checkbox-label"><input type="checkbox" id="download_music_videos"> Download Music Videos</label>
|
|
<label class="checkbox-label"><input type="checkbox" id="no_lrc"> No Lyrics</label>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn" id="btn-download" onclick="startDownload()">Start Download</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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">← Back</button>
|
|
<h2>Monochrome Download</h2>
|
|
<div class="form-group">
|
|
<label for="mono-url">Spotify URL (track, album, or playlist)</label>
|
|
<textarea id="mono-url" placeholder="https://open.spotify.com/track/... https://open.spotify.com/album/..." style="min-height:60px"></textarea>
|
|
</div>
|
|
<div class="form-row">
|
|
<div>
|
|
<label for="mono-quality">Quality</label>
|
|
<select id="mono-quality">
|
|
<option value="HI_RES_LOSSLESS">HI_RES_LOSSLESS (24-bit FLAC)</option>
|
|
<option value="LOSSLESS">LOSSLESS (16-bit FLAC)</option>
|
|
<option value="HIGH">HIGH (AAC 320kbps)</option>
|
|
<option value="LOW">LOW (AAC 96kbps)</option>
|
|
<option value="MP3_320">MP3 320kbps</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<button class="btn" id="btn-monochrome" onclick="startMonochromeDownload()">Start Download</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- JOBS PAGE -->
|
|
<div id="page-jobs" class="page">
|
|
<div class="card">
|
|
<h2>Download Jobs</h2>
|
|
<div id="jobs-list"><div class="empty">No jobs yet</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- FILES PAGE -->
|
|
<div id="page-files" class="page">
|
|
<div class="card">
|
|
<h2>Downloaded Files</h2>
|
|
<div class="breadcrumb" id="breadcrumb"></div>
|
|
<ul class="file-list" id="file-list"><div class="empty">No files yet</div></ul>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SETTINGS PAGE -->
|
|
<div id="page-settings" class="page">
|
|
{% if auth_enabled %}
|
|
<div class="card">
|
|
<h2>Account</h2>
|
|
<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 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">
|
|
<div class="dot dot-red"></div>
|
|
<span>Checking...</span>
|
|
</div>
|
|
<div style="margin-top:16px">
|
|
<label>Upload cookies.txt (Netscape format)</label>
|
|
<input type="file" id="cookie-file" accept=".txt" style="margin-top:8px; display:block">
|
|
<button class="btn btn-sm btn-secondary" style="margin-top:12px" onclick="uploadCookies()">Upload</button>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<h2>Widevine Device</h2>
|
|
<div class="cookie-status" id="wvd-status">
|
|
<div class="dot dot-red"></div>
|
|
<span>Checking...</span>
|
|
</div>
|
|
<div style="margin-top:16px">
|
|
<label>Upload device.wvd (required for AAC quality)</label>
|
|
<input type="file" id="wvd-file" accept=".wvd" style="margin-top:8px; display:block">
|
|
<button class="btn btn-sm btn-secondary" style="margin-top:12px" onclick="uploadWvd()">Upload</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<nav id="bottom-nav">
|
|
<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>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>
|
|
<span>Jobs</span>
|
|
</button>
|
|
<button class="bottom-tab" data-page="files" onclick="showPage('files')">
|
|
<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="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
|
|
<span>Files</span>
|
|
</button>
|
|
<button class="bottom-tab" data-page="settings" onclick="showPage('settings')">
|
|
<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="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg></span>
|
|
<span>Settings</span>
|
|
</button>
|
|
</nav>
|
|
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
let currentPath = "";
|
|
let jobPollInterval = null;
|
|
const expandedJobs = new Set();
|
|
|
|
function showPage(name) {
|
|
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');
|
|
|
|
// 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 === tabName));
|
|
|
|
if (name === 'jobs') loadJobs();
|
|
if (name === 'files') loadFiles("");
|
|
if (name === 'settings') { checkCookies(); checkWvd(); loadFallbackQuality(); }
|
|
|
|
if (jobPollInterval) clearInterval(jobPollInterval);
|
|
if (name === 'jobs') jobPollInterval = setInterval(loadJobs, 3000);
|
|
}
|
|
|
|
async function startDownload() {
|
|
const btn = document.getElementById('btn-download');
|
|
btn.disabled = true;
|
|
|
|
const data = {
|
|
urls: document.getElementById('urls').value,
|
|
audio_quality: document.getElementById('audio_quality').value,
|
|
download_mode: document.getElementById('download_mode').value,
|
|
video_format: document.getElementById('video_format').value,
|
|
cover_size: document.getElementById('cover_size').value,
|
|
save_cover: document.getElementById('save_cover').checked,
|
|
save_playlist: document.getElementById('save_playlist').checked,
|
|
overwrite: document.getElementById('overwrite').checked,
|
|
download_music_videos: document.getElementById('download_music_videos').checked,
|
|
no_lrc: document.getElementById('no_lrc').checked,
|
|
output_format: document.getElementById('output_format').value,
|
|
};
|
|
|
|
saveSettings();
|
|
|
|
try {
|
|
const res = await fetch('/api/download', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
const result = await res.json();
|
|
if (res.ok) {
|
|
document.getElementById('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 startMonochromeDownload() {
|
|
const btn = document.getElementById('btn-monochrome');
|
|
btn.disabled = true;
|
|
|
|
const url = document.getElementById('mono-url').value.trim();
|
|
const quality = document.getElementById('mono-quality').value;
|
|
|
|
localStorage.setItem('mono-quality', quality);
|
|
|
|
try {
|
|
const res = await fetch('/api/monochrome/download', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url, quality })
|
|
});
|
|
const result = await res.json();
|
|
if (res.ok) {
|
|
document.getElementById('mono-url').value = '';
|
|
showPage('jobs');
|
|
} else {
|
|
showToast(result.error || 'Failed to start download', 'error');
|
|
}
|
|
} catch (e) {
|
|
showToast('Error: ' + e.message, 'error');
|
|
}
|
|
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;
|
|
let mp3Total = 0, mp3Done = 0, inMp3 = false;
|
|
let monoTrack = 0, monoTotal = 0, isMono = false;
|
|
for (const line of output) {
|
|
const mono = line.match(/\[monochrome\] Track (\d+)\/(\d+)/);
|
|
if (mono) { monoTrack = parseInt(mono[1]); monoTotal = parseInt(mono[2]); isMono = true; }
|
|
if (!isMono) {
|
|
const tm = line.match(/Track (\d+)\/(\d+)/);
|
|
if (tm) { current = parseInt(tm[1]); total = parseInt(tm[2]); }
|
|
}
|
|
const dm = line.match(/\[download\]\s+([\d.]+)%/);
|
|
if (dm) dlPct = parseFloat(dm[1]);
|
|
const mm = line.match(/\[mp3\] Converting (\d+) file/);
|
|
if (mm) { mp3Total = parseInt(mm[1]); inMp3 = true; }
|
|
if (/\[mp3\] (Done:|Failed:)/.test(line)) mp3Done++;
|
|
}
|
|
if (isMono && monoTotal > 0) {
|
|
const pct = (monoTrack / monoTotal) * 100;
|
|
return { current: monoTrack, total: monoTotal, pct: Math.min(Math.round(pct), 100), phase: 'monochrome' };
|
|
}
|
|
if (inMp3 && mp3Total > 0) {
|
|
const pct = (mp3Done / mp3Total) * 100;
|
|
return { current: mp3Done, total: mp3Total, pct: Math.min(Math.round(pct), 100), phase: 'mp3' };
|
|
}
|
|
if (total <= 0) return null;
|
|
const pct = ((current - 1 + dlPct / 100) / total) * 100;
|
|
return { current, total, pct: Math.min(Math.round(pct), 100), phase: 'download' };
|
|
}
|
|
|
|
function toggleJobLog(jobId, currentlyOpen) {
|
|
if (currentlyOpen) expandedJobs.delete(jobId);
|
|
else expandedJobs.add(jobId);
|
|
loadJobs();
|
|
}
|
|
|
|
let loadJobsInFlight = false;
|
|
async function loadJobs() {
|
|
if (loadJobsInFlight) return;
|
|
loadJobsInFlight = true;
|
|
try {
|
|
const res = await fetch('/api/jobs');
|
|
const jobs = await res.json();
|
|
const container = document.getElementById('jobs-list');
|
|
|
|
if (jobs.length === 0) {
|
|
container.innerHTML = '<div class="empty">No jobs yet</div>';
|
|
return;
|
|
}
|
|
|
|
const html = jobs.slice().reverse().map(job => {
|
|
const hasOutput = job.output && job.output.length > 0;
|
|
const isOpen = expandedJobs.has(job.id);
|
|
const lastLine = hasOutput ? job.output[job.output.length - 1] : '';
|
|
const progress = job.status === 'running' ? parseProgress(job.output) : null;
|
|
|
|
let progressHtml = '';
|
|
if (progress) {
|
|
const label = progress.phase === 'mp3'
|
|
? `Converting to MP3 ${progress.current}/${progress.total}`
|
|
: progress.phase === 'monochrome'
|
|
? `Monochrome ${progress.current}/${progress.total}`
|
|
: `Downloading ${progress.current}/${progress.total}`;
|
|
progressHtml = `<div style="font-size:0.75rem;color:var(--text2);margin-bottom:4px">${label}</div>
|
|
<div class="job-progress"><div class="job-progress-bar" style="width:${progress.pct}%"></div></div>`;
|
|
}
|
|
|
|
let logHtml = '';
|
|
if (hasOutput) {
|
|
logHtml = `<button class="job-toggle" onclick="toggleJobLog('${job.id}', ${isOpen})">
|
|
<span class="arrow ${isOpen ? 'open' : ''}">▶</span>
|
|
${isOpen ? 'Hide Log' : 'Show Log'}
|
|
${!isOpen && lastLine ? `<span class="job-preview">${escapeHtml(lastLine)}</span>` : ''}
|
|
</button>
|
|
<div class="job-output-wrapper ${isOpen ? 'open' : ''}">
|
|
<div class="job-output">${escapeHtml(job.output.join('\n'))}</div>
|
|
</div>`;
|
|
}
|
|
|
|
return `<div class="job-card">
|
|
<div class="job-header">
|
|
<strong>Job ${job.id}</strong>
|
|
<span class="status-badge status-${job.status}">${job.status}</span>
|
|
</div>
|
|
<div class="job-urls">${job.urls.join(', ')}</div>
|
|
${progressHtml}
|
|
${logHtml}
|
|
<div class="job-actions">
|
|
${job.status === 'running' ? `<button class="btn btn-sm btn-danger" onclick="cancelJob('${job.id}')">Cancel</button>` : ''}
|
|
${job.status !== 'running' ? `<button class="btn btn-sm btn-danger" onclick="deleteJob('${job.id}')">Remove</button>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
container.innerHTML = html;
|
|
|
|
// Auto-scroll open outputs
|
|
container.querySelectorAll('.job-output-wrapper.open .job-output').forEach(el => {
|
|
el.scrollTop = el.scrollHeight;
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to load jobs', e);
|
|
} finally {
|
|
loadJobsInFlight = false;
|
|
}
|
|
}
|
|
|
|
async function cancelJob(id) {
|
|
await fetch('/api/jobs/' + id + '/cancel', { method: 'POST' });
|
|
loadJobs();
|
|
}
|
|
|
|
async function deleteJob(id) {
|
|
expandedJobs.delete(id);
|
|
await fetch('/api/jobs/' + id, { method: 'DELETE' });
|
|
loadJobs();
|
|
}
|
|
|
|
async function loadFiles(path) {
|
|
currentPath = path;
|
|
try {
|
|
const res = await fetch('/api/files?path=' + encodeURIComponent(path));
|
|
const files = await res.json();
|
|
|
|
// Breadcrumb
|
|
const bc = document.getElementById('breadcrumb');
|
|
const parts = path ? path.split('/') : [];
|
|
let bcHtml = '<a href="#" onclick="loadFiles(\'\');return false">Root</a>';
|
|
let accumulated = '';
|
|
for (const part of parts) {
|
|
accumulated += (accumulated ? '/' : '') + part;
|
|
bcHtml += ` <span>/</span> <a href="#" onclick="loadFiles(this.dataset.p);return false" data-p="${escapeAttr(accumulated)}">${escapeHtml(part)}</a>`;
|
|
}
|
|
bc.innerHTML = bcHtml;
|
|
|
|
// File list
|
|
const list = document.getElementById('file-list');
|
|
if (files.length === 0) {
|
|
list.innerHTML = '<div class="empty">No files yet</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = files.map(f => {
|
|
const safePath = escapeAttr(f.path);
|
|
const encodedPath = encodeURIComponent(f.path);
|
|
if (f.is_dir) {
|
|
return `<li class="file-item">
|
|
<span class="file-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
|
|
<span class="file-name" onclick="loadFiles(this.dataset.p)" data-p="${safePath}">${escapeHtml(f.name)}</span>
|
|
<div class="file-actions">
|
|
<button class="btn-icon" onclick="window.location='/api/files/download-folder?path=${encodedPath}'" title="Download as ZIP">⬇</button>
|
|
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, true)" data-p="${safePath}" title="Delete folder">🗑</button>
|
|
</div>
|
|
</li>`;
|
|
} else {
|
|
const size = formatSize(f.size);
|
|
return `<li class="file-item">
|
|
<span class="file-icon"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>
|
|
<span class="file-name" onclick="window.location='/api/files/download?path=${encodedPath}'">${escapeHtml(f.name)}</span>
|
|
<span class="file-size">${size}</span>
|
|
<div class="file-actions">
|
|
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, false)" data-p="${safePath}" title="Delete file">🗑</button>
|
|
</div>
|
|
</li>`;
|
|
}
|
|
}).join('');
|
|
} catch (e) {
|
|
console.error('Failed to load files', e);
|
|
}
|
|
}
|
|
|
|
async function deletePath(path, isDir) {
|
|
const kind = isDir ? 'folder and all its contents' : 'file';
|
|
if (!confirm(`Are you sure you want to delete this ${kind}?\n\n${path}`)) return;
|
|
try {
|
|
const res = await fetch('/api/files/delete?path=' + encodeURIComponent(path), { method: 'DELETE' });
|
|
if (res.ok) {
|
|
loadFiles(currentPath);
|
|
} else {
|
|
let msg = 'Delete failed';
|
|
try { const data = await res.json(); msg = data.error || msg; } catch (_) {}
|
|
alert(msg);
|
|
}
|
|
} catch (e) {
|
|
alert('Error: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function checkCookies() {
|
|
try {
|
|
const res = await fetch('/api/cookies');
|
|
const data = await res.json();
|
|
const el = document.getElementById('cookie-status');
|
|
if (data.exists) {
|
|
el.innerHTML = '<div class="dot dot-green"></div><span>cookies.txt found</span>';
|
|
} else {
|
|
el.innerHTML = '<div class="dot dot-red"></div><span>cookies.txt not found - upload or mount to /config/cookies.txt</span>';
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
async function uploadCookies() {
|
|
const file = document.getElementById('cookie-file').files[0];
|
|
if (!file) { showToast('Select a file first', 'error'); return; }
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
try {
|
|
const res = await fetch('/api/cookies', { method: 'POST', body: form });
|
|
if (res.ok) {
|
|
showToast('Cookies uploaded successfully');
|
|
checkCookies();
|
|
} else {
|
|
const data = await res.json();
|
|
showToast(data.error || 'Upload failed', 'error');
|
|
}
|
|
} catch (e) {
|
|
showToast('Error: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function checkWvd() {
|
|
try {
|
|
const res = await fetch('/api/wvd');
|
|
const data = await res.json();
|
|
const el = document.getElementById('wvd-status');
|
|
if (data.exists) {
|
|
el.innerHTML = '<div class="dot dot-green"></div><span>device.wvd found</span>';
|
|
} else {
|
|
el.innerHTML = '<div class="dot dot-red"></div><span>device.wvd not found</span>';
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
async function uploadWvd() {
|
|
const file = document.getElementById('wvd-file').files[0];
|
|
if (!file) { showToast('Select a file first', 'error'); return; }
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
try {
|
|
const res = await fetch('/api/wvd', { method: 'POST', body: form });
|
|
if (res.ok) {
|
|
showToast('WVD file uploaded successfully');
|
|
checkWvd();
|
|
} else {
|
|
const data = await res.json();
|
|
showToast(data.error || 'Upload failed', 'error');
|
|
}
|
|
} catch (e) {
|
|
showToast('Error: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
function formatSize(bytes) {
|
|
if (!bytes) return '';
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
let i = 0;
|
|
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
|
|
return bytes.toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function escapeAttr(str) {
|
|
return str.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function showToast(msg, type) {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = msg;
|
|
toast.className = 'show' + (type === 'error' ? ' error' : '');
|
|
clearTimeout(toast._timer);
|
|
toast._timer = setTimeout(() => { toast.className = ''; }, 3000);
|
|
}
|
|
|
|
function toggleAdvanced() {
|
|
const section = document.getElementById('advanced-section');
|
|
const toggle = document.getElementById('advanced-toggle');
|
|
const isOpen = section.classList.toggle('open');
|
|
toggle.classList.toggle('open', isOpen);
|
|
localStorage.setItem('votify-advanced-open', isOpen ? '1' : '');
|
|
}
|
|
|
|
// Drag-and-drop Spotify URLs onto the page
|
|
document.addEventListener('dragover', e => e.preventDefault());
|
|
document.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
const text = e.dataTransfer.getData('text') || e.dataTransfer.getData('text/uri-list') || '';
|
|
const lines = text.split(/[\r\n\s]+/).filter(l => l.includes('open.spotify.com'));
|
|
if (!lines.length) return;
|
|
// Drop onto active page's textarea
|
|
const monoActive = document.getElementById('page-monochrome').classList.contains('active');
|
|
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);
|
|
});
|
|
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';
|
|
const SETTING_IDS = ['audio_quality', 'output_format', 'video_format', 'cover_size', 'download_mode', 'save_cover', 'save_playlist', 'overwrite', 'download_music_videos', 'no_lrc'];
|
|
|
|
function saveSettings() {
|
|
const settings = {};
|
|
for (const id of SETTING_IDS) {
|
|
const el = document.getElementById(id);
|
|
settings[id] = el.type === 'checkbox' ? el.checked : el.value;
|
|
}
|
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
|
}
|
|
|
|
function loadSettings() {
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY));
|
|
if (!saved) return;
|
|
for (const id of SETTING_IDS) {
|
|
if (!(id in saved)) continue;
|
|
const el = document.getElementById(id);
|
|
if (el.type === 'checkbox') el.checked = saved[id];
|
|
else el.value = saved[id];
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
loadSettings();
|
|
const savedMonoQ = localStorage.getItem('mono-quality');
|
|
if (savedMonoQ) document.getElementById('mono-quality').value = savedMonoQ;
|
|
if (localStorage.getItem('votify-advanced-open')) {
|
|
document.getElementById('advanced-section').classList.add('open');
|
|
document.getElementById('advanced-toggle').classList.add('open');
|
|
}
|
|
checkCookies();
|
|
checkWvd();
|
|
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('/sw.js')
|
|
.then(reg => console.log('SW registered, scope:', reg.scope))
|
|
.catch(err => console.error('SW registration failed:', err));
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|