diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 04ad3f7..309ef24 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -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, diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index e9eb9a0..5a4fb45 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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) => {c.stored_videos.toLocaleString()}, @@ -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) => ( - {c.subscriber_count != null ? c.subscriber_count.toLocaleString() : "—"} + {c.subscriber_count != null ? formatViews(c.subscriber_count) : "—"} ), }, + { + 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) => {relativeTime(c.last_video_at) || "—"}, + }, + { + key: "length", + header: t("channels.cols.length"), + align: "right", + nowrap: true, + sortable: true, + sortValue: (c) => c.total_duration_seconds, + render: (c) => {fmtTotalDuration(c.total_duration_seconds)}, + }, + { + key: "types", + header: t("channels.cols.types"), + nowrap: true, + render: (c) => ( + + + {c.count_normal} / {c.count_short} / {c.count_live} + + + ), + }, { key: "sync", header: t("channels.cols.sync"), - width: "130px", + nowrap: true, cardLabel: false, render: (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(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 ( -
- {userTags.map((tg) => { - const on = c.tag_ids.includes(tg.id); - return ( - - ); - })} +
+ {attached.map((tg) => ( + + {tg.name} + + ))} + + {open && ( +
+ {userTags.map((tg) => { + const on = c.tag_ids.includes(tg.id); + return ( + + ); + })} +
+ )}
); } diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index ef1d5a8..1127b01 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -22,6 +22,8 @@ export interface Column { sortable?: boolean; sortValue?: (row: T) => string | number; filter?: ColumnFilter; + // Keep the cell on one line so the column auto-sizes to its content (table-layout: auto). + nowrap?: boolean; // Card fallback (below md): `cardPrimary` renders as the card heading (no label); `hideInCard` // omits the column; `cardLabel:false` shows the value without its header label. cardPrimary?: boolean; @@ -325,7 +327,7 @@ export default function DataTable({