diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx
index de3e04e6..da772928 100644
--- a/frontend/src/components/Playlists.tsx
+++ b/frontend/src/components/Playlists.tsx
@@ -630,7 +630,7 @@ export default function Playlists() {
if (e.key === "Escape") setRenaming(false);
}}
aria-label={t("playlists.rename")}
- className="min-w-0 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
+ className="min-w-0 flex-1 text-lg font-semibold bg-surface border border-border rounded-md px-2 py-0.5 focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
) : (
<>
diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx
index 2c0d7ccb..3944b966 100644
--- a/frontend/src/components/PlaylistsRail.tsx
+++ b/frontend/src/components/PlaylistsRail.tsx
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
-import { formatDuration } from "../lib/format";
+import { formatRuntime } from "../lib/format";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
@@ -247,7 +247,7 @@ export default function PlaylistsRail({
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
- ` · ${formatDuration(pl.total_duration_seconds)}`}
+ ` · ${formatRuntime(pl.total_duration_seconds)}`}
diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json
index 2c2867eb..1458913a 100644
--- a/frontend/src/i18n/locales/en/plex.json
+++ b/frontend/src/i18n/locales/en/plex.json
@@ -350,7 +350,8 @@
"browse": "Browse"
},
"browse": {
- "items": "{{count}} items"
+ "items_one": "{{count}} item",
+ "items_other": "{{count}} items"
},
"discover": {
"tab": "Discover",
diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json
index 5d85c1c0..84490c4b 100644
--- a/frontend/src/i18n/locales/hu/plex.json
+++ b/frontend/src/i18n/locales/hu/plex.json
@@ -350,7 +350,8 @@
"browse": "Böngészés"
},
"browse": {
- "items": "{{count}} elem"
+ "items_one": "{{count}} elem",
+ "items_other": "{{count}} elem"
},
"discover": {
"tab": "Felfedezés",
diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts
index 5d09d96d..7b738568 100644
--- a/frontend/src/lib/format.test.ts
+++ b/frontend/src/lib/format.test.ts
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { RELATIVE_UNITS, relativeTime, relativeFromMs } from "./format";
+import { RELATIVE_UNITS, formatRuntime, relativeTime, relativeFromMs } from "./format";
import en from "../i18n/locales/en/time.json";
import hu from "../i18n/locales/hu/time.json";
@@ -108,3 +108,35 @@ describe("relativeFromMs", () => {
expect(hu).toHaveProperty("justNow");
});
});
+
+// formatRuntime went from "one Plex movie's length" to "a playlist's total playtime" (E9 12D), and a
+// TOTAL is the only input that can legitimately be 1-59 seconds. That tier had no coverage and no
+// implementation: 45s floored to "0m", so a playlist of Shorts reported no runtime while the rail
+// beside it read "0:45". The table pins every tier boundary, so the sub-minute branch cannot be
+// dropped again as "dead code" — it is the one branch a whole-playlist figure reaches.
+describe("formatRuntime", () => {
+ const CASES: [number | null | undefined, string][] = [
+ [null, ""],
+ [undefined, ""],
+ [0, ""], // nothing to show — the call sites suppress their separator on the empty string
+ [1, "1s"],
+ [45, "45s"], // one Short: the regression case
+ [59, "59s"], // last second before the minute tier
+ [60, "1m"],
+ [90, "1m"], // floors, never rounds up — a total must not overstate
+ [3599, "59m"],
+ [3600, "1h 0m"], // the zero minutes stay: "1h" alone reads as an approximation
+ [5025, "1h 23m"],
+ [360000, "100h 0m"], // a large aggregate keeps counting hours rather than rolling to days
+ ];
+
+ it.each(CASES)("formats %o as %o", (sec, expected) => {
+ expect(formatRuntime(sec)).toBe(expected);
+ });
+
+ it("never reports a non-zero duration as zero", () => {
+ for (let sec = 1; sec < 3600; sec++) {
+ expect(formatRuntime(sec), `${sec}s`).not.toBe("0m");
+ }
+ });
+});
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 9f556899..2e2a0958 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -96,10 +96,16 @@ export function formatDuration(sec: number | null): string {
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
-/** Compact human runtime ("1h 30m", "45m") from seconds — used for Plex movie/episode lengths.
- * Empty string for a missing/zero duration (nothing to show). */
+/** Compact human runtime ("1h 30m", "45m", "45s") from seconds. Formats BOTH a single item's
+ * length (Plex movies/episodes) and an aggregate total (playlist playtime, on the YouTube and the
+ * Plex side alike) — it is the one renderer for a playlist total, so a total never appears in two
+ * shapes on one screen. Empty string for a missing/zero duration (nothing to show).
+ * The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land
+ * between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0m" made a
+ * non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */
export function formatRuntime(sec?: number | null): string {
if (!sec) return "";
+ if (sec < 60) return `${Math.floor(sec)}s`;
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return h ? `${h}h ${m}m` : `${m}m`;