213 lines
7.5 KiB
JavaScript
213 lines
7.5 KiB
JavaScript
|
|
const form = document.querySelector("#inspect-form");
|
|||
|
|
const urlInput = document.querySelector("#source-url");
|
|||
|
|
const submitButton = document.querySelector("#inspect-button");
|
|||
|
|
const errorNotice = document.querySelector("#error-notice");
|
|||
|
|
const errorMessage = document.querySelector("#error-message");
|
|||
|
|
const resultSection = document.querySelector("#result");
|
|||
|
|
const heroSection = document.querySelector(".hero");
|
|||
|
|
const newSourceButton = document.querySelector("#new-source");
|
|||
|
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|||
|
|
|
|||
|
|
const fields = {
|
|||
|
|
title: document.querySelector("#result-title"),
|
|||
|
|
sourceLink: document.querySelector("#source-link"),
|
|||
|
|
thumbnail: document.querySelector("#thumbnail"),
|
|||
|
|
thumbnailShell: document.querySelector("#thumbnail-shell"),
|
|||
|
|
duration: document.querySelector("#duration"),
|
|||
|
|
extractor: document.querySelector("#extractor"),
|
|||
|
|
uploader: document.querySelector("#uploader"),
|
|||
|
|
uploadDate: document.querySelector("#upload-date"),
|
|||
|
|
topQuality: document.querySelector("#top-quality"),
|
|||
|
|
bestSpecs: document.querySelector("#best-specs"),
|
|||
|
|
compatibleOption: document.querySelector("#compatible-option"),
|
|||
|
|
compatibleSpecs: document.querySelector("#compatible-specs"),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function displayValue(value, fallback = "Not available") {
|
|||
|
|
return value === null || value === undefined || value === "" ? fallback : String(value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatDuration(seconds) {
|
|||
|
|
if (!Number.isFinite(seconds)) return "Unknown duration";
|
|||
|
|
const total = Math.max(0, Math.round(seconds));
|
|||
|
|
const hours = Math.floor(total / 3600);
|
|||
|
|
const minutes = Math.floor((total % 3600) / 60);
|
|||
|
|
const remainingSeconds = total % 60;
|
|||
|
|
const parts = hours > 0 ? [hours, minutes, remainingSeconds] : [minutes, remainingSeconds];
|
|||
|
|
return parts.map((part) => String(part).padStart(2, "0")).join(":");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatUploadDate(value) {
|
|||
|
|
if (!/^\d{8}$/.test(value || "")) return displayValue(value);
|
|||
|
|
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolutionLabel(format) {
|
|||
|
|
if (format.width && format.height) return `${format.width} × ${format.height}`;
|
|||
|
|
if (format.audio_codec && format.audio_codec !== "none") return "Audio only";
|
|||
|
|
return "Unknown";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isVideo(format) {
|
|||
|
|
return format.video_codec && format.video_codec !== "none";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isAudio(format) {
|
|||
|
|
return format.audio_codec && format.audio_codec !== "none";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sortByVisualQuality(formats) {
|
|||
|
|
return [...formats].sort((left, right) => {
|
|||
|
|
return (right.height || 0) - (left.height || 0) || (right.fps || 0) - (left.fps || 0);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function codecFamily(codec) {
|
|||
|
|
if (!codec || codec === "none") return null;
|
|||
|
|
const normalized = codec.toLowerCase();
|
|||
|
|
if (normalized.startsWith("avc") || normalized === "h264") return "H.264";
|
|||
|
|
if (normalized.startsWith("av01")) return "AV1";
|
|||
|
|
if (normalized.startsWith("vp9") || normalized.startsWith("vp09")) return "VP9";
|
|||
|
|
if (normalized.startsWith("mp4a") || normalized === "aac") return "AAC";
|
|||
|
|
if (normalized.startsWith("opus")) return "Opus";
|
|||
|
|
return codec;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function addSpec(list, label, value) {
|
|||
|
|
const wrapper = document.createElement("div");
|
|||
|
|
const term = document.createElement("dt");
|
|||
|
|
const detail = document.createElement("dd");
|
|||
|
|
term.textContent = label;
|
|||
|
|
detail.textContent = value;
|
|||
|
|
wrapper.append(term, detail);
|
|||
|
|
list.append(wrapper);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderOption(list, video, audio) {
|
|||
|
|
list.replaceChildren();
|
|||
|
|
addSpec(list, "Quality", resolutionLabel(video));
|
|||
|
|
if (video.fps) addSpec(list, "FPS", String(video.fps));
|
|||
|
|
addSpec(list, "Video", displayValue(codecFamily(video.video_codec), "Unknown"));
|
|||
|
|
const audioCodec = isAudio(video) ? video.audio_codec : audio?.audio_codec;
|
|||
|
|
addSpec(list, "Audio", displayValue(codecFamily(audioCodec), "Unknown"));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderOutputOptions(formats) {
|
|||
|
|
const orderedVideo = sortByVisualQuality(formats.filter(isVideo));
|
|||
|
|
const audioOnly = formats.filter((format) => !isVideo(format) && isAudio(format));
|
|||
|
|
const bestVideo = orderedVideo[0];
|
|||
|
|
const bestAudio = audioOnly.at(-1);
|
|||
|
|
|
|||
|
|
if (!bestVideo) {
|
|||
|
|
fields.topQuality.textContent = "Audio only";
|
|||
|
|
fields.bestSpecs.replaceChildren();
|
|||
|
|
fields.compatibleOption.hidden = true;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fields.topQuality.textContent = resolutionLabel(bestVideo);
|
|||
|
|
renderOption(fields.bestSpecs, bestVideo, bestAudio);
|
|||
|
|
|
|||
|
|
const compatibleVideo = orderedVideo.find((format) => {
|
|||
|
|
const videoCodec = codecFamily(format.video_codec);
|
|||
|
|
const audioCodec = codecFamily(format.audio_codec);
|
|||
|
|
return videoCodec === "H.264" && (!isAudio(format) || audioCodec === "AAC");
|
|||
|
|
});
|
|||
|
|
const compatibleAudio = audioOnly.findLast((format) => codecFamily(format.audio_codec) === "AAC");
|
|||
|
|
|
|||
|
|
if (compatibleVideo && (isAudio(compatibleVideo) || compatibleAudio)) {
|
|||
|
|
renderOption(fields.compatibleSpecs, compatibleVideo, compatibleAudio);
|
|||
|
|
fields.compatibleOption.hidden = false;
|
|||
|
|
} else {
|
|||
|
|
fields.compatibleOption.hidden = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderResult(data) {
|
|||
|
|
fields.title.textContent = data.title;
|
|||
|
|
fields.sourceLink.href = data.source_url;
|
|||
|
|
fields.extractor.textContent = displayValue(data.extractor);
|
|||
|
|
fields.uploader.textContent = displayValue(data.uploader);
|
|||
|
|
fields.uploadDate.textContent = formatUploadDate(data.upload_date);
|
|||
|
|
fields.duration.textContent = formatDuration(data.duration);
|
|||
|
|
|
|||
|
|
if (data.thumbnail) {
|
|||
|
|
fields.thumbnail.src = data.thumbnail;
|
|||
|
|
fields.thumbnail.alt = `Preview for ${data.title}`;
|
|||
|
|
fields.thumbnail.hidden = false;
|
|||
|
|
fields.thumbnailShell.classList.remove("no-image");
|
|||
|
|
} else {
|
|||
|
|
fields.thumbnail.removeAttribute("src");
|
|||
|
|
fields.thumbnail.alt = "";
|
|||
|
|
fields.thumbnail.hidden = true;
|
|||
|
|
fields.thumbnailShell.classList.add("no-image");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
renderOutputOptions(data.formats);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function transitionDelay(milliseconds) {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
window.setTimeout(resolve, reducedMotion.matches ? 0 : milliseconds);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function showResult(data) {
|
|||
|
|
renderResult(data);
|
|||
|
|
heroSection.classList.add("is-leaving");
|
|||
|
|
await transitionDelay(180);
|
|||
|
|
heroSection.hidden = true;
|
|||
|
|
heroSection.classList.remove("is-leaving");
|
|||
|
|
resultSection.hidden = false;
|
|||
|
|
window.scrollTo({ top: 0, behavior: "auto" });
|
|||
|
|
window.requestAnimationFrame(() => resultSection.classList.add("is-visible"));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function showInspector() {
|
|||
|
|
resultSection.classList.remove("is-visible");
|
|||
|
|
resultSection.classList.add("is-leaving");
|
|||
|
|
await transitionDelay(180);
|
|||
|
|
resultSection.hidden = true;
|
|||
|
|
resultSection.classList.remove("is-leaving");
|
|||
|
|
heroSection.classList.add("is-leaving");
|
|||
|
|
heroSection.hidden = false;
|
|||
|
|
window.requestAnimationFrame(() => {
|
|||
|
|
heroSection.classList.remove("is-leaving");
|
|||
|
|
urlInput.focus();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setLoading(isLoading) {
|
|||
|
|
submitButton.disabled = isLoading;
|
|||
|
|
urlInput.disabled = isLoading;
|
|||
|
|
form.setAttribute("aria-busy", String(isLoading));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
form.addEventListener("submit", async (event) => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
errorNotice.hidden = true;
|
|||
|
|
resultSection.hidden = true;
|
|||
|
|
setLoading(true);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const response = await fetch("/api/inspect", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({ url: urlInput.value.trim() }),
|
|||
|
|
});
|
|||
|
|
const data = await response.json();
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
throw new Error(data.detail || "The source could not be inspected.");
|
|||
|
|
}
|
|||
|
|
await showResult(data);
|
|||
|
|
} catch (error) {
|
|||
|
|
errorMessage.textContent = error instanceof Error ? error.message : "Inspection failed.";
|
|||
|
|
errorNotice.hidden = false;
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
newSourceButton.addEventListener("click", showInspector);
|