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:
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user