/api/files is the library's home: list a user's done files (by folder, searched, sorted, or the ones shared with them), rename / move / keep / delete a file, make, rename and delete own folders (a deleted folder's files go back to their source folders), and set how long any folder keeps a file — done files shift by the difference, never closer than the GC's warning window. An upload can name its folder (an own folder or Uploads), checked before the body is read and again before the file lands; it is hashed while it streams, and any file can be hashed on first ask. The download says the hash (Repr-Digest) once it is known; a new file at an asset's path forgets it. Uploads, deletes, keeps, moves and folder changes are audited (ids only). The fileless "expired" rows of the old retention are deleted by migration 0103: an expired file now leaves the library. The shared route helpers moved to routes/files.py; the old /api/downloads library routes delegate to the new ones until the client moves over.
176 lines
8.9 KiB
Python
176 lines
8.9 KiB
Python
"""Local file ingest for the download center — 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: the file is already on the host, so ingest probes it and places it on
|
|
disk. A file that already is the phone-safe shape (`playable`) — or is not media at all — becomes 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.
|
|
|
|
Media that is NOT phone-safe (an iPhone's HEVC .mov, a VP9 .webm, an .mkv) is placed as it came and
|
|
queued: the asset stays `pending` and the job `queued`, and the download worker converts it
|
|
(`pipeline._convert_upload`) — so a share link never hands a friend a file their phone cannot play.
|
|
Only the converted file is kept (the owner's choice: one file per upload). A 10-bit HDR original
|
|
becomes 8-bit H.264 with its colour tags kept — still tagged HDR (HLG/PQ, BT.2020), not tone-mapped
|
|
to SDR (the owner's choice too). Until then the original sits at the asset's `rel_path`, which is
|
|
what a failed conversion's Retry starts from again, and what a delete removes
|
|
(`service.release_asset`).
|
|
|
|
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).
|
|
|
|
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 import audit
|
|
from app.audit import AuditAction
|
|
from app.config import settings
|
|
from app.downloads import edit as editmod
|
|
from app.downloads import folders, mediakind, playable, probe, quota, service, storage
|
|
from app.downloads.status import JobKind, JobStatus
|
|
from app.models import DownloadJob, MediaAsset, display_text
|
|
|
|
|
|
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,
|
|
sha256: str | None = None,
|
|
folder_id: int | None = None,
|
|
) -> DownloadJob:
|
|
"""Ingest an already-uploaded file at `src_path` into `user_id`'s library and return its job —
|
|
`done`, or `queued` for the worker's phone-safe conversion (see the module docstring). `src_path`
|
|
is MOVED into place on success (the caller stages it to a temp file first). `sha256` is the
|
|
hash of those bytes, when the caller took it on the way in; `folder_id` the folder it asked for
|
|
(`folders.upload_target`), else its Uploads folder.
|
|
|
|
Raises folders.FolderError when that folder is not one an upload may land in (or is gone).
|
|
|
|
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
|
|
if folder_id is not None:
|
|
folders.upload_target(db, user_id, folder_id)
|
|
|
|
data = probe.probe_json(src_path)
|
|
vcodec, acodec, width, height, duration = probe.facts(data)
|
|
kind = mediakind.derive(vcodec, acodec)
|
|
# Judged by the extension the file is PLACED under (and the worker re-judges), not the raw one:
|
|
# 'clip.mp4~' is stored as .mp4, and judging 'mp4~' queued a conversion with nothing to convert.
|
|
ext = storage.upload_ext(filename) or ""
|
|
safe = playable.plan(data, ext) if kind != mediakind.FILE else None
|
|
convert = safe is not None and safe.needs_change
|
|
|
|
# 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.files_root, rel)
|
|
# From here the file is on disk under `.uploads/`. If the transaction fails before it commits, the
|
|
# asset/job rows roll back and nothing names the placed file: the per-user delete path keys off
|
|
# asset.rel_path (which is gone), and the GC's `.uploads` sweep reaches it only an hour later. So
|
|
# roll back the on-disk side too — unless the commit itself raised after it landed (see below).
|
|
asset_id = asset.id
|
|
try:
|
|
final = storage.abs_path(settings.files_root, rel)
|
|
actual_size = final.stat().st_size if final.exists() else size
|
|
|
|
asset.status = "pending" if convert else "ready"
|
|
asset.rel_path = rel
|
|
# The hash names the bytes that arrived: a conversion replaces them, so it is kept only
|
|
# when the file stays as it came.
|
|
asset.sha256 = None if convert else sha256
|
|
asset.size_bytes = actual_size
|
|
asset.container = ext or None
|
|
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.files_root)
|
|
|
|
job = DownloadJob(
|
|
user_id=user_id,
|
|
asset_id=asset.id,
|
|
job_kind=JobKind.UPLOAD,
|
|
source_kind="upload",
|
|
source_ref=ref,
|
|
profile_snapshot={},
|
|
display_name=display_text(display_name),
|
|
status=JobStatus.QUEUED if convert else JobStatus.DONE,
|
|
progress=0 if convert else 100,
|
|
queue_pos=service.next_queue_pos(db),
|
|
)
|
|
db.add(job)
|
|
folders.place(db, job)
|
|
if folder_id is not None:
|
|
job.folder_id = folder_id
|
|
if not convert:
|
|
folders.stamp_expiry(db, job)
|
|
service.incr_ref_count(db, asset.id)
|
|
db.flush()
|
|
audit.record(db, user_id, AuditAction.FILE_UPLOAD, target_type="file", target_id=job.id)
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
if _row_landed(db, asset_id) is not False:
|
|
# The commit reached the server and lost its answer, or the row cannot be read: the file
|
|
# may be the asset's now, so it is never removed blind — the sweep takes it if no row
|
|
# names it.
|
|
raise
|
|
# 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.files_root, leaked).unlink(missing_ok=True)
|
|
raise
|
|
db.refresh(job)
|
|
return job
|
|
|
|
|
|
def _row_landed(db: Session, asset_id: int) -> bool | None:
|
|
"""After the ingest transaction raised: is the asset row there anyway? A commit can reach the
|
|
server and lose its answer (the connection drops after COMMIT). None when it cannot be read."""
|
|
try:
|
|
return db.get(MediaAsset, asset_id) is not None
|
|
except Exception: # noqa: BLE001 — "cannot tell" is the answer, the caller acts on it
|
|
# silent-ok: the caller re-raises the ingest's own error.
|
|
return None
|