feat: add dark source inspection interface

This commit is contained in:
bot
2026-08-12 18:42:03 +03:00
parent db75f2c4f8
commit 277aa8a568
9 changed files with 1090 additions and 16 deletions
+13 -3
View File
@@ -1,5 +1,11 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi import HTTPException
from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.concurrency import run_in_threadpool
from app.inspection import InspectionError, UnsafeUrlError, inspect_url
@@ -11,10 +17,14 @@ app = FastAPI(
version="0.1.0",
)
app_directory = Path(__file__).resolve().parent
templates = Jinja2Templates(directory=app_directory / "templates")
app.mount("/static", StaticFiles(directory=app_directory / "static"), name="static")
@app.get("/", tags=["system"])
def root() -> dict[str, str]:
return {"name": "Media Ingest", "status": "running"}
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request=request, name="index.html")
@app.get("/health", tags=["system"])
+212
View File
@@ -0,0 +1,212 @@
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);
+588
View File
@@ -0,0 +1,588 @@
:root {
color-scheme: dark;
--background: #090a0c;
--surface: #101216;
--surface-raised: #15181d;
--border: #242830;
--border-strong: #363c47;
--text: #f1f3f5;
--muted: #9299a4;
--quiet: #626974;
--accent: #d9ff43;
--accent-text: #111408;
--danger: #ff6b6b;
--radius: 14px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
html {
background: var(--background);
}
body {
min-width: 320px;
margin: 0;
color: var(--text);
background:
radial-gradient(circle at 50% -20%, rgba(217, 255, 67, 0.07), transparent 34rem),
var(--background);
}
button,
input {
font: inherit;
}
a {
color: inherit;
}
.site-header,
.page-shell,
footer {
width: min(1180px, calc(100% - 40px));
margin-inline: auto;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 76px;
border-bottom: 1px solid var(--border);
}
.brand {
display: inline-flex;
gap: 10px;
align-items: center;
color: var(--text);
font-size: 13px;
font-weight: 650;
letter-spacing: 0.08em;
text-decoration: none;
text-transform: uppercase;
}
.brand-mark {
width: 8px;
height: 8px;
background: var(--accent);
border-radius: 2px;
box-shadow: 0 0 16px rgba(217, 255, 67, 0.45);
}
.environment {
padding: 6px 9px;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.page-shell {
min-height: calc(100vh - 142px);
}
.hero {
max-width: 900px;
padding: 104px 0 82px;
transition: opacity 180ms ease, transform 180ms ease;
}
.hero.is-leaving {
opacity: 0;
transform: translateY(-10px);
}
.eyebrow {
margin: 0 0 18px;
color: var(--accent);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
text-wrap: pretty;
}
h1 {
max-width: 780px;
margin: 0;
font-size: clamp(42px, 6vw, 74px);
font-weight: 560;
letter-spacing: -0.055em;
line-height: 0.98;
}
.intro {
max-width: 590px;
margin: 30px 0 0;
color: var(--muted);
font-size: 16px;
line-height: 1.7;
}
.inspect-form {
margin-top: 48px;
}
.input-shell {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
padding: 8px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.input-shell:focus-within {
border-color: #59626f;
box-shadow: 0 0 0 3px rgba(217, 255, 67, 0.08);
}
input {
min-width: 0;
padding: 15px 14px;
color: var(--text);
background: transparent;
border: 0;
outline: 0;
}
input::placeholder {
color: var(--quiet);
}
button {
position: relative;
min-width: 142px;
padding: 13px 18px;
color: var(--accent-text);
font-size: 13px;
font-weight: 700;
background: var(--accent);
border: 0;
border-radius: 9px;
cursor: pointer;
transition: filter 140ms ease, transform 140ms ease;
}
button:hover {
filter: brightness(0.92);
}
button:active {
transform: translateY(1px);
}
button:disabled {
color: transparent;
cursor: wait;
filter: saturate(0.6);
}
.button-progress {
position: absolute;
top: calc(50% - 8px);
left: calc(50% - 8px);
display: none;
width: 16px;
height: 16px;
border: 2px solid rgba(17, 20, 8, 0.3);
border-top-color: var(--accent-text);
border-radius: 50%;
animation: spin 700ms linear infinite;
}
button:disabled .button-progress {
display: block;
}
.form-note {
margin: 12px 2px 0;
color: var(--quiet);
font-size: 12px;
}
.notice {
display: flex;
gap: 12px;
margin-top: 20px;
padding: 14px 16px;
color: #ffb4b4;
background: rgba(255, 107, 107, 0.07);
border: 1px solid rgba(255, 107, 107, 0.22);
border-radius: 10px;
font-size: 13px;
}
.notice-label {
flex: none;
color: var(--danger);
font-weight: 700;
}
.result {
padding: 72px 0 100px;
opacity: 0;
transform: translateY(12px);
transition: opacity 220ms ease, transform 220ms ease;
}
.result.is-visible {
opacity: 1;
transform: translateY(0);
}
.result.is-leaving {
opacity: 0;
transform: translateY(8px);
}
.result-heading {
display: flex;
gap: 24px;
align-items: flex-end;
justify-content: space-between;
padding: 58px 0 30px;
}
.result-heading .eyebrow,
.formats-heading .eyebrow {
margin-bottom: 12px;
}
h2 {
max-width: 820px;
margin: 0;
font-size: clamp(28px, 4vw, 46px);
font-weight: 560;
letter-spacing: -0.04em;
line-height: 1.08;
}
.source-link {
flex: none;
padding-bottom: 3px;
color: var(--muted);
border-bottom: 1px solid var(--border-strong);
font-size: 12px;
text-decoration: none;
}
.result-actions {
display: flex;
flex: none;
gap: 18px;
align-items: center;
}
.text-button {
min-width: 0;
padding: 0 0 3px;
color: var(--muted);
font-size: 12px;
font-weight: 400;
background: transparent;
border-bottom: 1px solid var(--border-strong);
border-radius: 0;
}
.text-button:hover {
color: var(--text);
filter: none;
}
.source-link:hover {
color: var(--text);
border-color: var(--text);
}
.source-card {
display: grid;
grid-template-columns: minmax(300px, 0.85fr) minmax(380px, 1.15fr);
max-width: 920px;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.thumbnail-shell {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--surface-raised);
}
.thumbnail-shell img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-shell.no-image::after {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--quiet);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
letter-spacing: 0.1em;
content: "NO PREVIEW";
}
.duration {
position: absolute;
right: 12px;
bottom: 12px;
padding: 6px 8px;
color: var(--text);
background: rgba(9, 10, 12, 0.86);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
}
.metadata {
display: grid;
grid-template-columns: 1fr 1fr;
margin: 0;
padding: 8px;
}
.metadata div {
min-width: 0;
padding: 18px 16px;
border-bottom: 1px solid var(--border);
}
.metadata div:nth-last-child(-n + 2) {
border-bottom: 0;
}
.metadata dt {
margin-bottom: 10px;
color: var(--quiet);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.metadata dd {
overflow: hidden;
margin: 0;
color: var(--text);
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.formats-heading {
display: flex;
align-items: end;
justify-content: space-between;
margin-top: 68px;
}
h3 {
margin: 0;
font-size: 26px;
font-weight: 570;
letter-spacing: -0.03em;
}
.formats-heading > p {
margin: 0 0 2px;
color: var(--quiet);
font-size: 12px;
}
.output-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-top: 22px;
}
.output-option {
display: grid;
grid-template-columns: auto 1fr;
gap: 22px;
padding: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.option-index {
color: var(--accent);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
}
.option-copy h4 {
margin: 0;
font-size: 17px;
font-weight: 620;
letter-spacing: -0.02em;
}
.option-copy p {
max-width: 370px;
margin: 9px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.6;
}
.option-specs {
display: flex;
grid-column: 1 / -1;
gap: 8px;
flex-wrap: wrap;
margin: 2px 0 0 32px;
}
.option-specs div {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 7px 9px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid var(--border);
border-radius: 7px;
}
.option-specs dt,
.option-specs dd {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 9px;
}
.option-specs dt {
color: var(--quiet);
text-transform: uppercase;
}
.option-specs dd {
color: var(--text);
}
footer {
display: flex;
justify-content: space-between;
padding: 24px 0 30px;
color: var(--quiet);
border-top: 1px solid var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
[hidden] {
display: none !important;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 760px) {
.site-header,
.page-shell,
footer {
width: min(100% - 28px, 1180px);
}
.hero {
padding: 72px 0 62px;
}
h1 {
font-size: clamp(40px, 13vw, 58px);
}
.input-shell {
grid-template-columns: 1fr;
}
button {
min-height: 48px;
}
.result-heading,
.formats-heading {
align-items: flex-start;
flex-direction: column;
}
.result-actions {
margin-top: -8px;
}
.source-card {
grid-template-columns: 1fr;
}
.output-options {
grid-template-columns: 1fr;
}
.metadata div {
padding: 20px 14px;
}
.formats-heading > p {
margin-top: -10px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+128
View File
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0a0b0d">
<title>Media Ingest</title>
<link rel="stylesheet" href="{{ url_for('static', path='/styles.css') }}">
<script src="{{ url_for('static', path='/app.js') }}" defer></script>
</head>
<body>
<header class="site-header">
<a class="brand" href="/" aria-label="Media Ingest home">
<span class="brand-mark" aria-hidden="true"></span>
<span>Media Ingest</span>
</a>
<span class="environment">Local</span>
</header>
<main class="page-shell">
<section class="hero" aria-labelledby="page-title">
<p class="eyebrow">Source inspection</p>
<h1 id="page-title">Bring the source.<br>See what is available.</h1>
<p class="intro">
Inspect public video sources before choosing what enters your workflow.
Nothing is downloaded at this stage.
</p>
<form class="inspect-form" id="inspect-form">
<label class="sr-only" for="source-url">Public video URL</label>
<div class="input-shell">
<input
id="source-url"
name="url"
type="url"
inputmode="url"
autocomplete="url"
maxlength="2048"
placeholder="Paste a public video URL"
required
>
<button id="inspect-button" type="submit">
<span class="button-label">Inspect source</span>
<span class="button-progress" aria-hidden="true"></span>
</button>
</div>
<p class="form-note">HTTP and HTTPS sources only. Playlists are not supported.</p>
</form>
<div class="notice error-notice" id="error-notice" role="alert" hidden>
<span class="notice-label">Inspection failed</span>
<span id="error-message"></span>
</div>
</section>
<section class="result" id="result" aria-live="polite" hidden>
<div class="result-heading">
<div>
<p class="eyebrow">Source found</p>
<h2 id="result-title"></h2>
</div>
<div class="result-actions">
<button class="text-button" id="new-source" type="button">New source</button>
<a id="source-link" class="source-link" target="_blank" rel="noreferrer">Open source</a>
</div>
</div>
<div class="source-card">
<div class="thumbnail-shell" id="thumbnail-shell">
<img id="thumbnail" alt="" referrerpolicy="no-referrer">
<span class="duration" id="duration"></span>
</div>
<dl class="metadata">
<div>
<dt>Platform</dt>
<dd id="extractor"></dd>
</div>
<div>
<dt>Publisher</dt>
<dd id="uploader"></dd>
</div>
<div>
<dt>Published</dt>
<dd id="upload-date"></dd>
</div>
<div>
<dt>Top quality</dt>
<dd id="top-quality"></dd>
</div>
</dl>
</div>
<div class="formats-heading">
<div>
<p class="eyebrow">Output choices</p>
<h3>Choose the outcome</h3>
</div>
<p>Technical stream selection happens automatically.</p>
</div>
<div class="output-options" id="output-options">
<article class="output-option" id="best-option">
<div class="option-index">01</div>
<div class="option-copy">
<h4>Best available</h4>
<p>Keep the highest source quality. Container and codec may vary.</p>
</div>
<dl class="option-specs" id="best-specs"></dl>
</article>
<article class="output-option" id="compatible-option">
<div class="option-index">02</div>
<div class="option-copy">
<h4>Compatible MP4</h4>
<p>Prefer H.264 video and AAC audio for editing and playback.</p>
</div>
<dl class="option-specs" id="compatible-specs"></dl>
</article>
</div>
</section>
</main>
<footer>
<span>Private media workflow</span>
<span>Inspection only</span>
</footer>
</body>
</html>