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:
parent
c000ea8bce
commit
cd28287968
6 changed files with 153 additions and 30 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue