Add the non-yt-dlp ingest path so a user-uploaded file joins the same download
library and inherits its function set. Any file type is accepted: media
(video/audio) gets the full set (metadata, share, play/edit), a generic file
(pdf/zip/docx/…) is download-only.
- MediaAsset.media_kind ("video"|"audio"|"file") — the stored discriminator that
gates library actions. Set at every finalize (download probe, editor carries the
source's kind, upload probe); consolidates the ad-hoc audio-detect that lived only
in links.py into one invariant (downloads.mediakind.derive). Migration 0076 adds
the column + backfills existing rows.
- downloads/upload.py: ingest_upload probes the file (ffprobe → codecs/dims/duration),
places it in a per-user .uploads tree, and creates an already-ready asset + done job
(the same cache-hit shortcut enqueue uses). Uploads never expire (irreplaceable).
- POST /api/downloads/upload: streaming multipart with the per-file cap
(download_upload_max_bytes, default 500 MiB) enforced on actual bytes read; quota
charges the known size up front (quota.check_upload).
- Security: a generic file is always served force-download (attachment +
application/octet-stream) on both the in-app and public endpoints, so uploaded
HTML/SVG can't execute inline. Edit + redownload reject non-media / upload sources.
- python-multipart added (first multipart endpoint in the app).
159 lines
6.4 KiB
Python
159 lines
6.4 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 json
|
|
import secrets
|
|
import subprocess
|
|
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, quota, service, storage
|
|
from app.models import DownloadJob, MediaAsset
|
|
|
|
|
|
def _probe(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
|
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
|
or the file isn't media (a file we can't probe is treated as a generic non-media upload).
|
|
|
|
A video stream flagged `attached_pic` (embedded cover art in an mp3/m4a) is skipped — it's a
|
|
thumbnail, not a real video track, so an audio file with album art still classifies as audio."""
|
|
try:
|
|
out = subprocess.run(
|
|
[
|
|
"ffprobe",
|
|
"-v",
|
|
"error",
|
|
"-show_streams",
|
|
"-show_format",
|
|
"-of",
|
|
"json",
|
|
str(path),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=True,
|
|
).stdout
|
|
data = json.loads(out)
|
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError):
|
|
return None, None, None, None, None
|
|
|
|
vcodec = acodec = None
|
|
width = height = None
|
|
for stream in data.get("streams", []):
|
|
kind = stream.get("codec_type")
|
|
if kind == "video" and vcodec is None:
|
|
if (stream.get("disposition") or {}).get("attached_pic"):
|
|
continue # cover art, not a video track
|
|
vcodec = stream.get("codec_name")
|
|
width = stream.get("width")
|
|
height = stream.get("height")
|
|
elif kind == "audio" and acodec is None:
|
|
acodec = stream.get("codec_name")
|
|
|
|
duration = None
|
|
try:
|
|
raw = (data.get("format") or {}).get("duration")
|
|
if raw is not None:
|
|
duration = int(float(raw))
|
|
except (TypeError, ValueError):
|
|
duration = None
|
|
return vcodec, acodec, width, height, duration
|
|
|
|
|
|
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(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)
|
|
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()
|
|
db.refresh(job)
|
|
return job
|