diff --git a/frontend/src/components/downloadRow.dom.test.tsx b/frontend/src/components/downloadRow.dom.test.tsx
index 3c8f58de..7e6274a2 100644
--- a/frontend/src/components/downloadRow.dom.test.tsx
+++ b/frontend/src/components/downloadRow.dom.test.tsx
@@ -213,3 +213,46 @@ describe("DownloadRow · narrow", () => {
expect(texts).not.toContain("0");
});
});
+
+// A link queued (or failed) before its details: the title, the channel line and the link line all
+// fell back to the same URL (the owner, 2026-09-27). Now the title IS the short link, once.
+describe("DownloadRow · a link with no details yet", () => {
+ const bare: DownloadJob = {
+ ...baseJob,
+ status: "error",
+ source_kind: "url",
+ source_ref: "https://www.facebook.com/watch?v=12345",
+ source_url: "https://www.facebook.com/watch?v=12345",
+ display_name: null,
+ asset: null,
+ };
+
+ it("shows the short link once, as the title's link, with no channel line", () => {
+ const { container } = render();
+ const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url);
+ expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]);
+ expect(container.textContent).not.toContain("https://");
+ });
+
+ it("keeps the named row as it was: title text, and the link on its own line", () => {
+ const named = { ...bare, display_name: "A clip" };
+ const { container } = render();
+ expect(screen.getByText("A clip").closest("a")).toBeNull();
+ const links = [...container.querySelectorAll("a")].filter((a) => a.href === bare.source_url);
+ expect(links.map((a) => a.textContent)).toEqual(["www.facebook.com/watch?v=12345"]);
+ });
+});
+
+// No picture at all: a quiet glyph of what it is instead of an empty box (the owner, 2026-09-27).
+describe("DownloadRow · no picture", () => {
+ it("draws a film glyph for a video, a note for audio", () => {
+ const { container, unmount } = render();
+ expect(container.querySelector("img")).toBeNull();
+ expect(container.querySelector("svg.lucide-film")).not.toBeNull();
+ unmount();
+ const { container: audio } = render(
+ ,
+ );
+ expect(audio.querySelector("svg.lucide-music")).not.toBeNull();
+ });
+});
diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx
index cf80c90a..c149b10f 100644
--- a/frontend/src/components/downloadRow.tsx
+++ b/frontend/src/components/downloadRow.tsx
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
-import { CalendarClock, File, Link2, Scissors } from "lucide-react";
+import { CalendarClock, File, Film, Link2, Music, Scissors } from "lucide-react";
import clsx from "clsx";
import { qk } from "../lib/queryKeys";
import { useLiveQuery } from "../lib/useLiveQuery";
@@ -15,7 +15,7 @@ import {
} from "../lib/format";
import { navItemAttrs } from "../lib/listNav";
import { failCopyKeys } from "../lib/downloadFailure";
-import { jobTitle } from "./downloadTitle";
+import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
@@ -113,19 +113,30 @@ function Thumb({ job }: { job: DownloadJob }) {
loading="lazy"
className="w-full h-full object-cover"
onError={() => {
- if (remote && src !== remote) setFailedPrimary(primary);
+ if (src !== remote) setFailedPrimary(primary);
}}
/>
- ) : null}
+ ) : (
+ // No picture at all (a link that failed before its details, a poster that never came): a
+ // quiet glyph of what it is, like the file glyph above — an empty box read as "still loading".
+
+ {job.asset?.media_kind === "audio" ? (
+
+ ) : (
+
+ )}
+
+ )}
);
}
// The channel to show under the title: a user override wins over the auto-extracted asset value.
-// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text.
-function jobChannel(job: DownloadJob): { name: string; url: string | null } {
+// `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text. No
+// channel known yet → no line: the ref it once fell back to repeated the title (the owner, 2026-09-27).
+function jobChannel(job: DownloadJob): { name: string | null; url: string | null } {
return {
- name: job.display_uploader || job.asset?.uploader || job.source_ref,
+ name: job.display_uploader || job.asset?.uploader || null,
url: job.display_uploader_url || job.asset?.uploader_url || null,
};
}
@@ -157,16 +168,6 @@ export function IconBtn({
);
}
-// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
-function displayUrl(url: string): string {
- try {
- const u = new URL(url);
- return (u.host + u.pathname + u.search).replace(/\/$/, "");
- } catch {
- return url;
- }
-}
-
// A link inside the row keeps the BROWSER's own right-click menu (open in a new tab, copy the
// address): the row's menu answers everywhere else on it (E28 C1).
const nativeMenu = { onContextMenu: (e: React.MouseEvent) => e.stopPropagation() };
@@ -195,6 +196,7 @@ function SourceRef({ url }: { url: string }) {
// Channel under the title — a clickable link when a channel URL is known, plain text otherwise.
function ChannelLine({ job }: { job: DownloadJob }) {
const { name, url } = jobChannel(job);
+ if (!name) return null;
if (url) {
return (
@@ -234,6 +236,8 @@ export function DownloadRow({
}) {
const { t } = useTranslation();
const running = job.status === "running";
+ const linkTitle = !hasOwnTitle(job) && !!job.source_url;
+ const sourceLink = linkTitle ? null : job.source_url;
return (
{t("downloads.clipBadge")}
)}
- {jobTitle(job)}
+ {linkTitle ? (
+ // No name yet: the title IS the link, so the link line below leaves it out (it said the
+ // same URL a third time).
+
+ {jobTitle(job)}
+
+ ) : (
+ jobTitle(job)
+ )}
@@ -303,9 +322,9 @@ export function DownloadRow({
(at 375). On touch each is a 44px box, so separate lines stacked 44px gaps (the owner,
2026-09-27: "what are those huge empty parts?"). */}
- {(!!job.source_url || !!job.extra_links?.length) && (
+ {(!!sourceLink || !!job.extra_links?.length) && (
- {job.source_url && }
+ {sourceLink && }
{job.extra_links?.map((url) => (
))}
diff --git a/frontend/src/components/downloadTitle.ts b/frontend/src/components/downloadTitle.ts
index e38d4963..105e321c 100644
--- a/frontend/src/components/downloadTitle.ts
+++ b/frontend/src/components/downloadTitle.ts
@@ -2,6 +2,27 @@ import type { DownloadJob } from "../lib/api";
// A download job's display title (a user rename wins over the extracted asset title), shared by the
// row, the admin list and the library player. Its own module so downloadRow exports only components.
+// With no name yet — a link queued before its details arrive, or one that failed first — it is the
+// link itself, shortened the way the row shows a link, never the raw ref (a URL, or a bare video id).
export function jobTitle(job: DownloadJob): string {
- return job.display_name || job.asset?.title || job.source_ref;
+ return (
+ job.display_name ||
+ job.asset?.title ||
+ (job.source_url ? displayUrl(job.source_url) : job.source_ref)
+ );
+}
+
+/** The job has a name of its own (a rename or an extracted title), not the link standing in for one. */
+export function hasOwnTitle(job: DownloadJob): boolean {
+ return !!(job.display_name || job.asset?.title);
+}
+
+// Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits.
+export function displayUrl(url: string): string {
+ try {
+ const u = new URL(url);
+ return (u.host + u.pathname + u.search).replace(/\/$/, "");
+ } catch {
+ return url;
+ }
}