fix(catalog): a 24/7 stream archive's years-long length is unknown, not summed or sorted by

YouTube reports a 24/7 stream's archive with the years it has run (prod: 122,962,914 s), and one
such video made the Watch later total read 44,073 hours. A length past a day is now no length
(`Video.length_seconds`, LENGTH_MAX_S): the API sends none, the playlist and channel totals
leave it out and count it as `live_without_length` ("+N live" beside the sum), and the duration
filter, sort and Home budget never match it. Migration 0117 moves the duration sort indexes onto
the length expression. An AST test holds who may still read the raw column.

The migration 0110 test now ends its read before the way down: 0117 builds an index
CONCURRENTLY there, which waited for that open transaction forever.
This commit is contained in:
2026-11-07 02:20:03 +01:00
parent 1bf3d6a592
commit 5b846136ad
26 changed files with 446 additions and 39 deletions
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import type { TFunction } from "i18next";
import { isLiveWithoutLength, withLiveCount } from "./liveWithoutLength";
const t = ((key: string, o: { count: number }) => `${key}:${o.count}`) as unknown as TFunction;
describe("a total and the live videos it cannot hold", () => {
it("counts a live video only when the server sent no length", () => {
expect(isLiveWithoutLength({ duration_seconds: null, live_status: "was_live" })).toBe(true);
expect(isLiveWithoutLength({ duration_seconds: null, live_status: "live" })).toBe(true);
expect(isLiveWithoutLength({ duration_seconds: 5400, live_status: "was_live" })).toBe(false);
// Not fetched yet, and not live: left out of the sum like before, not named as live.
expect(isLiveWithoutLength({ duration_seconds: null, live_status: "none" })).toBe(false);
});
it("names them beside the sum, or alone when nothing summed", () => {
expect(withLiveCount("12 hr 5 min", 2, t)).toBe("12 hr 5 min card.liveWithoutLength:2");
expect(withLiveCount("", 1, t)).toBe("card.liveWithoutLength:1");
expect(withLiveCount("12 hr 5 min", 0, t)).toBe("12 hr 5 min");
});
});
@@ -0,0 +1,18 @@
// A total of video lengths and the live videos it cannot hold. The server decides what a length is
// (a YouTube "length" past a day is a 24/7 stream's archive reporting the years it has run, and the
// API sends none for it: `catalog.models.LENGTH_MAX_S`), so this side keeps no copy of that limit. It
// only names what a sum left out: a live video — on air, upcoming, a premiere or an archive — with no
// length, as the server's `Video.live_without_length` counts it for the totals it sums itself.
import type { TFunction } from "i18next";
import type { Video } from "../../lib/api/feed";
export function isLiveWithoutLength(v: Pick<Video, "duration_seconds" | "live_status">): boolean {
return v.duration_seconds == null && v.live_status !== "none";
}
/** "12 hr 5 min +2 live": `total` is the formatted sum ("" when nothing summed), `live` the count
* it left out. Empty when both are. */
export function withLiveCount(total: string, live: number, t: TFunction): string {
const suffix = live > 0 ? t("card.liveWithoutLength", { count: live }) : "";
return [total, suffix].filter(Boolean).join(" ");
}
+2
View File
@@ -16,6 +16,8 @@
"restart": "Restart",
"play": "Play",
"stream": "stream",
"liveWithoutLength_one": "+{{count}} live",
"liveWithoutLength_other": "+{{count}} live",
"views": "views",
"info": "Details",
"noDescription": "No description",
+2
View File
@@ -16,6 +16,8 @@
"restart": "Elölről",
"play": "Lejátszás",
"stream": "közvetítés",
"liveWithoutLength_one": "+{{count}} élő",
"liveWithoutLength_other": "+{{count}} élő",
"views": "megtekintés",
"info": "Részletek",
"noDescription": "Nincs leírás",
+2
View File
@@ -77,6 +77,8 @@ export interface ManagedChannel {
* subscribe made here. */
added_at: string;
total_duration_seconds: number;
/** Live videos the total cannot hold (no length): the "+N live" beside it. */
live_without_length: number;
priority: number;
hidden: boolean;
deep_requested: boolean;
+2
View File
@@ -17,6 +17,8 @@ export interface Playlist {
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
item_count: number;
total_duration_seconds: number;
/** Live videos the total cannot hold (no length): the "+N live" beside it. */
live_without_length: number;
cover_thumbnail: string | null;
updated_at: string; // ISO; when the list last CHANGED (rename, add/remove/move/reorder)
has_video?: boolean; // only set when listed with ?contains=<video_id>
@@ -28,6 +28,7 @@ const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
last_video_at: null,
added_at: "2026-06-10T08:00:00+00:00",
total_duration_seconds: 0,
live_without_length: 0,
priority: 0,
hidden: false,
deep_requested: false,
@@ -11,6 +11,7 @@ import { ChannelTagsControl, type SuggestedTag } from "../../features/channel/Ch
import { StoredCell } from "./channelCells";
import { isFullySynced } from "./channelSync";
import { RelativeTime } from "../../components/ui/RelativeTime";
import { withLiveCount } from "../../features/video/liveWithoutLength";
import type { FilterChip } from "./TagFilterChips";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
@@ -236,6 +237,10 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
render: (c) => (
<span className="text-muted tabular-nums">
{formatTotalHours(c.total_duration_seconds, lang)}
{/* Its own line: the column is sized for the sum alone. */}
{c.live_without_length > 0 && (
<span className="block text-2xs">{withLiveCount("", c.live_without_length, t)}</span>
)}
</span>
),
},
@@ -61,6 +61,7 @@ const ch = (over: Partial<ManagedChannel> = {}): ManagedChannel => ({
last_video_at: null,
added_at: "2026-06-10T08:00:00+00:00",
total_duration_seconds: 0,
live_without_length: 0,
priority: 0,
hidden: false,
deep_requested: false,
@@ -134,6 +134,7 @@ const vid = (id: string, position_seconds: number): Video =>
status: "new",
position_seconds,
duration_seconds: 3000,
live_status: "none",
published_at: "2026-01-01T00:00:00Z",
watch_url: `https://www.youtube.com/watch?v=${id}`,
}) as unknown as Video;
@@ -998,6 +999,17 @@ describe("Playlists · the header", () => {
expect(await meta()).toBe("playlists.itemCount");
});
it("names the live videos the sum cannot hold beside it", async () => {
// A 24/7 stream's archive comes with no length (the server's rule): it is named, not summed.
const archive = (id: string) => ({ ...untimed(id), live_status: "was_live" }) as Video;
mount({ 1: [vid("A", 0), archive("B")] });
expect(await meta()).toBe("playlists.itemCount · 50 min card.liveWithoutLength");
cleanup();
mount({ 1: [archive("C")] });
expect(await meta()).toBe("playlists.itemCount · card.liveWithoutLength");
});
it("offers the view controls only once there is more than one row to arrange", async () => {
mount({ 1: [vid("A", 0)] });
await screen.findByText("Video A");
+6 -2
View File
@@ -68,6 +68,7 @@ import {
playlistViewParamUpdates,
} from "../../lib/playlistsUrlState";
import { formatRuntime } from "../../lib/format";
import { isLiveWithoutLength, withLiveCount } from "../../features/video/liveWithoutLength";
import { applyView, sameOrder } from "../../lib/playlistView";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
@@ -565,11 +566,13 @@ function PlaylistDetail() {
}, [items]);
// Total playtime of the whole playlist (sums every item, matching the itemCount beside it — not
// the filtered `shown` set). Items with an unknown duration count as 0; hidden when nothing sums.
// the filtered `shown` set). Items with an unknown duration count as 0, and a live one is named
// beside the sum instead ("+2 live"); hidden when there is neither.
const totalSeconds = useMemo(
() => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 0),
[items],
);
const liveCount = useMemo(() => items.filter(isLiveWithoutLength).length, [items]);
// The row's "…" opens the row menu under itself. Which row's menu is open is part of the
// cell (aria-expanded), so the columns rebuild when it changes — a cheap array of closures.
@@ -1619,7 +1622,8 @@ function PlaylistDetail() {
>
<span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })}
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
{(totalSeconds > 0 || liveCount > 0) &&
` · ${withLiveCount(formatRuntime(totalSeconds, lang), liveCount, t)}`}
{filterActive &&
` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span>
@@ -75,6 +75,7 @@ const pl = (id: number, name: string) =>
yt_playlist_id: null,
item_count: 1,
total_duration_seconds: 0,
live_without_length: 0,
cover_thumbnail: null,
dirty: false,
}) as unknown as Playlist;
@@ -80,6 +80,7 @@ const alpha = {
source: "local",
item_count: 3,
total_duration_seconds: 0,
live_without_length: 0,
cover_thumbnail: null,
dirty: false,
} as unknown as Playlist;
@@ -8,6 +8,7 @@ import { YouTubeIcon } from "../../lib/brandIcons";
import { api, type Playlist } from "../../lib/api";
import { compareText } from "../../lib/collate";
import { formatRuntime } from "../../lib/format";
import { withLiveCount } from "../../features/video/liveWithoutLength";
import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { LS } from "../../lib/storage";
@@ -378,8 +379,12 @@ export default function PlaylistsRail() {
</div>
<div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })}
{pl.total_duration_seconds > 0 &&
` · ${formatRuntime(pl.total_duration_seconds, lang)}`}
{(pl.total_duration_seconds > 0 || pl.live_without_length > 0) &&
` · ${withLiveCount(
formatRuntime(pl.total_duration_seconds, lang),
pl.live_without_length,
t,
)}`}
</div>
</div>
</Pressable>