Files
siftlode/frontend/src/lib/downloadQueue.ts
T
peter 2efa7d5a75 fix(files): an upload is said once in any order, copy buttons speak, a link list that failed says so
- The queue holds an upload it sees land done while this tab's upload still waits for its answer, so
  the dialog's notice is the only one whichever comes first; another device's upload follows after.
- One `useCopyFeedback` for every Copy beside a value (token, share link, set-password link): Copied
  for a moment, a notice a screen reader hears, a warning when the clipboard refuses; the token
  dialog's two Copy buttons are named by what they copy.
- The share dialog's link list shows loading, and a load error with Retry, never "no links" — and no
  New link beside a list it has not seen.
- The settings texts say that signing out elsewhere or a password change revokes the API tokens.
- DESIGN.md logs the folder-link decisions; the design lab keeps only the chosen renderings of the
  Links, Tokens and Files rounds.
2026-10-15 04:58:17 +02:00

139 lines
5.9 KiB
TypeScript

import { api, type DownloadStatus, type QueueJob } from "./api";
import { createStore } from "./store";
// The download queue as this tab has watched it: which jobs it has seen finish. The tray shows them
// ("Done · Open") until it is closed, and announces each once (the done notice).
//
// Watched where the queue is FETCHED, not in a component's effect: every reader of the queue fetches
// through `fetchQueue`, so a finish is seen once whichever of them polled, and nothing has to diff the
// previous render's data.
/** The statuses the tray lists as under way: waiting, working, or paused by its user. */
export const IN_FLIGHT: ReadonlySet<DownloadStatus> = new Set(["queued", "running", "paused"]);
/** The statuses a job leaves on its own, so the queue is worth asking about often. A paused job is
* not one: it waits for its user, however long that takes. */
export const MOVING: ReadonlySet<DownloadStatus> = new Set(["queued", "running"]);
/** The steps a running job reports that have a name of their own (`downloads.phase.*`). */
export const DOWNLOAD_PHASES: readonly string[] = [
"video",
"audio",
"editing",
"converting",
"merging",
"audio_extract",
"thumbnail",
"sponsorblock",
"metadata",
"processing",
];
let lastSeen: Map<number, string> | null = null;
const finished = createStore<QueueJob[]>([]);
const unannounced = createStore<QueueJob[]>([]);
const revealing = createStore<Reveal | null>(null);
// Finishes this tab already said out loud where they happened: an upload that answered done.
const announcedHere = new Set<number>();
// This tab's uploads still waiting for their answer, and the uploads the queue saw land done
// meanwhile: one of them may be the answer's own job, done before the answer arrived. Whose it is,
// only the answer can tell (`uploadEnded`), so until then none of them is announced.
let uploading = 0;
let held: QueueJob[] = [];
/** A job to show in the tray, asked for at `at` (ms). */
export interface Reveal {
id: number;
at: number;
}
/** Note a fresh queue: a job that is done now and was not on the last look has just finished. The
* first look only learns what is there — a file finished before this tab opened is old news. */
export function noteQueue(jobs: QueueJob[]): void {
const before = lastSeen;
lastSeen = new Map(jobs.map((j) => [j.id, j.status]));
if (!before) return;
const done = jobs.filter((j) => j.status === "done" && before.get(j.id) !== "done");
if (done.length === 0) return;
const ids = new Set(done.map((j) => j.id));
finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]);
// `delete` answers whether it was there: each id is spent on the one look that finds it done.
const news = done.filter((j) => !announcedHere.delete(j.id));
const landed = (j: QueueJob) =>
uploading > 0 && j.source_kind === "upload" && before.get(j.id) === undefined;
held = [...held, ...news.filter(landed)];
const now = news.filter((j) => !landed(j));
if (now.length) unannounced.set((list) => [...list, ...now]);
}
/** An upload from this tab is on its way: what the queue sees land done may be its own job. */
export const uploadStarted = (): void => {
uploading += 1;
};
/** An upload's answer came: `said` is the job it announced itself (done in that answer — the tray
* still keeps it, but its done notice would say it a second time, and as a download), null when it
* announced none (still converting, failed, cancelled). In whichever order the queue saw it. The
* rest held meanwhile (another device's uploads) are announced once no upload is waiting. */
export function uploadEnded(said: number | null): void {
uploading = Math.max(0, uploading - 1);
if (said !== null) {
const before = held.length;
held = held.filter((j) => j.id !== said);
if (held.length === before) announcedHere.add(said);
}
if (uploading > 0 || held.length === 0) return;
const release = held;
held = [];
unannounced.set((list) => [...list, ...release]);
}
/** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */
export async function fetchQueue(): Promise<QueueJob[]> {
const jobs = await api.downloads();
noteQueue(jobs);
return jobs;
}
/** The jobs this tab saw finish, newest first, until the tray is closed. */
export const useFinished = (): QueueJob[] => finished.useValue();
/** The tray was closed: what finished is no longer shown. */
export const clearFinished = (): void => finished.set([]);
/** A file was deleted: a "Done · Open" row for it would open and link nothing. */
export const forgetFinished = (id: number): void =>
finished.set((list) => (list.some((j) => j.id === id) ? list.filter((j) => j.id !== id) : list));
/** The finishes not yet announced — the render subscription that wakes the announcer. */
export const useUnannounced = (): QueueJob[] => unannounced.useValue();
/** Take the finishes to announce, clearing them: each is announced once. */
export function takeUnannounced(): QueueJob[] {
const list = unannounced.get();
if (list.length) unannounced.set([]);
return list;
}
/** "View" on a job just started (a download, a clip, an upload still converting): the tray opens on
* its row — unfolded, back if it was closed, the keyboard on the row. One the tray no longer lists
* because it is done already opens as a file. */
export const showInTray = (id: number): void => revealing.set({ id, at: Date.now() });
/** The pending "View", until the tray has answered it. */
export const useRevealing = (): Reveal | null => revealing.useValue();
/** The tray answered the "View" (or the job is not in the queue at all). */
export const doneRevealing = (): void => revealing.set(null);
/** Forget what was watched (a test starts from a tab that has looked at nothing). */
export function resetQueueWatch(): void {
lastSeen = null;
finished.set([]);
unannounced.set([]);
announcedHere.clear();
uploading = 0;
held = [];
revealing.set(null);
}