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.
This commit is contained in:
2026-10-11 03:30:34 +02:00
parent 07e0990ecf
commit bf99444ded
49 changed files with 984 additions and 226 deletions
@@ -74,6 +74,9 @@ const job = (id: number, status: DownloadJob["status"]): DownloadJob => ({
extra_links: [],
can_download: status === "done",
expired: false,
folder_id: null,
kept: false,
expires_at: null,
asset: null,
});
@@ -54,6 +54,9 @@ const baseJob: DownloadJob = {
extra_links: [],
can_download: true,
expired: false,
folder_id: null,
kept: false,
expires_at: null,
asset: null,
};
@@ -73,7 +76,6 @@ const withAsset = (over: Partial<NonNullable<DownloadJob["asset"]>>): DownloadJo
container: null,
media_kind: "video",
thumbnail_url: null,
expires_at: null,
...over,
},
});
@@ -52,6 +52,9 @@ const baseJob: DownloadJob = {
extra_links: [],
can_download: true,
expired: false,
folder_id: null,
kept: false,
expires_at: null,
asset: null,
};
@@ -74,7 +77,6 @@ const withAsset = (
container,
media_kind: "video",
thumbnail_url: null,
expires_at: null,
...over,
},
});