fix(playlists): "+N without length" counts every video the sum left out

The label said "without length" but the count held only live videos, so
a video not fetched yet, or gone before it was, was left out of the sum
and named nowhere. The server's `Video.without_length` and the client's
`isWithoutLength` are now one rule (no length, whatever the live
status), and the field, the key and the helpers are named for what they
count. The comments that still quoted "+N live" say the current copy.
This commit is contained in:
2026-11-08 00:49:21 +01:00
parent 55cbbfb6e4
commit e8b9bdd7c1
17 changed files with 97 additions and 97 deletions
+9 -9
View File
@@ -17,7 +17,6 @@ from sqlalchemy import (
String, String,
Text, Text,
UniqueConstraint, UniqueConstraint,
and_,
case, case,
func, func,
literal_column, literal_column,
@@ -125,7 +124,7 @@ LIVE_OR_UPCOMING = ("live", "upcoming")
# the stream has run (measured on prod, 2026-11-06: 122,962,914 s), and one such video made a Watch # the stream has run (measured on prod, 2026-11-06: 122,962,914 s), and one such video made a Watch
# later total read 44,073 hours. YouTube caps an upload at 12 hours, so nothing real is lost. Such a # later total read 44,073 hours. YouTube caps an upload at 12 hours, so nothing real is lost. Such a
# length is unknown wherever a length is used: the API sends none (`Video.length_seconds`), sums leave # length is unknown wherever a length is used: the API sends none (`Video.length_seconds`), sums leave
# it out (a playlist's counts it as `live_without_length`), and the duration filter, sort and Home budget never # it out (a playlist's counts it as `without_length`), and the duration filter, sort and Home budget never
# match it. The raw `duration_seconds` stays for what it does prove — that the video is not a Short — # match it. The raw `duration_seconds` stays for what it does prove — that the video is not a Short —
# and for the jobs that ask whether YouTube has answered yet (test_video_length holds who reads it). # and for the jobs that ask whether YouTube has answered yet (test_video_length holds who reads it).
LENGTH_MAX_S = 24 * 3600 LENGTH_MAX_S = 24 * 3600
@@ -239,15 +238,16 @@ class Video(Base, TimestampMixin):
) )
@hybrid_property @hybrid_property
def live_without_length(self) -> bool: def without_length(self) -> bool:
"""A live video (on air, upcoming, a premiere or an archive) whose length is unknown: what a """A video whose length is unknown — live, an archive past a day, or not fetched yet: what a
total leaves out and names as "+N live".""" sum of lengths leaves out and names as "+N without length". Every such video, so the count
return self.live_status != "none" and self.length_seconds is None and the sum never disagree about what was left out."""
return self.length_seconds is None
@live_without_length.inplace.expression @without_length.inplace.expression
@classmethod @classmethod
def _live_without_length_expression(cls) -> ColumnElement[bool]: def _without_length_expression(cls) -> ColumnElement[bool]:
return and_(cls.live_status != "none", cls.length_seconds.is_(None)) return cls.length_seconds.is_(None)
# Weighted full-text search document (DB-generated, never written by the app): title (A) > # Weighted full-text search document (DB-generated, never written by the app): title (A) >
# creator keywords + search queries (B) > truncated description (C). ts_rank honours the # creator keywords + search queries (B) > truncated description (C). ts_rank honours the
+10 -10
View File
@@ -147,15 +147,15 @@ def item_counts(db: Session, ids: list[int]) -> dict[int, int]:
def durations(db: Session, ids: list[int]) -> dict[int, tuple[int, int]]: def durations(db: Session, ids: list[int]) -> dict[int, tuple[int, int]]:
"""Each playlist's running time — the sum of its videos' known lengths — and how many live videos """Each playlist's running time — the sum of its videos' known lengths — and how many videos
it cannot hold (`Video.live_without_length`).""" it left out for having none (`Video.without_length`)."""
return { return {
pid: (int(total or 0), int(live or 0)) pid: (int(total or 0), int(left_out or 0))
for pid, total, live in db.execute( for pid, total, left_out in db.execute(
select( select(
PlaylistItem.playlist_id, PlaylistItem.playlist_id,
func.coalesce(func.sum(Video.length_seconds), 0), func.coalesce(func.sum(Video.length_seconds), 0),
func.count().filter(Video.live_without_length), func.count().filter(Video.without_length),
) )
.join(Video, Video.id == PlaylistItem.video_id) .join(Video, Video.id == PlaylistItem.video_id)
.where(PlaylistItem.playlist_id.in_(ids)) .where(PlaylistItem.playlist_id.in_(ids))
@@ -197,14 +197,14 @@ def covers(db: Session, ids: list[int]) -> dict[int, str | None]:
def playlist_totals(db: Session, playlist_id: int) -> tuple[int, int, int, str | None]: def playlist_totals(db: Session, playlist_id: int) -> tuple[int, int, int, str | None]:
"""One playlist's item count, running time, live videos the running time cannot hold """One playlist's item count, running time, the videos the running time left out for having no
(`Video.live_without_length`) and cover. An outer join, so an empty list still answers length (`Video.without_length`) and cover. An outer join, so an empty list still answers
(0, 0, 0, None).""" (0, 0, 0, None)."""
count, total, live = db.execute( count, total, left_out = db.execute(
select( select(
func.count(PlaylistItem.id), func.count(PlaylistItem.id),
func.coalesce(func.sum(Video.length_seconds), 0), func.coalesce(func.sum(Video.length_seconds), 0),
func.count().filter(Video.live_without_length), func.count().filter(Video.without_length),
) )
.select_from(PlaylistItem) .select_from(PlaylistItem)
.outerjoin(Video, Video.id == PlaylistItem.video_id) .outerjoin(Video, Video.id == PlaylistItem.video_id)
@@ -217,7 +217,7 @@ def playlist_totals(db: Session, playlist_id: int) -> tuple[int, int, int, str |
.order_by(PlaylistItem.position) .order_by(PlaylistItem.position)
.limit(1) .limit(1)
) )
return count or 0, total or 0, live or 0, cover return count or 0, total or 0, left_out or 0, cover
def playlist_rows(db: Session, user: User, playlist_id: int) -> list: def playlist_rows(db: Session, user: User, playlist_id: int) -> list:
+6 -6
View File
@@ -99,9 +99,9 @@ def _summary(
has_video: bool | None = None, has_video: bool | None = None,
cover: str | None = None, cover: str | None = None,
) -> dict: ) -> dict:
"""`duration` is `queries.durations`'s pair: the known running time, and the live videos it """`duration` is `queries.durations`'s pair: the known running time, and the videos it left out
cannot hold — the "+N live" beside it.""" for having no length — the "+N without length" beside it."""
total_duration, live = duration total_duration, left_out = duration
out = { out = {
"id": pl.id, "id": pl.id,
"name": pl.name, "name": pl.name,
@@ -111,7 +111,7 @@ def _summary(
"dirty": pl.dirty, "dirty": pl.dirty,
"item_count": count, "item_count": count,
"total_duration_seconds": total_duration, "total_duration_seconds": total_duration,
"live_without_length": live, "without_length": left_out,
"cover_thumbnail": cover, "cover_thumbnail": cover,
# When this list last CHANGED (rename, add/remove/move/reorder) — the rail's # When this list last CHANGED (rename, add/remove/move/reorder) — the rail's
# "recently changed first" sort reads it. See `service.touch` for why it isn't automatic. # "recently changed first" sort reads it. See `service.touch` for why it isn't automatic.
@@ -369,8 +369,8 @@ def rename_playlist(
raise _refused(400, "name_required") raise _refused(400, "name_required")
service.rename_playlist(db, pl, name) service.rename_playlist(db, pl, name)
db.commit() db.commit()
count, total, live, cover = queries.playlist_totals(db, pl.id) count, total, left_out, cover = queries.playlist_totals(db, pl.id)
return _summary(pl, count, (total, live), cover=cover) return _summary(pl, count, (total, left_out), cover=cover)
@router.delete("/{playlist_id}") @router.delete("/{playlist_id}")
+6 -6
View File
@@ -2,7 +2,7 @@
Measured on prod (2026-11-06): two 24/7 streams' archives report 122,962,914 s and 34,762,459 s, and the Measured on prod (2026-11-06): two 24/7 streams' archives report 122,962,914 s and 34,762,459 s, and the
owner's Watch later total read "44,073 hr 55 min". Such a video now has no length on any API answer, the owner's Watch later total read "44,073 hr 55 min". Such a video now has no length on any API answer, the
totals leave it out and count it as `live_without_length`, and the duration filter and sort pass it over totals leave it out (a playlist's counts it as `without_length`), and the duration filter and sort pass it over
like a video whose length is not fetched yet. An AST check holds who may still read the raw column. like a video whose length is not fetched yet. An AST check holds who may still read the raw column.
DB lane (the AST check needs none). DB lane (the AST check needs none).
""" """
@@ -30,7 +30,7 @@ def _seed(db, user):
("day", LENGTH_MAX_S, "was_live"), # exactly a day still is a length ("day", LENGTH_MAX_S, "was_live"), # exactly a day still is a length
("archive", YEARS, "was_live"), ("archive", YEARS, "was_live"),
("onair", None, "live"), ("onair", None, "live"),
("unfetched", None, "none"), # not live: not counted as "+N live" ("unfetched", None, "none"), # not live, but left out of the sum: counted all the same
] ]
for i, (vid, seconds, live) in enumerate(rows): for i, (vid, seconds, live) in enumerate(rows):
db.add( db.add(
@@ -121,7 +121,7 @@ def test_an_off_catalog_archive_has_no_length_either(db, make_user, as_user, mon
assert client.get("/api/videos/offshort").json()["duration_seconds"] == HOUR assert client.get("/api/videos/offshort").json()["duration_seconds"] == HOUR
def test_the_totals_leave_it_out_and_count_the_live_ones(db, make_user, as_user): def test_the_totals_leave_it_out_and_count_what_they_left_out(db, make_user, as_user):
user = make_user() user = make_user()
pl = _seed(db, user) pl = _seed(db, user)
client = as_user(user) client = as_user(user)
@@ -130,19 +130,19 @@ def test_the_totals_leave_it_out_and_count_the_live_ones(db, make_user, as_user)
assert listed.status_code == 200 assert listed.status_code == 200
(summary,) = [p for p in listed.json() if p["id"] == pl.id] (summary,) = [p for p in listed.json() if p["id"] == pl.id]
assert summary["total_duration_seconds"] == KNOWN_SUM assert summary["total_duration_seconds"] == KNOWN_SUM
assert summary["live_without_length"] == 2 # the archive and the one on air assert summary["without_length"] == 3 # the archive, the one on air, the unfetched one
renamed = client.patch(f"/api/playlists/{pl.id}", json={"name": "Later 2"}) renamed = client.patch(f"/api/playlists/{pl.id}", json={"name": "Later 2"})
assert renamed.status_code == 200 assert renamed.status_code == 200
assert renamed.json()["total_duration_seconds"] == KNOWN_SUM assert renamed.json()["total_duration_seconds"] == KNOWN_SUM
assert renamed.json()["live_without_length"] == 2 assert renamed.json()["without_length"] == 3
channels = client.get("/api/channels") channels = client.get("/api/channels")
assert channels.status_code == 200 assert channels.status_code == 200
(ch,) = channels.json() (ch,) = channels.json()
assert ch["total_duration_seconds"] == KNOWN_SUM assert ch["total_duration_seconds"] == KNOWN_SUM
# The channel table shows the sum alone (the count did not fit its column), so it is not sent. # The channel table shows the sum alone (the count did not fit its column), so it is not sent.
assert "live_without_length" not in ch assert "without_length" not in ch and "live_without_length" not in ch
def test_the_duration_filter_and_sort_pass_it_over(db, make_user, as_user): def test_the_duration_filter_and_sort_pass_it_over(db, make_user, as_user):
@@ -1,21 +0,0 @@
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");
});
});
@@ -1,18 +0,0 @@
// 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 without length": `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(" ");
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import type { TFunction } from "i18next";
import { isWithoutLength, withLeftOutCount } from "./withoutLength";
const t = ((key: string, o: { count: number }) => `${key}:${o.count}`) as unknown as TFunction;
describe("a total and the videos it cannot hold", () => {
it("counts every video the server sent no length for", () => {
expect(isWithoutLength({ duration_seconds: null })).toBe(true);
expect(isWithoutLength({ duration_seconds: 5400 })).toBe(false);
expect(isWithoutLength({ duration_seconds: 0 })).toBe(false);
});
it("names them beside the sum, or alone when nothing summed", () => {
expect(withLeftOutCount("12 hr 5 min", 2, t)).toBe("12 hr 5 min card.withoutLength:2");
expect(withLeftOutCount("", 1, t)).toBe("card.withoutLength:1");
expect(withLeftOutCount("12 hr 5 min", 0, t)).toBe("12 hr 5 min");
});
});
@@ -0,0 +1,19 @@
// A total of video lengths and the 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: every video with no length — live, an archive, or not fetched yet — as the
// server's `Video.without_length` counts it for the totals it sums itself. The two rules are the same,
// so the count and the sum never disagree about what was left out.
import type { TFunction } from "i18next";
import type { Video } from "../../lib/api/feed";
export function isWithoutLength(v: Pick<Video, "duration_seconds">): boolean {
return v.duration_seconds == null;
}
/** "12 hr 5 min +2 without length": `total` is the formatted sum ("" when nothing summed), `leftOut`
* the count it left out. Empty when both are. */
export function withLeftOutCount(total: string, leftOut: number, t: TFunction): string {
const suffix = leftOut > 0 ? t("card.withoutLength", { count: leftOut }) : "";
return [total, suffix].filter(Boolean).join(" ");
}
+2 -2
View File
@@ -16,8 +16,8 @@
"restart": "Restart", "restart": "Restart",
"play": "Play", "play": "Play",
"stream": "stream", "stream": "stream",
"liveWithoutLength_one": "+{{count}} without length", "withoutLength_one": "+{{count}} without length",
"liveWithoutLength_other": "+{{count}} without length", "withoutLength_other": "+{{count}} without length",
"views": "views", "views": "views",
"info": "Details", "info": "Details",
"noDescription": "No description", "noDescription": "No description",
+2 -2
View File
@@ -16,8 +16,8 @@
"restart": "Elölről", "restart": "Elölről",
"play": "Lejátszás", "play": "Lejátszás",
"stream": "közvetítés", "stream": "közvetítés",
"liveWithoutLength_one": "+{{count}} hossz nélkül", "withoutLength_one": "+{{count}} hossz nélkül",
"liveWithoutLength_other": "+{{count}} hossz nélkül", "withoutLength_other": "+{{count}} hossz nélkül",
"views": "megtekintés", "views": "megtekintés",
"info": "Részletek", "info": "Részletek",
"noDescription": "Nincs leírás", "noDescription": "Nincs leírás",
+2 -2
View File
@@ -17,8 +17,8 @@ export interface Playlist {
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
item_count: number; item_count: number;
total_duration_seconds: number; total_duration_seconds: number;
/** Live videos the total cannot hold (no length): the "+N without length" beside it. */ /** The videos the total left out for having no length: the "+N without length" beside it. */
live_without_length: number; without_length: number;
cover_thumbnail: string | null; cover_thumbnail: string | null;
updated_at: string; // ISO; when the list last CHANGED (rename, add/remove/move/reorder) 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> has_video?: boolean; // only set when listed with ?contains=<video_id>
@@ -192,10 +192,10 @@ describe("buildChannelColumns — Added", () => {
const length = cols.find((c) => c.key === "length"); const length = cols.find((c) => c.key === "length");
const row = { const row = {
...ch({ total_duration_seconds: 7200 }), ...ch({ total_duration_seconds: 7200 }),
live_without_length: 2, without_length: 2,
} as ManagedChannel; } as ManagedChannel;
const { container } = render(<>{length!.render(row)}</>); const { container } = render(<>{length!.render(row)}</>);
expect(container.textContent).not.toContain("card.liveWithoutLength"); expect(container.textContent).not.toContain("card.withoutLength");
expect(container.querySelectorAll("span")).toHaveLength(1); expect(container.querySelectorAll("span")).toHaveLength(1);
}); });
}); });
@@ -990,24 +990,25 @@ describe("Playlists · the header", () => {
) )
).textContent; ).textContent;
it("sums what is known, and says nothing about a runtime nothing sums to", async () => { it("sums what is known, and names what it left out beside it", async () => {
// Not fetched yet, and not live: left out of the sum all the same, so it is named.
mount({ 1: [vid("A", 0), untimed("B")] }); mount({ 1: [vid("A", 0), untimed("B")] });
expect(await meta()).toBe("playlists.itemCount · 50 min"); expect(await meta()).toBe("playlists.itemCount · 50 min card.withoutLength");
cleanup(); cleanup();
mount({ 1: [untimed("C")] }); mount({ 1: [vid("A", 0)] });
expect(await meta()).toBe("playlists.itemCount"); expect(await meta()).toBe("playlists.itemCount · 50 min");
}); });
it("names the live videos the sum cannot hold beside it", async () => { it("names a live video the sum cannot hold, alone when nothing summed", async () => {
// A 24/7 stream's archive comes with no length (the server's rule): it is named, not summed. // 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; const archive = (id: string) => ({ ...untimed(id), live_status: "was_live" }) as Video;
mount({ 1: [vid("A", 0), archive("B")] }); mount({ 1: [vid("A", 0), archive("B")] });
expect(await meta()).toBe("playlists.itemCount · 50 min card.liveWithoutLength"); expect(await meta()).toBe("playlists.itemCount · 50 min card.withoutLength");
cleanup(); cleanup();
mount({ 1: [archive("C")] }); mount({ 1: [archive("C")] });
expect(await meta()).toBe("playlists.itemCount · card.liveWithoutLength"); expect(await meta()).toBe("playlists.itemCount · card.withoutLength");
}); });
it("offers the view controls only once there is more than one row to arrange", async () => { it("offers the view controls only once there is more than one row to arrange", async () => {
+6 -6
View File
@@ -68,7 +68,7 @@ import {
playlistViewParamUpdates, playlistViewParamUpdates,
} from "../../lib/playlistsUrlState"; } from "../../lib/playlistsUrlState";
import { formatRuntime } from "../../lib/format"; import { formatRuntime } from "../../lib/format";
import { isLiveWithoutLength, withLiveCount } from "../../features/video/liveWithoutLength"; import { isWithoutLength, withLeftOutCount } from "../../features/video/withoutLength";
import { applyView, sameOrder } from "../../lib/playlistView"; import { applyView, sameOrder } from "../../lib/playlistView";
import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer"; import { useOpenLinkedPlayer } from "../../features/player/useOpenLinkedPlayer";
const PlayerModal = lazy(() => import("../../features/player/PlayerModal")); const PlayerModal = lazy(() => import("../../features/player/PlayerModal"));
@@ -566,13 +566,13 @@ function PlaylistDetail() {
}, [items]); }, [items]);
// Total playtime of the whole playlist (sums every item, matching the itemCount beside it — not // 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, and a live one is named // the filtered `shown` set). An item with an unknown duration is left out of the sum and named
// beside the sum instead ("+2 without length"); hidden when there is neither. // beside it instead ("+2 without length"); hidden when there is neither.
const totalSeconds = useMemo( const totalSeconds = useMemo(
() => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 0), () => items.reduce((acc, v) => acc + (v.duration_seconds ?? 0), 0),
[items], [items],
); );
const liveCount = useMemo(() => items.filter(isLiveWithoutLength).length, [items]); const leftOutCount = useMemo(() => items.filter(isWithoutLength).length, [items]);
// The row's "…" opens the row menu under itself. Which row's menu is open is part of the // 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. // cell (aria-expanded), so the columns rebuild when it changes — a cheap array of closures.
@@ -1622,8 +1622,8 @@ function PlaylistDetail() {
> >
<span className="text-xs text-muted"> <span className="text-xs text-muted">
{t("playlists.itemCount", { count: items.length })} {t("playlists.itemCount", { count: items.length })}
{(totalSeconds > 0 || liveCount > 0) && {(totalSeconds > 0 || leftOutCount > 0) &&
` · ${withLiveCount(formatRuntime(totalSeconds, lang), liveCount, t)}`} ` · ${withLeftOutCount(formatRuntime(totalSeconds, lang), leftOutCount, t)}`}
{filterActive && {filterActive &&
` · ${t("playlists.filteredCount", { count: shown.length })}`} ` · ${t("playlists.filteredCount", { count: shown.length })}`}
</span> </span>
@@ -75,7 +75,7 @@ const pl = (id: number, name: string) =>
yt_playlist_id: null, yt_playlist_id: null,
item_count: 1, item_count: 1,
total_duration_seconds: 0, total_duration_seconds: 0,
live_without_length: 0, without_length: 0,
cover_thumbnail: null, cover_thumbnail: null,
dirty: false, dirty: false,
}) as unknown as Playlist; }) as unknown as Playlist;
@@ -80,7 +80,7 @@ const alpha = {
source: "local", source: "local",
item_count: 3, item_count: 3,
total_duration_seconds: 0, total_duration_seconds: 0,
live_without_length: 0, without_length: 0,
cover_thumbnail: null, cover_thumbnail: null,
dirty: false, dirty: false,
} as unknown as Playlist; } as unknown as Playlist;
@@ -8,7 +8,7 @@ import { YouTubeIcon } from "../../lib/brandIcons";
import { api, type Playlist } from "../../lib/api"; import { api, type Playlist } from "../../lib/api";
import { compareText } from "../../lib/collate"; import { compareText } from "../../lib/collate";
import { formatRuntime } from "../../lib/format"; import { formatRuntime } from "../../lib/format";
import { withLiveCount } from "../../features/video/liveWithoutLength"; import { withLeftOutCount } from "../../features/video/withoutLength";
import { msg, notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { LS } from "../../lib/storage"; import { LS } from "../../lib/storage";
@@ -379,10 +379,10 @@ export default function PlaylistsRail() {
</div> </div>
<div className="text-2xs text-muted"> <div className="text-2xs text-muted">
{t("playlists.itemCount", { count: pl.item_count })} {t("playlists.itemCount", { count: pl.item_count })}
{(pl.total_duration_seconds > 0 || pl.live_without_length > 0) && {(pl.total_duration_seconds > 0 || pl.without_length > 0) &&
` · ${withLiveCount( ` · ${withLeftOutCount(
formatRuntime(pl.total_duration_seconds, lang), formatRuntime(pl.total_duration_seconds, lang),
pl.live_without_length, pl.without_length,
t, t,
)}`} )}`}
</div> </div>