Files
trackpull/templates/index.html

1244 lines
66 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>
{% if role == 'admin' %}
<button class="tab" onclick="showPage('admin')">Users</button>
{% endif %}
</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/...&#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>
<button class="btn btn-sm btn-secondary" onclick="showPage('artwork')">Artwork</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>
<textarea id="urls" placeholder="https://open.spotify.com/track/...&#10;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">&#9654;</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">&larr; 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/...&#10;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>
<!-- ARTWORK PAGE -->
<div id="page-artwork" class="page">
<div class="card">
<h2>Artwork</h2>
<div class="form-group">
<label for="artwork-url">Spotify URL (track, album, or playlist)</label>
<textarea id="artwork-url" placeholder="https://open.spotify.com/album/..." style="min-height:60px"></textarea>
</div>
<button class="btn" id="btn-artwork" onclick="fetchArtwork()">Get Artwork</button>
<div id="artwork-result" style="margin-top:20px"></div>
</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>
<!-- ADMIN PAGE -->
{% if role == 'admin' %}
<div id="page-admin" class="page">
<div class="card" id="admin-user-list-card">
<h2>User Management</h2>
<button class="btn btn-sm" style="margin-bottom:16px" onclick="toggleCreateUserForm()">+ Create User</button>
<div id="create-user-form" style="display:none; margin-bottom:16px; padding:16px; background:var(--surface2); border-radius:var(--radius)">
<div class="form-group">
<label>Username</label>
<input type="text" id="new-username" placeholder="Username">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="new-user-password" placeholder="Password">
</div>
<div class="form-group">
<label>Role</label>
<select id="new-user-role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div style="display:flex; gap:8px; margin-top:8px">
<button class="btn btn-sm" onclick="createUser()">Create</button>
<button class="btn btn-sm btn-secondary" onclick="toggleCreateUserForm()">Cancel</button>
</div>
</div>
<div id="user-list"></div>
</div>
<div id="user-detail-panel" style="display:none">
<div class="card">
<button class="btn btn-sm btn-secondary" style="margin-bottom:12px" onclick="closeUserDetail()">&larr; Back to Users</button>
<h2 id="user-detail-title"></h2>
<div style="display:flex; gap:8px; margin-bottom:16px">
<button class="btn btn-sm btn-secondary" id="user-detail-tab-jobs" onclick="switchUserDetailTab('jobs')">Jobs</button>
<button class="btn btn-sm btn-secondary" id="user-detail-tab-files" onclick="switchUserDetailTab('files')">Files</button>
</div>
<div id="user-detail-jobs"></div>
<div id="user-detail-files" style="display:none"></div>
</div>
</div>
</div>
{% endif %}
<!-- SETTINGS PAGE -->
<div id="page-settings" class="page">
<div class="card">
<h2>Account</h2>
<p style="color:var(--text2); font-size:0.85rem; margin-bottom:12px">Signed in as <strong>{{ username }}</strong></p>
<div class="form-group" style="margin-bottom:8px">
<label>Current Password</label>
<input type="password" id="current-password" placeholder="Current password" autocomplete="current-password">
</div>
<div class="form-group" style="margin-bottom:8px">
<label>New Password</label>
<input type="password" id="new-password" placeholder="New password" autocomplete="new-password">
</div>
<div class="form-group" style="margin-bottom:12px">
<label>Confirm New Password</label>
<input type="password" id="confirm-password" placeholder="Confirm new password" autocomplete="new-password">
</div>
<div style="display:flex; gap:8px; align-items:center">
<button class="btn btn-sm btn-secondary" onclick="changePassword()">Change Password</button>
<a href="/logout" class="btn btn-danger btn-sm" style="text-decoration:none">Logout</a>
</div>
</div>
{% if role == 'admin' %}
<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>Job &amp; File Expiry</h2>
<p style="font-size:0.85rem; color:var(--text2); margin-bottom:12px">Delete jobs and download files older than this many days (0 = never)</p>
<div style="display:flex; gap:8px; align-items:center">
<input type="number" id="job-expiry-days" min="0" max="365" style="width:90px; background:var(--surface2); border:1px solid #333; color:var(--text); border-radius:var(--radius); padding:8px 10px; font-size:0.9rem; font-family:inherit">
<span style="color:var(--text2); font-size:0.85rem">days</span>
<button class="btn btn-sm btn-secondary" onclick="saveExpiryDays()">Save</button>
</div>
</div>
{% endif %}
{% if role == 'admin' %}
<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>
{% endif %}
</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>
{% if role == 'admin' %}
<button class="bottom-tab" data-page="admin" onclick="showPage('admin')">
<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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
<span>Users</span>
</button>
{% endif %}
</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 sub-pages (download/monochrome/artwork), highlight the "Download" tab
const tabName = (name === 'download' || name === 'monochrome' || name === 'artwork') ? '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') { {% if role == 'admin' %}checkCookies(); checkWvd();{% endif %} loadFallbackQuality(); }
if (name === 'admin') loadUsers();
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();
const fq = document.getElementById('fallback-quality');
if (fq) fq.value = data.fallback_quality || 'aac-medium';
const ed = document.getElementById('job-expiry-days');
if (ed) ed.value = data.job_expiry_days ?? 30;
} catch (_) {}
}
const fqEl = document.getElementById('fallback-quality');
if (fqEl) fqEl.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');
}
});
async function saveExpiryDays() {
const days = parseInt(document.getElementById('job-expiry-days').value) || 0;
try {
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_expiry_days: days })
});
showToast('Expiry saved');
} catch (e) {
showToast('Error saving expiry', 'error');
}
}
async function changePassword() {
const current = document.getElementById('current-password').value;
const next = document.getElementById('new-password').value;
const confirm = document.getElementById('confirm-password').value;
if (!current || !next || !confirm) { showToast('Fill in all password fields', 'error'); return; }
if (next !== confirm) { showToast('New passwords do not match', 'error'); return; }
try {
const res = await fetch('/api/account/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: current, new_password: next })
});
const data = await res.json();
if (data.ok) {
showToast('Password changed');
document.getElementById('current-password').value = '';
document.getElementById('new-password').value = '';
document.getElementById('confirm-password').value = '';
} else {
showToast(data.error || 'Error changing password', 'error');
}
} catch (e) {
showToast('Error changing password', 'error');
}
}
// ---------------------------------------------------------------------------
// Admin functions
// ---------------------------------------------------------------------------
let adminViewingUserId = null;
let adminViewingUserName = null;
let adminUserDetailPath = '';
function toggleCreateUserForm() {
const f = document.getElementById('create-user-form');
f.style.display = f.style.display === 'none' ? 'block' : 'none';
}
async function loadUsers() {
const list = document.getElementById('user-list');
if (!list) return;
try {
const res = await fetch('/api/admin/users');
if (!res.ok) {
const text = await res.text();
console.error('loadUsers HTTP error', res.status, text);
list.innerHTML = `<p style="color:var(--danger)">Error loading users (HTTP ${res.status})</p>`;
return;
}
const users = await res.json();
if (!Array.isArray(users) || !users.length) { list.innerHTML = '<p style="color:var(--text2);font-size:0.85rem">No users yet.</p>'; return; }
list.innerHTML = users.map(u => `
<div style="display:flex; align-items:center; gap:8px; padding:10px 0; border-bottom:1px solid var(--surface2)">
<div style="flex:1">
<span style="font-weight:600">${esc(u.username)}</span>
<span class="status-badge ${u.role === 'admin' ? 'status-running' : 'status-queued'}" style="margin-left:8px">${esc(u.role)}</span>
<div style="font-size:0.75rem; color:var(--text2); margin-top:2px">Created ${new Date(u.created_at * 1000).toLocaleDateString()}</div>
</div>
<button class="btn btn-sm btn-secondary" onclick="viewUser('${esc(u.id)}','${esc(u.username)}')">View</button>
<button class="btn btn-sm btn-secondary" onclick="promptResetPassword('${esc(u.id)}','${esc(u.username)}')">Reset PW</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('${esc(u.id)}','${esc(u.username)}')">Delete</button>
</div>`).join('');
} catch (e) {
console.error('loadUsers error', e);
list.innerHTML = `<p style="color:var(--danger)">Error loading users: ${esc(String(e))}</p>`;
}
}
async function createUser() {
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-user-password').value;
const role = document.getElementById('new-user-role').value;
if (!username || !password) { showToast('Username and password required', 'error'); return; }
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role })
});
const data = await res.json();
if (data.ok) {
showToast('User created');
document.getElementById('new-username').value = '';
document.getElementById('new-user-password').value = '';
toggleCreateUserForm();
loadUsers();
} else {
showToast(data.error || 'Error creating user', 'error');
}
} catch (e) {
showToast('Error creating user', 'error');
}
}
async function deleteUser(userId, username) {
if (!confirm(`Delete user "${username}" and all their data?`)) return;
try {
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
const data = await res.json();
if (data.ok) { showToast('User deleted'); loadUsers(); }
else showToast(data.error || 'Error deleting user', 'error');
} catch (e) {
showToast('Error deleting user', 'error');
}
}
function viewUser(userId, username) {
adminViewingUserId = userId;
adminViewingUserName = username;
adminUserDetailPath = '';
document.getElementById('user-detail-title').textContent = username + "'s Data";
document.getElementById('admin-user-list-card').style.display = 'none';
document.getElementById('user-detail-panel').style.display = 'block';
switchUserDetailTab('jobs');
}
function closeUserDetail() {
adminViewingUserId = null;
document.getElementById('user-detail-panel').style.display = 'none';
document.getElementById('admin-user-list-card').style.display = 'block';
}
function switchUserDetailTab(tab) {
document.getElementById('user-detail-jobs').style.display = tab === 'jobs' ? 'block' : 'none';
document.getElementById('user-detail-files').style.display = tab === 'files' ? 'block' : 'none';
document.getElementById('user-detail-tab-jobs').style.fontWeight = tab === 'jobs' ? '700' : '';
document.getElementById('user-detail-tab-files').style.fontWeight = tab === 'files' ? '700' : '';
if (tab === 'jobs') loadUserJobs();
if (tab === 'files') loadUserFiles('');
}
async function loadUserJobs() {
const container = document.getElementById('user-detail-jobs');
try {
const res = await fetch(`/api/admin/users/${adminViewingUserId}/jobs`);
const jobs = await res.json();
if (!jobs.length) { container.innerHTML = '<p style="color:var(--text2);font-size:0.85rem">No jobs.</p>'; return; }
container.innerHTML = jobs.map(j => `
<div class="job-card" style="background:var(--surface2); border-radius:var(--radius); padding:12px; margin-bottom:8px">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:4px">
<code style="font-size:0.8rem">${esc(j.id)}</code>
<span class="status-badge status-${esc(j.status)}">${esc(j.status)}</span>
<span style="margin-left:auto; font-size:0.75rem; color:var(--text2)">${new Date(j.created_at * 1000).toLocaleString()}</span>
<button class="btn btn-sm btn-danger" onclick="adminDeleteJob('${esc(j.id)}')">Delete</button>
</div>
<div style="font-size:0.8rem; color:var(--text2)">${(j.urls || []).map(esc).join(', ')}</div>
</div>`).join('');
} catch (e) {
container.innerHTML = '<p style="color:var(--danger)">Error loading jobs</p>';
}
}
async function adminDeleteJob(jobId) {
if (!confirm('Delete this job?')) return;
try {
const res = await fetch(`/api/admin/jobs/${jobId}`, { method: 'DELETE' });
const data = await res.json();
if (data.ok) { showToast('Job deleted'); loadUserJobs(); }
else showToast(data.error || 'Error deleting job', 'error');
} catch (e) {
showToast('Error deleting job', 'error');
}
}
async function loadUserFiles(relPath) {
adminUserDetailPath = relPath;
const container = document.getElementById('user-detail-files');
const params = new URLSearchParams({ user_id: adminViewingUserId, path: relPath });
try {
const res = await fetch(`/api/admin/files?${params}`);
const items = await res.json();
let breadcrumbHtml = '<span style="cursor:pointer;color:var(--accent)" onclick="loadUserFiles(\'\')">Root</span>';
if (relPath) {
const parts = relPath.split('/');
parts.forEach((p, i) => {
const partial = parts.slice(0, i + 1).join('/');
breadcrumbHtml += ` / <span style="cursor:pointer;color:var(--accent)" onclick="loadUserFiles('${esc(partial)}')">${esc(p)}</span>`;
});
}
if (!items.length) {
container.innerHTML = `<div class="breadcrumb" style="margin-bottom:12px">${breadcrumbHtml}</div><p style="color:var(--text2);font-size:0.85rem">Empty.</p>`;
return;
}
const rows = items.map(item => {
const icon = item.is_dir ? '📁' : '🎵';
const size = item.is_dir ? '' : formatSize(item.size);
const dlParams = new URLSearchParams({ user_id: adminViewingUserId, path: item.path });
const dlUrl = item.is_dir
? `/api/admin/files/download-folder?${dlParams}`
: `/api/admin/files/download?${dlParams}`;
return `<div class="file-item">
<span class="file-icon">${icon}</span>
<span class="file-name" style="cursor:pointer;flex:1" onclick="${item.is_dir ? `loadUserFiles('${esc(item.path)}')` : ''}">${esc(item.name)}</span>
${size ? `<span class="file-size">${size}</span>` : ''}
<a href="${dlUrl}" class="btn btn-sm btn-secondary" style="text-decoration:none">↓</a>
<button class="btn btn-sm btn-danger" onclick="adminDeleteFile('${esc(item.path)}')">✕</button>
</div>`;
}).join('');
container.innerHTML = `<div class="breadcrumb" style="margin-bottom:12px">${breadcrumbHtml}</div><div class="file-list">${rows}</div>`;
} catch (e) {
container.innerHTML = '<p style="color:var(--danger)">Error loading files</p>';
}
}
async function adminDeleteFile(relPath) {
if (!confirm('Delete this item?')) return;
const params = new URLSearchParams({ user_id: adminViewingUserId, path: relPath });
try {
const res = await fetch(`/api/admin/files/delete?${params}`, { method: 'DELETE' });
const data = await res.json();
if (data.ok) { showToast('Deleted'); loadUserFiles(adminUserDetailPath); }
else showToast(data.error || 'Error deleting', 'error');
} catch (e) {
showToast('Error deleting', 'error');
}
}
async function promptResetPassword(userId, username) {
const pw = prompt(`Set new password for "${username}":`);
if (pw === null) return;
if (!pw) { showToast('Password cannot be empty', 'error'); return; }
const pw2 = prompt(`Confirm new password for "${username}":`);
if (pw2 === null) return;
if (pw !== pw2) { showToast('Passwords do not match', 'error'); return; }
try {
const res = await fetch(`/api/admin/users/${userId}/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_password: pw })
});
const data = await res.json();
if (data.ok) showToast(`Password updated for ${username}`);
else showToast(data.error || 'Error resetting password', 'error');
} catch (e) {
showToast('Error resetting password', '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' : ''}">&#9654;</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='+encodeURIComponent(this.dataset.p)" data-p="${safePath}" title="Download as ZIP">&#11015;</button>
<button class="btn-icon delete" onclick="deletePath(this.dataset.p, true)" data-p="${safePath}" title="Delete folder">&#128465;</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='+encodeURIComponent(this.dataset.p)" data-p="${safePath}">${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">&#128465;</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');
}
}
async function fetchArtwork() {
const btn = document.getElementById('btn-artwork');
const url = document.getElementById('artwork-url').value.trim().split('\n')[0].trim();
const resultDiv = document.getElementById('artwork-result');
if (!url) { showToast('Enter a Spotify URL', 'error'); return; }
btn.disabled = true;
resultDiv.innerHTML = '<span style="color:var(--text2)">Fetching artwork...</span>';
try {
const res = await fetch('/api/artwork?url=' + encodeURIComponent(url));
const data = await res.json();
if (res.ok && data.image_url) {
const img = document.createElement('img');
img.src = data.image_url;
img.alt = 'Cover artwork';
img.style.cssText = 'max-width:100%;border-radius:var(--radius);display:block;';
resultDiv.innerHTML = '';
resultDiv.appendChild(img);
} else {
resultDiv.innerHTML = '<span style="color:var(--danger)">' + escapeHtml(data.error || 'No artwork found') + '</span>';
}
} catch (e) {
resultDiv.innerHTML = '<span style="color:var(--danger)">Failed to fetch artwork</span>';
}
btn.disabled = false;
}
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,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
const esc = escapeHtml;
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');
const artworkActive = document.getElementById('page-artwork').classList.contains('active');
let taId = 'unified-urls';
if (monoActive) taId = 'mono-url';
else if (votifyActive) taId = 'urls';
else if (artworkActive) taId = 'artwork-url';
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', 'artwork-url']) {
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');
}
{% if role == 'admin' %}checkCookies(); checkWvd();{% endif %}
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>