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.
This commit is contained in:
2026-11-08 00:10:28 +01:00
parent 4d058cba81
commit 55cbbfb6e4
9 changed files with 25 additions and 22 deletions
+1 -1
View File
@@ -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
+4 -8
View File
@@ -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())
+1 -3
View File
@@ -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,
+2 -1
View File
@@ -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):
+1
View File
@@ -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 |
+1 -2
View File
@@ -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;
@@ -28,7 +28,6 @@ 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,
@@ -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", () => {
@@ -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<ManagedChan
sortValue: (c) => c.total_duration_seconds,
render: (c) => (
<span className="text-muted tabular-nums">
{/* 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 && (
<span className="block text-2xs">{withLiveCount("", c.live_without_length, t)}</span>
)}
</span>
),
},
@@ -61,7 +61,6 @@ 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,