A downloaded video was silently demoted to a download-only "file" (no in-app play/edit) whenever yt-dlp's info dict omitted vcodec/acodec — which it does for Facebook and other sources. Finalize derived media_kind from those nulls (`derive(None, None)` -> file), so a genuine H.264/AAC MP4 became non-playable in the library. Fix: probe the finished file on disk (the ground truth) at finalize and derive from that, falling back to the info dict only for a field the probe couldn't read. This is the same ffprobe the upload ingest already used, now extracted to a shared downloads/probe.py so the download worker and the upload path share one classifier (and can't drift). Also backfills the stored vcodec/acodec/width/height from the real file. Tests: TestResolveMediaFacts pins probe-wins-over-info (incl. the Facebook no-codec case and yt-dlp's literal "none"); upload's probe tests repoint to the shared module.
123 lines
5.9 KiB
Python
123 lines
5.9 KiB
Python
"""Local file ingest for the download center (E7 S5) — the non-yt-dlp path into the media library.
|
|
|
|
A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function
|
|
set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a
|
|
download it bypasses yt-dlp AND the worker entirely: the file is already on the host, so ingest
|
|
probes it, places it on disk, and creates an already-`ready` MediaAsset + `done` DownloadJob in one
|
|
step — the same "the file exists, mark it done now" shortcut `service.enqueue` takes on a cache hit.
|
|
|
|
Kinds (the invariant lives in `mediakind`): a probe with a real video stream -> video, audio-only
|
|
-> audio, neither -> a generic 'file' (pdf/zip/docx/…) that is download-only and never rendered
|
|
inline (see the force-download serving in the file/watch routes). This module is the ingest seam a
|
|
future sub-function (e.g. convert) plugs a new adapter into, mirroring how it sidesteps the worker.
|
|
|
|
An upload does NOT get an `expires_at`: it's user-provided and may be irreplaceable (there's no
|
|
source to re-download), so it doesn't age out on the retention clock the way a re-fetchable download
|
|
does — it lives until the user deletes it.
|
|
"""
|
|
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import settings
|
|
from app.downloads import edit as editmod
|
|
from app.downloads import mediakind, probe, quota, service, storage
|
|
from app.models import DownloadJob, MediaAsset
|
|
|
|
|
|
def _title_from_filename(filename: str) -> str:
|
|
"""A human display title from the original upload name: the stem with separators tidied to
|
|
spaces (kept as-is otherwise — this is display metadata, not the shell-safe on-disk name)."""
|
|
stem = Path(filename).stem.replace("_", " ").replace(".", " ").strip()
|
|
return (stem or "Upload")[:255]
|
|
|
|
|
|
def ingest_upload(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
filename: str,
|
|
src_path: Path,
|
|
size: int,
|
|
display_name: str | None = None,
|
|
) -> DownloadJob:
|
|
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return the `done`
|
|
job. `src_path` is MOVED into place on success (the caller stages it to a temp file first).
|
|
|
|
Raises quota.QuotaExceeded (holdings/footprint cap) — checked BEFORE any disk write, so a
|
|
rejected upload leaves nothing behind and the caller just cleans up its temp file."""
|
|
quota.check_upload(db, user_id, size) # the file's size is known → charge it up front
|
|
|
|
vcodec, acodec, width, height, duration = probe.probe_media(src_path)
|
|
kind = mediakind.derive(vcodec, acodec)
|
|
|
|
# An upload never dedups (each file is its own asset): a random ref makes the cache key unique,
|
|
# so `format_sig` is a constant marker rather than a format hash.
|
|
ref = f"{user_id}:{secrets.token_hex(8)}"[:512]
|
|
asset = MediaAsset(
|
|
source_kind="upload", source_ref=ref, format_sig="upload", status="pending", ref_count=0
|
|
)
|
|
db.add(asset)
|
|
db.flush() # need asset.id for the on-disk path
|
|
|
|
rel = storage.upload_rel_path(user_id, asset.id, filename)
|
|
storage.place_file(src_path, settings.download_root, rel)
|
|
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
|
|
# asset/job rows roll back but the placed file would be ORPHANED — nothing references it, the
|
|
# per-user delete path keys off asset.rel_path (which is gone), and the GC orphan sweep skips the
|
|
# dot-prefixed `.uploads/` tree. So roll back the on-disk side too on any failure.
|
|
try:
|
|
final = storage.abs_path(settings.download_root, rel)
|
|
actual_size = final.stat().st_size if final.exists() else size
|
|
|
|
asset.status = "ready"
|
|
asset.rel_path = rel
|
|
asset.size_bytes = actual_size
|
|
asset.container = storage.upload_ext(filename)
|
|
asset.vcodec = vcodec
|
|
asset.acodec = acodec
|
|
asset.width = width
|
|
asset.height = height
|
|
asset.duration_s = duration
|
|
asset.media_kind = kind
|
|
asset.title = (display_name or "").strip() or _title_from_filename(filename)
|
|
asset.last_access_at = datetime.now(timezone.utc)
|
|
# No expires_at — see the module docstring (an upload is irreplaceable, so it doesn't age out).
|
|
if kind == mediakind.VIDEO:
|
|
# A frame poster gives the library row a thumbnail + the share link an og:image. Audio has
|
|
# no frame and a generic file no player, so neither gets one. Best-effort (ffmpeg decodes
|
|
# server side even for a codec the browser can't play).
|
|
asset.poster_path = editmod.ensure_poster(asset, settings.download_root)
|
|
|
|
job = DownloadJob(
|
|
user_id=user_id,
|
|
asset_id=asset.id,
|
|
job_kind="upload",
|
|
source_kind="upload",
|
|
source_ref=ref,
|
|
profile_snapshot={},
|
|
display_name=(display_name or "").strip() or None,
|
|
status="done",
|
|
progress=100,
|
|
queue_pos=service.next_queue_pos(db),
|
|
)
|
|
db.add(job)
|
|
service.incr_ref_count(db, asset.id)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
# The rows are gone; remove the now-unreferenced file so no bytes leak. Clean the poster by its
|
|
# DETERMINISTIC path (rel with a .jpg suffix, the same path ensure_poster writes to) rather than
|
|
# ensure_poster's return value: it writes the frame directly to that path and returns None on an
|
|
# ffmpeg failure, so a partial poster can be on disk while the return is None — gating on the
|
|
# return would skip that orphan. unlink is missing_ok, so the poster path is a harmless no-op
|
|
# when none was generated (audio/file, or a video whose poster never started).
|
|
for leaked in (rel, str(Path(rel).with_suffix(".jpg"))):
|
|
storage.abs_path(settings.download_root, leaked).unlink(missing_ok=True)
|
|
raise
|
|
db.refresh(job)
|
|
return job
|