sleep-meditation/containers/sleep-meditation-downloader/site/app.js
Ivo Oskamp 0d9f20690f Release v0.1.6
- 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>
2026-05-10 15:13:10 +02:00

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 => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[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();