configured initial yt-dlp worker fallback download location

This commit is contained in:
ben
2026-03-31 21:20:46 -04:00
parent 2a5648506e
commit 8b21034d78
3 changed files with 70 additions and 12 deletions

View File

@@ -36,7 +36,7 @@ define the target architecture for a private backend yt-dlp worker with thin cha
** notes ** notes
- first architecture draft captured in `docs/architecture-v2.org` - first architecture draft captured in `docs/architecture-v2.org`
* [ ] 2.0.1: build backend yt-dlp worker (3) * [X] 2.0.1: build backend yt-dlp worker (3)
create the minimal backend/service skeleton and establish a working yt-dlp baseline with clean hooks for future frontends create the minimal backend/service skeleton and establish a working yt-dlp baseline with clean hooks for future frontends
** pm notes ** pm notes
- foundation; don't need the full finished service here, just the basic shape plus enough real yt-dlp execution to validate the seam and build on it. - foundation; don't need the full finished service here, just the basic shape plus enough real yt-dlp execution to validate the seam and build on it.
@@ -55,10 +55,13 @@ create the minimal backend/service skeleton and establish a working yt-dlp basel
- submit/request path exists - submit/request path exists
- status/progress hook exists - status/progress hook exists
- basic health/version visibility exists - basic health/version visibility exists
4f. 4. make local testing practical without breaking container defaults
- backend can run when `/config` or `/downloads` are not writable in local dev
- env vars may override executable/config/download paths
- status makes the effective runtime command/paths inspectable
** evidence ** evidence
- commit: - commit: 7926534,2a56485
- tests: - tests:
1. `python3 -m py_compile ./youdis/main.py ./youdis/models.py ./youdis/adapters/__init__.py ./youdis/adapters/discord.py` 1. `python3 -m py_compile ./youdis/main.py ./youdis/models.py ./youdis/adapters/__init__.py ./youdis/adapters/discord.py`
2. `python3 -m uvicorn youdis.main:app --host 127.0.0.1 --port 8000` 2. `python3 -m uvicorn youdis.main:app --host 127.0.0.1 --port 8000`
@@ -73,11 +76,12 @@ create the minimal backend/service skeleton and establish a working yt-dlp basel
11. if testing in container: `docker run --rm -p 8000:8000 -v [config]:/config -v [downloads]:/downloads youdis:v2` 11. if testing in container: `docker run --rm -p 8000:8000 -v [config]:/config -v [downloads]:/downloads youdis:v2`
:OUTPUT_STEP1-9: :OUTPUT_STEP1-9:
user@paladin:~/proj/youdis$ curl http://127.0.0.1:8000/health user@paladin:~/proj/youdis$ curl http://127.0.0.1:8000/health
{"status":"ok"}user@paladin:~/proj/youdis$ {"status":"ok"}
user@paladin:~/proj/youdis$ curl http://127.0.0.1:8000/version user@paladin:~/proj/youdis$ curl http://127.0.0.1:8000/version
{"version":"20250829-ec72c56","active_job":false}user@paladin:~/proj/youdis$ {"version":"20250829-ec72c56","active_job":false}
user@paladin:~/proj/youdis$ curl -X POST http://127.0.0.1:8000/jobs -H 'content-type: application/json' -d '{"url":"https://www.youtube.com/watch?v=3i72yY_LaW4"}' user@paladin:~/proj/youdis$ curl -X POST http://127.0.0.1:8000/jobs -H 'content-type: application/json' -d '{"url":"https://www.youtube.com/watch?v=3i72yY_LaW4"}'
{"job_id":"cc85165e-d906-4eee-864f-1a398b6de2e0","state":"accepted","url":"https://www.youtube.com/watch?v=3i72yY_LaW4","message":"accepted","phase":"queued","disposition":null,"requester_id":null,"requester_name":null,"origin":null,"result_path":null,"command":[],"returncode":null,"created_at":"2026-04-01T00:44:35.657196","updated_at":"2026-04-01T00:44:35.657198"}user@paladin:~/proj/youdis$ curl http://127.0.0.1:8000/jobs/current {"job_id":"cc85165e-d906-4eee-864f-1a398b6de2e0","state":"accepted","url":"https://www.youtube.com/watch?v=3i72yY_LaW4","message":"accepted","phase":"queued","disposition":null,"requester_id":null,"requester_name":null,"origin":null,"result_path":null,"command":[],"returncode":null,"created_at":"2026-04-01T00:44:35.657196","updated_at":"2026-04-01T00:44:35.657198"}
user@paladin:~/proj/youdis$ curl http://127.0.0.1:8000/jobs/current
{"active":false,"job":{"job_id":"cc85165e-d906-4eee-864f-1a398b6de2e0","state":"failed","url":"https://www.youtube.com/watch?v=3i72yY_LaW4","message":"ERROR: unable to create directory [Errno 13] Permission denied: '/downloads'","phase":null,"disposition":null,"requester_id":null,"requester_name":null,"origin":null,"result_path":null,"command":["yt-dlp","--config-locations","/home/user/proj/youdis/default-yt-dlp.conf","https://www.youtube.com/watch?v=3i72yY_LaW4"],"returncode":1,"created_at":"2026-04-01T00:44:35.657196","updated_at":"2026-03-31T20:44:36.653353"}}user@paladin:~/proj/youdis$ {"active":false,"job":{"job_id":"cc85165e-d906-4eee-864f-1a398b6de2e0","state":"failed","url":"https://www.youtube.com/watch?v=3i72yY_LaW4","message":"ERROR: unable to create directory [Errno 13] Permission denied: '/downloads'","phase":null,"disposition":null,"requester_id":null,"requester_name":null,"origin":null,"result_path":null,"command":["yt-dlp","--config-locations","/home/user/proj/youdis/default-yt-dlp.conf","https://www.youtube.com/watch?v=3i72yY_LaW4"],"returncode":1,"created_at":"2026-04-01T00:44:35.657196","updated_at":"2026-03-31T20:44:36.653353"}}user@paladin:~/proj/youdis$
:END: :END:
- datetime: [2026-03-31 Tue 20:45] - datetime: [2026-03-31 Tue 20:45]
@@ -87,7 +91,9 @@ user@paladin:~/proj/youdis$ curl -X POST http://127.0.0.1:8000/jobs -H 'content-
- confirm `--config-locations` behavior against the installed `yt-dlp` version during integration testing - confirm `--config-locations` behavior against the installed `yt-dlp` version during integration testing
- current backend scaffold is not yet wired into `dockerfile` or `run-youdis.sh` - current backend scaffold is not yet wired into `dockerfile` or `run-youdis.sh`
- archive-hit and result-path parsing currently depend on `yt-dlp` stdout text patterns, so treat them as provisional until integration-tested - archive-hit and result-path parsing currently depend on `yt-dlp` stdout text patterns, so treat them as provisional until integration-tested
- local dev now falls back to repo-local `.runtime/{config,downloads}` when `/config` or `/downloads` are not writable
- had to uninstall yt-dlp python pkg from the venv, which resulted in a '403 Forbidden'
* [ ] 2.0.2: update discord bot to use new backend (3) * [ ] 2.0.2: update discord bot to use new backend (3)
update the discord bot into a thin frontend that talks to the backend and verify the flow end to end update the discord bot into a thin frontend that talks to the backend and verify the flow end to end
** pm notes ** pm notes

View File

@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from os import getenv from os import getenv
from pathlib import Path from pathlib import Path
import tempfile
from uuid import uuid4 from uuid import uuid4
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
@@ -16,6 +17,12 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONFIG = REPO_ROOT / "default-yt-dlp.conf" DEFAULT_CONFIG = REPO_ROOT / "default-yt-dlp.conf"
VERSION_FILE = REPO_ROOT / "youdis-version.txt" VERSION_FILE = REPO_ROOT / "youdis-version.txt"
YTDLP_EXECUTABLE = getenv("YOUDIS_YTDLP_EXECUTABLE", "yt-dlp") YTDLP_EXECUTABLE = getenv("YOUDIS_YTDLP_EXECUTABLE", "yt-dlp")
PREFERRED_CONFIG_ROOT = Path(getenv("YOUDIS_CONFIG_DIR", "/config"))
PREFERRED_DOWNLOAD_ROOT = Path(getenv("YOUDIS_DOWNLOAD_DIR", "/downloads"))
LOCAL_RUNTIME_ROOT = REPO_ROOT / ".runtime"
FALLBACK_CONFIG_ROOT = LOCAL_RUNTIME_ROOT / "config"
FALLBACK_DOWNLOAD_ROOT = LOCAL_RUNTIME_ROOT / "downloads"
OUTPUT_TEMPLATE = "%(uploader)s/%(playlist_title)s/%(playlist_index)s%(playlist_index& - )s%(title)s.%(ext)s"
@dataclass @dataclass
@@ -43,11 +50,33 @@ def read_version() -> str:
return "unknown" return "unknown"
def build_ytdlp_command(request: JobRequest) -> list[str]: def ensure_writable_directory(preferred: Path, fallback: Path) -> Path:
for candidate in (preferred, fallback):
try:
candidate.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=candidate, prefix=".youdis-write-", delete=True):
pass
return candidate
except OSError:
continue
raise OSError(f"no writable runtime directory available for {preferred} or {fallback}")
def resolve_runtime_paths() -> tuple[Path, Path]:
config_root = ensure_writable_directory(PREFERRED_CONFIG_ROOT, FALLBACK_CONFIG_ROOT)
download_root = ensure_writable_directory(PREFERRED_DOWNLOAD_ROOT, FALLBACK_DOWNLOAD_ROOT)
return config_root, download_root
def build_ytdlp_command(request: JobRequest, config_root: Path, download_root: Path) -> list[str]:
return [ return [
YTDLP_EXECUTABLE, YTDLP_EXECUTABLE,
"--config-locations", "--config-locations",
str(DEFAULT_CONFIG), str(DEFAULT_CONFIG),
"--download-archive",
str(config_root / "archive.txt"),
"--output",
str(download_root / OUTPUT_TEMPLATE),
request.url, request.url,
] ]
@@ -121,13 +150,11 @@ async def finalize_job(job: ManagedJob, returncode: int) -> None:
async def run_job(job: ManagedJob, request: JobRequest) -> None: async def run_job(job: ManagedJob, request: JobRequest) -> None:
command = build_ytdlp_command(request)
update_status( update_status(
job, job,
state="running", state="running",
phase="starting", phase="starting",
message="starting yt-dlp", message="starting yt-dlp",
command=command,
) )
try: try:
@@ -142,6 +169,15 @@ async def run_job(job: ManagedJob, request: JobRequest) -> None:
await finalize_job(job, 78) await finalize_job(job, 78)
return return
config_root, download_root = resolve_runtime_paths()
command = build_ytdlp_command(request, config_root, download_root)
update_status(
job,
command=command,
archive_path=str(config_root / "archive.txt"),
result_path=str(download_root),
)
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*command, *command,
stdout=PIPE, stdout=PIPE,
@@ -157,6 +193,16 @@ async def run_job(job: ManagedJob, request: JobRequest) -> None:
) )
await finalize_job(job, 127) await finalize_job(job, 127)
return return
except OSError as exc:
update_status(
job,
state="failed",
phase=None,
message=f"runtime path setup failed: {exc}",
returncode=73,
)
await finalize_job(job, 73)
return
try: try:
job.process = process job.process = process
@@ -220,7 +266,11 @@ async def health() -> HealthResponse:
@app.get("/version", response_model=VersionResponse) @app.get("/version", response_model=VersionResponse)
async def version() -> VersionResponse: async def version() -> VersionResponse:
return VersionResponse(version=read_version(), active_job=active_job is not None) return VersionResponse(
version=read_version(),
active_job=active_job is not None,
ytdlp_executable=YTDLP_EXECUTABLE,
)
@app.get("/jobs/current", response_model=CurrentJobResponse) @app.get("/jobs/current", response_model=CurrentJobResponse)

View File

@@ -26,6 +26,7 @@ class JobStatus(BaseModel):
requester_name: str | None = None requester_name: str | None = None
origin: str | None = None origin: str | None = None
result_path: str | None = None result_path: str | None = None
archive_path: str | None = None
command: list[str] = Field(default_factory=list) command: list[str] = Field(default_factory=list)
returncode: int | None = None returncode: int | None = None
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
@@ -44,3 +45,4 @@ class HealthResponse(BaseModel):
class VersionResponse(BaseModel): class VersionResponse(BaseModel):
version: str version: str
active_job: bool active_job: bool
ytdlp_executable: str