);
}
-
-export function UsageBar() {
- const { t, i18n } = useTranslation();
- const lang = i18n.language;
- // Poll live (like the library list) so the footprint updates the moment the worker finishes an
- // edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
- // a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint
- // only moves while a job is in flight, so poll fast (2s) then and idle-back-off to 15s otherwise
- // (running_jobs counts queued+running — it flips back to 2s on the first poll after a new enqueue).
- const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, {
- intervalMs: (u) => (u?.running_jobs ? 2000 : 15000),
- });
- const u = usage.data;
- if (!u) return null;
- const pct =
- u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100);
- return (
-
-
- {t("downloads.usage.title")}
-
- {formatBytes(u.footprint_bytes, lang)}
- {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`} ·{" "}
- {u.unlimited
- ? t("downloads.usage.unlimited")
- : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
-
-
- {!u.unlimited && (
-
-
90 ? "bg-danger" : "bg-accent")}
- style={{ width: `${pct}%` }}
- />
-
- )}
-
- );
-}
diff --git a/frontend/src/components/fileFolders.tsx b/frontend/src/components/fileFolders.tsx
new file mode 100644
index 00000000..0ba8afe2
--- /dev/null
+++ b/frontend/src/components/fileFolders.tsx
@@ -0,0 +1,35 @@
+import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
+import { YouTubeIcon } from "../lib/brandIcons";
+import type { FileFolder } from "../lib/api";
+
+// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
+// (`youtube`, `plex`, `upload`, `hub`, `site:
`, see backend downloads/folders.py) and leaves
+// its name to the client, in the reader's language; an own folder carries the name its user gave it.
+
+const SITE_PREFIX = "site:";
+
+type T = (key: string) => string;
+
+/** The name of a source: a site is its host, the rest are words of the UI. */
+export function sourceLabel(key: string, t: T): string {
+ if (key.startsWith(SITE_PREFIX)) return key.slice(SITE_PREFIX.length);
+ if (key === "youtube" || key === "plex" || key === "upload" || key === "hub")
+ return t(`files.source.${key}`);
+ return key;
+}
+
+export function sourceIcon(key: string): LucideIcon | typeof YouTubeIcon {
+ if (key === "youtube") return YouTubeIcon;
+ if (key === "plex") return Film;
+ if (key === "upload") return Upload;
+ if (key === "hub") return Server;
+ return Globe;
+}
+
+export function folderLabel(folder: FileFolder, t: T): string {
+ return folder.kind === "own" ? (folder.name ?? "") : sourceLabel(folder.source_key ?? "", t);
+}
+
+export function folderIcon(folder: FileFolder): LucideIcon | typeof YouTubeIcon {
+ return folder.kind === "own" ? Folder : sourceIcon(folder.source_key ?? "");
+}
diff --git a/frontend/src/components/filesContext.ts b/frontend/src/components/filesContext.ts
new file mode 100644
index 00000000..7e2995ef
--- /dev/null
+++ b/frontend/src/components/filesContext.ts
@@ -0,0 +1,47 @@
+import { createContext, useContext } from "react";
+
+// The Files module's contexts + hooks, split out of FilesProvider so that file exports only
+// components (Fast Refresh).
+
+/** What the Files page lists: every file of mine, the ones shared with me, or one folder's. */
+export type FilesView = { kind: "all" } | { kind: "shared" } | { kind: "folder"; id: number };
+
+export const ALL_FILES: FilesView = { kind: "all" };
+
+/** The view as one string — the `?folder=` bookmark and the remembered view share it. */
+export function viewParam(view: FilesView): string {
+ return view.kind === "folder" ? String(view.id) : view.kind;
+}
+
+/** The view a stored or bookmarked string names; anything else is the whole library. */
+export function parseView(raw: string | null | undefined): FilesView | null {
+ if (raw === "all") return ALL_FILES;
+ if (raw === "shared") return { kind: "shared" };
+ const id = raw && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
+ return id !== null && Number.isSafeInteger(id) ? { kind: "folder", id } : null;
+}
+
+export const sameView = (a: FilesView, b: FilesView): boolean => viewParam(a) === viewParam(b);
+
+export type FilesState = {
+ search: string;
+ view: FilesView;
+};
+
+export interface FilesActions {
+ setSearch: (q: string) => void;
+ setView: (view: FilesView) => void;
+}
+
+export const FilesStateContext = createContext({ search: "", view: ALL_FILES });
+export const FilesActionsContext = createContext({
+ setSearch: () => {},
+ setView: () => {},
+});
+
+export function useFiles(): FilesState {
+ return useContext(FilesStateContext);
+}
+export function useFilesActions(): FilesActions {
+ return useContext(FilesActionsContext);
+}
diff --git a/frontend/src/components/moduleRegistry.tsx b/frontend/src/components/moduleRegistry.tsx
index e6cab72b..f0129bc4 100644
--- a/frontend/src/components/moduleRegistry.tsx
+++ b/frontend/src/components/moduleRegistry.tsx
@@ -26,6 +26,8 @@ const Playlists = lazyWithPreload(() => import("./Playlists"));
const PlexBrowse = lazyWithPreload(() => import("./PlexBrowse"));
const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
+const Files = lazyWithPreload(() => import("./Files"));
+const FilesRail = lazyWithPreload(() => import("./FilesRail"));
const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter"));
const Stats = lazyWithPreload(() => import("./Stats"));
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
@@ -54,6 +56,7 @@ export const PAGE_CONTENT: Record = {
playlists: { Component: Playlists, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "100rem" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
+ files: { Component: Files, maxWidth: "80rem" },
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
@@ -73,8 +76,9 @@ type RailProps = {
onToggleCollapse: () => void;
};
-// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists has its own).
-type RailCollapseKey = "filter" | "playlists";
+// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists and files
+// have their own).
+type RailCollapseKey = "filter" | "playlists" | "files";
export type RailDef = {
Rail: ComponentType;
@@ -83,11 +87,12 @@ export type RailDef = {
collapse: RailCollapseKey;
};
-// page id → its side rail (only the three pages that have one). App renders it in the rail slot.
+// page id → its side rail (only the pages that have one). App renders it in the rail slot.
export const PAGE_RAIL: Partial> = {
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
+ files: { Rail: FilesRail, panelId: "files", collapse: "files" },
};
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
diff --git a/frontend/src/components/navigationInit.ts b/frontend/src/components/navigationInit.ts
index e7453483..dfcd6e0c 100644
--- a/frontend/src/components/navigationInit.ts
+++ b/frontend/src/components/navigationInit.ts
@@ -28,6 +28,7 @@ export function loadInitialPage(): Page {
// without an explicit ?page. Feed filters (or a channel overlay) imply the feed.
if (params.has("v")) return "plex";
if (params.has("pl")) return "playlists";
+ if (params.has("folder")) return "files";
if (hasFilterParams(params) || params.has("chan")) return "feed";
// A bare URL → Home, the surface whose whole job is to answer "what should I watch" before
// the user has to decide anything. It deliberately OVERRIDES the remembered last module: landing
diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts
index c16e9881..06b5bc37 100644
--- a/frontend/src/components/ui/pressableSelectable.contract.test.ts
+++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts
@@ -17,6 +17,7 @@ const files = readSources(__dirname, [
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record = {
+ "FilesRail.tsx": [1, "the load-error retry"],
"Home.tsx": [
2,
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
diff --git a/frontend/src/components/useDownloadActions.tsx b/frontend/src/components/useDownloadActions.tsx
index d035d12c..2dbe3fbd 100644
--- a/frontend/src/components/useDownloadActions.tsx
+++ b/frontend/src/components/useDownloadActions.tsx
@@ -1,40 +1,21 @@
-import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
-import {
- Ban,
- Download,
- Link2,
- Pause,
- Pencil,
- Play,
- PlayCircle,
- RefreshCw,
- RotateCcw,
- Scissors,
- Share2,
- SkipBack,
- Trash2,
-} from "lucide-react";
-import { qk } from "../lib/queryKeys";
+import { Ban, Pause, Play, RefreshCw } from "lucide-react";
import { api, type DownloadJob, type DownloadJobKind } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
-import { invalidateDownloads, quickShareLink } from "../lib/downloads";
-import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
+import { invalidateDownloads } from "../lib/downloads";
import { useConfirm } from "./confirmContext";
import { useCopyItems } from "./copyItems";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
-// A download's ONE action list: the row's "…", its right-click, long-press and Shift+F10,
-// on the queue, the library and the shared tab. The row keeps its primary action (play; pause /
-// resume / retry in the queue), the save link and its state (the error and its details) in sight;
-// a library row also keeps Share (the link copy) in sight, ahead of save. The rest — the editors,
-// managing the shares, re-download, the copies and the destructive rows — lives here.
+// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
+// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
+// sight; the copies and Cancel live here. A finished download is a library file: its actions are
+// the Files page's (useFileActions).
-export type DownloadTab = "queue" | "done" | "shared";
-export type JobOp = "pause" | "resume" | "cancel" | "delete" | "redownload";
+export type JobOp = "pause" | "resume" | "cancel";
// The Cancel confirmation of a queued job; an upload's names what it deletes.
const CANCEL_DOWNLOAD = {
@@ -63,27 +44,7 @@ const CANCEL_COPY: Record = {
};
// Deleting a download may leave its shared cache file for other holders. An upload and a clip are
// their owner's alone (a clip's asset is keyed by its user), so deleting one removes the file for good.
-const DELETE_COPY: Record = {
- download: { title: "downloads.confirm.deleteTitle", body: "downloads.confirm.deleteBody" },
- upload: {
- title: "downloads.confirm.deleteUploadTitle",
- body: "downloads.confirm.deleteUploadBody",
- },
- edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
- clip: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
-};
-
-export function useDownloadActions({
- onPlay,
- onEdit,
- onEditDetails,
- onManageShares,
-}: {
- onPlay: (job: DownloadJob, fromStart?: boolean) => void;
- onEdit: (job: DownloadJob) => void;
- onEditDetails: (job: DownloadJob) => void;
- onManageShares: (job: DownloadJob) => void;
-}) {
+export function useDownloadActions() {
const qc = useQueryClient();
const { t } = useTranslation();
const confirm = useConfirm();
@@ -95,83 +56,16 @@ export function useDownloadActions({
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
- : op === "cancel"
- ? api.cancelDownload(id)
- : op === "redownload"
- ? api.redownloadDownload(id)
- : api.deleteDownload(id),
+ : api.cancelDownload(id),
onSuccess: () => invalidateDownloads(qc),
- // A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
- // skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
});
- // Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
- const removeShared = useMutation({
- mutationFn: (id: number) => api.removeSharedDownload(id),
- onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
- meta: { failTitle: "errors.writeFailed.removeShared" },
- });
-
- // The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
- // it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
- // One-click share: ensure a downloadable link, copy it, toast with "open in new tab"
- // (see quickShareLink). Re-choosing reuses the existing link; a per-job in-flight guard also stops a
- // double pick from racing two POSTs into two links before the cache is primed. Advanced controls
- // (password, expiry, revoke, share-with-a-user) live in the Share dialog, "Manage sharing".
- const [sharingId, setSharingId] = useState(null);
- const quickShare = async (job: DownloadJob) => {
- if (sharingId !== null) return;
- setSharingId(job.id);
- await quickShareLink(qc, job, t).finally(() => setSharingId(null));
- };
-
const icon = (I: typeof Play) => ;
- // A started item continues, and can also start over — the card menus' words and glyph (C5).
- const play = (job: DownloadJob): ActionMenuItem[] => {
- const started = (job.position_seconds ?? 0) > 0;
- return [
- {
- id: "play",
- label: started ? t("card.continue") : t("downloads.actions.play"),
- icon: icon(PlayCircle),
- onSelect: () => onPlay(job),
- },
- ...(started
- ? [
- {
- id: "restart",
- label: t("card.restartTitle"),
- icon: icon(SkipBack),
- onSelect: () => onPlay(job, true),
- },
- ]
- : []),
- ];
- };
- // The same link the row's save button is, followed inside the click: the server answers it as an
- // attachment, so the page stays where it is.
- const save = (job: DownloadJob): ActionMenuItem => ({
- id: "save",
- label: t("downloads.actions.download"),
- icon: icon(Download),
- inGesture: true,
- onSelect: () => {
- const a = document.createElement("a");
- a.href = api.downloadFileUrl(job.id);
- a.click();
- },
- });
- const edit = (job: DownloadJob): ActionMenuItem => ({
- id: "edit",
- label: t("downloads.actions.edit"),
- icon: icon(Scissors),
- onSelect: () => onEdit(job),
- });
const copies = (job: DownloadJob) => copy(jobTitle(job), job.source_url);
function queueItems(job: DownloadJob): ActionMenuItem[] {
@@ -212,112 +106,11 @@ export function useDownloadActions({
);
}
- function libraryItems(job: DownloadJob): ActionMenuItem[] {
- // Row actions gated on the stored media_kind + job_kind (see downloadActions).
- const a = libraryRowActions(job);
- return menuGroups(
- // The row's own buttons first, in the row's order: sharing the link is the library's main
- // errand, ahead of saving the file.
- [
- ...(a.play ? play(job) : []),
- {
- id: "share",
- label: t("downloads.actions.share"),
- icon: icon(Share2),
- disabled: sharingId !== null,
- onSelect: () => void quickShare(job),
- },
- save(job),
- ],
- [
- ...(a.edit ? [edit(job)] : []),
- ...(a.editDetails
- ? [
- {
- id: "edit-details",
- label: t("downloads.actions.editDetails"),
- icon: icon(Pencil),
- onSelect: () => onEditDetails(job),
- },
- ]
- : []),
- ...(a.redownload
- ? [
- {
- id: "redownload",
- label: t("downloads.actions.redownload"),
- icon: icon(RotateCcw),
- onSelect: () =>
- void confirmThen(
- t("downloads.confirm.redownloadTitle"),
- t("downloads.confirm.redownloadBody"),
- t("downloads.confirm.redownloadAction"),
- () => act.mutate({ id: job.id, op: "redownload" }),
- ),
- },
- ]
- : []),
- ],
- [
- {
- id: "share-manage",
- label: t("downloads.actions.shareManage"),
- icon: icon(Link2),
- onSelect: () => onManageShares(job),
- },
- ...copies(job),
- ],
- [
- {
- id: "delete",
- label: t("downloads.actions.delete"),
- icon: icon(Trash2),
- danger: true,
- onSelect: () => {
- const copy = DELETE_COPY[job.job_kind ?? "download"];
- void confirmThen(t(copy.title), t(copy.body), t("downloads.actions.delete"), () =>
- act.mutate({ id: job.id, op: "delete" }),
- );
- },
- },
- ],
- );
- }
-
- function sharedItems(job: DownloadJob): ActionMenuItem[] {
- // Same media_kind + can_download gate as the library, via the shared helper.
- const a = sharedRowActions(job);
- return menuGroups(
- [...(a.play ? play(job) : []), ...(a.save ? [save(job)] : [])],
- a.edit ? [edit(job)] : [],
- copies(job),
- [
- {
- id: "remove-shared",
- label: t("downloads.actions.removeShared"),
- icon: icon(Trash2),
- danger: true,
- onSelect: () =>
- void confirmThen(
- t("downloads.confirm.removeSharedTitle"),
- t("downloads.confirm.removeSharedBody"),
- t("downloads.actions.removeShared"),
- () => removeShared.mutate(job.id),
- ),
- },
- ],
- );
- }
-
- /** The job's menu rows on the tab it is listed on. */
- const items = (job: DownloadJob, tab: DownloadTab): ActionMenuItem[] =>
- tab === "queue" ? queueItems(job) : tab === "done" ? libraryItems(job) : sharedItems(job);
+ /** The job's menu rows. */
+ const items = (job: DownloadJob): ActionMenuItem[] => queueItems(job);
/** The queue's visible primary action and the error dialog's Retry. */
const run = (job: DownloadJob, op: JobOp) => act.mutate({ id: job.id, op });
- /** The library row's visible Share: the same one-click link copy as its menu row. */
- const share = (job: DownloadJob) => void quickShare(job);
-
- return { items, run, share };
+ return { items, run };
}
diff --git a/frontend/src/components/useFileActions.tsx b/frontend/src/components/useFileActions.tsx
new file mode 100644
index 00000000..aded44a6
--- /dev/null
+++ b/frontend/src/components/useFileActions.tsx
@@ -0,0 +1,303 @@
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import {
+ Download,
+ FolderInput,
+ Info,
+ Link,
+ Pencil,
+ Pin,
+ PinOff,
+ PlayCircle,
+ RotateCcw,
+ Scissors,
+ Share2,
+ SkipBack,
+ Trash2,
+} from "lucide-react";
+import { api, type DownloadJobKind, type FileFolder, type LibraryFile } from "../lib/api";
+import { notifyActionError } from "../lib/actionErrors";
+import { invalidateDownloads, quickShareLink } from "../lib/downloads";
+import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
+import { useConfirm } from "./confirmContext";
+import { useCopyItems } from "./copyItems";
+import { jobTitle } from "./downloadTitle";
+import { folderLabel } from "./fileFolders";
+import { type ActionMenuItem } from "./ui/ActionMenu";
+import { menuGroups } from "./ui/menuGroups";
+
+// The Files rows' ONE menu, and the actions the rows show as icons (the same ones, so a row's every
+// action is reachable by right click — DESIGN.md "Library list columns"). My own files and the ones
+// shared with me get different sets: a shared file is the owner's, so it is played, saved, cut or
+// dropped from my list, never renamed, moved or deleted.
+
+const DELETE_COPY: Record = {
+ download: { title: "downloads.confirm.deleteTitle", body: "downloads.confirm.deleteBody" },
+ upload: {
+ title: "downloads.confirm.deleteUploadTitle",
+ body: "downloads.confirm.deleteUploadBody",
+ },
+ edit: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
+ clip: { title: "downloads.confirm.deleteEditTitle", body: "downloads.confirm.deleteEditBody" },
+};
+
+type Patch = { name?: string | null; folder_id?: number; kept?: boolean };
+// Which failure title a PATCH speaks under: what it was asked to change.
+const patchFail = (p: Patch) =>
+ "folder_id" in p
+ ? "errors.writeFailed.file.move"
+ : "kept" in p
+ ? "errors.writeFailed.file.keep"
+ : "errors.writeFailed.file.rename";
+
+export function useFileActions({
+ folders,
+ onPlay,
+ onEdit,
+ onRename,
+ onEditDetails,
+ onManageShares,
+}: {
+ /** The caller's folders — the Move submenu's places. */
+ folders: readonly FileFolder[];
+ onPlay: (file: LibraryFile, fromStart?: boolean) => void;
+ onEdit: (file: LibraryFile) => void;
+ onRename: (file: LibraryFile) => void;
+ onEditDetails: (file: LibraryFile) => void;
+ onManageShares: (file: LibraryFile) => void;
+}) {
+ const qc = useQueryClient();
+ const { t } = useTranslation();
+ const confirm = useConfirm();
+ const copy = useCopyItems();
+
+ const update = useMutation({
+ mutationFn: ({ id, patch }: { id: number; patch: Patch }) => api.updateFile(id, patch),
+ onSuccess: () => invalidateDownloads(qc),
+ onError: (e, v) => notifyActionError(e, t(patchFail(v.patch))),
+ });
+ const remove = useMutation({
+ mutationFn: (id: number) => api.deleteFile(id),
+ onSuccess: () => invalidateDownloads(qc),
+ meta: { failTitle: "errors.writeFailed.file.delete" },
+ });
+ const redownload = useMutation({
+ mutationFn: (id: number) => api.redownloadDownload(id),
+ onSuccess: () => invalidateDownloads(qc),
+ meta: { failTitle: "errors.writeFailed.job.redownload" },
+ });
+ const removeShared = useMutation({
+ mutationFn: (id: number) => api.removeSharedFile(id),
+ onSuccess: () => invalidateDownloads(qc),
+ meta: { failTitle: "errors.writeFailed.removeShared" },
+ });
+
+ const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
+ if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
+ };
+
+ const [sharingId, setSharingId] = useState(null);
+ const quickShare = async (file: LibraryFile) => {
+ if (sharingId !== null) return;
+ setSharingId(file.id);
+ await quickShareLink(qc, file, t).finally(() => setSharingId(null));
+ };
+
+ const icon = (I: typeof PlayCircle) => ;
+ const play = (file: LibraryFile): ActionMenuItem[] => {
+ const started = (file.position_seconds ?? 0) > 0;
+ return [
+ {
+ id: "play",
+ label: started ? t("card.continue") : t("downloads.actions.play"),
+ icon: icon(PlayCircle),
+ onSelect: () => onPlay(file),
+ },
+ ...(started
+ ? [
+ {
+ id: "restart",
+ label: t("card.restartTitle"),
+ icon: icon(SkipBack),
+ onSelect: () => onPlay(file, true),
+ },
+ ]
+ : []),
+ ];
+ };
+ const save = (file: LibraryFile): ActionMenuItem => ({
+ id: "save",
+ label: t("downloads.actions.download"),
+ icon: icon(Download),
+ inGesture: true,
+ onSelect: () => {
+ const a = document.createElement("a");
+ a.href = api.fileUrl(file.id);
+ a.click();
+ },
+ });
+ const copyLink = (file: LibraryFile): ActionMenuItem => ({
+ id: "share-link",
+ label: t("files.actions.copyLink"),
+ icon: icon(Link),
+ disabled: sharingId !== null,
+ onSelect: () => void quickShare(file),
+ });
+ const edit = (file: LibraryFile): ActionMenuItem => ({
+ id: "edit",
+ label: t("downloads.actions.edit"),
+ icon: icon(Scissors),
+ onSelect: () => onEdit(file),
+ });
+ // "Copy link" is the share link here (the library's daily errand); the page it came from is the
+ // source link, named so.
+ const copies = (file: LibraryFile) =>
+ copy(jobTitle(file), file.source_url, "title", t("files.actions.copySourceLink"));
+
+ // Move: every own folder (the one it is in, ticked), then home — its own source folder — when it
+ // has left it. The server refuses any other place (`folders.move`), so none is offered.
+ const moveTo = (file: LibraryFile): ActionMenuItem => {
+ const own = folders.filter((f) => f.kind === "own");
+ const home = folders.find((f) => f.kind === "source" && f.source_key === file.source_key);
+ const to = (f: FileFolder) => () => update.mutate({ id: file.id, patch: { folder_id: f.id } });
+ const items: ActionMenuItem[] = own.map((f) => ({
+ id: `move-${f.id}`,
+ label: folderLabel(f, t),
+ check: { kind: "one", checked: f.id === file.folder_id },
+ onSelect: to(f),
+ }));
+ if (home && home.id !== file.folder_id)
+ items.push({
+ id: "move-home",
+ label: t("files.actions.moveHome", { folder: folderLabel(home, t) }),
+ separatorBefore: items.length > 0,
+ onSelect: to(home),
+ });
+ if (items.length === 0)
+ items.push({
+ id: "move-none",
+ label: t("files.actions.noOwnFolders"),
+ disabled: true,
+ onSelect: () => {},
+ });
+ return { id: "move", label: t("files.actions.move"), icon: icon(FolderInput), items };
+ };
+
+ function mineItems(file: LibraryFile): ActionMenuItem[] {
+ const a = libraryRowActions(file);
+ return menuGroups(
+ // The row's icons first, in the row's order.
+ [
+ ...(a.play ? play(file) : []),
+ ...(a.play ? [copyLink(file)] : []),
+ {
+ id: "share",
+ label: t("files.actions.share"),
+ icon: icon(Share2),
+ onSelect: () => onManageShares(file),
+ },
+ save(file),
+ ],
+ [
+ ...(a.edit ? [edit(file)] : []),
+ {
+ id: "rename",
+ label: t("files.actions.rename"),
+ icon: icon(Pencil),
+ onSelect: () => onRename(file),
+ },
+ moveTo(file),
+ {
+ id: "keep",
+ label: t(file.kept ? "files.actions.unkeep" : "files.actions.keep"),
+ icon: icon(file.kept ? PinOff : Pin),
+ onSelect: () => update.mutate({ id: file.id, patch: { kept: !file.kept } }),
+ },
+ ],
+ [
+ ...(a.editDetails
+ ? [
+ {
+ id: "details",
+ label: t("files.actions.details"),
+ icon: icon(Info),
+ onSelect: () => onEditDetails(file),
+ },
+ ]
+ : []),
+ ...(a.redownload
+ ? [
+ {
+ id: "redownload",
+ label: t("downloads.actions.redownload"),
+ icon: icon(RotateCcw),
+ onSelect: () =>
+ void confirmThen(
+ t("downloads.confirm.redownloadTitle"),
+ t("downloads.confirm.redownloadBody"),
+ t("downloads.confirm.redownloadAction"),
+ () => redownload.mutate(file.id),
+ ),
+ },
+ ]
+ : []),
+ ...copies(file),
+ ],
+ [
+ {
+ id: "delete",
+ label: t("downloads.actions.delete"),
+ icon: icon(Trash2),
+ danger: true,
+ onSelect: () => {
+ const c = DELETE_COPY[file.job_kind ?? "download"];
+ void confirmThen(t(c.title), t(c.body), t("downloads.actions.delete"), () =>
+ remove.mutate(file.id),
+ );
+ },
+ },
+ ],
+ );
+ }
+
+ function sharedItems(file: LibraryFile): ActionMenuItem[] {
+ const a = sharedRowActions(file);
+ return menuGroups(
+ [...(a.play ? play(file) : []), ...(a.save ? [save(file)] : [])],
+ a.edit ? [edit(file)] : [],
+ copies(file),
+ [
+ {
+ id: "remove-shared",
+ label: t("downloads.actions.removeShared"),
+ icon: icon(Trash2),
+ danger: true,
+ onSelect: () =>
+ void confirmThen(
+ t("downloads.confirm.removeSharedTitle"),
+ t("downloads.confirm.removeSharedBody"),
+ t("downloads.actions.removeShared"),
+ () => removeShared.mutate(file.id),
+ ),
+ },
+ ],
+ );
+ }
+
+ /** The file's menu rows. */
+ const items = (file: LibraryFile): ActionMenuItem[] =>
+ file.shared ? sharedItems(file) : mineItems(file);
+
+ /** The row's Copy-link icon: the menu's row, one click. */
+ const share = (file: LibraryFile) => void quickShare(file);
+
+ /** Save the inline rename: an empty name gives the file its own title back. */
+ const rename = (file: LibraryFile, name: string) => {
+ const next = name.trim();
+ if (next === (file.display_name ?? "")) return;
+ update.mutate({ id: file.id, patch: { name: next || null } });
+ };
+
+ return { items, share, rename, sharing: sharingId };
+}
diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json
index c6460e62..c4a19dd4 100644
--- a/frontend/src/i18n/locales/en/downloads.json
+++ b/frontend/src/i18n/locales/en/downloads.json
@@ -41,8 +41,6 @@
},
"tabs": {
"queue": "Queue",
- "done": "Library",
- "shared": "Shared with me",
"system": "System"
},
"status": {
@@ -51,8 +49,7 @@
"paused": "Paused",
"done": "Ready",
"error": "Failed",
- "canceled": "Canceled",
- "expired": "Expired"
+ "canceled": "Canceled"
},
"phase": {
"video": "Downloading video",
@@ -75,9 +72,6 @@
"download": "Save to my device",
"play": "Play",
"rename": "Rename",
- "editDetails": "Edit details…",
- "share": "Share (copy link)",
- "shareManage": "Manage sharing…",
"edit": "Edit / trim…",
"redownload": "Re-download (rebuild file)",
"removeShared": "Remove from my list"
@@ -87,21 +81,13 @@
},
"empty": {
"queue": "Nothing in the queue.",
- "done": "No downloads yet.",
- "shared": "Nothing has been shared with you.",
"admin": "No downloads on this instance yet."
},
- "usage": {
- "title": "Your storage",
- "items": "{{used}} / {{max}} downloads",
- "unlimited": "Unlimited"
- },
"source": {
"open": "Open the original page in a new tab"
},
"edit": {
"title": "Edit details",
- "name": "Title",
"channel": "Channel",
"channelUrl": "Channel link",
"extraLinks": "Extra links",
diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json
index 4e23c8ff..f19995fd 100644
--- a/frontend/src/i18n/locales/en/errors.json
+++ b/frontend/src/i18n/locales/en/errors.json
@@ -97,7 +97,13 @@
"videoReset": "Couldn't reset the video",
"watchLaterAdd": "Couldn't add it to Watch later",
"watchLaterRemove": "Couldn't remove it from Watch later",
- "clearSearch": "Couldn't clear the search results"
+ "clearSearch": "Couldn't clear the search results",
+ "file": {
+ "rename": "Couldn't rename the file",
+ "move": "Couldn't move the file",
+ "keep": "Couldn't change whether the file is kept",
+ "delete": "Couldn't delete the file"
+ }
},
"ok": "OK",
"offline": {
diff --git a/frontend/src/i18n/locales/en/files.json b/frontend/src/i18n/locales/en/files.json
new file mode 100644
index 00000000..20596505
--- /dev/null
+++ b/frontend/src/i18n/locales/en/files.json
@@ -0,0 +1,60 @@
+{
+ "navLabel": "Files",
+ "searchPlaceholder": "Search files…",
+ "count_one": "{{count}} item",
+ "count_other": "{{count}} items",
+ "rowMenu": "File actions",
+ "phoneOk": "Plays on a phone too",
+ "rail": {
+ "library": "Library",
+ "all": "All files",
+ "shared": "Shared with me",
+ "sources": "Sources",
+ "own": "My folders",
+ "noSources": "Nothing downloaded yet",
+ "noOwn": "No folders of your own yet"
+ },
+ "source": {
+ "youtube": "YouTube",
+ "plex": "Plex clips",
+ "upload": "Uploads",
+ "hub": "Hub"
+ },
+ "usage": {
+ "title": "Storage"
+ },
+ "columns": {
+ "name": "Name",
+ "source": "Source",
+ "phone": "Phone",
+ "retention": "Keeps",
+ "size": "Size",
+ "added": "Added"
+ },
+ "retention": {
+ "kept": "Kept",
+ "never": "Kept until you delete it",
+ "until": "Goes on {{when}}",
+ "days_one": "{{count}} day",
+ "days_other": "{{count}} days"
+ },
+ "empty": {
+ "all": "No files yet.",
+ "folder": "This folder is empty.",
+ "shared": "Nothing has been shared with you.",
+ "search": "No file matches “{{q}}”."
+ },
+ "actions": {
+ "copyLink": "Copy link",
+ "copySourceLink": "Copy source link",
+ "share": "Share…",
+ "rename": "Rename",
+ "move": "Move to",
+ "moveHome": "Back to {{folder}}",
+ "noOwnFolders": "No folders of your own yet",
+ "keep": "Keep",
+ "unkeep": "Stop keeping",
+ "details": "Details…",
+ "upload": "Upload"
+ }
+}
diff --git a/frontend/src/i18n/locales/en/keys.json b/frontend/src/i18n/locales/en/keys.json
index f94e817b..af882d3e 100644
--- a/frontend/src/i18n/locales/en/keys.json
+++ b/frontend/src/i18n/locales/en/keys.json
@@ -27,6 +27,7 @@
"goPlaylists": "Go to Playlists",
"goPlex": "Go to Plex",
"goNotifications": "Go to Notifications",
+ "goFiles": "Go to Files",
"goDownloads": "Go to Downloads",
"goStats": "Go to Stats",
"goSettings": "Go to Settings",
diff --git a/frontend/src/i18n/locales/en/nav.json b/frontend/src/i18n/locales/en/nav.json
index 407e1041..1146c97e 100644
--- a/frontend/src/i18n/locales/en/nav.json
+++ b/frontend/src/i18n/locales/en/nav.json
@@ -13,6 +13,7 @@
"playlists": "Playlists",
"plex": "Plex",
"notifications": "Notifications",
+ "files": "Files",
"downloads": "Downloads",
"stats": "Stats",
"scheduler": "Scheduler",
diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json
index 668a41cc..8f64d196 100644
--- a/frontend/src/i18n/locales/hu/downloads.json
+++ b/frontend/src/i18n/locales/hu/downloads.json
@@ -41,8 +41,6 @@
},
"tabs": {
"queue": "Sor",
- "done": "Könyvtár",
- "shared": "Velem megosztva",
"system": "Rendszer"
},
"status": {
@@ -51,8 +49,7 @@
"paused": "Szüneteltetve",
"done": "Kész",
"error": "Sikertelen",
- "canceled": "Megszakítva",
- "expired": "Lejárt"
+ "canceled": "Megszakítva"
},
"phase": {
"video": "Videósáv letöltése",
@@ -75,9 +72,6 @@
"download": "Mentés a gépemre",
"play": "Lejátszás",
"rename": "Átnevezés",
- "editDetails": "Adatok szerkesztése…",
- "share": "Megosztás (link másolása)",
- "shareManage": "Megosztás kezelése…",
"edit": "Szerkesztés / vágás…",
"redownload": "Újraletöltés (fájl újraépítése)",
"removeShared": "Eltávolítás a listámból"
@@ -87,21 +81,13 @@
},
"empty": {
"queue": "A sor üres.",
- "done": "Még nincs letöltés.",
- "shared": "Még nem osztottak meg veled semmit.",
"admin": "Ezen a példányon még nincs letöltés."
},
- "usage": {
- "title": "Tárhelyed",
- "items": "{{used}} / {{max}} letöltés",
- "unlimited": "Korlátlan"
- },
"source": {
"open": "Eredeti oldal megnyitása új lapon"
},
"edit": {
"title": "Adatok szerkesztése",
- "name": "Cím",
"channel": "Csatorna",
"channelUrl": "Csatorna link",
"extraLinks": "További linkek",
diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json
index 1a054ce7..6ba23727 100644
--- a/frontend/src/i18n/locales/hu/errors.json
+++ b/frontend/src/i18n/locales/hu/errors.json
@@ -97,7 +97,13 @@
"videoReset": "Nem sikerült alaphelyzetbe állítani a videót",
"watchLaterAdd": "Nem sikerült hozzáadni a Megnézem később listához",
"watchLaterRemove": "Nem sikerült eltávolítani a Megnézem később listából",
- "clearSearch": "Nem sikerült törölni a keresési találatokat"
+ "clearSearch": "Nem sikerült törölni a keresési találatokat",
+ "file": {
+ "rename": "Nem sikerült átnevezni a fájlt",
+ "move": "Nem sikerült áthelyezni a fájlt",
+ "keep": "Nem sikerült módosítani a fájl megtartását",
+ "delete": "Nem sikerült törölni a fájlt"
+ }
},
"ok": "OK",
"offline": {
diff --git a/frontend/src/i18n/locales/hu/files.json b/frontend/src/i18n/locales/hu/files.json
new file mode 100644
index 00000000..d83a6f89
--- /dev/null
+++ b/frontend/src/i18n/locales/hu/files.json
@@ -0,0 +1,60 @@
+{
+ "navLabel": "Fájlok",
+ "searchPlaceholder": "Keresés a fájlok között…",
+ "count_one": "{{count}} elem",
+ "count_other": "{{count}} elem",
+ "rowMenu": "Fájl műveletei",
+ "phoneOk": "Telefonon is lejátszható",
+ "rail": {
+ "library": "Könyvtár",
+ "all": "Minden fájl",
+ "shared": "Megosztva velem",
+ "sources": "Források",
+ "own": "Saját mappák",
+ "noSources": "Még nincs letöltött fájl",
+ "noOwn": "Még nincs saját mappád"
+ },
+ "source": {
+ "youtube": "YouTube",
+ "plex": "Plex klipek",
+ "upload": "Feltöltések",
+ "hub": "Hub"
+ },
+ "usage": {
+ "title": "Tárhely"
+ },
+ "columns": {
+ "name": "Név",
+ "source": "Forrás",
+ "phone": "Telefon",
+ "retention": "Megőrzés",
+ "size": "Méret",
+ "added": "Hozzáadva"
+ },
+ "retention": {
+ "kept": "Megtartva",
+ "never": "Addig marad, amíg nem törlöd",
+ "until": "Törlődik: {{when}}",
+ "days_one": "{{count}} nap",
+ "days_other": "{{count}} nap"
+ },
+ "empty": {
+ "all": "Még nincs fájlod.",
+ "folder": "Ez a mappa üres.",
+ "shared": "Veled még nem osztottak meg semmit.",
+ "search": "Egy fájl sem illik erre: „{{q}}”."
+ },
+ "actions": {
+ "copyLink": "Link másolása",
+ "copySourceLink": "Forrás linkjének másolása",
+ "share": "Megosztás…",
+ "rename": "Átnevezés",
+ "move": "Áthelyezés",
+ "moveHome": "Vissza ide: {{folder}}",
+ "noOwnFolders": "Még nincs saját mappád",
+ "keep": "Megtartom",
+ "unkeep": "Megtartás visszavonása",
+ "details": "Részletek…",
+ "upload": "Feltöltés"
+ }
+}
diff --git a/frontend/src/i18n/locales/hu/keys.json b/frontend/src/i18n/locales/hu/keys.json
index 27498d28..e4f9a689 100644
--- a/frontend/src/i18n/locales/hu/keys.json
+++ b/frontend/src/i18n/locales/hu/keys.json
@@ -27,6 +27,7 @@
"goPlaylists": "Ugrás: Lejátszási listák",
"goPlex": "Ugrás: Plex",
"goNotifications": "Ugrás: Értesítések",
+ "goFiles": "Ugrás a Fájlokhoz",
"goDownloads": "Ugrás: Letöltések",
"goStats": "Ugrás: Statisztika",
"goSettings": "Ugrás: Beállítások",
diff --git a/frontend/src/i18n/locales/hu/nav.json b/frontend/src/i18n/locales/hu/nav.json
index 1a7a1dee..1c4391ac 100644
--- a/frontend/src/i18n/locales/hu/nav.json
+++ b/frontend/src/i18n/locales/hu/nav.json
@@ -13,6 +13,7 @@
"playlists": "Listák",
"plex": "Plex",
"notifications": "Értesítések",
+ "files": "Fájlok",
"downloads": "Letöltések",
"stats": "Statisztika",
"scheduler": "Ütemező",
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 9e765723..999cdd79 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -60,6 +60,8 @@ export {
} from "./api/plex";
import { downloadsApi } from "./api/downloads";
export type * from "./api/downloads";
+import { filesApi } from "./api/files";
+export type * from "./api/files";
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
export type * from "./api/diagnostics";
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
@@ -80,6 +82,7 @@ export const api = {
...adminApi,
...plexApi,
...downloadsApi,
+ ...filesApi,
...diagnosticsApi,
...debugSessionsApi,
};
diff --git a/frontend/src/lib/api/core.test.ts b/frontend/src/lib/api/core.test.ts
index 742f8d62..98ca4373 100644
--- a/frontend/src/lib/api/core.test.ts
+++ b/frontend/src/lib/api/core.test.ts
@@ -24,6 +24,7 @@ vi.mock("../activeAccount", () => ({
import { setDebugSession } from "../log";
import { downloadsApi } from "./downloads";
+import { filesApi } from "./files";
import {
beacon,
req,
@@ -48,10 +49,13 @@ class FakeXHR {
onabort: (() => void) | null = null;
sent = false;
body: unknown = null;
+ url = "";
constructor() {
FakeXHR.last = this;
}
- open() {}
+ open(_method: string, url: string) {
+ this.url = url;
+ }
setRequestHeader(k: string, v: string) {
// A real XHR COMBINES values for a name it already has, case-insensitively, rather than
// overwriting — so a plain assignment here would hide exactly the duplicate-header bug the
@@ -470,10 +474,10 @@ describe("the debug session a tab records into", () => {
// body, which the server reads and spools BEFORE its auth dependency runs; the raw body is what lets
// `current_user` resolve first. A revert to FormData here would silently re-open that, and the
// backend's own test only sees the shape the client actually sends.
-describe("uploadDownload's wire shape", () => {
+describe("uploadFile's wire shape", () => {
it("sends the File as the raw body, with the names as percent-encoded headers", () => {
const file = new File(["abc"], "nyaralás videó.mp4", { type: "video/mp4" });
- void downloadsApi.uploadDownload(file, { displayName: "Nyaralás 2026" });
+ void filesApi.uploadFile(file, { displayName: "Nyaralás 2026" });
const x = FakeXHR.last;
expect(x.body).toBe(file);
expect(x.body).not.toBeInstanceOf(FormData);
@@ -484,8 +488,15 @@ describe("uploadDownload's wire shape", () => {
});
it("omits the display-name header when there is no display name", () => {
- void downloadsApi.uploadDownload(new File(["abc"], "clip.mp4"));
+ void filesApi.uploadFile(new File(["abc"], "clip.mp4"));
expect(FakeXHR.last.headers["X-Upload-Display-Name"]).toBeUndefined();
+ expect(FakeXHR.last.headers["X-Upload-Folder"]).toBeUndefined();
+ });
+
+ it("names the folder it goes into, when it has one", () => {
+ void filesApi.uploadFile(new File(["abc"], "clip.mp4"), { folderId: 7 });
+ expect(FakeXHR.last.headers["X-Upload-Folder"]).toBe("7");
+ expect(FakeXHR.last.url).toBe("/api/files/upload");
});
});
diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts
index 35cb5607..3f9149ff 100644
--- a/frontend/src/lib/api/downloads.ts
+++ b/frontend/src/lib/api/downloads.ts
@@ -1,7 +1,7 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
-import { beacon, req, uploadReq, getActiveAccount } from "./core";
+import { beacon, req, getActiveAccount } from "./core";
export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -105,7 +105,6 @@ export interface DownloadJob {
// An edit's recipe, or a clip's spec (its request without the name) — by job_kind.
edit_spec?: EditSpec | Omit | 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;
@@ -142,20 +141,6 @@ export interface ShareLink {
// languages is exactly the drift that CLAUDE.md's comment-vs-code rule is about.
export const DISPLAY_NAME_MAX = 255;
-export interface DownloadUsage {
- footprint_bytes: number;
- active_jobs: number;
- running_jobs: number;
- max_bytes: number;
- max_concurrent: number;
- max_jobs: number;
- unlimited: boolean;
- // The per-file cap on an upload, so the dialog can refuse an over-cap file before spending the
- // user's bandwidth on it. Not the same thing as `max_bytes`, which is the whole library
- // footprint: this one is a single request's ceiling and is enforced twice server-side anyway.
- upload_max_bytes: number;
-}
-
export interface AdminStorage {
files_on_disk: number;
total_bytes: number;
@@ -214,29 +199,6 @@ export const downloadsApi = {
spec?: DownloadSpec;
display_name?: string;
}): Promise => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
- // Ingest a local file into the library. Sent via XHR rather than `req` so the dialog can
- // show upload progress; any file type is accepted (media gets the full function set, a generic
- // file is download-only). Returns the job the ingest creates: `done`, or `queued` while the
- // worker converts media no phone could play as it is (`downloads/upload.py`).
- // The body IS the file, and the two names ride as headers. Not a style choice: a multipart part is
- // a DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency
- // runs — so with the old shape an unauthenticated caller could make the server write the whole
- // per-file allowance to temp. Headers are latin-1 on the wire, hence `encodeURIComponent`;
- // the server percent-decodes both. Content-Type is left to the browser (the File's own type).
- uploadDownload: (
- file: File,
- opts: { displayName?: string; onProgress?: (pct: number) => void; signal?: AbortSignal } = {},
- ): Promise => {
- const headers: Record = {
- "X-Upload-Filename": encodeURIComponent(file.name || "upload"),
- };
- if (opts.displayName) headers["X-Upload-Display-Name"] = encodeURIComponent(opts.displayName);
- return uploadReq("/api/downloads/upload", file, {
- onProgress: opts.onProgress,
- signal: opts.signal,
- headers,
- });
- },
enqueueEdit: (body: {
source_job_id: number;
edit_spec: EditSpec;
@@ -250,11 +212,6 @@ export const downloadsApi = {
downloadStoryboard: (id: number): Promise =>
req(`/api/downloads/${id}/storyboard`),
downloads: (): Promise => req("/api/downloads"),
- downloadsShared: (): Promise => req("/api/downloads/shared"),
- // Remove a shared-with-me item from your list (deletes only your share grant, not the file).
- removeSharedDownload: (jobId: number): Promise<{ removed: number }> =>
- req(`/api/downloads/shared/${jobId}`, { method: "DELETE" }),
- downloadUsage: (): Promise => req("/api/downloads/usage"),
downloadIndex: (): Promise> => req("/api/downloads/index"),
pauseDownload: (id: number): Promise =>
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
@@ -266,18 +223,6 @@ export const downloadsApi = {
// deletes the old file, and rebuilds it under the current format/naming rules.
redownloadDownload: (id: number): Promise =>
req(`/api/downloads/${id}/redownload`, { method: "POST" }, { idempotent: true }),
- deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
- // Update a download's display metadata (title / channel name+link / extra reference URLs).
- updateDownloadMeta: (
- id: number,
- meta: {
- display_name?: string;
- display_uploader?: string;
- display_uploader_url?: string;
- extra_links?: string[];
- },
- ): Promise =>
- req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }),
shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
unshareDownload: (id: number, email: string) =>
@@ -330,14 +275,6 @@ export const downloadsApi = {
},
onSettled,
),
- // A plain navigation can't send the X-Siftlode-Account header, so pass the active
- // account via ?account= (the server honours it only for an account in this browser's wallet) —
- // otherwise the download resolves to the session-default account and 404s for a per-tab
- // account's file.
- downloadFileUrl: (id: number): string => {
- const a = getActiveAccount();
- return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`;
- },
// Filmstrip sprite (an
/background src → direct nav, so it needs the ?account= wallet gate).
storyboardImageUrl: (id: number): string => {
const a = getActiveAccount();
diff --git a/frontend/src/lib/api/facade.test.ts b/frontend/src/lib/api/facade.test.ts
index fb1f219c..24e8ceb3 100644
--- a/frontend/src/lib/api/facade.test.ts
+++ b/frontend/src/lib/api/facade.test.ts
@@ -32,6 +32,7 @@ const SLICE_MODULES = [
["adminApi", () => import("./admin")],
["plexApi", () => import("./plex")],
["downloadsApi", () => import("./downloads")],
+ ["filesApi", () => import("./files")],
["diagnosticsApi", () => import("./diagnostics")],
["debugSessionsApi", () => import("./diagnostics")],
] as const;
diff --git a/frontend/src/lib/api/files.ts b/frontend/src/lib/api/files.ts
new file mode 100644
index 00000000..8ec3afb8
--- /dev/null
+++ b/frontend/src/lib/api/files.ts
@@ -0,0 +1,123 @@
+// The Files library: a user's done files, the folders they sit in, and the file itself
+// (backend `routes/files.py`). A library file is a done download job, so it is typed as one.
+import { getActiveAccount, req, uploadReq } from "./core";
+import type { DownloadJob } from "./downloads";
+
+// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user.
+export type FolderKind = "source" | "own";
+
+export interface FileFolder {
+ id: number;
+ kind: FolderKind;
+ // A source folder's origin — `youtube`, `plex`, `upload`, `site:` — which the client names
+ // in the reader's language; null on an own folder.
+ source_key: string | null;
+ name: string | null; // an own folder's name; null on a source folder
+ retention_days: number | null; // null = kept until deleted
+ count: number; // its done files
+ size_bytes: number;
+ created_at: string | null;
+}
+
+export type FileSort = "added" | "name" | "size";
+export type FileOrder = "asc" | "desc";
+
+export interface FileListParams {
+ folderId?: number | null; // one folder's files; absent = every file
+ q?: string;
+ sort?: FileSort;
+ order?: FileOrder;
+ shared?: boolean; // the files others shared with me instead of mine
+}
+
+export interface LibraryFile extends DownloadJob {
+ // When it was shared with me (the "added" of a shared file); null on my own.
+ shared_at?: string | null;
+ // Where it came from — its source folder's key (`youtube`, `site:`, …), wherever it sits now.
+ source_key: string;
+}
+
+// What a PATCH may change. Presence decides: a null / "" text falls back to the system value.
+export interface FilePatch {
+ name?: string | null;
+ uploader?: string | null;
+ uploader_url?: string | null;
+ extra_links?: string[];
+ folder_id?: number;
+ kept?: boolean;
+}
+
+export interface StorageUsage {
+ footprint_bytes: number;
+ active_jobs: number;
+ running_jobs: number;
+ max_bytes: number;
+ max_concurrent: number;
+ max_jobs: number;
+ unlimited: boolean;
+ // The per-file cap on an upload, so the dialog can refuse an over-cap file before spending the
+ // user's bandwidth on it. Not the same thing as `max_bytes`, which is the whole library
+ // footprint: this one is a single request's ceiling and is enforced twice server-side anyway.
+ upload_max_bytes: number;
+}
+
+function listQuery(p: FileListParams): string {
+ const q = new URLSearchParams();
+ if (p.shared) q.set("scope", "shared");
+ else if (p.folderId != null) q.set("folder_id", String(p.folderId));
+ if (p.q?.trim()) q.set("q", p.q.trim());
+ if (p.sort) q.set("sort", p.sort);
+ if (p.order) q.set("order", p.order);
+ const s = q.toString();
+ return s ? `?${s}` : "";
+}
+
+export const filesApi = {
+ fileFolders: (): Promise => req("/api/files/folders"),
+ files: (p: FileListParams = {}): Promise => req(`/api/files${listQuery(p)}`),
+ filesUsage: (): Promise => req("/api/files/usage"),
+ updateFile: (id: number, patch: FilePatch): Promise =>
+ req(`/api/files/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
+ deleteFile: (id: number): Promise<{ ok: true; deleted: number }> =>
+ req(`/api/files/${id}`, { method: "DELETE" }),
+ // Drop a file someone shared with me from my list (only my grant goes, not their file).
+ removeSharedFile: (id: number): Promise<{ ok: true; removed: number }> =>
+ req(`/api/files/shared/${id}`, { method: "DELETE" }),
+ // Put a local file in the library. Sent via XHR rather than `req` so the dialog can show upload
+ // progress; any file type is accepted (media gets the full function set, a generic file is
+ // download-only). Returns the job the ingest creates: `done`, or `queued` while the worker
+ // converts media no phone could play as it is (`downloads/upload.py`).
+ // The body IS the file, and the names ride as headers. Not a style choice: a multipart part is a
+ // DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency runs
+ // — so with that shape an unauthenticated caller could make the server write the whole per-file
+ // allowance to temp. Headers are latin-1 on the wire, hence `encodeURIComponent`; the server
+ // percent-decodes them. Content-Type is left to the browser (the File's own type). No folder =
+ // the Uploads folder.
+ uploadFile: (
+ file: File,
+ opts: {
+ displayName?: string;
+ folderId?: number | null;
+ onProgress?: (pct: number) => void;
+ signal?: AbortSignal;
+ } = {},
+ ): Promise => {
+ const headers: Record = {
+ "X-Upload-Filename": encodeURIComponent(file.name || "upload"),
+ };
+ if (opts.displayName) headers["X-Upload-Display-Name"] = encodeURIComponent(opts.displayName);
+ if (opts.folderId != null) headers["X-Upload-Folder"] = String(opts.folderId);
+ return uploadReq("/api/files/upload", file, {
+ onProgress: opts.onProgress,
+ signal: opts.signal,
+ headers,
+ });
+ },
+ // A plain navigation can't send the X-Siftlode-Account header, so pass the active account via
+ // ?account= (the server honours it only for an account in this browser's wallet) — otherwise the
+ // file resolves to the session-default account and 404s for a per-tab account's file.
+ fileUrl: (id: number): string => {
+ const a = getActiveAccount();
+ return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
+ },
+};
diff --git a/frontend/src/lib/downloads.ts b/frontend/src/lib/downloads.ts
index 6a65271e..f93abcd1 100644
--- a/frontend/src/lib/downloads.ts
+++ b/frontend/src/lib/downloads.ts
@@ -5,12 +5,15 @@ import { copyText } from "./clipboard";
import { notify } from "./notifications";
import { notifyActionError } from "./actionErrors";
-// The download queue, the per-card download index, and the storage-usage meter all move together
-// whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
+// The download queue, the per-card download index, the library (its listings and folder counts) and
+// the storage meter all move together whenever a job is enqueued, deleted, finishes, or a file is
+// renamed or moved — invalidate them as one unit.
export function invalidateDownloads(qc: QueryClient) {
qc.invalidateQueries({ queryKey: qk.downloads() });
qc.invalidateQueries({ queryKey: qk.downloadIndex() });
- qc.invalidateQueries({ queryKey: qk.downloadUsage() });
+ qc.invalidateQueries({ queryKey: qk.files() });
+ qc.invalidateQueries({ queryKey: qk.fileFolders() });
+ qc.invalidateQueries({ queryKey: qk.storageUsage() });
}
// One-click share: the row's Share button turns "share this" into a single action.
diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts
index cd561f54..5bf8219f 100644
--- a/frontend/src/lib/keymap.ts
+++ b/frontend/src/lib/keymap.ts
@@ -117,6 +117,7 @@ export const SHORTCUTS = [
labelKey: "keys.goNotifications",
page: "notifications",
},
+ { id: "goFiles", scope: "page", keys: ["g l"], labelKey: "keys.goFiles", page: "files" },
{
id: "goDownloads",
scope: "page",
diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts
index 2b64a0cf..1937d084 100644
--- a/frontend/src/lib/modules.ts
+++ b/frontend/src/lib/modules.ts
@@ -8,6 +8,7 @@ import {
ListVideo,
ScrollText,
FileSearch,
+ FolderOpen,
Settings,
SlidersHorizontal,
Tv,
@@ -88,6 +89,12 @@ const MODULES: readonly ModuleDef[] = [
icon: Bell,
hasBadge: true,
},
+ {
+ id: "files",
+ labelKey: "files.navLabel",
+ railKey: "nav.rail.files",
+ icon: FolderOpen,
+ },
{
id: "downloads",
labelKey: "downloads.navLabel",
diff --git a/frontend/src/lib/pageMeta.test.ts b/frontend/src/lib/pageMeta.test.ts
index 2bca35da..dff34685 100644
--- a/frontend/src/lib/pageMeta.test.ts
+++ b/frontend/src/lib/pageMeta.test.ts
@@ -11,6 +11,7 @@ const EXPECTED: Record = {
channels: "header.channelManager",
playlists: "header.account.playlists",
notifications: "inbox.navLabel",
+ files: "files.navLabel",
downloads: "downloads.navLabel",
stats: "header.usageStats",
plex: "plex.navLabel",
diff --git a/frontend/src/lib/panelLayout.ts b/frontend/src/lib/panelLayout.ts
index 76161489..089c6da8 100644
--- a/frontend/src/lib/panelLayout.ts
+++ b/frontend/src/lib/panelLayout.ts
@@ -1,10 +1,10 @@
// Per-user customization of a side panel's group cards: their order, which are collapsed, and
// which are hidden — the same "customize" affordance the feed filter rail had, generalized so
-// every panel (feed / plex / playlists) has its own saved layout. Persisted to localStorage and
+// every panel (feed / plex / playlists / files) has its own saved layout. Persisted to localStorage and
// the server-side `preferences` blob (schemaless), so it follows the account across devices.
import { LS, readAccount, writeAccount } from "./storage";
-export type PanelId = "feed" | "plex" | "playlists";
+export type PanelId = "feed" | "plex" | "playlists" | "files";
export interface PanelLayout {
order: string[];
@@ -30,6 +30,7 @@ const PANEL_ORDER: Record = {
"contentrating",
],
playlists: ["manage", "list"],
+ files: ["library", "sources", "own"],
};
// localStorage key per panel (feed keeps its original key for backward compatibility).
@@ -37,13 +38,18 @@ const LS_KEY: Record = {
feed: LS.sidebarLayout,
plex: LS.plexLayout,
playlists: LS.playlistsLayout,
+ files: LS.filesLayout,
};
// Key on the server `preferences` blob per panel (feed keeps its original key).
-export const PREF_KEY: Record = {
+export const PREF_KEY: Record<
+ PanelId,
+ "sidebarLayout" | "plexLayout" | "playlistsLayout" | "filesLayout"
+> = {
feed: "sidebarLayout",
plex: "plexLayout",
playlists: "playlistsLayout",
+ files: "filesLayout",
};
export function defaultLayout(panel: PanelId): PanelLayout {
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index a3d9db4f..144bc358 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -1,4 +1,4 @@
-import type { FeedFilters } from "./api";
+import type { FeedFilters, FileListParams } from "./api";
// Single source of truth for React Query keys. Every key root lives here exactly once, so a typo
// at one of the ~200 call sites can no longer mint an orphan key that silently never invalidates
@@ -64,11 +64,14 @@ export const qk = {
// --- sharing ---
shareRecipients: () => ["share-recipients"] as const,
+ // --- files (the library) ---
+ // Every listing's key starts with "files", so one invalidation reaches each folder, search and sort.
+ files: (p?: FileListParams) => (p ? (["files", p] as const) : (["files"] as const)),
+ fileFolders: () => ["file-folders"] as const,
+ storageUsage: () => ["storage-usage"] as const,
// --- downloads ---
downloads: () => ["downloads"] as const,
- downloadsShared: () => ["downloads-shared"] as const,
downloadIndex: () => ["download-index"] as const,
- downloadUsage: () => ["download-usage"] as const,
downloadProfiles: () => ["download-profiles"] as const,
downloadCredentials: () => ["download-credentials"] as const,
downloadLinks: (jobId: number) => ["download-links", jobId] as const,
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts
index ce71a7d7..134abd23 100644
--- a/frontend/src/lib/storage.ts
+++ b/frontend/src/lib/storage.ts
@@ -10,6 +10,12 @@ export const LS = {
plexLayout: "siftlode.plexLayout",
playlistsLayout: "siftlode.playlistsLayout",
playlistsCollapsed: "siftlode.playlistsCollapsed",
+ filesLayout: "siftlode.filesLayout",
+ filesCollapsed: "siftlode.filesCollapsed",
+ // The Files view last open (every file, shared with me, or a folder id), per-account.
+ filesView: "siftlode.filesView",
+ // The Files table's sort and rows-per-page (DataTable's persist blob).
+ filesTable: "siftlode.filesTable",
hints: "siftlode.hints",
lang: "siftlode.lang",
filters: "siftlode.filters",
diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts
index b74c0033..8dfdb02d 100644
--- a/frontend/src/lib/urlState.ts
+++ b/frontend/src/lib/urlState.ts
@@ -131,6 +131,7 @@ const PAGES = [
"audit",
"logs",
"notifications",
+ "files",
"downloads",
"plex",
] as const;
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index d5576a18..4ebc9efc 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -10,6 +10,7 @@ import { NavigationProvider } from "./components/NavigationProvider";
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { SavedViewsProvider } from "./components/SavedViewsProvider";
import { PlaylistsProvider } from "./components/PlaylistsProvider";
+import { FilesProvider } from "./components/FilesProvider";
import { PlexProvider } from "./components/PlexProvider";
import { PlexViewBridge } from "./components/PlexViewBridge";
import { ChannelsProvider } from "./components/ChannelsProvider";
@@ -81,27 +82,29 @@ const root =
-
-
- {/* SavedViewsProvider sits below BOTH filter providers so it can switch its
+
+
+
+ {/* SavedViewsProvider sits below BOTH filter providers so it can switch its
source (feed ⟷ plex) by the active page and read the right live filters. */}
-
-
-
-
-
- {/* The same quiet screen as the gate's, so a chunk that is still
+
+
+
+
+
+ {/* The same quiet screen as the gate's, so a chunk that is still
loading when the gate opens continues it instead of blanking. */}
- }>
-
-
-
-
-
-
-
-
-
+ }>
+
+
+
+
+
+
+
+
+
+