const statusBar = document.getElementById("statusBar"); const statusLabel = document.getElementById("statusLabel"); const searchForm = document.getElementById("searchForm"); const searchQuery = document.getElementById("searchQuery"); const searchResults = document.getElementById("searchResults"); const searchWarning = document.getElementById("searchWarning"); const queryVariants = document.getElementById("queryVariants"); const dropzone = document.getElementById("dropzone"); const fileInput = document.getElementById("fileInput"); const uploadResult = document.getElementById("uploadResult"); const downloadForm = document.getElementById("downloadForm"); const downloadUrl = document.getElementById("downloadUrl"); const downloadResult = document.getElementById("downloadResult"); const cardTemplate = document.getElementById("searchCardTemplate"); const previewModal = document.getElementById("previewModal"); const previewMediaFrame = document.getElementById("previewMediaFrame"); const previewTitle = document.getElementById("previewTitle"); const previewVideo = document.getElementById("previewVideo"); const previewThumbnail = document.getElementById("previewThumbnail"); const previewDuration = document.getElementById("previewDuration"); const qualitySelect = document.getElementById("qualitySelect"); const confirmDownload = document.getElementById("confirmDownload"); const closePreviewModal = document.getElementById("closePreviewModal"); const startRange = document.getElementById("startRange"); const endRange = document.getElementById("endRange"); const rangeSummary = document.getElementById("rangeSummary"); const setStartFromPreview = document.getElementById("setStartFromPreview"); const setEndFromPreview = document.getElementById("setEndFromPreview"); let pendingDownload = null; function setStatus(label, progress) { statusLabel.textContent = label; statusBar.style.width = `${Math.max(0, Math.min(100, progress))}%`; } function toClock(totalSeconds) { const seconds = Math.max(0, Math.floor(Number(totalSeconds) || 0)); const hours = String(Math.floor(seconds / 3600)).padStart(2, "0"); const minutes = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0"); const secs = String(seconds % 60).padStart(2, "0"); return `${hours}:${minutes}:${secs}`; } function syncRanges() { let start = Number(startRange.value || 0); let end = Number(endRange.value || 0); if (start > end) { if (document.activeElement === startRange) { end = start; endRange.value = String(end); } else { start = end; startRange.value = String(start); } } rangeSummary.textContent = `${toClock(start)} - ${toClock(end)}`; } function renderQueryVariants(queries = []) { queryVariants.innerHTML = ""; if (!queries.length) { queryVariants.classList.add("hidden"); return; } for (const item of queries) { const badge = document.createElement("span"); badge.className = "rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs uppercase tracking-[0.18em] text-zinc-300"; badge.textContent = item; queryVariants.appendChild(badge); } queryVariants.classList.remove("hidden"); } function connectWS() { const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const socket = new WebSocket(`${protocol}://${window.location.host}/ws`); socket.addEventListener("message", (event) => { const payload = JSON.parse(event.data); if (payload.event !== "progress") { return; } const data = payload.data; const label = data.status || `${data.type || "task"} in progress`; setStatus(label, Number(data.progress ?? 0)); if (data.type === "upload" && data.status === "completed") { uploadResult.textContent = `${data.filename} saved successfully`; } if (data.type === "download" && data.status === "completed") { downloadResult.textContent = data.output || "download completed"; } if (data.status === "error") { downloadResult.textContent = data.message || "task failed"; } }); socket.addEventListener("close", () => { setTimeout(connectWS, 1000); }); } async function api(path, options = {}) { const response = await fetch(path, options); const data = await response.json().catch(() => ({})); if (!response.ok) { const error = new Error(data.error || "request failed"); error.status = response.status; error.data = data; throw error; } return data; } function renderResults(results) { searchResults.innerHTML = ""; if (!results.length) { searchResults.innerHTML = `
No results matched the current search sources.
`; return; } for (const item of results) { const node = cardTemplate.content.firstElementChild.cloneNode(true); node.href = item.link; node.querySelector("img").src = item.thumbnailUrl || "https://placehold.co/1280x720/0a0a0a/ffffff?text=No+Preview"; node.querySelector("img").alt = item.title; node.querySelector("h3").textContent = item.title; node.querySelector("p").textContent = item.reason; node.querySelector(".source-badge").textContent = item.source; searchResults.appendChild(node); } } searchForm.addEventListener("submit", async (event) => { event.preventDefault(); setStatus("preparing search", 5); searchWarning.classList.add("hidden"); try { const data = await api("/api/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: searchQuery.value }), }); renderResults(data.results || []); renderQueryVariants(data.queries || []); if (data.warning) { searchWarning.textContent = data.warning; searchWarning.classList.remove("hidden"); } setStatus("search complete", 100); } catch (error) { searchWarning.textContent = error.message; searchWarning.classList.remove("hidden"); renderQueryVariants([]); setStatus("search failed", 100); } }); async function uploadFile(file) { const formData = new FormData(); formData.append("file", file); uploadResult.textContent = "uploading..."; try { await api("/api/upload", { method: "POST", body: formData }); } catch (error) { uploadResult.textContent = error.message; } } function openPreviewModal(preview) { previewTitle.textContent = preview.title; previewThumbnail.src = preview.thumbnail; previewThumbnail.alt = preview.title; previewVideo.pause(); previewVideo.removeAttribute("src"); previewVideo.load(); previewMediaFrame.style.aspectRatio = ""; if (preview.previewStreamUrl) { previewVideo.src = preview.previewStreamUrl; previewVideo.classList.remove("hidden"); previewThumbnail.classList.add("hidden"); } else { previewVideo.classList.add("hidden"); previewThumbnail.classList.remove("hidden"); } previewDuration.textContent = preview.duration; qualitySelect.innerHTML = ""; for (const item of preview.qualities || []) { const option = document.createElement("option"); option.value = item.value; option.textContent = item.label; qualitySelect.appendChild(option); } const maxDuration = Number(preview.durationSeconds || 0); startRange.max = String(maxDuration); endRange.max = String(maxDuration); startRange.value = "0"; endRange.value = String(maxDuration); syncRanges(); previewModal.classList.remove("hidden"); previewModal.classList.add("flex"); } function closeModal() { previewVideo.pause(); previewVideo.removeAttribute("src"); previewVideo.load(); previewMediaFrame.style.aspectRatio = ""; previewModal.classList.add("hidden"); previewModal.classList.remove("flex"); startRange.value = "0"; endRange.value = "0"; syncRanges(); pendingDownload = null; } dropzone.addEventListener("dragover", (event) => { event.preventDefault(); dropzone.classList.add("border-white/60", "bg-white/[0.08]"); }); dropzone.addEventListener("dragleave", () => { dropzone.classList.remove("border-white/60", "bg-white/[0.08]"); }); dropzone.addEventListener("drop", async (event) => { event.preventDefault(); dropzone.classList.remove("border-white/60", "bg-white/[0.08]"); const file = event.dataTransfer.files[0]; if (file) { await uploadFile(file); } }); fileInput.addEventListener("change", async () => { const [file] = fileInput.files; if (file) { await uploadFile(file); } }); downloadForm.addEventListener("submit", async (event) => { event.preventDefault(); downloadResult.textContent = "checking duplicate history..."; try { const dup = await api(`/api/history/check?url=${encodeURIComponent(downloadUrl.value)}`); let force = false; if (dup.exists) { force = window.confirm("동일 URL 다운로드 이력이 있습니다. 계속 진행할까요?"); if (!force) { downloadResult.textContent = "cancelled"; return; } } pendingDownload = { url: downloadUrl.value, force }; downloadResult.textContent = "loading preview..."; const preview = await api("/api/download/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: downloadUrl.value }), }); openPreviewModal(preview); downloadResult.textContent = "preview loaded"; } catch (error) { downloadResult.textContent = error.message; } }); confirmDownload.addEventListener("click", async () => { if (!pendingDownload) { return; } try { const data = await api("/api/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: pendingDownload.url, start: toClock(startRange.value), end: toClock(endRange.value), quality: qualitySelect.value, force: pendingDownload.force, }), }); closeModal(); downloadResult.textContent = data.message; } catch (error) { downloadResult.textContent = error.message; } }); closePreviewModal.addEventListener("click", closeModal); previewModal.addEventListener("click", (event) => { if (event.target === previewModal) { closeModal(); } }); startRange.addEventListener("input", syncRanges); endRange.addEventListener("input", syncRanges); setStartFromPreview.addEventListener("click", () => { startRange.value = String(Math.floor(previewVideo.currentTime || 0)); syncRanges(); }); setEndFromPreview.addEventListener("click", () => { endRange.value = String(Math.floor(previewVideo.currentTime || 0)); syncRanges(); }); previewVideo.addEventListener("loadedmetadata", () => { if (previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0) { previewMediaFrame.style.aspectRatio = `${previewVideo.videoWidth} / ${previewVideo.videoHeight}`; } }); previewThumbnail.addEventListener("load", () => { if (!previewVideo.src && previewThumbnail.naturalWidth > 0 && previewThumbnail.naturalHeight > 0) { previewMediaFrame.style.aspectRatio = `${previewThumbnail.naturalWidth} / ${previewThumbnail.naturalHeight}`; } }); connectWS(); setStatus("idle", 0);