feat(channels): aggregate columns + smarter tag display

- Add Last upload, total Length and a Normal/Shorts/Live breakdown, computed on read in the
  existing grouped query (~35ms over the full catalog — measured, so no denormalization).
- Subscribers shown compact (919K), since the YouTube API already rounds them.
- Tag cell now shows only the tags actually attached to a channel; a '+' opens a per-channel
  picker to attach/detach, instead of listing every user tag on every row.
This commit is contained in:
npeter83 2026-06-17 23:24:25 +02:00
parent 14da25bf19
commit 0aa16543d9
6 changed files with 153 additions and 30 deletions

View file

@ -27,16 +27,33 @@ def list_channels(
.order_by(Subscription.priority.desc(), func.lower(Channel.title))
).all()
# Per-channel stored video count (shared data) in one grouped query.
# Per-channel aggregates over the shared catalog in ONE grouped pass: stored count, last
# upload, total duration, and a Normal/Shorts/Live breakdown. Cheap — a single grouped scan
# (~35 ms over the full 233k-row catalog on the dev DB), so computed on read rather than
# denormalised (measure-first: reads aren't frequent/hot enough to warrant cached columns).
channel_ids = [c.id for _, c in rows]
stored: dict[str, int] = {}
agg: dict[str, dict] = {}
if channel_ids:
for cid, count in db.execute(
select(Video.channel_id, func.count(Video.id))
for cid, n, last_up, total_dur, shorts, live in db.execute(
select(
Video.channel_id,
func.count(Video.id),
func.max(Video.published_at),
func.coalesce(func.sum(Video.duration_seconds), 0),
func.count(Video.id).filter(Video.is_short.is_(True)),
func.count(Video.id).filter(Video.live_status.in_(("live", "upcoming"))),
)
.where(Video.channel_id.in_(channel_ids))
.group_by(Video.channel_id)
).all():
stored[cid] = count
agg[cid] = {
"stored": n,
"last_video_at": last_up.isoformat() if last_up else None,
"total_duration_seconds": int(total_dur or 0),
"count_short": int(shorts),
"count_live": int(live),
"count_normal": max(0, int(n) - int(shorts) - int(live)),
}
# Channels already in the *global* deep-backfill queue — i.e. at least one user (any
# user) has requested full history and it isn't done yet. Their whole back-catalogue is
@ -71,7 +88,12 @@ def list_channels(
"thumbnail_url": ch.thumbnail_url,
"subscriber_count": ch.subscriber_count,
"video_count": ch.video_count,
"stored_videos": stored.get(ch.id, 0),
"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),
"count_normal": (agg.get(ch.id) or {}).get("count_normal", 0),
"count_short": (agg.get(ch.id) or {}).get("count_short", 0),
"count_live": (agg.get(ch.id) or {}).get("count_live", 0),
"priority": sub.priority,
"hidden": sub.hidden,
"deep_requested": sub.deep_requested,

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -15,7 +15,7 @@ import {
X,
} from "lucide-react";
import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
import { formatEta } from "../lib/format";
import { formatEta, formatViews, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Avatar from "./Avatar";
@ -24,6 +24,13 @@ import { useConfirm } from "./ConfirmProvider";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
// Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge).
function fmtTotalDuration(sec: number): string {
if (!sec) return "—";
const h = Math.round(sec / 3600);
return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
}
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
{ id: "all", labelKey: "channels.filters.all" },
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
@ -231,7 +238,7 @@ export default function Channels({
key: "stored",
header: t("channels.cols.stored"),
align: "right",
width: "84px",
nowrap: true,
sortable: true,
sortValue: (c) => c.stored_videos,
render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>,
@ -240,26 +247,54 @@ export default function Channels({
key: "subs",
header: t("channels.cols.subs"),
align: "right",
width: "92px",
nowrap: true,
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">
{c.subscriber_count != null ? c.subscriber_count.toLocaleString() : "—"}
{c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"}
</span>
),
},
{
key: "lastUpload",
header: t("channels.cols.lastUpload"),
nowrap: true,
sortable: true,
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
render: (c) => <span className="text-muted">{relativeTime(c.last_video_at) || "—"}</span>,
},
{
key: "length",
header: t("channels.cols.length"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.total_duration_seconds,
render: (c) => <span className="text-muted tabular-nums">{fmtTotalDuration(c.total_duration_seconds)}</span>,
},
{
key: "types",
header: t("channels.cols.types"),
nowrap: true,
render: (c) => (
<Tooltip hint={t("channels.cols.typesHint")}>
<span className="text-muted tabular-nums cursor-help">
{c.count_normal} / {c.count_short} / {c.count_live}
</span>
</Tooltip>
),
},
{
key: "sync",
header: t("channels.cols.sync"),
width: "130px",
nowrap: true,
cardLabel: false,
render: (c) => <SyncCell c={c} />,
},
{
key: "tags",
header: t("channels.cols.tags"),
width: "360px",
cardLabel: false,
filter: {
kind: "multi",
@ -279,6 +314,7 @@ export default function Channels({
key: "actions",
header: t("channels.cols.actions"),
align: "right",
nowrap: true,
width: "104px",
cardLabel: false,
render: (c) => (
@ -574,25 +610,70 @@ function TagsCell({
userTags: Tag[];
onToggleTag: (tagId: number) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (userTags.length === 0) return null;
// Show only the tags actually attached to this channel; the “+” opens a per-channel
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
// on every row). Tag create/delete still lives in the top "YOUR TAGS" row.
const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id));
return (
<div className="flex flex-wrap items-center gap-1">
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (
<button
key={tg.id}
onClick={() => onToggleTag(tg.id)}
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
on
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{tg.name}
</button>
);
})}
<div ref={ref} className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
{attached.map((tg) => (
<span
key={tg.id}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent"
>
{tg.name}
</span>
))}
<button
onClick={() => setOpen((o) => !o)}
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<Plus className="w-3 h-3" />
</button>
{open && (
<div className="glass-menu absolute left-0 top-full mt-1 z-30 w-44 rounded-xl p-1.5 animate-[popIn_0.16s_ease]">
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (
<button
key={tg.id}
onClick={() => onToggleTag(tg.id)}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
{tg.name}
</button>
);
})}
</div>
)}
</div>
);
}

View file

@ -29,6 +29,10 @@
"channel": "Kanal",
"stored": "Gespeichert",
"subs": "Abonnenten",
"lastUpload": "Letzter Upload",
"length": "Länge",
"types": "N/S/L",
"typesHint": "Videos nach Typ: Normale / Shorts / Live",
"sync": "Sync",
"tags": "Tags",
"actions": "Aktionen"
@ -51,6 +55,7 @@
"stored": "{{formatted}} gespeichert",
"subs": "{{formatted}} Abonnenten",
"openOnYouTube": "Auf YouTube öffnen",
"editTags": "Tags bearbeiten",
"priorityHint": "Deine Einstufung für diesen Kanal. Sortiere den Feed nach „Kanalpriorität“, um Kanäle mit höherer Priorität nach oben zu bringen.",
"raisePriority": "Priorität erhöhen",
"lowerPriority": "Priorität senken",

View file

@ -29,6 +29,10 @@
"channel": "Channel",
"stored": "Stored",
"subs": "Subscribers",
"lastUpload": "Last upload",
"length": "Length",
"types": "N/S/L",
"typesHint": "Videos by type: Normal / Shorts / Live",
"sync": "Sync",
"tags": "Tags",
"actions": "Actions"
@ -51,6 +55,7 @@
"stored": "{{formatted}} stored",
"subs": "{{formatted}} subs",
"openOnYouTube": "Open on YouTube",
"editTags": "Edit tags",
"priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.",
"raisePriority": "Raise priority",
"lowerPriority": "Lower priority",

View file

@ -29,6 +29,10 @@
"channel": "Csatorna",
"stored": "Tárolt",
"subs": "Feliratkozók",
"lastUpload": "Utolsó feltöltés",
"length": "Hossz",
"types": "N/S/L",
"typesHint": "Videók típusonként: Normál / Short / Live",
"sync": "Szinkron",
"tags": "Címkék",
"actions": "Műveletek"
@ -51,6 +55,7 @@
"stored": "{{formatted}} tárolt",
"subs": "{{formatted}} feliratkozó",
"openOnYouTube": "Megnyitás a YouTube-on",
"editTags": "Címkék szerkesztése",
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
"raisePriority": "Prioritás növelése",
"lowerPriority": "Prioritás csökkentése",

View file

@ -293,6 +293,11 @@ export interface ManagedChannel {
subscriber_count: number | null;
video_count: number | null;
stored_videos: number;
last_video_at: string | null;
total_duration_seconds: number;
count_normal: number;
count_short: number;
count_live: number;
priority: number;
hidden: boolean;
deep_requested: boolean;