Files
ai-media-hub/frontend/app.js
T
AI Assistant 8101f17f5b
build-push / docker (push) Failing after 20m32s
Refactor search fallback and preview flows
2026-03-16 11:12:43 +09:00

690 lines
24 KiB
JavaScript

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 platformToggles = Array.from(document.querySelectorAll("[data-platform-toggle]"));
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 rangeSummary = document.getElementById("rangeSummary");
const rangeFill = document.getElementById("rangeFill");
const startThumb = document.getElementById("startThumb");
const endThumb = document.getElementById("endThumb");
const startLabel = document.getElementById("startLabel");
const endLabel = document.getElementById("endLabel");
const setStartFromPreview = document.getElementById("setStartFromPreview");
const setEndFromPreview = document.getElementById("setEndFromPreview");
const debugToggle = document.getElementById("debugToggle");
const debugPanel = document.getElementById("debugPanel");
const closeDebugPanel = document.getElementById("closeDebugPanel");
const clearLogs = document.getElementById("clearLogs");
const downloadLogs = document.getElementById("downloadLogs");
const debugLogList = document.getElementById("debugLogList");
const debugSummary = document.getElementById("debugSummary");
const resultModal = document.getElementById("resultModal");
const resultModalTitle = document.getElementById("resultModalTitle");
const resultModalSource = document.getElementById("resultModalSource");
const resultModalSnippet = document.getElementById("resultModalSnippet");
const resultModalReason = document.getElementById("resultModalReason");
const resultModalFrame = document.getElementById("resultModalFrame");
const resultModalOpenExternal = document.getElementById("resultModalOpenExternal");
const resultModalDownload = document.getElementById("resultModalDownload");
const closeResultModal = document.getElementById("closeResultModal");
let pendingDownload = null;
let cropStart = 0;
let cropEnd = 0;
let cropMax = 0;
let activeThumb = null;
let activeResultItem = null;
const activePlatforms = new Set(["envato", "artgrid", "google video"]);
const hlsInstances = new WeakMap();
const debugEntries = [];
const PREVIEW_PLACEHOLDER = "https://placehold.co/1280x720/0a0a0a/ffffff?text=Preview";
function setStatus(label, progress) {
statusLabel.textContent = label;
statusBar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
logEvent("status", { label, progress });
}
function setHidden(element, hidden, visibleDisplayClass = "flex") {
element.classList.toggle("hidden", hidden);
if (visibleDisplayClass) {
element.classList.toggle(visibleDisplayClass, !hidden);
}
}
function showWarning(message) {
searchWarning.textContent = message;
setHidden(searchWarning, !message, "");
}
function logEvent(type, payload) {
const entry = {
ts: new Date().toISOString(),
type,
payload,
};
debugEntries.push(entry);
if (debugEntries.length > 400) {
debugEntries.shift();
}
renderLogs();
}
function safeStringify(value) {
try {
return JSON.stringify(compactPayload(value), null, 2);
} catch {
return String(value);
}
}
function compactPayload(value, depth = 0) {
if (depth > 3) {
return "[truncated]";
}
if (Array.isArray(value)) {
if (value.length > 8) {
return {
type: "array",
length: value.length,
sample: value.slice(0, 5).map((item) => compactPayload(item, depth + 1)),
};
}
return value.map((item) => compactPayload(item, depth + 1));
}
if (value && typeof value === "object") {
const entries = Object.entries(value);
return Object.fromEntries(entries.map(([key, item]) => [key, compactPayload(item, depth + 1)]));
}
if (typeof value === "string" && value.length > 500) {
return `${value.slice(0, 500)}...`;
}
return value;
}
function renderLogs() {
debugSummary.textContent = `${debugEntries.length} events captured`;
debugLogList.innerHTML = "";
for (const entry of debugEntries.slice().reverse()) {
const node = document.createElement("div");
node.className = "debug-entry";
node.innerHTML = `
<div class="debug-entry__meta">
<span>${entry.type}</span>
<span>${entry.ts}</span>
</div>
<div class="debug-entry__payload">${safeStringify(entry.payload)}</div>
`;
debugLogList.appendChild(node);
}
}
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 = cropStart;
let end = cropEnd;
if (start > end) {
if (activeThumb === "start") {
end = start;
} else {
start = end;
}
}
cropStart = start;
cropEnd = end;
const startPct = cropMax > 0 ? (cropStart / cropMax) * 100 : 0;
const endPct = cropMax > 0 ? (cropEnd / cropMax) * 100 : 0;
startThumb.style.left = `calc(${startPct}% - 10px)`;
endThumb.style.left = `calc(${endPct}% - 10px)`;
rangeFill.style.left = `${startPct}%`;
rangeFill.style.width = `${Math.max(0, endPct - startPct)}%`;
rangeSummary.textContent = `${toClock(start)} - ${toClock(end)}`;
startLabel.textContent = `Start ${toClock(start)}`;
endLabel.textContent = `End ${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 syncPlatformButtons() {
for (const button of platformToggles) {
const platform = button.dataset.platformToggle;
const active = activePlatforms.has(platform);
button.classList.toggle("bg-white", active);
button.classList.toggle("text-black", active);
button.classList.toggle("border-white", active);
button.classList.toggle("bg-transparent", !active);
button.classList.toggle("text-zinc-300", !active);
button.classList.toggle("border-white/20", !active);
}
logEvent("platforms:update", { active: Array.from(activePlatforms) });
}
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") {
logEvent("ws:message:ignored", payload);
return;
}
logEvent("ws:message", payload);
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", () => {
logEvent("ws:close", { reason: "socket closed" });
setTimeout(connectWS, 1000);
});
socket.addEventListener("open", () => {
logEvent("ws:open", { url: socket.url });
});
socket.addEventListener("error", () => {
logEvent("ws:error", { url: socket.url });
});
}
async function api(path, options = {}) {
logEvent("api:request", {
path,
method: options.method || "GET",
hasBody: Boolean(options.body),
bodyPreview: typeof options.body === "string" ? options.body.slice(0, 800) : "[non-string body]",
});
const response = await fetch(path, options);
const rawText = await response.text();
let data = {};
if (rawText) {
try {
data = JSON.parse(rawText);
} catch {
data = { rawText };
}
}
logEvent("api:response", {
path,
status: response.status,
ok: response.ok,
body: compactPayload(data),
});
if (!response.ok) {
const message = data.error || data.rawText || `request failed (${response.status})`;
const error = new Error(message);
error.status = response.status;
error.data = data;
throw error;
}
return data;
}
function attachVideoSource(video, src) {
detachVideoSource(video);
if (!src) {
logEvent("preview:attach:skipped", { reason: "empty src" });
return;
}
if (src.endsWith(".m3u8")) {
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls({ enableWorker: false });
hls.loadSource(src);
hls.attachMedia(video);
hlsInstances.set(video, hls);
logEvent("preview:attach:hls", { src, mode: "hls.js" });
return;
}
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src;
logEvent("preview:attach:hls", { src, mode: "native" });
return;
}
logEvent("preview:attach:skipped", { reason: "hls unsupported", src });
return;
}
video.src = src;
logEvent("preview:attach:file", { src });
}
function startHoverPreview(video, src) {
if (!src) {
return;
}
attachVideoSource(video, src);
video.classList.remove("hidden");
const attemptPlay = () => {
video.play().catch((error) => {
logEvent("preview:hover:play:error", { src, message: String(error) });
});
};
if (video.readyState >= 2) {
attemptPlay();
return;
}
const onReady = () => {
video.removeEventListener("loadeddata", onReady);
video.removeEventListener("canplay", onReady);
attemptPlay();
};
video.addEventListener("loadeddata", onReady, { once: true });
video.addEventListener("canplay", onReady, { once: true });
if (video.load) {
video.load();
}
}
function detachVideoSource(video) {
const existing = hlsInstances.get(video);
if (existing) {
existing.destroy();
hlsInstances.delete(video);
logEvent("preview:detach:hls", { ok: true });
}
video.removeAttribute("src");
video.load();
}
function resetPreviewPlayer() {
previewVideo.pause();
detachVideoSource(previewVideo);
previewMediaFrame.style.aspectRatio = "";
}
function showModal(element) {
setHidden(element, false);
}
function hideModal(element) {
setHidden(element, true);
}
function renderResults(results) {
searchResults.innerHTML = "";
if (!results.length) {
searchResults.innerHTML = `<div class="rounded-3xl border border-white/10 bg-black/30 p-5 text-sm text-zinc-400">No results matched the current search sources.</div>`;
return;
}
for (const item of results) {
const node = cardTemplate.content.firstElementChild.cloneNode(true);
const image = node.querySelector("img");
const previewVideo = node.querySelector(".preview-hover");
const overlays = node.querySelectorAll(".preview-overlay");
image.src = item.thumbnailUrl || PREVIEW_PLACEHOLDER;
image.alt = item.title;
node.querySelector("h3").textContent = item.title;
node.querySelector(".result-snippet").textContent = item.snippet || item.reason || item.source || "";
node.querySelector(".result-reason").textContent = item.reason ? `AI 노트: ${item.reason}` : "";
node.querySelector(".source-badge").textContent = item.source;
node.addEventListener("click", () => openResultModal(item));
previewVideo.poster = item.thumbnailUrl || "";
if (item.previewVideoUrl) {
const mediaArea = node.querySelector(".relative");
mediaArea.addEventListener("mouseenter", () => {
logEvent("preview:hover:start", { title: item.title, source: item.source, previewVideoUrl: item.previewVideoUrl });
overlays.forEach((overlay) => overlay.classList.add("hidden"));
startHoverPreview(previewVideo, item.previewVideoUrl);
});
mediaArea.addEventListener("mouseleave", () => {
logEvent("preview:hover:end", { title: item.title, source: item.source });
previewVideo.pause();
previewVideo.currentTime = 0;
previewVideo.classList.add("hidden");
detachVideoSource(previewVideo);
overlays.forEach((overlay) => overlay.classList.remove("hidden"));
});
}
searchResults.appendChild(node);
}
}
async function prepareDirectDownload(targetUrl) {
downloadResult.textContent = "checking duplicate history...";
const dup = await api(`/api/history/check?url=${encodeURIComponent(targetUrl)}`);
let force = false;
if (dup.exists) {
force = window.confirm("동일 URL 다운로드 이력이 있습니다. 계속 진행할까요?");
if (!force) {
downloadResult.textContent = "cancelled";
return;
}
}
pendingDownload = { url: targetUrl, force };
downloadResult.textContent = "loading preview...";
const preview = await api("/api/download/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: targetUrl }),
});
openPreviewModal(preview);
downloadResult.textContent = "preview loaded";
}
function openResultModal(item) {
activeResultItem = item;
resultModalTitle.textContent = item.title || "Untitled";
resultModalSource.textContent = item.source || "";
resultModalSnippet.textContent = item.snippet || "원본 페이지에서 사용할 수 있는 설명이 없습니다.";
resultModalReason.textContent = item.reason || "AI 노트가 없습니다.";
resultModalFrame.src = item.link || "about:blank";
resultModalOpenExternal.href = item.link || "#";
const canDirectDownload = item.source === "Google Video" && item.link;
resultModalDownload.classList.toggle("hidden", !canDirectDownload);
showModal(resultModal);
logEvent("result:modal:open", { title: item.title, source: item.source, link: item.link });
}
function closeResultViewer() {
if (!resultModal.classList.contains("hidden")) {
logEvent("result:modal:close", { title: activeResultItem?.title || "" });
}
activeResultItem = null;
resultModalFrame.src = "about:blank";
hideModal(resultModal);
}
searchForm.addEventListener("submit", async (event) => {
event.preventDefault();
setStatus("preparing search", 5);
showWarning("");
try {
const data = await api("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: searchQuery.value, platforms: Array.from(activePlatforms) }),
});
renderResults(data.results || []);
renderQueryVariants(data.queries || []);
showWarning(data.warning || "");
logEvent("search:completed", { results: data.results?.length || 0, queries: data.queries || [] });
setStatus("search complete", 100);
} catch (error) {
showWarning(error.message);
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 });
logEvent("upload:completed", { fileName: file.name, size: file.size });
} catch (error) {
uploadResult.textContent = error.message;
}
}
function openPreviewModal(preview) {
logEvent("preview:modal:open", preview);
previewTitle.textContent = preview.title;
previewThumbnail.src = preview.thumbnail || PREVIEW_PLACEHOLDER;
previewThumbnail.alt = preview.title;
resetPreviewPlayer();
if (preview.previewStreamUrl) {
attachVideoSource(previewVideo, preview.previewStreamUrl);
setHidden(previewVideo, false, "");
setHidden(previewThumbnail, true, "");
} else {
setHidden(previewVideo, true, "");
setHidden(previewThumbnail, false, "");
}
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);
}
cropMax = Number(preview.durationSeconds || 0);
cropStart = 0;
cropEnd = cropMax;
syncRanges();
showModal(previewModal);
}
function closeModal() {
logEvent("preview:modal:close", { title: previewTitle.textContent });
resetPreviewPlayer();
hideModal(previewModal);
cropStart = 0;
cropEnd = 0;
cropMax = 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();
try {
await prepareDirectDownload(downloadUrl.value);
} catch (error) {
downloadResult.textContent = error.message;
logEvent("download:preview:error", { message: error.message, data: error.data || null });
}
});
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(cropStart),
end: toClock(cropEnd),
quality: qualitySelect.value,
force: pendingDownload.force,
}),
});
closeModal();
downloadResult.textContent = data.message;
} catch (error) {
downloadResult.textContent = error.message;
logEvent("download:start:error", { message: error.message, data: error.data || null });
}
});
closePreviewModal.addEventListener("click", closeModal);
closeResultModal.addEventListener("click", closeResultViewer);
resultModal.addEventListener("click", (event) => {
if (event.target === resultModal) {
closeResultViewer();
}
});
resultModalDownload.addEventListener("click", async () => {
if (!activeResultItem?.link) {
return;
}
try {
closeResultViewer();
await prepareDirectDownload(activeResultItem.link);
} catch (error) {
downloadResult.textContent = error.message;
logEvent("download:preview:error", { message: error.message, data: error.data || null, source: activeResultItem?.source || "" });
}
});
previewModal.addEventListener("click", (event) => {
if (event.target === previewModal) {
closeModal();
}
});
setStartFromPreview.addEventListener("click", () => {
cropStart = Math.floor(previewVideo.currentTime || 0);
activeThumb = "start";
syncRanges();
});
setEndFromPreview.addEventListener("click", () => {
cropEnd = Math.floor(previewVideo.currentTime || 0);
activeThumb = "end";
syncRanges();
});
for (const [thumb, name] of [[startThumb, "start"], [endThumb, "end"]]) {
thumb.addEventListener("pointerdown", (event) => {
event.preventDefault();
activeThumb = name;
thumb.setPointerCapture(event.pointerId);
});
thumb.addEventListener("pointermove", (event) => {
if (activeThumb !== name || cropMax <= 0) {
return;
}
const track = thumb.parentElement.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (event.clientX - track.left) / track.width));
const value = Math.round(ratio * cropMax);
if (name === "start") {
cropStart = value;
} else {
cropEnd = value;
}
syncRanges();
});
thumb.addEventListener("pointerup", () => {
activeThumb = null;
});
}
previewVideo.addEventListener("loadedmetadata", () => {
logEvent("preview:modal:loadedmetadata", {
width: previewVideo.videoWidth,
height: previewVideo.videoHeight,
src: previewVideo.currentSrc || previewVideo.src,
});
if (previewVideo.videoWidth > 0 && previewVideo.videoHeight > 0) {
previewMediaFrame.style.aspectRatio = `${previewVideo.videoWidth} / ${previewVideo.videoHeight}`;
}
});
previewThumbnail.addEventListener("load", () => {
logEvent("preview:thumbnail:loaded", {
width: previewThumbnail.naturalWidth,
height: previewThumbnail.naturalHeight,
src: previewThumbnail.currentSrc || previewThumbnail.src,
});
if (!previewVideo.src && previewThumbnail.naturalWidth > 0 && previewThumbnail.naturalHeight > 0) {
previewMediaFrame.style.aspectRatio = `${previewThumbnail.naturalWidth} / ${previewThumbnail.naturalHeight}`;
}
});
for (const button of platformToggles) {
button.addEventListener("click", () => {
const platform = button.dataset.platformToggle;
if (activePlatforms.has(platform) && activePlatforms.size > 1) {
activePlatforms.delete(platform);
} else {
activePlatforms.add(platform);
}
syncPlatformButtons();
setStatus(`active platforms: ${Array.from(activePlatforms).join(", ")}`, 0);
});
}
debugToggle.addEventListener("click", () => {
debugPanel.classList.remove("hidden");
logEvent("debug:panel:open", {});
});
closeDebugPanel.addEventListener("click", () => {
debugPanel.classList.add("hidden");
});
clearLogs.addEventListener("click", () => {
debugEntries.length = 0;
renderLogs();
});
downloadLogs.addEventListener("click", () => {
const blob = new Blob(
[debugEntries.map((entry) => `[${entry.ts}] ${entry.type}\n${safeStringify(entry.payload)}\n`).join("\n")],
{ type: "text/plain;charset=utf-8" },
);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `ai-media-hub-${new Date().toISOString().replace(/[:.]/g, "-")}.log`;
anchor.click();
URL.revokeObjectURL(url);
});
window.addEventListener("error", (event) => {
logEvent("window:error", { message: event.message, filename: event.filename, lineno: event.lineno, colno: event.colno });
});
window.addEventListener("unhandledrejection", (event) => {
logEvent("window:unhandledrejection", { reason: String(event.reason) });
});
connectWS();
syncPlatformButtons();
setStatus(`active platforms: ${Array.from(activePlatforms).join(", ")}`, 0);
logEvent("app:ready", { activePlatforms: Array.from(activePlatforms) });