From 55cbbfb6e430e8acb1070a398db45f7ac2bcc421 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 8 Nov 2026 00:10:28 +0100 Subject: [PATCH] fix(channels): the Length column shows the sum alone The "+N without length" line did not fit the 4.5 rem column (88 px of text in a 56 px cell) and spilled into Tags. The owner wants no such line there: the cell shows the summed hours, and the channel list no longer sends a count nothing reads. A playlist's total keeps it. --- backend/app/catalog/models.py | 2 +- backend/app/catalog/queries.py | 12 ++++-------- backend/app/routes/channels.py | 4 +--- backend/tests/test_video_length.py | 3 ++- docs/DESIGN.md | 1 + frontend/src/lib/api/channels.ts | 3 +-- .../modules/channels/channelColumns.dom.test.tsx | 14 +++++++++++++- frontend/src/modules/channels/channelColumns.tsx | 7 ++----- .../channels/useChannelActions.dom.test.tsx | 1 - 9 files changed, 25 insertions(+), 22 deletions(-) diff --git a/backend/app/catalog/models.py b/backend/app/catalog/models.py index daf44bcb..5d1d7dc3 100644 --- a/backend/app/catalog/models.py +++ b/backend/app/catalog/models.py @@ -125,7 +125,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 # 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 -# it out and count it as `live_without_length`, and the duration filter, sort and Home budget never +# it out (a playlist's counts it as `live_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 — # and for the jobs that ask whether YouTube has answered yet (test_video_length holds who reads it). LENGTH_MAX_S = 24 * 3600 diff --git a/backend/app/catalog/queries.py b/backend/app/catalog/queries.py index 1955f458..131f08ba 100644 --- a/backend/app/catalog/queries.py +++ b/backend/app/catalog/queries.py @@ -44,8 +44,8 @@ def channel_titles(db: Session, ids: set[str]) -> dict[str, str]: def channel_aggregates(channel_ids: list[str]) -> Select: - """Per-channel stored count, last upload, total duration and the live videos the total cannot - hold (`Video.live_without_length`), in one grouped pass. Every column it + """Per-channel stored count, last upload and total duration (the lengths it knows: a video with + none is left out, `Video.length_seconds`), in one grouped pass. Every column it reads is in `ix_videos_channel_pub_cover` (migration 0095), so Postgres answers it from the index alone; a test holds that (test_channel_list_index_only).""" return ( @@ -54,18 +54,14 @@ def channel_aggregates(channel_ids: list[str]) -> Select: func.count(Video.id), func.max(Video.published_at), func.coalesce(func.sum(Video.length_seconds), 0), - func.count().filter(Video.live_without_length), ) .where(Video.channel_id.in_(channel_ids)) .group_by(Video.channel_id) ) -def aggregates( - db: Session, channel_ids: list[str] -) -> list[tuple[str, int, datetime | None, int, int]]: - """`channel_aggregates`, run: (channel id, stored videos, last upload, total seconds, live - videos without a length).""" +def aggregates(db: Session, channel_ids: list[str]) -> list[tuple[str, int, datetime | None, int]]: + """`channel_aggregates`, run: (channel id, stored videos, last upload, total seconds).""" if not channel_ids: return [] return list(db.execute(channel_aggregates(channel_ids)).tuples()) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 33e80a47..beedfa9e 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -46,12 +46,11 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_ # computed here — the manager table dropped its N/S/L column.) channel_ids = [c.id for _, c in rows] agg: dict[str, dict] = {} - for cid, n, last_up, total_dur, live in queries.aggregates(db, channel_ids): + for cid, n, last_up, total_dur in queries.aggregates(db, channel_ids): agg[cid] = { "stored": n, "last_video_at": iso(last_up), "total_duration_seconds": int(total_dur or 0), - "live_without_length": live, } # Channels already in the *global* deep-backfill queue — i.e. at least one user (any @@ -69,7 +68,6 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_ "stored_videos": (agg.get(ch.id) or {}).get("stored", 0), "last_video_at": (agg.get(ch.id) or {}).get("last_video_at"), "total_duration_seconds": (agg.get(ch.id) or {}).get("total_duration_seconds", 0), - "live_without_length": (agg.get(ch.id) or {}).get("live_without_length", 0), "priority": sub.priority, "hidden": sub.hidden, "deep_requested": sub.deep_requested, diff --git a/backend/tests/test_video_length.py b/backend/tests/test_video_length.py index 5dbdda47..9bb73298 100644 --- a/backend/tests/test_video_length.py +++ b/backend/tests/test_video_length.py @@ -141,7 +141,8 @@ def test_the_totals_leave_it_out_and_count_the_live_ones(db, make_user, as_user) assert channels.status_code == 200 (ch,) = channels.json() assert ch["total_duration_seconds"] == KNOWN_SUM - assert ch["live_without_length"] == 2 + # 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 def test_the_duration_filter_and_sort_pass_it_over(db, make_user, as_user): diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1eb9f52c..27a63d6c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -877,3 +877,4 @@ of a full-width panel and nothing on the right. | 2026-11-07 | Text over the picture sits on a dark panel (owner a) | The player's loading, stall and error text, the subtitle notice and the track OSD sit on one dark panel (`OVER_VIDEO`, black 75 %, a white 15 % edge) | A shadow behind the letters; bare text as before | Bare white or red text was unreadable over a bright frame (seen live); the panel is the style the notices already had | | 2026-11-07 | The panel over the picture is darker, its error the media red (owner a) | `OVER_VIDEO` is black 85 % under `media-scope`: an error on it is the media red in both themes | Only the red changed (3.75:1 on a white frame); black 90 % | A light theme's red was 1.6:1 on the 75 % panel over a white frame; now 5.47:1 at worst, white text too | | 2026-11-07 | A total's left-out videos are "without length" (owner b) | A summed length names what it left out as "+N without length" / "+N hossz nélkül" | "+N live" as before; "+N stream" | The count holds archives and upcoming streams too, which a card calls a stream or upcoming: "live" there named only one of them (One name per feature) | +| 2026-11-08 | The Channels table's Length is the sum alone (owner) | The table's Length cell shows the summed hours only; the "+N without length" stays on a playlist's total | The line wrapped in the column; the column widened to 6.5 rem | The 4.5 rem column is sized for the sum: the longer line spilled into Tags (88 px in 56), and the owner wants no such line there | diff --git a/frontend/src/lib/api/channels.ts b/frontend/src/lib/api/channels.ts index ce1879cc..9be34b85 100644 --- a/frontend/src/lib/api/channels.ts +++ b/frontend/src/lib/api/channels.ts @@ -76,9 +76,8 @@ export interface ManagedChannel { /** When the follow reached this list: the subscription import that first saw it, or a * subscribe made here. */ added_at: string; + /** The lengths it knows; a video with none (a live one, a 24/7 archive) is left out. */ total_duration_seconds: number; - /** Live videos the total cannot hold (no length): the "+N without length" beside it. */ - live_without_length: number; priority: number; hidden: boolean; deep_requested: boolean; diff --git a/frontend/src/modules/channels/channelColumns.dom.test.tsx b/frontend/src/modules/channels/channelColumns.dom.test.tsx index eb2b6b05..284261ef 100644 --- a/frontend/src/modules/channels/channelColumns.dom.test.tsx +++ b/frontend/src/modules/channels/channelColumns.dom.test.tsx @@ -28,7 +28,6 @@ const ch = (over: Partial): 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, @@ -186,6 +185,19 @@ describe("buildChannelColumns — Added", () => { render(<>{added!.render(ch({ added_at: "2026-10-12T08:00:00+00:00" }))}); expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy(); }); + + // The column is sized for the sum: a count of videos without a length beside it spilled into + // Tags, so the table shows the sum alone — even from a server that still sends the count. + it("Length shows the sum alone", () => { + const length = cols.find((c) => c.key === "length"); + const row = { + ...ch({ total_duration_seconds: 7200 }), + live_without_length: 2, + } as ManagedChannel; + const { container } = render(<>{length!.render(row)}); + expect(container.textContent).not.toContain("card.liveWithoutLength"); + expect(container.querySelectorAll("span")).toHaveLength(1); + }); }); describe("tagFilterChips", () => { diff --git a/frontend/src/modules/channels/channelColumns.tsx b/frontend/src/modules/channels/channelColumns.tsx index ee7f6939..e1df55ed 100644 --- a/frontend/src/modules/channels/channelColumns.tsx +++ b/frontend/src/modules/channels/channelColumns.tsx @@ -11,7 +11,6 @@ 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,11 +235,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.total_duration_seconds, render: (c) => ( + {/* The sum alone: the column is sized for it, and a count of the videos it left out + (no length) did not fit beside it. */} {formatTotalHours(c.total_duration_seconds, lang)} - {/* Its own line: the column is sized for the sum alone. */} - {c.live_without_length > 0 && ( - {withLiveCount("", c.live_without_length, t)} - )} ), }, diff --git a/frontend/src/modules/channels/useChannelActions.dom.test.tsx b/frontend/src/modules/channels/useChannelActions.dom.test.tsx index 1525590d..66d0bd32 100644 --- a/frontend/src/modules/channels/useChannelActions.dom.test.tsx +++ b/frontend/src/modules/channels/useChannelActions.dom.test.tsx @@ -61,7 +61,6 @@ const ch = (over: Partial = {}): 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,