initial ytdlp worker
This commit is contained in:
1
youdis/__init__.py
Normal file
1
youdis/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Youdis v2 backend package."""
|
||||||
1
youdis/adapters/__init__.py
Normal file
1
youdis/adapters/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Frontend adapters for the youdis backend."""
|
||||||
1
youdis/adapters/discord.py
Normal file
1
youdis/adapters/discord.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Discord adapter placeholder for the v2 backend."""
|
||||||
255
youdis/main.py
Normal file
255
youdis/main.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import asyncio
|
||||||
|
from asyncio.subprocess import PIPE, STDOUT
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from os import getenv
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
|
from .models import CurrentJobResponse, HealthResponse, JobRequest, JobStatus, VersionResponse
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT_CONFIG = REPO_ROOT / "default-yt-dlp.conf"
|
||||||
|
VERSION_FILE = REPO_ROOT / "youdis-version.txt"
|
||||||
|
YTDLP_EXECUTABLE = getenv("YOUDIS_YTDLP_EXECUTABLE", "yt-dlp")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ManagedJob:
|
||||||
|
status: JobStatus
|
||||||
|
process: asyncio.subprocess.Process | None = None
|
||||||
|
task: asyncio.Task | None = None
|
||||||
|
cancel_requested: bool = False
|
||||||
|
output_lines: deque[str] = field(default_factory=lambda: deque(maxlen=25))
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="youdis", version="2")
|
||||||
|
job_lock = asyncio.Lock()
|
||||||
|
active_job: ManagedJob | None = None
|
||||||
|
last_job: JobStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc() -> datetime:
|
||||||
|
return datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
def read_version() -> str:
|
||||||
|
if VERSION_FILE.exists():
|
||||||
|
return VERSION_FILE.read_text().strip()
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def build_ytdlp_command(request: JobRequest) -> list[str]:
|
||||||
|
return [
|
||||||
|
YTDLP_EXECUTABLE,
|
||||||
|
"--config-locations",
|
||||||
|
str(DEFAULT_CONFIG),
|
||||||
|
request.url,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def clone_status(status: JobStatus) -> JobStatus:
|
||||||
|
return JobStatus(**status.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
def update_status(job: ManagedJob, **changes: object) -> None:
|
||||||
|
for key, value in changes.items():
|
||||||
|
setattr(job.status, key, value)
|
||||||
|
job.status.updated_at = now_utc()
|
||||||
|
|
||||||
|
|
||||||
|
def classify_output_line(job: ManagedJob, line: str) -> None:
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
|
||||||
|
job.output_lines.append(line)
|
||||||
|
message = line.strip()
|
||||||
|
if not message:
|
||||||
|
return
|
||||||
|
|
||||||
|
lowered = message.lower()
|
||||||
|
if "has already been recorded in the archive" in lowered:
|
||||||
|
update_status(
|
||||||
|
job,
|
||||||
|
disposition="archive_hit",
|
||||||
|
phase="downloading",
|
||||||
|
message="already in archive",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "[download]" in lowered:
|
||||||
|
update_status(job, phase="downloading", message=message)
|
||||||
|
if "destination:" in lowered:
|
||||||
|
result_path = message.split(":", 1)[1].strip()
|
||||||
|
update_status(job, result_path=result_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "merging formats into" in lowered:
|
||||||
|
result_path = message.split("into", 1)[1].strip().strip('"')
|
||||||
|
update_status(job, phase="postprocessing", message=message, result_path=result_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
update_status(job, message=message)
|
||||||
|
|
||||||
|
|
||||||
|
async def finalize_job(job: ManagedJob, returncode: int) -> None:
|
||||||
|
disposition = job.status.disposition
|
||||||
|
if job.cancel_requested:
|
||||||
|
state = "cancelled"
|
||||||
|
message = "cancelled"
|
||||||
|
elif returncode == 0 and disposition == "archive_hit":
|
||||||
|
state = "completed"
|
||||||
|
message = "already in archive"
|
||||||
|
elif returncode == 0:
|
||||||
|
state = "completed"
|
||||||
|
message = job.status.message or "completed"
|
||||||
|
else:
|
||||||
|
state = "failed"
|
||||||
|
message = job.status.message or f"yt-dlp exited with code {returncode}"
|
||||||
|
|
||||||
|
update_status(job, state=state, phase=None, returncode=returncode, message=message)
|
||||||
|
|
||||||
|
global active_job, last_job
|
||||||
|
async with job_lock:
|
||||||
|
if active_job is job:
|
||||||
|
active_job = None
|
||||||
|
last_job = clone_status(job.status)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_job(job: ManagedJob, request: JobRequest) -> None:
|
||||||
|
command = build_ytdlp_command(request)
|
||||||
|
update_status(
|
||||||
|
job,
|
||||||
|
state="running",
|
||||||
|
phase="starting",
|
||||||
|
message="starting yt-dlp",
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not DEFAULT_CONFIG.exists():
|
||||||
|
update_status(
|
||||||
|
job,
|
||||||
|
state="failed",
|
||||||
|
phase=None,
|
||||||
|
message=f"default config not found: {DEFAULT_CONFIG}",
|
||||||
|
returncode=78,
|
||||||
|
)
|
||||||
|
await finalize_job(job, 78)
|
||||||
|
return
|
||||||
|
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*command,
|
||||||
|
stdout=PIPE,
|
||||||
|
stderr=STDOUT,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
update_status(
|
||||||
|
job,
|
||||||
|
state="failed",
|
||||||
|
phase=None,
|
||||||
|
message="yt-dlp executable not found",
|
||||||
|
returncode=127,
|
||||||
|
)
|
||||||
|
await finalize_job(job, 127)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
job.process = process
|
||||||
|
update_status(job, phase="running", message="yt-dlp running")
|
||||||
|
|
||||||
|
assert process.stdout is not None
|
||||||
|
while True:
|
||||||
|
line = await process.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
classify_output_line(job, line.decode(errors="replace").strip())
|
||||||
|
|
||||||
|
returncode = await process.wait()
|
||||||
|
await finalize_job(job, returncode)
|
||||||
|
except Exception as exc:
|
||||||
|
update_status(
|
||||||
|
job,
|
||||||
|
state="failed",
|
||||||
|
phase=None,
|
||||||
|
message=f"backend runner error: {exc}",
|
||||||
|
returncode=1,
|
||||||
|
)
|
||||||
|
await finalize_job(job, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_job(request: JobRequest) -> JobStatus:
|
||||||
|
global active_job
|
||||||
|
async with job_lock:
|
||||||
|
if active_job is not None:
|
||||||
|
busy_job = active_job.status
|
||||||
|
return JobStatus(
|
||||||
|
job_id=busy_job.job_id,
|
||||||
|
state="busy",
|
||||||
|
url=request.url,
|
||||||
|
message=f"busy with {busy_job.url}",
|
||||||
|
requester_id=request.requester_id,
|
||||||
|
requester_name=request.requester_name,
|
||||||
|
origin=request.origin,
|
||||||
|
)
|
||||||
|
|
||||||
|
status = JobStatus(
|
||||||
|
job_id=str(uuid4()),
|
||||||
|
state="accepted",
|
||||||
|
url=request.url,
|
||||||
|
message="accepted",
|
||||||
|
phase="queued",
|
||||||
|
requester_id=request.requester_id,
|
||||||
|
requester_name=request.requester_name,
|
||||||
|
origin=request.origin,
|
||||||
|
)
|
||||||
|
managed_job = ManagedJob(status=status)
|
||||||
|
managed_job.task = asyncio.create_task(run_job(managed_job, request))
|
||||||
|
active_job = managed_job
|
||||||
|
return clone_status(status)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse)
|
||||||
|
async def health() -> HealthResponse:
|
||||||
|
return HealthResponse(status="ok")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/version", response_model=VersionResponse)
|
||||||
|
async def version() -> VersionResponse:
|
||||||
|
return VersionResponse(version=read_version(), active_job=active_job is not None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/jobs/current", response_model=CurrentJobResponse)
|
||||||
|
async def current_job() -> CurrentJobResponse:
|
||||||
|
async with job_lock:
|
||||||
|
if active_job is not None:
|
||||||
|
return CurrentJobResponse(active=True, job=clone_status(active_job.status))
|
||||||
|
if last_job is not None:
|
||||||
|
return CurrentJobResponse(active=False, job=clone_status(last_job))
|
||||||
|
return CurrentJobResponse(active=False, job=None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/jobs", response_model=JobStatus)
|
||||||
|
async def submit_job(request: JobRequest) -> JobStatus:
|
||||||
|
return await create_job(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/jobs/current/cancel", response_model=JobStatus)
|
||||||
|
async def cancel_current_job() -> JobStatus:
|
||||||
|
async with job_lock:
|
||||||
|
if active_job is None:
|
||||||
|
raise HTTPException(status_code=404, detail="no active job")
|
||||||
|
job = active_job
|
||||||
|
if job.process is None:
|
||||||
|
update_status(job, message="cancel requested before yt-dlp started")
|
||||||
|
job.cancel_requested = True
|
||||||
|
return clone_status(job.status)
|
||||||
|
|
||||||
|
job.cancel_requested = True
|
||||||
|
update_status(job, message="cancel requested", phase="running")
|
||||||
|
job.process.terminate()
|
||||||
|
return clone_status(job.status)
|
||||||
46
youdis/models.py
Normal file
46
youdis/models.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
JobState = Literal["accepted", "busy", "running", "completed", "failed", "cancelled"]
|
||||||
|
|
||||||
|
|
||||||
|
class JobRequest(BaseModel):
|
||||||
|
url: str
|
||||||
|
requester_id: str | None = None
|
||||||
|
requester_name: str | None = None
|
||||||
|
origin: str | None = None
|
||||||
|
requested_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
state: JobState
|
||||||
|
url: str
|
||||||
|
message: str | None = None
|
||||||
|
phase: str | None = None
|
||||||
|
disposition: str | None = None
|
||||||
|
requester_id: str | None = None
|
||||||
|
requester_name: str | None = None
|
||||||
|
origin: str | None = None
|
||||||
|
result_path: str | None = None
|
||||||
|
command: list[str] = Field(default_factory=list)
|
||||||
|
returncode: int | None = None
|
||||||
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentJobResponse(BaseModel):
|
||||||
|
active: bool
|
||||||
|
job: JobStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: Literal["ok"]
|
||||||
|
|
||||||
|
|
||||||
|
class VersionResponse(BaseModel):
|
||||||
|
version: str
|
||||||
|
active_job: bool
|
||||||
Reference in New Issue
Block a user