The nine Capture tables (files, jobs, folders, shares, links, quota, cookies, profiles, library resume points) leave app/models.py for the unit's own module; app.tables registers every unit's tables for the migrations, the test database and the ownership check. No schema change.
386 lines
22 KiB
Python
386 lines
22 KiB
Python
"""Capture's tables: the downloaded files (`MediaAsset`), each user's jobs over them and the folders
|
|
they sit in, the shares and public links that hand them on, a user's quota, cookies and format
|
|
profiles, and where they stopped playing one. Only this unit writes them; another unit reads them
|
|
along a declared edge, or asks this unit's service."""
|
|
|
|
from datetime import date, datetime, timezone
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
Boolean,
|
|
CheckConstraint,
|
|
Date,
|
|
DateTime,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
JSON,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
func,
|
|
text,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, validates
|
|
|
|
from app.db import Base
|
|
from app.models import DISPLAY_TEXT_MAX, TimestampMixin, UpdatedAtMixin
|
|
|
|
|
|
class LibraryState(Base):
|
|
"""Where a user stopped in a library item (their own download, or one shared with them) — the
|
|
library player's resume point. Keyed by the JOB, not the file: a job is the user's library entry,
|
|
a re-download keeps it, and deleting the job deletes the row; the shared file under it may outlive
|
|
both. A revoked share leaves its recipient's row behind, unread — only a job the user can still
|
|
reach is ever read or written — and it is back if the item is shared again. Rows exist only for a
|
|
position worth keeping (see `routes._common.kept_position`); no row means "from the start"."""
|
|
|
|
__tablename__ = "library_states"
|
|
__table_args__ = (UniqueConstraint("user_id", "job_id", name="uq_library_user_job"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
|
job_id: Mapped[int] = mapped_column(
|
|
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
|
)
|
|
# int4: a checkpoint body must stay under POSITION_MAX_S, or the flush is a DataError (a 500).
|
|
position_seconds: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
progress_updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
|
|
|
|
class MediaAsset(Base, TimestampMixin):
|
|
"""A physical downloaded media file — the SHARED cache layer of the download center.
|
|
|
|
Mirrors the app's "shared catalog, private per-user state" model: one row per unique
|
|
``(source_kind, source_ref, format_sig)`` is the global cache. When a second user requests
|
|
the same video with a format that hashes to the same `format_sig`, their `download_job`
|
|
links to this ready asset instantly — no re-download, no cost. Only the SOURCE download is
|
|
shared; per-user edited derivatives (phase 2) are never cached here.
|
|
|
|
`rel_path` is relative to `settings.files_root` (the mount), so the physical filename is
|
|
system-decided and bound to the source id — never renamed (the user's custom name is display
|
|
metadata on `download_job`, applied only when downloading to their own machine). The naming
|
|
fields (`title`/`uploader`/`upload_date`) are denormalized so the Plex-tree path can be built
|
|
uniformly for both catalog videos and ad-hoc URLs. `ref_count` (active jobs pointing here) frees
|
|
the file with its last holder, and `last_access_at` orders the GC's LRU eviction. How long a file
|
|
stays is not the asset's: each holder's job carries its own `expires_at` (its library folder's)."""
|
|
|
|
__tablename__ = "media_assets"
|
|
__table_args__ = (
|
|
UniqueConstraint(
|
|
"source_kind", "source_ref", "format_sig", name="uq_media_asset_cache_key"
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
|
source_ref: Mapped[str] = mapped_column(String(512)) # video id, or full URL for ad-hoc
|
|
format_sig: Mapped[str] = mapped_column(String(64)) # hash of the output-affecting spec
|
|
status: Mapped[str] = mapped_column(
|
|
String(16), default="pending", server_default="pending", index=True
|
|
) # "pending" | "ready" | "error"
|
|
rel_path: Mapped[str | None] = mapped_column(Text) # relative to files_root
|
|
storyboard_path: Mapped[str | None] = mapped_column(Text) # phase-2 filmstrip mosaic
|
|
# A locally-generated poster frame (ffmpeg), set only when the source had NO thumbnail so the
|
|
# card / watch page / link preview can still show an image. Relative to files_root.
|
|
poster_path: Mapped[str | None] = mapped_column(Text)
|
|
size_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
|
duration_s: Mapped[int | None] = mapped_column(Integer)
|
|
width: Mapped[int | None] = mapped_column(Integer)
|
|
height: Mapped[int | None] = mapped_column(Integer)
|
|
container: Mapped[str | None] = mapped_column(String(16))
|
|
vcodec: Mapped[str | None] = mapped_column(String(32))
|
|
acodec: Mapped[str | None] = mapped_column(String(32))
|
|
# "video" | "audio" | "file" — the media-kind discriminator that drives which library actions
|
|
# apply (play/edit for media; download-only for a generic uploaded file). Null while an asset is
|
|
# still pending (kind is known once the file is probed at finalize). It is the single stored home
|
|
# of the invariant `downloads.mediakind.derive` used to compute — never re-derived in prose (an
|
|
# earlier ad-hoc `vcodec is None and acodec` check lived only in links.py; consolidated here).
|
|
media_kind: Mapped[str | None] = mapped_column(String(16))
|
|
nfo_written: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
# Denormalized naming/metadata for the Plex tree + ad-hoc display.
|
|
title: Mapped[str | None] = mapped_column(Text)
|
|
uploader: Mapped[str | None] = mapped_column(String(255))
|
|
# yt-dlp's channel/uploader page URL (channel_url|uploader_url) — lets an auto-filled channel
|
|
# render as a clickable link. Null for sources without one (e.g. reddit) or older rows.
|
|
uploader_url: Mapped[str | None] = mapped_column(Text)
|
|
upload_date: Mapped[date | None] = mapped_column(Date)
|
|
thumbnail_url: Mapped[str | None] = mapped_column(Text)
|
|
# yt-dlp's canonical page URL for the source (webpage_url) — the clean "downloaded from"
|
|
# reference shown in the Files library. Null until the worker extracts it (older/queued rows
|
|
# fall back to a URL derived from source_kind+source_ref).
|
|
source_webpage_url: Mapped[str | None] = mapped_column(Text)
|
|
error: Mapped[str | None] = mapped_column(Text)
|
|
ref_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
last_access_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
# The file's SHA-256 (hex), so a machine fetching it can check what it got. Hashed while an upload
|
|
# streams in, otherwise on the first ask (`routes.files`); NULL = not hashed yet.
|
|
sha256: Mapped[str | None] = mapped_column(String(64))
|
|
|
|
@validates("rel_path")
|
|
def _forget_hash(self, _key: str, rel_path: str | None) -> str | None:
|
|
# A hash names the bytes at `rel_path`: a new file there (a conversion, a retry) is not them.
|
|
self.sha256 = None
|
|
return rel_path
|
|
|
|
|
|
class DownloadProfile(Base, TimestampMixin):
|
|
"""A named download preset — a reusable format profile.
|
|
|
|
`user_id` NULL + `is_builtin` marks the shipped presets (Best MP4, 1080p, audio-only, …);
|
|
a user's saved custom profiles carry their `user_id`. `spec` is the format definition
|
|
(mode av/v/a, max_height, container, audio_format, codec prefs, embed subs/chapters/
|
|
thumbnail, sponsorblock) that `downloads.formats` turns into a yt-dlp selector + a cache
|
|
`format_sig`."""
|
|
|
|
__tablename__ = "download_profiles"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
) # NULL for a builtin preset
|
|
name: Mapped[str] = mapped_column(String(80))
|
|
spec: Mapped[dict] = mapped_column(JSON)
|
|
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
|
|
|
|
class FileFolder(Base, TimestampMixin):
|
|
"""A folder of the Files library (`app.downloads.folders`): a row, never a directory.
|
|
|
|
A SOURCE folder (`source_key` set: youtube / plex / upload / site:<host>) is made the first time a
|
|
user gets a file from that origin; an OWN folder (`name` set) is one the user made; the HUB folder
|
|
(neither set, one per user) is where they swap files with a machine of theirs. Every file —
|
|
a `DownloadJob` — sits in exactly one (`DownloadJob.folder_id`). `retention_days` is how long a
|
|
file stays once done (NULL = until deleted); a file's own `expires_at` is stamped from it."""
|
|
|
|
__tablename__ = "file_folders"
|
|
__table_args__ = (
|
|
UniqueConstraint("user_id", "source_key", name="uq_file_folder_source"),
|
|
Index(
|
|
"uq_file_folder_own_name",
|
|
"user_id",
|
|
func.lower(text("name")),
|
|
unique=True,
|
|
postgresql_where=text("name IS NOT NULL"),
|
|
),
|
|
# One Hub folder per user.
|
|
Index("uq_file_folder_hub", "user_id", unique=True, postgresql_where=text("kind = 'hub'")),
|
|
CheckConstraint(
|
|
"(kind = 'source' AND source_key IS NOT NULL AND name IS NULL)"
|
|
" OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)"
|
|
" OR (kind = 'hub' AND name IS NULL AND source_key IS NULL)",
|
|
name="ck_file_folder_kind",
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
|
kind: Mapped[str] = mapped_column(String(16)) # folders.FolderKind
|
|
# "site:" + a host name (DNS caps one at 253 characters).
|
|
source_key: Mapped[str | None] = mapped_column(String(260))
|
|
name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
|
retention_days: Mapped[int | None] = mapped_column(Integer)
|
|
|
|
|
|
def _ready_on_insert(context) -> datetime | None:
|
|
"""A job inserted already done (an upload kept as it came) was ready the moment it was made; one
|
|
that becomes done later is stamped then (`folders.stamp_done`, `service.finalize_if_running`)."""
|
|
done = context.get_current_parameters().get("status") == "done"
|
|
return datetime.now(timezone.utc) if done else None
|
|
|
|
|
|
class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
|
"""A user's request to download one source — the per-user layer over `MediaAsset`.
|
|
|
|
The worker claims `queued` jobs with ``FOR UPDATE SKIP LOCKED`` and writes live
|
|
`progress`/`speed_bps`/`eta_s`/`phase` here (the frontend polls via useLiveQuery: there is
|
|
no server push channel, and the worker is a separate process anyway). `profile_snapshot`
|
|
freezes the spec at enqueue so later edits/deletion of the profile don't change this job.
|
|
`display_name` is the user's own name for the file (display + the Content-Disposition on
|
|
local download); the on-disk asset is never renamed. `asset_id` links to the shared file
|
|
(SET NULL if the asset is GC'd — the job then shows as expired).
|
|
|
|
Phase 2 (editor): `job_kind='edit'` jobs derive a new per-user clip from an already-downloaded
|
|
ready job. They still hang off a `MediaAsset` (with `source_kind='edit'`, a per-user cache key)
|
|
so all the file-serve/ref-count/GC/quota machinery is reused; the worker branches on the
|
|
asset's `source_kind` and runs ffmpeg instead of yt-dlp. `source_job_id`/`source_asset_id`
|
|
point at the parent download the clip was cut from; `edit_spec` is the (trim/crop) recipe."""
|
|
|
|
__tablename__ = "download_jobs"
|
|
__table_args__ = (
|
|
Index("ix_download_jobs_claim", "status", "queue_pos"),
|
|
# Every way a job becomes done stamps when (`folders.stamp_done`, `finalize_if_running`).
|
|
CheckConstraint(
|
|
"status <> 'done' OR ready_at IS NOT NULL", name="ck_download_jobs_ready_at"
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
|
asset_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("media_assets.id", ondelete="SET NULL"), index=True
|
|
)
|
|
source_kind: Mapped[str] = mapped_column(String(16)) # "youtube" | "url"
|
|
source_ref: Mapped[str] = mapped_column(String(512))
|
|
# "download" (default), "edit" (a per-user trim/crop derivative of another job), "upload" (a
|
|
# user's own file, converted by the worker when it is not phone-safe — see `downloads.upload`) or
|
|
# "clip" (a range cut straight from a YouTube video or a Plex item — see `downloads.clip`).
|
|
job_kind: Mapped[str] = mapped_column(String(16), default="download", server_default="download")
|
|
# For edit jobs: the parent download this clip is cut from (SET NULL if the parent is deleted).
|
|
source_job_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("download_jobs.id", ondelete="SET NULL")
|
|
)
|
|
source_asset_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("media_assets.id", ondelete="SET NULL")
|
|
)
|
|
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}} in pixels. For clip
|
|
# jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop
|
|
# in fractions of the box it was drawn in (`downloads.clip.normalize`).
|
|
edit_spec: Mapped[dict | None] = mapped_column(JSON)
|
|
profile_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("download_profiles.id", ondelete="SET NULL")
|
|
)
|
|
profile_snapshot: Mapped[dict] = mapped_column(JSON)
|
|
display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
|
# Per-user display overrides + extras (like display_name — metadata only, never the file).
|
|
# `display_uploader`/`display_uploader_url` override the asset's auto channel name/link;
|
|
# `extra_links` is an optional list of extra reference URLs shown on the card + watch page.
|
|
display_uploader: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
|
|
display_uploader_url: Mapped[str | None] = mapped_column(Text)
|
|
extra_links: Mapped[list | None] = mapped_column(JSON)
|
|
status: Mapped[str] = mapped_column(
|
|
String(16), default="queued", server_default="queued", index=True
|
|
) # queued | running | paused | done | error | canceled
|
|
progress: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
speed_bps: Mapped[int | None] = mapped_column(BigInteger)
|
|
eta_s: Mapped[int | None] = mapped_column(Integer)
|
|
phase: Mapped[str | None] = mapped_column(String(32))
|
|
error: Mapped[str | None] = mapped_column(Text) # raw yt-dlp/ffmpeg text (technical detail)
|
|
error_code: Mapped[str | None] = mapped_column(
|
|
String(24)
|
|
) # category, see downloads.errors.classify
|
|
# The host-specific failure-catalog entry on top of the category (`facebook.login_wall`), or NULL.
|
|
error_detail: Mapped[str | None] = mapped_column(String(48)) # see downloads.errors.diagnose
|
|
queue_pos: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
# The Files library: the folder this file sits in (`downloads.folders.place` at creation; an own
|
|
# folder is emptied back into the source folders before it is deleted, hence RESTRICT), whether
|
|
# the user keeps it past its folder's retention, and when it goes (stamped once done; NULL =
|
|
# never). `expiry_warned` marks the one warning the GC sends before it deletes the file.
|
|
folder_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("file_folders.id", ondelete="RESTRICT"), index=True
|
|
)
|
|
# The origin the file came from (the source folder's key), recorded where it is placed: an edit
|
|
# traces its origin through its parent, and that parent may be deleted or expire later — the
|
|
# file must still know which source folder it may go back to. NULL only for a file placed before
|
|
# the column existed outside a source folder; `folders.source_key_for` works those out.
|
|
source_key: Mapped[str | None] = mapped_column(String(260))
|
|
kept: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
|
expiry_warned: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
# When the file became done — what the library's "Added" means: a download that failed for days
|
|
# and then went through is new to its user, so it sorts as new. `created_at` stays when it was
|
|
# asked for (the queue's order). Set again when a file is made anew (a retry, a re-download).
|
|
ready_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), default=_ready_on_insert
|
|
)
|
|
|
|
|
|
class DownloadShare(Base, TimestampMixin):
|
|
"""A user→user grant: `to_user` may access `from_user`'s downloaded file (private by
|
|
default). Since assets are already deduped/shared on disk, a share is just an ACL row."""
|
|
|
|
__tablename__ = "download_shares"
|
|
__table_args__ = (UniqueConstraint("job_id", "to_user_id", name="uq_download_share"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
job_id: Mapped[int] = mapped_column(ForeignKey("download_jobs.id", ondelete="CASCADE"))
|
|
from_user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
|
)
|
|
to_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
|
|
|
|
|
class DownloadQuota(Base, UpdatedAtMixin):
|
|
"""Per-user download limits (admin-managed). A row exists only when an admin customizes a
|
|
user; otherwise the sysconfig `download_default_*` values apply. `unlimited` bypasses the
|
|
byte cap entirely (e.g. for the admin's own account)."""
|
|
|
|
__tablename__ = "download_quotas"
|
|
|
|
user_id: Mapped[int] = mapped_column(
|
|
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
max_bytes: Mapped[int] = mapped_column(BigInteger)
|
|
max_concurrent: Mapped[int] = mapped_column(Integer)
|
|
max_jobs: Mapped[int] = mapped_column(Integer)
|
|
unlimited: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
|
|
|
|
class DownloadLink(Base, TimestampMixin):
|
|
"""A public capability link to one download, or to one of its owner's own folders — the "share
|
|
by link" surface.
|
|
|
|
Anyone holding the unguessable token can watch the file on the login-free `/watch/{token}`
|
|
player page (Google-Drive style); a folder link opens the folder's list there, with what is in
|
|
the folder at the time it is opened. The token itself is never stored as written: a link is
|
|
found by its SHA-256 (`token_hash`), and `token_enc` is the Fernet-encrypted copy its owner
|
|
copies the link from again — absent without a key (`downloads/links.py`). Optional per-link
|
|
controls: `expires_at`, `allow_download` (stream-only vs downloadable), and a `password_hash`
|
|
(argon2, like a user password). Revoke = delete the row. Distinct from `DownloadShare`, which
|
|
grants a *registered* user access via the in-app "Shared with me" list."""
|
|
|
|
__tablename__ = "download_links"
|
|
__table_args__ = (
|
|
# A link opens exactly one thing: a file or a folder.
|
|
CheckConstraint("(job_id IS NULL) <> (folder_id IS NULL)", name="ck_download_link_target"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
job_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("download_jobs.id", ondelete="CASCADE"), index=True
|
|
)
|
|
folder_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("file_folders.id", ondelete="CASCADE"), index=True
|
|
)
|
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
|
|
token_enc: Mapped[str | None] = mapped_column(Text) # Fernet ciphertext — never returned/logged
|
|
created_by: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
|
allow_download: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
password_hash: Mapped[str | None] = mapped_column(Text)
|
|
# Bumped whenever the password changes; folded into the signed watch grant so rotating the
|
|
# password immediately invalidates any grants issued under the old one (instead of them living
|
|
# out the 6h grant TTL).
|
|
password_version: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
|
|
|
|
|
class DownloadCredential(Base, TimestampMixin, UpdatedAtMixin):
|
|
"""A user's stored login cookies for ONE auth-walled download host (Facebook, Instagram, …).
|
|
|
|
Per-user, per-host: each user pastes their OWN browser cookies.txt (Netscape format) for a host
|
|
where they're logged in (e.g. a closed Facebook group they belong to), and a download of a
|
|
yt-dlp-supported, auth-walled link on that host is retried with the ACTING user's cookie. The
|
|
blob is Fernet-encrypted at rest (`cookies_enc`) and is NEVER returned by any API/log/audit —
|
|
the routes are write-only (host + updated_at only). See `downloads.credentials`.
|
|
|
|
Two load-bearing security controls live around this row, NOT on it:
|
|
* **cache privacy** — a cookie-authed download SALTS its MediaAsset `format_sig` with the
|
|
user id, so the authed asset is private to that user and can never be cache-served to
|
|
another user (`downloads.credentials.cookie_salt`).
|
|
* **host scope (SEC-12)** — a cookie is attached ONLY when the download URL's netloc matches
|
|
this row's `host_key` (`downloads.credentials.host_key_for_url`), so an arbitrary `?url=`
|
|
can't exfiltrate the user's session to a look-alike host.
|
|
|
|
`host_key` is a short canonical key from the supported-host registry (e.g. "facebook"), not the
|
|
raw netloc — one credential can cover several domains of the same site (facebook.com/fb.watch)."""
|
|
|
|
__tablename__ = "download_credentials"
|
|
__table_args__ = (UniqueConstraint("user_id", "host_key", name="uq_download_credential"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
|
host_key: Mapped[str] = mapped_column(String(64))
|
|
cookies_enc: Mapped[str] = mapped_column(Text) # Fernet ciphertext — never returned/logged
|