The Files library starts here. A user's every download, clip and upload now lands in a source folder made per origin (YouTube, Plex clips, uploads, one per other site), decided once in downloads/folders.py at every place a job is made. How long a file stays moves from the shared asset to the user's job: stamped from its folder when it is done, never for a file the user keeps. The GC warns each user once, then takes the expired file out of their library; the cache cap never evicts a kept file, and an evicted one leaves every holder's library. The media mount is /files now (FILES_ROOT, FILES_HOST_PATH, volume siftlode_files); docs/self-hosting.md says how to carry an old volume over. Migration 0102 places every existing job and carries each done file's expiry over from its asset. safe_abs_path takes a missing path and answers None, which drops one baselined type error.
76 lines
2.6 KiB
Python
76 lines
2.6 KiB
Python
"""Short-lived-session DB helpers + staging paths shared by the worker parent and the child pipeline."""
|
|
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.config import settings
|
|
from app.db import SessionLocal
|
|
from app.models import DownloadJob, MediaAsset
|
|
|
|
|
|
# --- small DB helpers (short-lived sessions; the download itself holds no transaction) -------
|
|
|
|
|
|
def _set_job(job_id: int, **fields) -> None:
|
|
with SessionLocal() as db:
|
|
job = db.get(DownloadJob, job_id)
|
|
if job is None:
|
|
return
|
|
for k, v in fields.items():
|
|
setattr(job, k, v)
|
|
db.commit()
|
|
|
|
|
|
def _job_status(job_id: int) -> str | None:
|
|
with SessionLocal() as db:
|
|
return db.execute(
|
|
text("SELECT status FROM download_jobs WHERE id = :id"), {"id": job_id}
|
|
).scalar()
|
|
|
|
|
|
def _touch_job(job_id: int) -> None:
|
|
"""Bump a running job's `updated_at` without changing any value — a stall-watchdog heartbeat for
|
|
phases that legitimately write no progress. Extraction writes nothing, and a multi-client-set
|
|
fallback re-writes identical values (progress 0 / phase 'downloading'), so ORM `onupdate` would
|
|
NOT fire and two slow extraction attempts could otherwise cross the stall timeout and be killed
|
|
mid-extraction though they'd have succeeded."""
|
|
with SessionLocal() as db:
|
|
db.execute(
|
|
text("UPDATE download_jobs SET updated_at=now() WHERE id=:id AND status='running'"),
|
|
{"id": job_id},
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def _requeue_if_running(job_id: int, phase: str) -> None:
|
|
"""Put the job back to 'queued' for a later retry, but ONLY while it is still 'running'. The
|
|
route flips status to paused/cancelled to stop a job; a blind `status='queued'` here would
|
|
clobber that and silently revive a job the user just cancelled. The conditional WHERE makes the
|
|
requeue a no-op unless we still own a running job."""
|
|
with SessionLocal() as db:
|
|
db.execute(
|
|
text(
|
|
"UPDATE download_jobs SET status='queued', phase=:phase, updated_at=now() "
|
|
"WHERE id=:id AND status='running'"
|
|
),
|
|
{"id": job_id, "phase": phase},
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def _reset_asset_pending(asset_id: int) -> None:
|
|
with SessionLocal() as db:
|
|
asset = db.get(MediaAsset, asset_id)
|
|
if asset and asset.status == "downloading":
|
|
asset.status = "pending"
|
|
db.commit()
|
|
|
|
|
|
def _staging_dir(asset_id: int) -> Path:
|
|
return Path(settings.files_root) / ".staging" / f"asset-{asset_id}"
|
|
|
|
|
|
def _edit_staging_dir(asset_id: int) -> Path:
|
|
return Path(settings.files_root) / ".staging" / f"edit-{asset_id}"
|