Initial commit

This commit is contained in:
2026-06-12 15:29:30 -07:00
commit e6e0c8d77e
13 changed files with 1030 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.py[cod]
backend/downloads/*
!backend/downloads/.gitkeep

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

112
README.md Normal file
View File

@@ -0,0 +1,112 @@
# Local YouTube Audio Converter
A local-only FastAPI web app for converting audio from YouTube video URLs into downloadable audio files.
Use this only for media you own, created, or have permission to download and convert.
## Features
- Dark, responsive web UI at `http://localhost:8000`
- YouTube URL validation
- Metadata preview with title, channel, duration, and thumbnail
- Output formats: MP3, WAV, FLAC, M4A, OGG
- MP3 quality options: 128, 192, 256, 320 kbps
- Background conversion jobs with status polling
- Per-job local download folders under `backend/downloads/`
## Linux Setup
Install system dependencies:
```bash
sudo apt update
sudo apt install -y python3 python3-venv python3-pip ffmpeg
```
Create and activate a virtual environment:
```bash
python3 -m venv .venv
source .venv/bin/activate
```
Install Python dependencies:
```bash
pip install -r requirements.txt
```
Confirm the required tools are available:
```bash
ffmpeg -version
yt-dlp --version
```
## Run
Start the local server:
```bash
.venv/bin/uvicorn backend.main:app --reload --host 127.0.0.1 --port 8000
```
Open:
```text
http://localhost:8000
```
Converted files are stored locally in:
```text
backend/downloads/<job_id>/
```
## API
- `GET /` serves the frontend
- `POST /api/info` returns metadata for a YouTube URL
- `POST /api/convert` starts a conversion job
- `GET /api/status/{job_id}` returns job status
- `GET /api/download/{job_id}` downloads the converted file
## Troubleshooting
Missing `ffmpeg`:
```bash
sudo apt install -y ffmpeg
```
Virtual environment creation fails with `ensurepip is not available`:
```bash
sudo apt install -y python3-venv python3-pip
python3 -m venv .venv
```
Missing or outdated `yt-dlp`:
```bash
source .venv/bin/activate
pip install --upgrade yt-dlp
```
Invalid URL:
- Use a full YouTube video URL, such as `https://www.youtube.com/watch?v=...`
- Playlist-only URLs are not supported.
yt-dlp failure:
- Update `yt-dlp`.
- If you see `HTTP Error 403: Forbidden`, make sure you started the app with `.venv/bin/uvicorn` so it uses the current virtualenv copy of `yt-dlp`, not an older system package.
- Try the same URL in a browser.
- Some videos may be unavailable, private, region-locked, age-restricted, or blocked from extraction.
Port already in use:
```bash
uvicorn backend.main:app --reload --host 127.0.0.1 --port 8001
```

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

183
backend/main.py Normal file
View File

@@ -0,0 +1,183 @@
from __future__ import annotations
import traceback
from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from typing import Any
from uuid import uuid4
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from backend.services.converter import (
ConversionError,
clean_source_files,
convert_audio,
download_audio,
normalize_format,
normalize_quality,
sanitize_filename,
)
from backend.services.youtube import YouTubeServiceError, fetch_video_info, validate_youtube_url
BASE_DIR = Path(__file__).resolve().parent
PROJECT_DIR = BASE_DIR.parent
FRONTEND_DIR = PROJECT_DIR / "frontend"
DOWNLOADS_DIR = BASE_DIR / "downloads"
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="Local YouTube Audio Converter")
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
executor = ThreadPoolExecutor(max_workers=2)
jobs_lock = Lock()
jobs: dict[str, "Job"] = {}
class InfoRequest(BaseModel):
url: str
class ConvertRequest(BaseModel):
url: str
format: str
quality: str | None = "192"
@dataclass
class Job:
id: str
url: str
format: str
quality: str
status: str = "queued"
message: str = "Queued"
metadata: dict[str, Any] | None = None
filename: str | None = None
file_path: str | None = None
error: str | None = None
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
@app.get("/")
def index() -> FileResponse:
return FileResponse(FRONTEND_DIR / "index.html")
@app.post("/api/info")
def api_info(payload: InfoRequest) -> dict[str, Any]:
try:
validate_youtube_url(payload.url)
return fetch_video_info(payload.url)
except YouTubeServiceError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/convert")
def api_convert(payload: ConvertRequest) -> dict[str, str]:
try:
valid_url = validate_youtube_url(payload.url)
format_name = normalize_format(payload.format)
quality = normalize_quality(format_name, payload.quality)
except (YouTubeServiceError, ConversionError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
job_id = str(uuid4())
job = Job(id=job_id, url=valid_url, format=format_name, quality=quality)
with jobs_lock:
jobs[job_id] = job
executor.submit(_run_conversion, job_id)
return {"job_id": job_id}
@app.get("/api/status/{job_id}")
def api_status(job_id: str) -> dict[str, Any]:
job = _get_job(job_id)
payload = asdict(job)
payload.pop("file_path", None)
if job.status == "complete":
payload["download_url"] = f"/api/download/{job_id}"
return payload
@app.get("/api/download/{job_id}")
def api_download(job_id: str) -> FileResponse:
job = _get_job(job_id)
if job.status != "complete" or not job.file_path or not job.filename:
raise HTTPException(status_code=404, detail="This conversion is not ready for download.")
path = Path(job.file_path)
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail="The converted file no longer exists.")
return FileResponse(path, filename=job.filename, media_type="application/octet-stream")
def _run_conversion(job_id: str) -> None:
job = _get_job(job_id)
workdir = DOWNLOADS_DIR / job_id
try:
_update_job(job_id, status="validating", message="Validating URL")
validate_youtube_url(job.url)
_update_job(job_id, status="fetching_info", message="Fetching video info")
metadata = fetch_video_info(job.url)
_update_job(job_id, metadata=metadata)
base_name = sanitize_filename(metadata.get("title") or "youtube-audio")
filename = f"{base_name}.{job.format}"
output_path = workdir / filename
_update_job(job_id, status="downloading", message="Downloading audio")
source_path = download_audio(job.url, workdir)
_update_job(job_id, status="converting", message="Converting")
final_path = convert_audio(source_path, output_path, job.format, job.quality)
clean_source_files(workdir)
_update_job(
job_id,
status="complete",
message="Ready to download",
filename=filename,
file_path=str(final_path),
error=None,
)
except (YouTubeServiceError, ConversionError) as exc:
clean_source_files(workdir)
_update_job(job_id, status="error", message="Error", error=str(exc))
except Exception as exc:
clean_source_files(workdir)
traceback.print_exc()
_update_job(job_id, status="error", message="Error", error=f"Unexpected error: {exc}")
def _get_job(job_id: str) -> Job:
with jobs_lock:
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found.")
return job
def _update_job(job_id: str, **changes: Any) -> None:
with jobs_lock:
job = jobs.get(job_id)
if not job:
return
for key, value in changes.items():
setattr(job, key, value)
job.updated_at = datetime.now(timezone.utc).isoformat()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,130 @@
import re
import shutil
import subprocess
import sys
import unicodedata
from pathlib import Path
class ConversionError(RuntimeError):
pass
SUPPORTED_FORMATS = {"mp3", "wav", "flac", "m4a", "ogg"}
MP3_QUALITIES = {"128", "192", "256", "320"}
def normalize_format(format_name: str) -> str:
normalized = (format_name or "").strip().lower()
if normalized not in SUPPORTED_FORMATS:
raise ConversionError("Choose a supported output format.")
return normalized
def normalize_quality(format_name: str, quality: str | None) -> str:
if format_name != "mp3":
return "192"
normalized = (quality or "192").strip()
if normalized not in MP3_QUALITIES:
raise ConversionError("Choose a supported MP3 quality.")
return normalized
def sanitize_filename(name: str, fallback: str = "youtube-audio") -> str:
normalized = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode("ascii")
normalized = re.sub(r"[^\w\s.-]", "", normalized)
normalized = re.sub(r"[\s_]+", "-", normalized).strip(".-")
return (normalized or fallback)[:120]
def download_audio(url: str, workdir: Path) -> Path:
workdir.mkdir(parents=True, exist_ok=True)
output_template = str(workdir / "source.%(ext)s")
command = [
sys.executable,
"-m",
"yt_dlp",
"--no-playlist",
"--no-warnings",
"-f",
"bestaudio/best",
"-o",
output_template,
url,
]
_run_process(command, "Could not download audio from YouTube.", timeout=1800)
source_files = sorted(
path for path in workdir.glob("source.*") if path.is_file() and not path.name.endswith(".part")
)
if not source_files:
raise ConversionError("The audio download finished, but no source file was created.")
return source_files[0]
def convert_audio(source_path: Path, output_path: Path, format_name: str, quality: str) -> Path:
if not shutil.which("ffmpeg"):
raise ConversionError("ffmpeg is not installed or is not available on PATH.")
format_name = normalize_format(format_name)
quality = normalize_quality(format_name, quality)
output_path.parent.mkdir(parents=True, exist_ok=True)
command = ["ffmpeg", "-y", "-i", str(source_path), "-vn"]
if format_name == "mp3":
command.extend(["-codec:a", "libmp3lame", "-b:a", f"{quality}k"])
elif format_name == "wav":
command.extend(["-codec:a", "pcm_s16le"])
elif format_name == "flac":
command.extend(["-codec:a", "flac"])
elif format_name == "m4a":
command.extend(["-codec:a", "aac", "-b:a", "192k"])
elif format_name == "ogg":
command.extend(["-codec:a", "libvorbis", "-q:a", "5"])
command.append(str(output_path))
_run_process(command, "Could not convert the downloaded audio.", timeout=1800)
if not output_path.exists():
raise ConversionError("Conversion finished, but no output file was created.")
return output_path
def clean_source_files(workdir: Path) -> None:
for path in workdir.glob("source.*"):
if path.is_file():
path.unlink(missing_ok=True)
def _run_process(command: list[str], fallback: str, timeout: int) -> None:
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
raise ConversionError(f"{fallback} The process timed out.") from exc
if result.returncode != 0:
raise ConversionError(_friendly_process_error(result.stderr, fallback))
def _friendly_process_error(stderr: str, fallback: str) -> str:
lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()]
if not lines:
return fallback
useful = [line for line in lines if "error" in line.lower() or line.startswith(("ERROR:", "WARNING:"))]
message = useful[-1] if useful else lines[-1]
for prefix in ("ERROR:", "WARNING:"):
if message.startswith(prefix):
message = message.removeprefix(prefix).strip()
return message or fallback

116
backend/services/youtube.py Normal file
View File

@@ -0,0 +1,116 @@
import json
import subprocess
import sys
from typing import Any
from urllib.parse import parse_qs, urlparse
class YouTubeServiceError(RuntimeError):
pass
YOUTUBE_HOSTS = {
"youtube.com",
"www.youtube.com",
"m.youtube.com",
"music.youtube.com",
"youtu.be",
"www.youtu.be",
"youtube-nocookie.com",
"www.youtube-nocookie.com",
}
def validate_youtube_url(url: str) -> str:
candidate = (url or "").strip()
parsed = urlparse(candidate)
if parsed.scheme not in {"http", "https"}:
raise YouTubeServiceError("Enter a full YouTube URL starting with http:// or https://.")
host = (parsed.netloc or "").lower().removesuffix(".")
if host not in YOUTUBE_HOSTS:
raise YouTubeServiceError("Only YouTube video URLs are supported.")
path = parsed.path or ""
query = parse_qs(parsed.query)
if host in {"youtu.be", "www.youtu.be"} and path.strip("/"):
return candidate
if host.endswith("youtube.com") or host.endswith("youtube-nocookie.com"):
if path == "/watch" and query.get("v", [""])[0].strip():
return candidate
parts = [part for part in path.split("/") if part]
if len(parts) >= 2 and parts[0] in {"shorts", "live", "embed"} and parts[1].strip():
return candidate
raise YouTubeServiceError("That does not look like a supported YouTube video URL.")
def fetch_video_info(url: str) -> dict[str, Any]:
valid_url = validate_youtube_url(url)
command = [
sys.executable,
"-m",
"yt_dlp",
"--dump-single-json",
"--no-playlist",
"--skip-download",
"--no-warnings",
valid_url,
]
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=90,
)
except subprocess.TimeoutExpired as exc:
raise YouTubeServiceError("Fetching video info timed out.") from exc
if result.returncode != 0:
raise YouTubeServiceError(_friendly_process_error(result.stderr, "Could not fetch video info."))
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise YouTubeServiceError("yt-dlp returned video info in an unreadable format.") from exc
return {
"title": data.get("title") or "Untitled YouTube video",
"channel": data.get("channel") or data.get("uploader") or "Unknown channel",
"duration": _format_duration(data.get("duration")),
"duration_seconds": data.get("duration"),
"thumbnail": data.get("thumbnail"),
"webpage_url": data.get("webpage_url") or valid_url,
}
def _format_duration(seconds: Any) -> str:
if not isinstance(seconds, (int, float)) or seconds < 0:
return "Unknown"
total = int(seconds)
hours, remainder = divmod(total, 3600)
minutes, secs = divmod(remainder, 60)
if hours:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes}:{secs:02d}"
def _friendly_process_error(stderr: str, fallback: str) -> str:
lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()]
if not lines:
return fallback
message = lines[-1]
for prefix in ("ERROR:", "WARNING:"):
if message.startswith(prefix):
message = message.removeprefix(prefix).strip()
return message or fallback

146
frontend/app.js Normal file
View File

@@ -0,0 +1,146 @@
const form = document.querySelector("#convert-form");
const urlInput = document.querySelector("#url");
const formatSelect = document.querySelector("#format");
const qualityField = document.querySelector("#quality-field");
const qualitySelect = document.querySelector("#quality");
const convertButton = document.querySelector("#convert-button");
const metadataPanel = document.querySelector("#metadata");
const thumbnail = document.querySelector("#thumbnail");
const videoTitle = document.querySelector("#video-title");
const videoChannel = document.querySelector("#video-channel");
const videoDuration = document.querySelector("#video-duration");
const statusDot = document.querySelector("#status-dot");
const statusText = document.querySelector("#status-text");
const errorText = document.querySelector("#error-text");
const downloadLink = document.querySelector("#download-link");
let pollTimer = null;
formatSelect.addEventListener("change", () => {
qualityField.classList.toggle("hidden", formatSelect.value !== "mp3");
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
clearPolling();
resetDownload();
setBusy(true);
setStatus("active", "Validating URL");
const payload = {
url: urlInput.value.trim(),
format: formatSelect.value,
quality: qualitySelect.value,
};
try {
setStatus("active", "Fetching video info");
const info = await postJson("/api/info", { url: payload.url });
renderMetadata(info);
setStatus("active", "Queued");
const { job_id: jobId } = await postJson("/api/convert", payload);
pollStatus(jobId);
} catch (error) {
showError(error.message);
setBusy(false);
}
});
async function pollStatus(jobId) {
try {
const status = await getJson(`/api/status/${jobId}`);
if (status.metadata) {
renderMetadata(status.metadata);
}
if (status.status === "complete") {
setStatus("done", "Ready to download");
downloadLink.href = status.download_url;
downloadLink.classList.remove("hidden");
setBusy(false);
return;
}
if (status.status === "error") {
showError(status.error || "Conversion failed.");
setBusy(false);
return;
}
setStatus("active", status.message || "Working");
pollTimer = window.setTimeout(() => pollStatus(jobId), 1200);
} catch (error) {
showError(error.message);
setBusy(false);
}
}
async function postJson(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return parseJsonResponse(response);
}
async function getJson(url) {
const response = await fetch(url);
return parseJsonResponse(response);
}
async function parseJsonResponse(response) {
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.detail || "Request failed.");
}
return data;
}
function renderMetadata(info) {
videoTitle.textContent = info.title || "Untitled YouTube video";
videoChannel.textContent = info.channel || "Unknown channel";
videoDuration.textContent = info.duration ? `Duration ${info.duration}` : "Duration unknown";
if (info.thumbnail) {
thumbnail.src = info.thumbnail;
thumbnail.classList.remove("hidden");
} else {
thumbnail.removeAttribute("src");
thumbnail.classList.add("hidden");
}
metadataPanel.classList.remove("hidden");
}
function setStatus(kind, message) {
statusDot.className = `status-dot ${kind}`;
statusText.textContent = message;
errorText.classList.add("hidden");
errorText.textContent = "";
}
function showError(message) {
setStatus("error", "Error");
errorText.textContent = message;
errorText.classList.remove("hidden");
}
function resetDownload() {
downloadLink.classList.add("hidden");
downloadLink.removeAttribute("href");
}
function setBusy(isBusy) {
convertButton.disabled = isBusy;
convertButton.textContent = isBusy ? "Converting..." : "Convert";
}
function clearPolling() {
if (pollTimer) {
window.clearTimeout(pollTimer);
pollTimer = null;
}
}

71
frontend/index.html Normal file
View File

@@ -0,0 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>YouTube Audio Converter</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<main class="shell">
<section class="converter-card">
<div class="header">
<p class="eyebrow">Local utility</p>
<h1>YouTube Audio Converter</h1>
</div>
<form id="convert-form" class="form">
<label class="field field-wide" for="url">
<span>YouTube URL</span>
<input id="url" name="url" type="url" placeholder="https://www.youtube.com/watch?v=..." autocomplete="off" required />
</label>
<div class="controls">
<label class="field" for="format">
<span>Format</span>
<select id="format" name="format">
<option value="mp3">MP3</option>
<option value="wav">WAV</option>
<option value="flac">FLAC</option>
<option value="m4a">M4A</option>
<option value="ogg">OGG</option>
</select>
</label>
<label class="field" id="quality-field" for="quality">
<span>MP3 quality</span>
<select id="quality" name="quality">
<option value="128">128 kbps</option>
<option value="192" selected>192 kbps</option>
<option value="256">256 kbps</option>
<option value="320">320 kbps</option>
</select>
</label>
</div>
<button id="convert-button" type="submit">Convert</button>
</form>
<section id="metadata" class="metadata hidden" aria-live="polite">
<img id="thumbnail" alt="" />
<div>
<h2 id="video-title"></h2>
<p id="video-channel"></p>
<p id="video-duration"></p>
</div>
</section>
<section class="status-panel" aria-live="polite">
<div class="status-line">
<span id="status-dot" class="status-dot idle"></span>
<span id="status-text">Idle</span>
</div>
<p id="error-text" class="error-text hidden"></p>
<a id="download-link" class="download-button hidden" href="#">Download audio</a>
</section>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

251
frontend/style.css Normal file
View File

@@ -0,0 +1,251 @@
:root {
color-scheme: dark;
--bg: #10100f;
--panel: #1b1a18;
--panel-strong: #23211f;
--text: #f4f0e8;
--muted: #aaa49a;
--line: #38342f;
--accent: #3ddc97;
--accent-strong: #2fbd80;
--warning: #f2c14e;
--error: #ff6b6b;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(61, 220, 151, 0.14), transparent 34rem),
linear-gradient(135deg, #10100f 0%, #171614 54%, #0c0c0b 100%);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.shell {
display: grid;
min-height: 100vh;
place-items: center;
padding: 28px;
}
.converter-card {
width: min(760px, 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(27, 26, 24, 0.94);
box-shadow: var(--shadow);
padding: clamp(22px, 4vw, 38px);
}
.header {
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: clamp(2rem, 5vw, 3.4rem);
line-height: 1;
letter-spacing: 0;
}
.form {
display: grid;
gap: 18px;
}
.field {
display: grid;
gap: 8px;
}
.field span {
color: var(--muted);
font-size: 0.88rem;
font-weight: 650;
}
input,
select,
button {
width: 100%;
border-radius: 8px;
font: inherit;
}
input,
select {
border: 1px solid var(--line);
background: var(--panel-strong);
color: var(--text);
min-height: 50px;
padding: 0 14px;
outline: none;
}
input:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(61, 220, 151, 0.16);
}
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
button,
.download-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 52px;
border: 0;
background: var(--accent);
color: #09110d;
cursor: pointer;
font-weight: 800;
text-decoration: none;
transition:
transform 160ms ease,
background 160ms ease,
opacity 160ms ease;
}
button:hover,
.download-button:hover {
background: var(--accent-strong);
transform: translateY(-1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
}
.metadata {
display: grid;
grid-template-columns: 150px 1fr;
gap: 16px;
align-items: center;
margin-top: 24px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(35, 33, 31, 0.72);
padding: 12px;
}
.metadata img {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 6px;
object-fit: cover;
background: #0c0c0b;
}
.metadata h2 {
margin-bottom: 8px;
font-size: 1rem;
line-height: 1.35;
}
.metadata p {
margin-bottom: 4px;
color: var(--muted);
font-size: 0.92rem;
}
.status-panel {
margin-top: 22px;
border-top: 1px solid var(--line);
padding-top: 18px;
}
.status-line {
display: flex;
align-items: center;
gap: 10px;
min-height: 28px;
color: var(--muted);
}
.status-dot {
width: 10px;
height: 10px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--muted);
}
.status-dot.active {
background: var(--warning);
box-shadow: 0 0 0 6px rgba(242, 193, 78, 0.13);
}
.status-dot.done {
background: var(--accent);
box-shadow: 0 0 0 6px rgba(61, 220, 151, 0.13);
}
.status-dot.error {
background: var(--error);
box-shadow: 0 0 0 6px rgba(255, 107, 107, 0.13);
}
.error-text {
margin: 10px 0 0;
color: var(--error);
}
.download-button {
margin-top: 16px;
width: 100%;
border-radius: 8px;
}
.hidden {
display: none !important;
}
@media (max-width: 640px) {
.shell {
padding: 16px;
place-items: start center;
}
.converter-card {
margin-top: 18px;
}
.controls,
.metadata {
grid-template-columns: 1fr;
}
}

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi
uvicorn[standard]
yt-dlp