Best available
+Keep the highest source quality. Container and codec may vary.
+diff --git a/README.md b/README.md index 3604f24..c50dca6 100644 --- a/README.md +++ b/README.md @@ -36,22 +36,27 @@ accounts, playlists, live recording, or multiple simultaneous downloads. ## Development stages -1. Create a minimal containerized web application with a health check. -2. Inspect a URL and display sanitized source metadata and formats. -3. Add a durable job queue and separate worker. -4. Download media and report progress. -5. Add original-quality and compatible-MP4 output modes. -6. Add cleanup, limits, failure handling, and security tests. -7. Validate the complete local Docker deployment. -8. Deploy privately to the VPS through Cloudflare Access and Tunnel. +- [x] Create a minimal containerized web application with a health check. +- [x] Inspect a URL and display sanitized source metadata and formats. +- [x] Add a dark, responsive inspection interface. +- [ ] Define download presets and their exact yt-dlp selectors. +- [ ] Add a durable job queue and separate worker. +- [ ] Download media and report progress. +- [ ] Add cleanup, limits, failure handling, and security tests. +- [ ] 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. ## Local development -The current milestone is a minimal FastAPI application running in a hardened -local container. Docker publishes it only on the PC's loopback interface, so it -is not exposed to other devices on the LAN. +The current milestone provides a browser interface for inspecting public video +sources. It shows normalized source metadata and previews best-available and +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: @@ -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 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: ```bash @@ -107,6 +116,11 @@ The original project discussion is retained in that affect the implementation will be documented in this repository rather 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 This tool is intended for material the operator is authorized to retrieve and diff --git a/app/main.py b/app/main.py index 1c30728..4e893b1 100644 --- a/app/main.py +++ b/app/main.py @@ -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"]) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..3c9cee9 --- /dev/null +++ b/app/static/app.js @@ -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); diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..d6e05a1 --- /dev/null +++ b/app/static/styles.css @@ -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; + } +} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..28587e9 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,128 @@ + + +
+ + + + +Source inspection
++ Inspect public video sources before choosing what enters your workflow. + Nothing is downloaded at this stage. +
+ + + +Source found
+ +Output choices
+Technical stream selection happens automatically.
+