mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 10:54:00 -06:00
Full-featured self-hosted yt-dlp web frontend:
- Python 3.12+ / FastAPI backend with async SQLite, SSE transport, session isolation
- Vue 3 / TypeScript / Pinia frontend with real-time progress, theme picker
- 3 built-in themes (cyberpunk/dark/light) + drop-in custom theme system
- Admin auth (bcrypt), purge system, cookie upload, file serving
- Docker multi-stage build, GitHub Actions CI/CD
- 179 backend tests, 29 frontend tests (208 total)
Slices: S01 (Foundation), S02 (SSE+Sessions), S03 (Frontend),
S04 (Admin+Auth), S05 (Themes), S06 (Docker+CI)
146 lines
4 KiB
Python
146 lines
4 KiB
Python
"""Job-related Pydantic models for media.rip()."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import enum
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class JobStatus(str, enum.Enum):
|
|
"""Status values for a download job."""
|
|
|
|
queued = "queued"
|
|
extracting = "extracting"
|
|
downloading = "downloading"
|
|
completed = "completed"
|
|
failed = "failed"
|
|
expired = "expired"
|
|
|
|
|
|
class JobCreate(BaseModel):
|
|
"""Payload for creating a new download job."""
|
|
|
|
url: str
|
|
format_id: str | None = None
|
|
quality: str | None = None
|
|
output_template: str | None = None
|
|
|
|
|
|
class Job(BaseModel):
|
|
"""Full job model matching the DB schema."""
|
|
|
|
id: str
|
|
session_id: str
|
|
url: str
|
|
status: JobStatus = JobStatus.queued
|
|
format_id: str | None = None
|
|
quality: str | None = None
|
|
output_template: str | None = None
|
|
filename: str | None = None
|
|
filesize: int | None = None
|
|
progress_percent: float = Field(default=0.0)
|
|
speed: str | None = None
|
|
eta: str | None = None
|
|
error_message: str | None = None
|
|
created_at: str
|
|
started_at: str | None = None
|
|
completed_at: str | None = None
|
|
|
|
|
|
class ProgressEvent(BaseModel):
|
|
"""Real-time progress event, typically pushed via SSE."""
|
|
|
|
job_id: str
|
|
status: str
|
|
percent: float
|
|
speed: str | None = None
|
|
eta: str | None = None
|
|
downloaded_bytes: int | None = None
|
|
total_bytes: int | None = None
|
|
filename: str | None = None
|
|
|
|
@classmethod
|
|
def from_yt_dlp(cls, job_id: str, d: dict) -> ProgressEvent:
|
|
"""Normalize a raw yt-dlp progress hook dictionary.
|
|
|
|
Handles the common case where ``total_bytes`` is *None* (subtitles,
|
|
live streams, some extractors) by falling back to
|
|
``total_bytes_estimate``. If both are absent, percent is ``0.0``.
|
|
"""
|
|
status = d.get("status", "unknown")
|
|
|
|
downloaded = d.get("downloaded_bytes") or 0
|
|
total = d.get("total_bytes") or d.get("total_bytes_estimate")
|
|
|
|
if total and downloaded:
|
|
percent = round(downloaded / total * 100, 2)
|
|
else:
|
|
percent = 0.0
|
|
|
|
# Speed: yt-dlp provides bytes/sec as a float or None
|
|
raw_speed = d.get("speed")
|
|
if raw_speed is not None:
|
|
speed = _format_speed(raw_speed)
|
|
else:
|
|
speed = None
|
|
|
|
# ETA: yt-dlp provides seconds remaining as int or None
|
|
raw_eta = d.get("eta")
|
|
if raw_eta is not None:
|
|
eta = _format_eta(int(raw_eta))
|
|
else:
|
|
eta = None
|
|
|
|
return cls(
|
|
job_id=job_id,
|
|
status=status,
|
|
percent=percent,
|
|
speed=speed,
|
|
eta=eta,
|
|
downloaded_bytes=downloaded if downloaded else None,
|
|
total_bytes=total,
|
|
filename=d.get("filename"),
|
|
)
|
|
|
|
|
|
class FormatInfo(BaseModel):
|
|
"""Available format information returned by yt-dlp extract_info."""
|
|
|
|
format_id: str
|
|
ext: str
|
|
resolution: str | None = None
|
|
codec: str | None = None
|
|
filesize: int | None = None
|
|
format_note: str | None = None
|
|
vcodec: str | None = None
|
|
acodec: str | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _format_speed(bytes_per_sec: float) -> str:
|
|
"""Format bytes/sec into a human-readable string."""
|
|
if bytes_per_sec < 1024:
|
|
return f"{bytes_per_sec:.0f} B/s"
|
|
elif bytes_per_sec < 1024 * 1024:
|
|
return f"{bytes_per_sec / 1024:.1f} KiB/s"
|
|
elif bytes_per_sec < 1024 * 1024 * 1024:
|
|
return f"{bytes_per_sec / (1024 * 1024):.1f} MiB/s"
|
|
else:
|
|
return f"{bytes_per_sec / (1024 * 1024 * 1024):.2f} GiB/s"
|
|
|
|
|
|
def _format_eta(seconds: int) -> str:
|
|
"""Format seconds into a human-readable ETA string."""
|
|
if seconds < 60:
|
|
return f"{seconds}s"
|
|
elif seconds < 3600:
|
|
m, s = divmod(seconds, 60)
|
|
return f"{m}m{s:02d}s"
|
|
else:
|
|
h, remainder = divmod(seconds, 3600)
|
|
m, s = divmod(remainder, 60)
|
|
return f"{h}h{m:02d}m{s:02d}s"
|