feat(downloads): one-click Share + downloadable-by-default links (E7 S3)

D2-A: the library row's Share button becomes a single action — quickShareLink()
ensures a public link (minting on demand), copies it to the clipboard, and toasts
with an "Open in new tab" action. A copy failure still toasts (warning) with that
action so the link is never a dead end. Advanced controls (password/expiry/revoke/
user-ACL) stay in the existing ShareDialog, reached via a new "Manage sharing" row
button.

D1 (forward-only): CreateLinkIn.allow_download defaults to true, so a newly minted
link is downloadable by default. The default lives in the API, not a migration —
the download_links column default stays false, so no existing link flips. The
ShareDialog create-default is flipped to match.

Tests: TestCreateLinkDefault (default true; explicit false honored); 4 vitest cases
for quickShareLink (mint-vs-reuse, copy ok/fail, create failure). HU/EN parity.
This commit is contained in:
2026-08-29 06:36:07 +02:00
parent 94fcc40bde
commit fdca12968b
8 changed files with 202 additions and 8 deletions
+6 -1
View File
@@ -823,7 +823,12 @@ def list_links(
class CreateLinkIn(BaseModel): class CreateLinkIn(BaseModel):
password: str = "" password: str = ""
allow_download: bool = False # D1 (E7 S3): a newly shared download is downloadable by default — the common intent is "let the
# recipient have the file", not stream-only. Forward-only: this only sets the default for links
# minted from now on; existing links keep whatever `allow_download` they were created with (the
# column default stays False, so no back-catalogue link silently flips). The owner can still
# uncheck it in the Share dialog, and toggle any link stream-only afterwards.
allow_download: bool = True
expires_days: int | None = None expires_days: int | None = None
+18
View File
@@ -93,6 +93,24 @@ class TestPublicMetaAudioFlag:
assert meta["audio"] is False assert meta["audio"] is False
class TestCreateLinkDefault:
"""D1 (E7 S3): a newly minted share link is downloadable by default. The one-click Share relies on
this — it POSTs no `allow_download`, so the API default decides. Forward-only lives here (the API
default), NOT in a migration: the DownloadLink column default stays False, so no existing back-
catalogue link silently flips to downloadable."""
def test_omitted_allow_download_defaults_downloadable(self):
from app.routes.downloads import CreateLinkIn
assert CreateLinkIn().allow_download is True
def test_explicit_false_is_respected(self):
# The owner can still uncheck it to mint a stream-only link.
from app.routes.downloads import CreateLinkIn
assert CreateLinkIn(allow_download=False).allow_download is False
class TestIsExpired: class TestIsExpired:
def test_none_expiry_never_expires(self): def test_none_expiry_never_expires(self):
assert is_expired(SimpleNamespace(expires_at=None)) is False assert is_expired(SimpleNamespace(expires_at=None)) is False
+24 -2
View File
@@ -6,6 +6,7 @@ import {
Ban, Ban,
ClipboardPaste, ClipboardPaste,
Download, Download,
Link2,
Pause, Pause,
Play, Play,
Plus, Plus,
@@ -29,7 +30,7 @@ import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api"; import { api, type DownloadJob } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads"; import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { inputCls } from "./ui/form"; import { inputCls } from "./ui/form";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow"; import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
@@ -101,6 +102,21 @@ export default function DownloadCenter() {
if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn(); if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
}; };
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
// double-click 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, via the manage-share button.
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
try {
await quickShareLink(qc, job, t);
} finally {
setSharingId(null);
}
};
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context + // Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
// a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste // a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste
// button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if // button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if
@@ -261,9 +277,15 @@ export default function DownloadCenter() {
> >
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</IconBtn> </IconBtn>
<IconBtn onClick={() => setShareJob(job)} title={t("downloads.actions.share")}> <IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" /> <Share2 className="w-4 h-4" />
</IconBtn> </IconBtn>
<IconBtn
onClick={() => setShareJob(job)}
title={t("downloads.actions.shareManage")}
>
<Link2 className="w-4 h-4" />
</IconBtn>
<IconBtn <IconBtn
onClick={() => onClick={() =>
confirmThen( confirmThen(
+5 -3
View File
@@ -254,7 +254,9 @@ function LinkShare({ job }: { job: DownloadJob }) {
queryFn: () => api.downloadLinks(job.id), queryFn: () => api.downloadLinks(job.id),
}); });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [allowDownload, setAllowDownload] = useState(false); // D1 (E7 S3): downloadable by default — matches the one-click Share (which relies on the backend
// default) and the common "let them have the file" intent. The owner can uncheck it here.
const [allowDownload, setAllowDownload] = useState(true);
const [expiryDays, setExpiryDays] = useState<number | null>(null); const [expiryDays, setExpiryDays] = useState<number | null>(null);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) }); const invalidate = () => qc.invalidateQueries({ queryKey: qk.downloadLinks(job.id) });
@@ -276,8 +278,8 @@ function LinkShare({ job }: { job: DownloadJob }) {
}); });
// Open with a link already generated: sharing is almost always the goal, so mint a default // Open with a link already generated: sharing is almost always the goal, so mint a default
// (no password, no expiry, view-only) link the moment the dialog finds none — as if the user // (no password, no expiry, downloadable per D1) link the moment the dialog finds none — as if the
// had pressed "New link" → "Create". All further controls (password, download toggle, expiry, // user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// extra links, revoke) stay available on the created link and via the "New link" button. // extra links, revoke) stay available on the created link and via the "New link" button.
const autoTried = useRef(false); const autoTried = useRef(false);
useEffect(() => { useEffect(() => {
+5 -1
View File
@@ -58,7 +58,8 @@
"download": "Save to my device", "download": "Save to my device",
"rename": "Rename", "rename": "Rename",
"editDetails": "Edit details", "editDetails": "Edit details",
"share": "Share", "share": "Share (copy link)",
"shareManage": "Manage sharing",
"edit": "Edit / trim", "edit": "Edit / trim",
"redownload": "Re-download (rebuild file)", "redownload": "Re-download (rebuild file)",
"removeShared": "Remove from my list" "removeShared": "Remove from my list"
@@ -104,6 +105,9 @@
"noUsers": "No other users yet.", "noUsers": "No other users yet.",
"linkSection": "Share a link", "linkSection": "Share a link",
"linkHint": "Anyone with the link can watch — no account needed.", "linkHint": "Anyone with the link can watch — no account needed.",
"linkCopied": "Share link copied to clipboard",
"openNewTab": "Open in new tab",
"linkFailed": "Couldn’t create the share link",
"noLinks": "No links yet.", "noLinks": "No links yet.",
"newLink": "Create a link", "newLink": "Create a link",
"createLink": "Create link", "createLink": "Create link",
+5 -1
View File
@@ -58,7 +58,8 @@
"download": "Mentés a gépemre", "download": "Mentés a gépemre",
"rename": "Átnevezés", "rename": "Átnevezés",
"editDetails": "Adatok szerkesztése", "editDetails": "Adatok szerkesztése",
"share": "Megosztás", "share": "Megosztás (link másolása)",
"shareManage": "Megosztás kezelése",
"edit": "Szerkesztés / vágás", "edit": "Szerkesztés / vágás",
"redownload": "Újraletöltés (fájl újraépítése)", "redownload": "Újraletöltés (fájl újraépítése)",
"removeShared": "Eltávolítás a listámból" "removeShared": "Eltávolítás a listámból"
@@ -104,6 +105,9 @@
"noUsers": "Nincs más felhasználó.", "noUsers": "Nincs más felhasználó.",
"linkSection": "Megosztás linkkel", "linkSection": "Megosztás linkkel",
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.", "linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
"linkCopied": "Megosztási link a vágólapra másolva",
"openNewTab": "Megnyitás új lapon",
"linkFailed": "Nem sikerült a megosztási link létrehozása",
"noLinks": "Még nincs link.", "noLinks": "Még nincs link.",
"newLink": "Link létrehozása", "newLink": "Link létrehozása",
"createLink": "Létrehozás", "createLink": "Létrehozás",
+97
View File
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
//
// One-click share (E7 S3 · D2-A): quickShareLink turns the row's Share button into a single action —
// ensure a public link (mint on demand, downloadable per D1), copy it, and toast with an "open in new
// tab" action. These pin the branch matrix: reuse-vs-mint, copy-ok-vs-fail, and the create-failure path.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
// vi.mock is hoisted above these consts, so the mock fns must live in a hoisted block too.
const { downloadLinks, createDownloadLink, copyText, notify } = vi.hoisted(() => ({
downloadLinks: vi.fn(),
createDownloadLink: vi.fn(),
copyText: vi.fn(),
notify: vi.fn(),
}));
vi.mock("./api", () => ({ api: { downloadLinks, createDownloadLink } }));
vi.mock("./clipboard", () => ({ copyText }));
vi.mock("./notifications", () => ({ notify }));
import { quickShareLink } from "./downloads";
const t = (k: string) => k;
const origin = window.location.origin;
type ToastArg = {
level: string;
message: string;
action?: { label: string; onClick: () => void };
};
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
function qc() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
beforeEach(() => {
downloadLinks.mockReset();
createDownloadLink.mockReset();
copyText.mockReset().mockResolvedValue(true);
notify.mockReset();
});
describe("quickShareLink", () => {
it("mints a downloadable link on demand, copies it, and toasts with an open-in-new-tab action", async () => {
downloadLinks.mockResolvedValue([]); // none yet
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
const url = await quickShareLink(qc(), { id: 42 }, t);
expect(url).toBe(`${origin}/watch/tok7`);
// Minted with an EMPTY body so the backend D1 default (allow_download=true) applies.
expect(createDownloadLink).toHaveBeenCalledWith(42, {});
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
const arg = firstToast();
expect(arg.level).toBe("success");
expect(arg.message).toBe("downloads.share.linkCopied");
expect(arg.action!.label).toBe("downloads.share.openNewTab");
arg.action!.onClick();
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
});
it("reuses an existing link instead of minting a duplicate", async () => {
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
const url = await quickShareLink(qc(), { id: 1 }, t);
expect(url).toBe(`${origin}/watch/old`);
expect(createDownloadLink).not.toHaveBeenCalled();
expect(firstToast().level).toBe("success");
});
it("still toasts (warning) with the open action when the clipboard copy fails", async () => {
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
copyText.mockResolvedValue(false);
const url = await quickShareLink(qc(), { id: 2 }, t);
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
const arg = firstToast();
expect(arg.level).toBe("warning");
expect(arg.message).toBe("downloads.share.copyFailed");
expect(arg.action!.label).toBe("downloads.share.openNewTab");
});
it("reports an error and returns null when link creation fails", async () => {
downloadLinks.mockResolvedValue([]);
createDownloadLink.mockRejectedValue(new Error("boom"));
const url = await quickShareLink(qc(), { id: 9 }, t);
expect(url).toBeNull();
expect(copyText).not.toHaveBeenCalled();
expect(notify).toHaveBeenCalledWith({ level: "error", message: "downloads.share.linkFailed" });
});
});
+42
View File
@@ -1,5 +1,8 @@
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { api } from "./api";
import { copyText } from "./clipboard";
import { notify } from "./notifications";
// The download queue, the per-card download index, and the storage-usage meter all move together // 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. // whenever a job is enqueued, deleted, or its state changes — invalidate them as one unit.
@@ -8,3 +11,42 @@ export function invalidateDownloads(qc: QueryClient) {
qc.invalidateQueries({ queryKey: qk.downloadIndex() }); qc.invalidateQueries({ queryKey: qk.downloadIndex() });
qc.invalidateQueries({ queryKey: qk.downloadUsage() }); qc.invalidateQueries({ queryKey: qk.downloadUsage() });
} }
// One-click share (E7 S3 · D2-A): the row's Share button turns "share this" into a single action.
// Ensure the job has a public link (minting one on demand — omitting `allow_download` so the backend
// D1 default makes it downloadable), copy it to the clipboard, and toast with an "open in new tab"
// action. A copy failure still toasts (warning) WITH that action, so the link is never a dead end.
// Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog. Returns
// the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
export async function quickShareLink(
qc: QueryClient,
job: { id: number },
t: (key: string) => string,
): Promise<string | null> {
let link;
try {
const existing = await qc.fetchQuery({
queryKey: qk.downloadLinks(job.id),
queryFn: () => api.downloadLinks(job.id),
});
link = existing[0];
if (!link) {
link = await api.createDownloadLink(job.id, {}); // omit allow_download → backend D1 default (true)
qc.setQueryData(qk.downloadLinks(job.id), [link]);
}
} catch {
notify({ level: "error", message: t("downloads.share.linkFailed") });
return null;
}
const url = window.location.origin + link.url;
const action = {
label: t("downloads.share.openNewTab"),
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
};
notify(
(await copyText(url))
? { level: "success", message: t("downloads.share.linkCopied"), action }
: { level: "warning", message: t("downloads.share.copyFailed"), action },
);
return url;
}