fix(downloads): an upload's conversion is guarded, counted, named and cleaned up
- Every ffmpeg/ffprobe over a library file names the demuxers it may use
(edit.INPUT_GUARD; a downloaded thumbnail edit.THUMB_GUARD): a playlist-type
demuxer would read local paths named inside a user's upload.
- A re-encode ends on an even frame (edit.EVEN_FRAME): an odd 4:4:4/RGB source
failed its conversion; a clip stores the frame its file has.
- An upload counts against its owner's cap from the moment it lands (queued, or
kept after a failed conversion) — the footprint, the GC's cap and the admin
view read one ON_DISK predicate — and a conversion that outgrows the cap is
refused, keeping the original.
- The converted name keeps _a{id} (a dotted bare name once lost it and two
uploads shared a file); a converted file placed but not committed is removed,
and the orphan sweep enters .uploads; the LRU cap never evicts an upload.
- Ingest judges the extension the file is stored under; a failed conversion is
convert_failed / upload_missing, reports the "converting" phase, and notifies
as an upload. Job statuses in routes/downloads use JobStatus.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
"""Per-user download limits + footprint accounting.
|
||||
|
||||
A user's *footprint* is the total size of the ready assets their library references (cache hits
|
||||
count — it's "how much disk you're responsible for", not "how much you personally downloaded").
|
||||
count — it's "how much disk you're responsible for", not "how much you personally downloaded"). An
|
||||
upload counts from the moment it lands, ready or not: its original is on disk while its conversion
|
||||
waits, and stays there when the conversion fails (for Retry) — bytes a ready-only sum never saw.
|
||||
Limits come from a per-user DownloadQuota row if the admin set one, else the sysconfig defaults.
|
||||
`unlimited` (e.g. the admin's own account) bypasses the byte cap.
|
||||
|
||||
@@ -18,16 +20,24 @@ is unknown until it finishes. Edit jobs do not consult it at all (tracked as SEC
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.downloads.status import JobStatus
|
||||
from app.models import DownloadJob, DownloadQuota, MediaAsset
|
||||
|
||||
# Jobs a user is considered to be "holding" (occupy a job slot / footprint). Terminal failures
|
||||
# (error, canceled) don't count.
|
||||
_HOLDING = ("queued", "running", "paused", "done")
|
||||
_RUNNING = ("queued", "running")
|
||||
_HOLDING = (JobStatus.QUEUED, JobStatus.RUNNING, JobStatus.PAUSED, JobStatus.DONE)
|
||||
_RUNNING = (JobStatus.QUEUED, JobStatus.RUNNING)
|
||||
# An asset whose file is on disk: a ready one, or an upload in any state (its original lands at
|
||||
# ingest, see the module docstring). Every byte sum — a user's footprint, the GC's total-cap pass,
|
||||
# the admin's storage view — reads it, so they all count the same disk.
|
||||
ON_DISK = or_(
|
||||
MediaAsset.status == "ready",
|
||||
and_(MediaAsset.source_kind == "upload", MediaAsset.rel_path.is_not(None)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -67,12 +77,17 @@ def resolve(db: Session, user_id: int) -> QuotaLimits:
|
||||
|
||||
|
||||
def footprint(db: Session, user_id: int) -> int:
|
||||
"""Total bytes of the distinct ready assets this user's active jobs reference."""
|
||||
"""Total bytes of the distinct assets this user's jobs hold on disk: the ready ones their active
|
||||
jobs reference, and every upload of theirs — including one whose conversion failed, which keeps
|
||||
its original (see the module docstring)."""
|
||||
held_assets = (
|
||||
select(DownloadJob.asset_id)
|
||||
.where(
|
||||
DownloadJob.user_id == user_id,
|
||||
DownloadJob.status.in_(_HOLDING),
|
||||
or_(
|
||||
DownloadJob.status.in_(_HOLDING),
|
||||
and_(DownloadJob.job_kind == "upload", DownloadJob.status == JobStatus.ERROR),
|
||||
),
|
||||
DownloadJob.asset_id.is_not(None),
|
||||
)
|
||||
.distinct()
|
||||
@@ -80,7 +95,7 @@ def footprint(db: Session, user_id: int) -> int:
|
||||
return int(
|
||||
db.execute(
|
||||
select(func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(
|
||||
MediaAsset.id.in_(held_assets), MediaAsset.status == "ready"
|
||||
MediaAsset.id.in_(held_assets), ON_DISK
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
@@ -111,7 +126,7 @@ def at_concurrency_limit(db: Session, user_id: int) -> bool:
|
||||
lim = resolve(db, user_id)
|
||||
if lim.unlimited:
|
||||
return False
|
||||
running = _count(db, user_id, ("running",))
|
||||
running = _count(db, user_id, (JobStatus.RUNNING,))
|
||||
return running >= lim.max_concurrent
|
||||
|
||||
|
||||
@@ -160,6 +175,18 @@ def check_upload(db: Session, user_id: int, add_bytes: int) -> None:
|
||||
raise QuotaExceeded("max_bytes", lim.max_bytes, projected)
|
||||
|
||||
|
||||
def fits_after_conversion(db: Session, user_id: int, old_bytes: int, new_bytes: int) -> bool:
|
||||
"""Whether an upload converted from `old_bytes` (what it is charged at now) to `new_bytes` keeps
|
||||
its owner within the cap. A re-encode can come out larger than the original, and nothing else
|
||||
re-checks it once the upload was let in."""
|
||||
if new_bytes <= old_bytes: # a smaller file never breaks a cap the larger one was let in under
|
||||
return True
|
||||
lim = resolve(db, user_id)
|
||||
if lim.unlimited:
|
||||
return True
|
||||
return footprint(db, user_id) - old_bytes + new_bytes <= lim.max_bytes
|
||||
|
||||
|
||||
def usage(db: Session, user_id: int) -> dict:
|
||||
"""Compact usage snapshot for the UI (footprint + counts vs limits)."""
|
||||
lim = resolve(db, user_id)
|
||||
|
||||
Reference in New Issue
Block a user