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,
},
});
+5 -1
View File
@@ -40,7 +40,6 @@ interface DownloadAsset {
// while still in flight (set at finalize). See backend downloads/mediakind.py.
media_kind: "video" | "audio" | "file" | null;
thumbnail_url: string | null;
expires_at: string | null;
}
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
@@ -107,6 +106,11 @@ export interface DownloadJob {
edit_spec?: EditSpec | Omit<ClipRequest, "display_name"> | null;
can_download: boolean;
expired: boolean;
// The Files library (backend downloads/folders): its folder, whether the user keeps it past the
// folder's retention, and when it goes (null = never).
folder_id: number | null;
kept: boolean;
expires_at: string | null;
asset: DownloadAsset | null;
shared?: boolean;
user_email?: string;