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
+25 -11
View File
@@ -36,22 +36,27 @@ accounts, playlists, live recording, or multiple simultaneous downloads.
## Development stages ## Development stages
1. Create a minimal containerized web application with a health check. - [x] Create a minimal containerized web application with a health check.
2. Inspect a URL and display sanitized source metadata and formats. - [x] Inspect a URL and display sanitized source metadata and formats.
3. Add a durable job queue and separate worker. - [x] Add a dark, responsive inspection interface.
4. Download media and report progress. - [ ] Define download presets and their exact yt-dlp selectors.
5. Add original-quality and compatible-MP4 output modes. - [ ] Add a durable job queue and separate worker.
6. Add cleanup, limits, failure handling, and security tests. - [ ] Download media and report progress.
7. Validate the complete local Docker deployment. - [ ] Add cleanup, limits, failure handling, and security tests.
8. Deploy privately to the VPS through Cloudflare Access and Tunnel. - [ ] Validate the complete local Docker deployment.
- [ ] Deploy privately to the VPS through Cloudflare Access and Tunnel.
Each stage should produce a small, working, reviewable commit. Each stage should produce a small, working, reviewable commit.
## Local development ## Local development
The current milestone is a minimal FastAPI application running in a hardened The current milestone provides a browser interface for inspecting public video
local container. Docker publishes it only on the PC's loopback interface, so it sources. It shows normalized source metadata and previews best-available and
is not exposed to other devices on the LAN. compatible-MP4 outcomes. Download jobs are not implemented yet.
The application runs as a non-root user in a read-only container with Linux
capabilities dropped. Docker publishes it only on the PC's loopback interface,
so it is not exposed to other devices on the LAN.
Build and start it: Build and start it:
@@ -85,6 +90,10 @@ hostname must resolve entirely to public IP addresses. Playlists and
multi-video sources are rejected, and raw signed media URLs are not returned multi-video sources are rejected, and raw signed media URLs are not returned
to the browser. to the browser.
The initial URL validation is not yet a complete SSRF defense. Do not expose
this development build to the Internet. See [`docs/SECURITY.md`](docs/SECURITY.md)
for the implemented boundary and work required before deployment.
Run the automated tests in an ephemeral container: Run the automated tests in an ephemeral container:
```bash ```bash
@@ -107,6 +116,11 @@ The original project discussion is retained in
that affect the implementation will be documented in this repository rather that affect the implementation will be documented in this repository rather
than relying on that discussion alone. than relying on that discussion alone.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) describes the current and
planned application structure.
- [`docs/SECURITY.md`](docs/SECURITY.md) tracks the trust boundary and
pre-deployment requirements.
## Responsible use ## Responsible use
This tool is intended for material the operator is authorized to retrieve and This tool is intended for material the operator is authorized to retrieve and
+13 -3
View File
@@ -1,5 +1,11 @@
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi import HTTPException 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 starlette.concurrency import run_in_threadpool
from app.inspection import InspectionError, UnsafeUrlError, inspect_url from app.inspection import InspectionError, UnsafeUrlError, inspect_url
@@ -11,10 +17,14 @@ app = FastAPI(
version="0.1.0", 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]: @app.get("/", response_class=HTMLResponse, include_in_schema=False)
return {"name": "Media Ingest", "status": "running"} def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request=request, name="index.html")
@app.get("/health", tags=["system"]) @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>
+48
View File
@@ -0,0 +1,48 @@
# Architecture
## Current state
```text
Browser
-> FastAPI / Jinja interface
-> POST /api/inspect
-> URL validation
-> yt-dlp metadata extraction (download disabled)
-> normalized metadata and output previews
```
FastAPI runs inspection in a thread pool. The browser receives a deliberately
small response and never receives yt-dlp's signed media URLs. Displayed output
options are previews; backend download selectors are not implemented yet.
## Next application boundary
Downloads will use durable jobs rather than long-running HTTP requests:
```text
Web service -> SQLite job record <- Worker service
|
+-> yt-dlp
+-> FFmpeg / ffprobe
+-> per-job directory
```
The worker will claim one queued job at a time, update progress, validate the
finished media, and record the artifact. This preserves state across web
restarts and keeps media processing outside request handlers.
## Planned output presets
1. **Best available** — highest useful source quality, merging separate video
and audio streams without transcoding.
2. **Compatible MP4** — prefer the highest H.264 video and AAC audio streams
that can be merged into MP4 without transcoding.
If no compatible combination exists, the first version should report that
instead of silently starting an expensive transcode. Transcoding can later be
an explicit third outcome with CPU and time limits.
## Production boundary
Production will route Cloudflare Access through Cloudflare Tunnel to the web
service. The origin will not publish an application port directly.
+63
View File
@@ -0,0 +1,63 @@
# Security
## Trust model
The submitted URL and all remote metadata are untrusted. yt-dlp and FFmpeg
process attacker-controlled network content and must not have host privileges
or access to unrelated files and services. The current development build is
not ready for public exposure.
## Controls currently implemented
- port bound only to `127.0.0.1`
- non-root, read-only container with a small `/tmp` tmpfs
- all Linux capabilities dropped and `no-new-privileges` enabled
- yt-dlp plugins disabled and configuration files ignored
- inspection only; media download disabled
- playlists and multi-video results rejected
- only HTTP/HTTPS URLs on ports 80/443 accepted
- URL credentials rejected
- initially resolved IPv4 and IPv6 addresses must all be globally routable
- raw signed media URLs omitted from API responses
- remote metadata inserted into the interface as text, not HTML
## Required before download jobs
- maximum source duration and estimated output size
- per-job timeout and cancellation
- one concurrent worker job initially
- bounded CPU, memory, process, and temporary-storage use
- randomized job directories and server-generated filenames
- no user-controlled output templates or command fragments
- FFmpeg/ffprobe validation of completed artifacts
- safe handling of partial files and failed merges
- automatic expiry and deletion
- durable job recovery after process or container restarts
- logs that exclude signed URLs, cookies, and credentials
## Required before Internet deployment
- Cloudflare Access deny-by-default policy
- Cloudflare Tunnel with Access-token validation at the origin
- no directly reachable origin application port
- request-rate and body-size limits
- explicit production configuration validation
- dependency and container-image update procedure
## SSRF limitation
Initial-host validation is necessary but incomplete. Extractors may follow
redirects and retrieve manifests, APIs, or media from additional hosts. DNS
answers can also change between validation and connection.
Before Internet deployment, outbound traffic from the processing service must
be restricted at the network layer so private, loopback, link-local, multicast,
and infrastructure-metadata destinations cannot be reached. URL validation is
an additional layer, not the sole SSRF defense.
## Deferred features
Browser cookies and authenticated source accounts are excluded. A cookie file
is effectively an account credential. Supporting one requires encrypted secret
handling, isolated injection, redacted logging, rotation, and a deliberate
account-compromise risk review.
+1
View File
@@ -1,3 +1,4 @@
fastapi==0.139.2 fastapi==0.139.2
jinja2==3.1.6
uvicorn==0.51.0 uvicorn==0.51.0
yt-dlp==2026.6.9 yt-dlp==2026.6.9
+12 -2
View File
@@ -12,8 +12,18 @@ def test_health_returns_healthy() -> None:
assert response.json() == {"status": "healthy"} assert response.json() == {"status": "healthy"}
def test_root_identifies_application() -> None: def test_root_renders_interface() -> None:
response = client.get("/") response = client.get("/")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == {"name": "Media Ingest", "status": "running"} assert response.headers["content-type"].startswith("text/html")
assert "Media Ingest" in response.text
assert 'id="inspect-form"' in response.text
assert 'id="new-source"' in response.text
def test_static_styles_are_served() -> None:
response = client.get("/static/styles.css")
assert response.status_code == 200
assert "--background" in response.text