Capture's routes (downloads, files, public watch links) and the token list no longer build SQL, call the session or set a model's fields: reads are downloads/queries, writes the unit's service (service, folders, links, quota), and each write flushes for the handler to commit. Whose a row is goes through userscope (owned_by, owned_row), which owned_or_404 now uses too. The fence holds Capture below its routes as well: it reads only Catalog's and Plex's tables, writes no other unit's, filters owners through userscope and commits only in its jobs and the upload ingest. A test per service use case, among them that the tray lists only the caller's files.
407 lines
15 KiB
Python
407 lines
15 KiB
Python
"""Capture's reads: what the library, the download tray, the share dialogs and the public watch pages
|
|
show. Each returns rows or plain values and writes nothing; whose a row is goes through
|
|
`userscope.owned_by` / `owned_row`, so a row of someone else's reads as a missing one.
|
|
|
|
The HTTP layer (`routes.downloads`, `routes.files`, `routes.public`) asks here and serializes; the
|
|
writes are the service's (`service`, `folders`, `links`, `quota`)."""
|
|
|
|
from collections.abc import Sequence
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.apitokens import FilesCaller, Right
|
|
from app.collation import collated
|
|
from app.downloads import folders, links, quota
|
|
from app.downloads.models import (
|
|
DownloadJob,
|
|
DownloadLink,
|
|
DownloadProfile,
|
|
DownloadQuota,
|
|
DownloadShare,
|
|
FileFolder,
|
|
LibraryState,
|
|
MediaAsset,
|
|
)
|
|
from app.downloads.status import FINISHED, JobStatus
|
|
from app.models import User
|
|
from app.userscope import owned_by, owned_row
|
|
from app.utils import like_escape
|
|
|
|
# Cap a listing so a heavy library can't return an unbounded result set (the old Download Center's
|
|
# own cap; far beyond any real library).
|
|
MAX_LIST = 500
|
|
|
|
|
|
# --- jobs and their files ------------------------------------------------------------------
|
|
|
|
|
|
def own_job(db: Session, user: User, job_id: int) -> DownloadJob | None:
|
|
return owned_row(db, DownloadJob, job_id, user)
|
|
|
|
|
|
def job(db: Session, job_id: int) -> DownloadJob | None:
|
|
"""A job by id, whoever's — for a public link, which already names its job."""
|
|
return db.get(DownloadJob, job_id)
|
|
|
|
|
|
def accessible_job(db: Session, user: User, job_id: int) -> DownloadJob | None:
|
|
"""A file the caller may read: their own, or one shared with them."""
|
|
row = db.get(DownloadJob, job_id)
|
|
if row is None:
|
|
return None
|
|
if row.user_id == user.id or shared_with(db, job_id, user) is not None:
|
|
return row
|
|
return None
|
|
|
|
|
|
def readable_job(db: Session, caller: FilesCaller, job_id: int) -> DownloadJob | None:
|
|
"""A file `caller` may read: as `accessible_job` for the user; for a token, one of the user's
|
|
own in a folder it may read."""
|
|
if caller.grants is None:
|
|
return accessible_job(db, caller.user, job_id)
|
|
row = own_job(db, caller.user, job_id)
|
|
if row is None or not caller.may(row.folder_id, Right.READ):
|
|
return None
|
|
return row
|
|
|
|
|
|
def asset_of(db: Session, job: DownloadJob | None) -> MediaAsset | None:
|
|
"""The file under a job, if it has one yet."""
|
|
return db.get(MediaAsset, job.asset_id) if job is not None and job.asset_id else None
|
|
|
|
|
|
def assets_for(db: Session, jobs: Sequence[DownloadJob]) -> dict[int, MediaAsset]:
|
|
ids = {j.asset_id for j in jobs if j.asset_id}
|
|
if not ids:
|
|
return {}
|
|
rows = db.execute(select(MediaAsset).where(MediaAsset.id.in_(ids))).scalars()
|
|
return {a.id: a for a in rows}
|
|
|
|
|
|
def positions_for(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, int]:
|
|
"""`user`'s resume point in each of `jobs` that has one (the library player's `library_states`)."""
|
|
ids = [j.id for j in jobs]
|
|
if not ids:
|
|
return {}
|
|
rows = db.execute(
|
|
select(LibraryState.job_id, LibraryState.position_seconds).where(
|
|
owned_by(LibraryState.user_id, user), LibraryState.job_id.in_(ids)
|
|
)
|
|
).all()
|
|
return {job_id: pos for job_id, pos in rows}
|
|
|
|
|
|
def source_keys(db: Session, jobs: Sequence[DownloadJob]) -> dict[int, str]:
|
|
"""Where each file came from (its source folder's key), for the list's Source column, the
|
|
"back to its source folder" move and the download tray's site: a file in a source folder has its
|
|
folder's key; one moved into an own folder (or someone else's, shared) is traced back by
|
|
`folders.source_key_for`."""
|
|
ids = {j.folder_id for j in jobs if j.folder_id is not None}
|
|
by_id = (
|
|
{f.id: f for f in db.execute(select(FileFolder).where(FileFolder.id.in_(ids))).scalars()}
|
|
if ids
|
|
else {}
|
|
)
|
|
out: dict[int, str] = {}
|
|
for j in jobs:
|
|
folder = by_id.get(j.folder_id) if j.folder_id is not None else None
|
|
if folder is not None and folder.kind == folders.FolderKind.SOURCE and folder.source_key:
|
|
out[j.id] = folder.source_key
|
|
else:
|
|
out[j.id] = folders.source_key_for(db, j)
|
|
return out
|
|
|
|
|
|
def queue(db: Session, user: User, recent: datetime) -> list[DownloadJob]:
|
|
"""The download tray: what is waiting, running, paused or failed, and what finished (done or
|
|
canceled) since `recent`. A done job is timed from when it became done, never from its last
|
|
write: a rename, a move or the GC's expiry warning touches an old file's row, and the tray would
|
|
announce it as just finished."""
|
|
return list(
|
|
db.execute(
|
|
select(DownloadJob)
|
|
.where(
|
|
owned_by(DownloadJob.user_id, user),
|
|
DownloadJob.status.not_in(FINISHED)
|
|
| ((DownloadJob.status == JobStatus.DONE) & (DownloadJob.ready_at >= recent))
|
|
| ((DownloadJob.status == JobStatus.CANCELED) & (DownloadJob.updated_at >= recent)),
|
|
)
|
|
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
|
.limit(MAX_LIST)
|
|
).scalars()
|
|
)
|
|
|
|
|
|
def statuses_by_source(db: Session, user: User, statuses: Sequence[str]) -> list[tuple[str, str]]:
|
|
"""(source_ref, status) of each of `user`'s jobs in one of `statuses`."""
|
|
return list(
|
|
db.execute(
|
|
select(DownloadJob.source_ref, DownloadJob.status).where(
|
|
owned_by(DownloadJob.user_id, user), DownloadJob.status.in_(tuple(statuses))
|
|
)
|
|
).tuples()
|
|
)
|
|
|
|
|
|
def library(
|
|
db: Session,
|
|
user: User,
|
|
*,
|
|
folder_id: int | None,
|
|
listable: set[int] | None,
|
|
shared: bool,
|
|
term: str,
|
|
sort: str,
|
|
order: str,
|
|
lang: str,
|
|
) -> list[DownloadJob]:
|
|
"""The caller's done files (optionally one folder's, or only in the folders a token may list),
|
|
or the ones shared with them; searched by name and channel, sorted by when they were added, by
|
|
name (in the reader's alphabet) or by size. A file is added to its owner's list when it is done,
|
|
not when it was asked for (a download that failed for days and then went through is new); one
|
|
shared with the caller, when it was shared."""
|
|
stmt = (
|
|
select(DownloadJob)
|
|
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
|
.where(DownloadJob.status == JobStatus.DONE)
|
|
)
|
|
if shared:
|
|
stmt = stmt.join(DownloadShare, DownloadShare.job_id == DownloadJob.id).where(
|
|
owned_by(DownloadShare.to_user_id, user)
|
|
)
|
|
else:
|
|
stmt = stmt.where(owned_by(DownloadJob.user_id, user))
|
|
if folder_id is not None:
|
|
stmt = stmt.where(DownloadJob.folder_id == folder_id)
|
|
elif listable is not None:
|
|
stmt = stmt.where(DownloadJob.folder_id.in_(listable))
|
|
name = func.coalesce(DownloadJob.display_name, MediaAsset.title, "")
|
|
if term:
|
|
like = f"%{like_escape(term)}%"
|
|
stmt = stmt.where(
|
|
or_(
|
|
name.ilike(like),
|
|
func.coalesce(DownloadJob.display_uploader, MediaAsset.uploader, "").ilike(like),
|
|
)
|
|
)
|
|
key = {
|
|
"added": DownloadShare.created_at if shared else DownloadJob.ready_at,
|
|
"name": collated(name, lang),
|
|
"size": MediaAsset.size_bytes,
|
|
}[sort]
|
|
first = key.asc() if order == "asc" else key.desc()
|
|
tie = DownloadJob.id.asc() if order == "asc" else DownloadJob.id.desc()
|
|
return list(db.execute(stmt.order_by(first.nulls_last(), tie).limit(MAX_LIST)).scalars())
|
|
|
|
|
|
def recent_jobs(db: Session) -> list[DownloadJob]:
|
|
"""Everyone's newest jobs, for the admin's list."""
|
|
return list(
|
|
db.execute(
|
|
select(DownloadJob)
|
|
.order_by(DownloadJob.created_at.desc(), DownloadJob.id.desc())
|
|
.limit(MAX_LIST)
|
|
).scalars()
|
|
)
|
|
|
|
|
|
# --- shares --------------------------------------------------------------------------------
|
|
|
|
|
|
def shared_with(db: Session, job_id: int, user: User | int) -> DownloadShare | None:
|
|
"""The grant that hands this job to `user`, if there is one."""
|
|
return db.execute(
|
|
select(DownloadShare).where(
|
|
DownloadShare.job_id == job_id, owned_by(DownloadShare.to_user_id, user)
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def shared_at(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, datetime]:
|
|
rows = db.execute(
|
|
select(DownloadShare.job_id, DownloadShare.created_at).where(
|
|
owned_by(DownloadShare.to_user_id, user),
|
|
DownloadShare.job_id.in_([j.id for j in jobs]),
|
|
)
|
|
).all()
|
|
return {job_id: at for job_id, at in rows}
|
|
|
|
|
|
# --- folders -------------------------------------------------------------------------------
|
|
|
|
|
|
def own_folder(db: Session, user: User, folder_id: int) -> FileFolder | None:
|
|
return owned_row(db, FileFolder, folder_id, user)
|
|
|
|
|
|
def folders_by_id(db: Session, ids: set[int]) -> dict[int, FileFolder]:
|
|
"""These folders, by id — for a reader that already knows which ones (a token's grants)."""
|
|
if not ids:
|
|
return {}
|
|
return {f.id: f for f in db.execute(select(FileFolder).where(FileFolder.id.in_(ids))).scalars()}
|
|
|
|
|
|
def folders_with_totals(
|
|
db: Session, user: User, listable: set[int] | None
|
|
) -> list[tuple[FileFolder, int, int]]:
|
|
"""The user's folders (only `listable` ones, when given) with how many done files each holds and
|
|
their size. A source folder its files have all left stays (count 0)."""
|
|
done = (DownloadJob.folder_id == FileFolder.id) & (DownloadJob.status == JobStatus.DONE)
|
|
stmt = select(
|
|
FileFolder,
|
|
func.count(DownloadJob.id),
|
|
func.coalesce(func.sum(MediaAsset.size_bytes), 0),
|
|
).where(owned_by(FileFolder.user_id, user))
|
|
if listable is not None:
|
|
stmt = stmt.where(FileFolder.id.in_(listable))
|
|
rows = db.execute(
|
|
stmt.outerjoin(DownloadJob, done)
|
|
.outerjoin(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
|
.group_by(FileFolder.id)
|
|
.order_by(FileFolder.kind.desc(), FileFolder.source_key, FileFolder.name, FileFolder.id)
|
|
).tuples()
|
|
return [(f, count, int(size or 0)) for f, count, size in rows]
|
|
|
|
|
|
def folder_totals(db: Session, folder_id: int) -> tuple[int, int]:
|
|
count, size = db.execute(
|
|
select(func.count(DownloadJob.id), func.coalesce(func.sum(MediaAsset.size_bytes), 0))
|
|
.select_from(DownloadJob)
|
|
.outerjoin(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
|
.where(DownloadJob.folder_id == folder_id, DownloadJob.status == JobStatus.DONE)
|
|
).one()
|
|
return count, int(size)
|
|
|
|
|
|
def linked_folders(db: Session, folder_ids: list[int]) -> set[int]:
|
|
"""Which of these folders a live public link opens (one not past its expiry)."""
|
|
now = datetime.now(timezone.utc)
|
|
rows = db.execute(
|
|
select(DownloadLink.folder_id).where(
|
|
DownloadLink.folder_id.in_(folder_ids),
|
|
or_(DownloadLink.expires_at.is_(None), DownloadLink.expires_at > now),
|
|
)
|
|
).scalars()
|
|
return {folder_id for folder_id in rows if folder_id is not None}
|
|
|
|
|
|
# --- links ---------------------------------------------------------------------------------
|
|
|
|
|
|
def own_link(db: Session, user: User, link_id: int) -> DownloadLink | None:
|
|
"""A link to one of the user's files or folders."""
|
|
link = db.get(DownloadLink, link_id)
|
|
if link is None:
|
|
return None
|
|
target = (
|
|
db.get(DownloadJob, link.job_id)
|
|
if link.job_id is not None
|
|
else db.get(FileFolder, link.folder_id)
|
|
)
|
|
if target is None or target.user_id != user.id:
|
|
return None
|
|
return link
|
|
|
|
|
|
def links_to(
|
|
db: Session, *, job_id: int | None = None, folder_id: int | None = None
|
|
) -> list[DownloadLink]:
|
|
"""The links to one file or one folder, newest first."""
|
|
target = (
|
|
DownloadLink.job_id == job_id if job_id is not None else DownloadLink.folder_id == folder_id
|
|
)
|
|
return list(
|
|
db.execute(select(DownloadLink).where(target).order_by(DownloadLink.id.desc())).scalars()
|
|
)
|
|
|
|
|
|
def link_folder(db: Session, link: DownloadLink) -> FileFolder | None:
|
|
return db.get(FileFolder, link.folder_id) if link.folder_id is not None else None
|
|
|
|
|
|
def folder_job(db: Session, folder: FileFolder, job_id: int) -> DownloadJob | None:
|
|
"""One of the files a link to `folder` reaches (`links.in_folder`), while it is in it."""
|
|
return db.execute(
|
|
select(DownloadJob).where(DownloadJob.id == job_id, *links.in_folder(folder))
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def owner_suspended(db: Session, job: DownloadJob) -> bool:
|
|
"""The job's owner is suspended (or gone): the worker leaves their queued jobs where they are."""
|
|
owner = db.get(User, job.user_id)
|
|
return owner is None or owner.is_suspended
|
|
|
|
|
|
# --- profiles ------------------------------------------------------------------------------
|
|
|
|
|
|
def profiles_for(db: Session, user: User) -> list[DownloadProfile]:
|
|
"""The built-in format profiles, then the user's own."""
|
|
return list(
|
|
db.execute(
|
|
select(DownloadProfile)
|
|
.where(DownloadProfile.is_builtin.is_(True) | owned_by(DownloadProfile.user_id, user))
|
|
.order_by(
|
|
DownloadProfile.is_builtin.desc(), DownloadProfile.sort_order, DownloadProfile.id
|
|
)
|
|
).scalars()
|
|
)
|
|
|
|
|
|
def own_profile(db: Session, user: User, profile_id: int) -> DownloadProfile | None:
|
|
"""One of the user's own profiles (a built-in is no one's to change)."""
|
|
p = owned_row(db, DownloadProfile, profile_id, user)
|
|
return None if p is None or p.is_builtin else p
|
|
|
|
|
|
def usable_profile(db: Session, user: User, profile_id: int) -> DownloadProfile | None:
|
|
"""A profile the user may enqueue with: a built-in, or their own."""
|
|
p = db.get(DownloadProfile, profile_id)
|
|
if p is None or (not p.is_builtin and p.user_id != user.id):
|
|
return None
|
|
return p
|
|
|
|
|
|
def default_profile(db: Session) -> DownloadProfile | None:
|
|
"""The first built-in, for an enqueue that named no format."""
|
|
return db.execute(
|
|
select(DownloadProfile)
|
|
.where(DownloadProfile.is_builtin.is_(True))
|
|
.order_by(DownloadProfile.sort_order)
|
|
.limit(1)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
# --- storage and quota (admin) -------------------------------------------------------------
|
|
|
|
|
|
def storage_totals(db: Session) -> tuple[int, int, int]:
|
|
"""(files on disk, their bytes, every asset row). On disk is `quota.ON_DISK`: ready files, and
|
|
uploads waiting for or failed in their conversion — the same disk the footprint and the GC cap
|
|
count."""
|
|
on_disk = db.execute(
|
|
select(func.count(), func.coalesce(func.sum(MediaAsset.size_bytes), 0)).where(quota.ON_DISK)
|
|
).one()
|
|
total_assets = db.execute(select(func.count()).select_from(MediaAsset)).scalar_one()
|
|
return on_disk[0], int(on_disk[1]), total_assets
|
|
|
|
|
|
def done_files_per_user(db: Session) -> dict[int, int]:
|
|
"""How many finished files each user's Files lists."""
|
|
return dict(
|
|
db.execute(
|
|
select(DownloadJob.user_id, func.count())
|
|
.where(DownloadJob.status == JobStatus.DONE)
|
|
.group_by(DownloadJob.user_id)
|
|
)
|
|
.tuples()
|
|
.all()
|
|
)
|
|
|
|
|
|
def custom_quota(db: Session, user_id: int) -> DownloadQuota | None:
|
|
"""The admin's own limits for this user, if any were set."""
|
|
return db.get(DownloadQuota, user_id)
|