From e6e0c8d77e4ab9c7d8083a213189eaf7012df091 Mon Sep 17 00:00:00 2001 From: oceans2alaska Date: Fri, 12 Jun 2026 15:29:30 -0700 Subject: [PATCH] Initial commit --- .gitignore | 5 + .idea/.gitignore | 10 ++ README.md | 112 +++++++++++++++ backend/__init__.py | 1 + backend/downloads/.gitkeep | 1 + backend/main.py | 183 +++++++++++++++++++++++++ backend/services/__init__.py | 1 + backend/services/converter.py | 130 ++++++++++++++++++ backend/services/youtube.py | 116 ++++++++++++++++ frontend/app.js | 146 ++++++++++++++++++++ frontend/index.html | 71 ++++++++++ frontend/style.css | 251 ++++++++++++++++++++++++++++++++++ requirements.txt | 3 + 13 files changed, 1030 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 README.md create mode 100644 backend/__init__.py create mode 100644 backend/downloads/.gitkeep create mode 100644 backend/main.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/converter.py create mode 100644 backend/services/youtube.py create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/style.css create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c13082a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.py[cod] +backend/downloads/* +!backend/downloads/.gitkeep diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f3faba --- /dev/null +++ b/README.md @@ -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// +``` + +## 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 +``` diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/downloads/.gitkeep b/backend/downloads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/downloads/.gitkeep @@ -0,0 +1 @@ + diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..ef65c88 --- /dev/null +++ b/backend/main.py @@ -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() diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/services/converter.py b/backend/services/converter.py new file mode 100644 index 0000000..7d30ac8 --- /dev/null +++ b/backend/services/converter.py @@ -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 diff --git a/backend/services/youtube.py b/backend/services/youtube.py new file mode 100644 index 0000000..87cb6e0 --- /dev/null +++ b/backend/services/youtube.py @@ -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 diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..6d3783c --- /dev/null +++ b/frontend/app.js @@ -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; + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ee80cbb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,71 @@ + + + + + + YouTube Audio Converter + + + +
+
+
+

Local utility

+

YouTube Audio Converter

+
+ +
+ + +
+ + + +
+ + +
+ + + +
+
+ + Idle +
+ + +
+
+
+ + + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..8d0d270 --- /dev/null +++ b/frontend/style.css @@ -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; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..212c4cf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi +uvicorn[standard] +yt-dlp