Files
siftlode/backend/tests/test_migration_0102.py
T
peter bf99444ded feat(files): every file sits in a library folder, and keeps its own expiry
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.
2026-10-11 03:30:34 +02:00

74 lines
3.3 KiB
Python

"""Migration 0102's data step, DB-lane: every existing job lands in its source folder, and a done
file's expiry moves from the shared asset to the user's job.
The schema half is checked by test_migrations.py; a row placed in the wrong folder is invisible there.
"""
from alembic import command
from alembic.config import Config
from sqlalchemy import text
from app.db import engine
def test_existing_jobs_are_placed_and_keep_their_expiry(db, make_user):
alice, bob = make_user().id, make_user().id
cfg = Config("alembic.ini")
command.downgrade(cfg, "0101_plex_text_plain")
try:
with engine.begin() as conn:
def asset(kind, ref, expires=None):
return conn.execute(
text(
"INSERT INTO media_assets (source_kind, source_ref, format_sig, status,"
" ref_count, expires_at) VALUES (:k, :r, :sig, 'ready', 1, :e)"
" RETURNING id"
),
{"k": kind, "r": ref, "sig": kind + ref, "e": expires},
).scalar_one()
def job(user, kind, ref, asset_id, status="done", spec=None, parent=None):
return conn.execute(
text(
"INSERT INTO download_jobs (user_id, asset_id, source_kind, source_ref,"
" profile_snapshot, status, edit_spec, source_job_id)"
" VALUES (:u, :a, :k, :r, '{}', :s, CAST(:spec AS json), :p) RETURNING id"
),
{"u": user, "a": asset_id, "k": kind, "r": ref, "s": status,
"spec": spec, "p": parent},
).scalar_one() # fmt: skip
yt = asset("youtube", "abcdefghijk", "2030-01-02T00:00:00+00:00")
tt = asset("url", "https://www.tiktok.com/@a/video/1", "2030-01-03T00:00:00+00:00")
up = asset("upload", "u1")
cl = asset("clip", "c1", "2030-01-04T00:00:00+00:00")
j_yt = job(alice, "youtube", "abcdefghijk", yt)
j_yt_bob = job(bob, "youtube", "abcdefghijk", yt, status="queued")
j_tt = job(alice, "url", "https://www.tiktok.com/@a/video/1", tt)
j_up = job(alice, "upload", "u1", up)
j_cl = job(alice, "clip", "c1", cl, spec='{"source": "plex", "ref": "9"}')
j_ed = job(alice, "edit", "e1", cl, parent=j_tt)
finally:
command.upgrade(cfg, "head")
with engine.connect() as conn:
rows = conn.execute(
text(
"SELECT j.id, f.user_id, f.source_key, f.retention_days, j.expires_at::date"
" FROM download_jobs j JOIN file_folders f ON f.id = j.folder_id"
)
).all()
folders = conn.execute(text("SELECT count(*) FROM file_folders")).scalar()
got = {r[0]: (r[1], r[2], r[3], str(r[4]) if r[4] else None) for r in rows}
assert got == {
j_yt: (alice, "youtube", 30, "2030-01-02"),
j_yt_bob: (bob, "youtube", 30, None), # not done yet: no expiry until it is
j_tt: (alice, "site:tiktok.com", 30, "2030-01-03"),
j_up: (alice, "upload", None, None), # an upload never expires
j_cl: (alice, "plex", 30, "2030-01-04"),
j_ed: (alice, "site:tiktok.com", 30, "2030-01-04"),
}
# alice: youtube, tiktok, upload, plex; bob: youtube.
assert folders == 5