- Switch to shared build-and-push.sh; version read from docs/changelog.md - Add docs/changelog.md; remove version.txt, .last-branch, .gitignore - Stack: image tag via SLEEP_MEDITATION_IMAGE_TAG - Downloader: YouTube support (yt-dlp + ffmpeg), best audio to mp3 - Downloader: Content-Type validation for direct URLs - Downloader: auto-fetch YouTube title; title field optional - Downloader: progress bar with phase (downloading/converting) - Downloader: store source URL per file; show Source link in manage list Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
8.3 KiB
JavaScript
232 lines
8.3 KiB
JavaScript
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
|
const dlUrl = document.querySelector("#dl-url");
|
|
const dlTitle = document.querySelector("#dl-title");
|
|
const dlBtn = document.querySelector("#dl-btn");
|
|
const dlStatus = document.querySelector("#dl-status");
|
|
const dlProgress = document.querySelector("#dl-progress");
|
|
|
|
const renameSelect = document.querySelector("#rename-select");
|
|
const renameInput = document.querySelector("#rename-input");
|
|
const renameBtn = document.querySelector("#rename-btn");
|
|
const renameStatus = document.querySelector("#rename-status");
|
|
|
|
const manageList = document.querySelector("#manage-list");
|
|
|
|
// ── Download ──────────────────────────────────────────────────────────────────
|
|
dlBtn.addEventListener("click", async () => {
|
|
const url = dlUrl.value.trim();
|
|
const title = dlTitle.value.trim();
|
|
if (!url) { dlStatus.textContent = "Please enter a URL."; return; }
|
|
|
|
dlStatus.textContent = "Starting download\u2026";
|
|
dlBtn.disabled = true;
|
|
dlProgress.hidden = false;
|
|
dlProgress.removeAttribute("value");
|
|
|
|
try {
|
|
const res = await fetch("/api/download", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url, title }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
dlStatus.textContent = `Error: ${data.error}`;
|
|
dlBtn.disabled = false;
|
|
dlProgress.hidden = true;
|
|
return;
|
|
}
|
|
pollDownload(data.track_id);
|
|
} catch {
|
|
dlStatus.textContent = "Request failed.";
|
|
dlBtn.disabled = false;
|
|
dlProgress.hidden = true;
|
|
}
|
|
});
|
|
|
|
function pollDownload(trackId) {
|
|
const iv = setInterval(async () => {
|
|
try {
|
|
const res = await fetch(`/api/download/status/${encodeURIComponent(trackId)}`);
|
|
const data = await res.json();
|
|
if (data.status === "done") {
|
|
clearInterval(iv);
|
|
dlProgress.value = 100;
|
|
dlStatus.textContent = "Download complete!";
|
|
dlBtn.disabled = false;
|
|
dlUrl.value = "";
|
|
dlTitle.value = "";
|
|
setTimeout(() => { dlProgress.hidden = true; }, 800);
|
|
refresh();
|
|
} else if (data.status === "error") {
|
|
clearInterval(iv);
|
|
dlStatus.textContent = `Error: ${data.error || "unknown"}`;
|
|
dlBtn.disabled = false;
|
|
dlProgress.hidden = true;
|
|
} else {
|
|
if (typeof data.progress === "number") {
|
|
dlProgress.value = data.progress;
|
|
} else {
|
|
dlProgress.removeAttribute("value");
|
|
}
|
|
const phase = data.phase === "converting" ? "Converting" : "Downloading";
|
|
const pctTxt = typeof data.progress === "number" ? ` ${data.progress}%` : "";
|
|
dlStatus.textContent = `${phase}${pctTxt}\u2026`;
|
|
}
|
|
} catch {
|
|
clearInterval(iv);
|
|
dlStatus.textContent = "Status check failed.";
|
|
dlBtn.disabled = false;
|
|
dlProgress.hidden = true;
|
|
}
|
|
}, 750);
|
|
}
|
|
|
|
// ── Rename ────────────────────────────────────────────────────────────────────
|
|
async function loadRenameDropdown() {
|
|
const [downloads, tracks] = await Promise.all([fetchDownloads(), fetchTracks()]);
|
|
renameSelect.innerHTML = "";
|
|
|
|
if (tracks.length) {
|
|
const grp = document.createElement("optgroup");
|
|
grp.label = "Playlist";
|
|
tracks.forEach(t => {
|
|
const opt = document.createElement("option");
|
|
opt.value = JSON.stringify({ type: "playlist", src: t.src, title: t.title });
|
|
opt.textContent = t.title;
|
|
grp.appendChild(opt);
|
|
});
|
|
renameSelect.appendChild(grp);
|
|
}
|
|
|
|
if (downloads.length) {
|
|
const grp = document.createElement("optgroup");
|
|
grp.label = "Downloads";
|
|
downloads.forEach(t => {
|
|
const opt = document.createElement("option");
|
|
opt.value = JSON.stringify({ type: "download", filename: t.filename, title: t.title });
|
|
opt.textContent = t.title;
|
|
grp.appendChild(opt);
|
|
});
|
|
renameSelect.appendChild(grp);
|
|
}
|
|
|
|
syncRenameInput();
|
|
}
|
|
|
|
function syncRenameInput() {
|
|
try { renameInput.value = JSON.parse(renameSelect.value).title; } catch {}
|
|
}
|
|
|
|
renameSelect.addEventListener("change", syncRenameInput);
|
|
|
|
renameBtn.addEventListener("click", async () => {
|
|
const newTitle = renameInput.value.trim();
|
|
if (!newTitle) return;
|
|
let item;
|
|
try { item = JSON.parse(renameSelect.value); } catch { return; }
|
|
|
|
renameStatus.textContent = "Saving\u2026";
|
|
|
|
try {
|
|
let res;
|
|
if (item.type === "playlist") {
|
|
res = await fetch("/api/tracks/rename", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ src: item.src, title: newTitle }),
|
|
});
|
|
} else {
|
|
res = await fetch(`/api/downloads/${encodeURIComponent(item.filename)}/rename`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: newTitle }),
|
|
});
|
|
}
|
|
if (res.ok) {
|
|
renameStatus.textContent = "Saved!";
|
|
refresh();
|
|
} else {
|
|
renameStatus.textContent = "Save failed.";
|
|
}
|
|
} catch {
|
|
renameStatus.textContent = "Request failed.";
|
|
}
|
|
});
|
|
|
|
// ── Manage downloads ──────────────────────────────────────────────────────────
|
|
async function loadManageList() {
|
|
const tracks = await fetchDownloads();
|
|
if (!tracks.length) {
|
|
manageList.innerHTML = "<p class='hint'>No tracks downloaded yet.</p>";
|
|
return;
|
|
}
|
|
manageList.innerHTML = "";
|
|
tracks.forEach(t => {
|
|
const item = document.createElement("div");
|
|
item.className = "track-item track-item--col";
|
|
const sourceLink = t.source
|
|
? `<a class="track-source" href="${escapeAttr(t.source)}" target="_blank" rel="noopener noreferrer">Source</a>`
|
|
: "";
|
|
item.innerHTML = `
|
|
<div class="track-row">
|
|
<span class="track-title">${escapeHtml(t.title)}</span>
|
|
${sourceLink}
|
|
<button class="btn-danger" type="button">Delete</button>
|
|
</div>
|
|
<div class="track-skip">
|
|
<label class="skip-label">Skip intro</label>
|
|
<input type="number" class="skip-input" min="0" step="1" value="${t.skip || 0}" placeholder="0">
|
|
<span class="skip-unit">sec</span>
|
|
<button class="btn-save" type="button">Save</button>
|
|
</div>`;
|
|
|
|
item.querySelector(".btn-danger").addEventListener("click", async () => {
|
|
await fetch(`/api/downloads/${encodeURIComponent(t.filename)}`, { method: "DELETE" });
|
|
refresh();
|
|
});
|
|
|
|
const skipInput = item.querySelector(".skip-input");
|
|
item.querySelector(".btn-save").addEventListener("click", async () => {
|
|
const seconds = parseInt(skipInput.value, 10) || 0;
|
|
await fetch(`/api/downloads/${encodeURIComponent(t.filename)}/skip`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ seconds }),
|
|
});
|
|
});
|
|
|
|
manageList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, c => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
}[c]));
|
|
}
|
|
function escapeAttr(s) { return escapeHtml(s); }
|
|
|
|
async function fetchDownloads() {
|
|
try {
|
|
const res = await fetch("/api/downloads");
|
|
return res.ok ? await res.json() : [];
|
|
} catch { return []; }
|
|
}
|
|
|
|
async function fetchTracks() {
|
|
try {
|
|
const res = await fetch("/api/tracks");
|
|
return res.ok ? await res.json() : [];
|
|
} catch { return []; }
|
|
}
|
|
|
|
function refresh() {
|
|
loadRenameDropdown();
|
|
loadManageList();
|
|
}
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
refresh();
|