The Files library starts here. A user's every download, clip and upload now lands in a source folder made per origin (YouTube, Plex clips, uploads, one per other site), decided once in downloads/folders.py at every place a job is made. How long a file stays moves from the shared asset to the user's job: stamped from its folder when it is done, never for a file the user keeps. The GC warns each user once, then takes the expired file out of their library; the cache cap never evicts a kept file, and an evicted one leaves every holder's library. The media mount is /files now (FILES_ROOT, FILES_HOST_PATH, volume siftlode_files); docs/self-hosting.md says how to carry an old volume over. Migration 0102 places every existing job and carries each done file's expiry over from its asset. safe_abs_path takes a missing path and answers None, which drops one baselined type error.
194 lines
7.5 KiB
Python
194 lines
7.5 KiB
Python
"""The Files library: folders, and a file's retention on the user's job
|
|
|
|
Revision ID: 0102_file_folders
|
|
Revises: 0101_plex_text_plain
|
|
Create Date: 2026-10-11
|
|
|
|
Every download, clip and upload of a user now sits in a folder of their Files library
|
|
(`app.downloads.folders`): a source folder made per origin (YouTube, Plex, uploads, one per other
|
|
site) or one of their own. How long a file stays moves from the shared asset to each user's job
|
|
(`download_jobs.expires_at`, stamped from the folder's `retention_days`), so one user keeping a file
|
|
no longer depends on another's copy; the asset's own `expires_at` / `gc_notified` go.
|
|
|
|
This places every existing job in its source folder and carries each done job's expiry over from
|
|
the asset it holds (an upload keeps none). The source-key rule is copied here, frozen, from
|
|
`folders.source_key_for` as it is today — a migration must not follow the code's later changes.
|
|
"""
|
|
|
|
from urllib.parse import urlsplit
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
|
|
revision: str = "0102_file_folders"
|
|
down_revision: str | None = "0101_plex_text_plain"
|
|
branch_labels: str | None = None
|
|
depends_on: str | None = None
|
|
|
|
_FRONT_DOORS = ("www.", "m.", "mobile.", "web.")
|
|
_YOUTUBE_HOSTS = {"youtube.com", "youtu.be", "youtube-nocookie.com", "music.youtube.com"}
|
|
_DEFAULT_RETENTION_DAYS = 30 # config.download_retention_days on the day this was written
|
|
|
|
|
|
def _site_key(url: str) -> str:
|
|
host = (urlsplit(url).hostname or "").lower().rstrip(".")
|
|
for door in _FRONT_DOORS:
|
|
if host.startswith(door) and host.count(".") > 1:
|
|
host = host[len(door) :]
|
|
break
|
|
if host in _YOUTUBE_HOSTS:
|
|
return "youtube"
|
|
return f"site:{host or 'unknown'}"
|
|
|
|
|
|
def _asset_key(kind: str | None, ref: str | None) -> str:
|
|
if kind == "youtube":
|
|
return "youtube"
|
|
if kind == "url" and ref:
|
|
return _site_key(ref)
|
|
return "upload"
|
|
|
|
|
|
def _key(job, jobs_by_id, assets) -> str:
|
|
kind = job.source_kind
|
|
if kind == "youtube":
|
|
return "youtube"
|
|
if kind == "upload":
|
|
return "upload"
|
|
if kind == "clip":
|
|
spec = job.edit_spec or {}
|
|
return "plex" if spec.get("source") == "plex" else "youtube"
|
|
if kind == "url":
|
|
return _site_key(job.source_ref)
|
|
if kind == "edit":
|
|
parent = jobs_by_id.get(job.source_job_id)
|
|
if parent is not None and parent.id != job.id:
|
|
return _key(parent, jobs_by_id, assets)
|
|
asset = assets.get(job.source_asset_id)
|
|
if asset is not None:
|
|
return _asset_key(asset.source_kind, asset.source_ref)
|
|
return "upload"
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.create_table(
|
|
"file_folders",
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column(
|
|
"user_id",
|
|
sa.Integer(),
|
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
),
|
|
sa.Column("kind", sa.String(16), nullable=False),
|
|
sa.Column("source_key", sa.String(260)), # "site:" + a host (<= 253)
|
|
sa.Column("name", sa.String(255)),
|
|
sa.Column("retention_days", sa.Integer()),
|
|
sa.Column(
|
|
"created_at",
|
|
sa.DateTime(timezone=True),
|
|
server_default=sa.func.now(),
|
|
nullable=False,
|
|
),
|
|
sa.UniqueConstraint("user_id", "source_key", name="uq_file_folder_source"),
|
|
sa.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)",
|
|
name="ck_file_folder_kind",
|
|
),
|
|
)
|
|
op.create_index("ix_file_folders_user_id", "file_folders", ["user_id"])
|
|
op.create_index(
|
|
"uq_file_folder_own_name",
|
|
"file_folders",
|
|
["user_id", sa.text("lower(name)")],
|
|
unique=True,
|
|
postgresql_where=sa.text("name IS NOT NULL"),
|
|
)
|
|
|
|
op.add_column(
|
|
"download_jobs",
|
|
sa.Column(
|
|
"folder_id",
|
|
sa.Integer(),
|
|
sa.ForeignKey("file_folders.id", ondelete="RESTRICT"),
|
|
),
|
|
)
|
|
op.add_column(
|
|
"download_jobs",
|
|
sa.Column("kept", sa.Boolean(), server_default="false", nullable=False),
|
|
)
|
|
op.add_column("download_jobs", sa.Column("expires_at", sa.DateTime(timezone=True)))
|
|
op.add_column(
|
|
"download_jobs",
|
|
sa.Column("expiry_warned", sa.Boolean(), server_default="false", nullable=False),
|
|
)
|
|
op.create_index("ix_download_jobs_folder_id", "download_jobs", ["folder_id"])
|
|
op.create_index("ix_download_jobs_expires_at", "download_jobs", ["expires_at"])
|
|
|
|
conn = op.get_bind()
|
|
raw = conn.execute(
|
|
sa.text("SELECT value FROM system_config WHERE key = 'download_retention_days'")
|
|
).scalar()
|
|
retention = int(raw) if raw not in (None, "") else _DEFAULT_RETENTION_DAYS
|
|
|
|
jobs = conn.execute(
|
|
sa.text(
|
|
"SELECT j.id, j.user_id, j.source_kind, j.source_ref, j.edit_spec, j.source_job_id,"
|
|
" j.source_asset_id, j.status, j.asset_id, a.expires_at AS asset_expires_at"
|
|
" FROM download_jobs j LEFT JOIN media_assets a ON a.id = j.asset_id"
|
|
)
|
|
).fetchall()
|
|
jobs_by_id = {j.id: j for j in jobs}
|
|
assets = {
|
|
a.id: a
|
|
for a in conn.execute(sa.text("SELECT id, source_kind, source_ref FROM media_assets"))
|
|
}
|
|
|
|
folder_ids: dict[tuple[int, str], int] = {}
|
|
for job in jobs:
|
|
key = _key(job, jobs_by_id, assets)
|
|
slot = (job.user_id, key)
|
|
if slot not in folder_ids:
|
|
folder_ids[slot] = conn.execute(
|
|
sa.text(
|
|
"INSERT INTO file_folders (user_id, kind, source_key, retention_days)"
|
|
" VALUES (:u, 'source', :k, :r) RETURNING id"
|
|
),
|
|
{"u": job.user_id, "k": key, "r": None if key == "upload" else retention},
|
|
).scalar_one()
|
|
# A done file keeps the expiry it had (its asset's); an upload had none and keeps none.
|
|
expires = job.asset_expires_at if job.status == "done" and key != "upload" else None
|
|
conn.execute(
|
|
sa.text("UPDATE download_jobs SET folder_id = :f, expires_at = :e WHERE id = :id"),
|
|
{"f": folder_ids[slot], "e": expires, "id": job.id},
|
|
)
|
|
|
|
op.drop_column("media_assets", "expires_at")
|
|
op.drop_column("media_assets", "gc_notified")
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.add_column("media_assets", sa.Column("expires_at", sa.DateTime(timezone=True)))
|
|
op.add_column(
|
|
"media_assets",
|
|
sa.Column("gc_notified", sa.Boolean(), server_default="false", nullable=False),
|
|
)
|
|
# The asset goes when its last holder would: the latest of its holders' expiries (a holder that
|
|
# never expires keeps the asset unexpired).
|
|
op.execute(
|
|
"UPDATE media_assets a SET expires_at = sub.e FROM ("
|
|
" SELECT asset_id, CASE WHEN bool_or(expires_at IS NULL) THEN NULL ELSE max(expires_at) END"
|
|
" AS e FROM download_jobs WHERE asset_id IS NOT NULL AND status = 'done' GROUP BY asset_id"
|
|
") sub WHERE a.id = sub.asset_id AND a.source_kind <> 'upload'"
|
|
)
|
|
op.drop_index("ix_download_jobs_expires_at", table_name="download_jobs")
|
|
op.drop_index("ix_download_jobs_folder_id", table_name="download_jobs")
|
|
op.drop_column("download_jobs", "expiry_warned")
|
|
op.drop_column("download_jobs", "expires_at")
|
|
op.drop_column("download_jobs", "kept")
|
|
op.drop_column("download_jobs", "folder_id")
|
|
op.drop_index("uq_file_folder_own_name", table_name="file_folders")
|
|
op.drop_index("ix_file_folders_user_id", table_name="file_folders")
|
|
op.drop_table("file_folders")
|