(t: TFu
};
}
-function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) {
- return (
-
-
- {ok && }
- {label}
-
-
- );
+// A channel is "fully synced" when its recent uploads AND full back-catalogue are both in — the
+// predicate the retired Sync column used, now surfaced as a ✓ charm on the Stored count and driven
+// by the top sync-filter chips.
+const isFullySynced = (c: ManagedChannel) => c.recent_synced && c.backfill_done;
+
+/** Sync-status pre-filter for the subscribed table. The Sync COLUMN is gone (R14) — its status folded
+ * into a charm on Stored — so the top chips' `filters.sync` value is applied here instead of via a
+ * column predicate. Same vocab as the old column filter: "fully" | "needsFull" | "" (all). */
+export function applySyncFilter(rows: ManagedChannel[], value: unknown): ManagedChannel[] {
+ if (value === "fully") return rows.filter(isFullySynced);
+ if (value === "needsFull") return rows.filter((c) => !c.backfill_done);
+ return rows;
}
-// Status only — the backfill *action* lives in the Actions column. When both recent and
-// full history are in, collapse to a single "fully synced" chip; otherwise show what's
-// present plus the missing/queued state.
-function SyncCell({ c }: { c: ManagedChannel }) {
+// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
+// full history are both in — folding the retired Sync column's at-a-glance status into the value it
+// describes. Full detail stays in the hover tooltip.
+function StoredCell({ c }: { c: ManagedChannel }) {
const { t } = useTranslation();
- if (c.recent_synced && c.backfill_done) {
- return (
-
- );
- }
return (
-
-
- {c.backfill_done ? (
-
- ) : c.deep_requested ? (
-
- ) : c.deep_in_queue ? (
-
- ) : (
-
+
+ {isFullySynced(c) && (
+
+
+
)}
-
+ {c.stored_videos.toLocaleString()}
+
);
}
@@ -213,7 +183,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.priority,
@@ -231,7 +201,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column (
(c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
- // Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a
- // cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses).
- // Without this a single long name blows the column ~289→429px and overflows the centered table.
+ // A FLUID column (R14): no width cap. Under the manager table's fixed layout this cell takes a
+ // share of the leftover space and ChannelLink truncates the name to fit (full name on hover via
+ // title), so a long name can't push the table wider than its container at any zoom.
render: (c) => (
-
+
,
},
{
@@ -278,17 +249,18 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.stored_videos,
- render: (c) => (
- {c.stored_videos.toLocaleString()}
- ),
+ // The stored count carries the "fully synced" ✓ charm (R14 folded the retired Sync column in).
+ render: (c) => ,
},
- subsColumn(t),
+ { ...subsColumn(t), width: "6rem" },
{
key: "lastUpload",
header: t("channels.cols.lastUpload"),
nowrap: true,
+ width: "6rem",
sortable: true,
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
render: (c) => {relativeTime(c.last_video_at) || "—"},
@@ -298,6 +270,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column c.total_duration_seconds,
render: (c) => (
@@ -306,45 +279,13 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column
),
},
- {
- 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"),
- nowrap: true,
- cardLabel: false,
- filter: {
- kind: "select",
- options: [
- { value: "fully", label: t("channels.filters.fullySynced") },
- { value: "needsFull", label: t("channels.filters.needsFull") },
- ],
- test: (c, v) =>
- v === "fully"
- ? c.recent_synced && c.backfill_done
- : v === "needsFull"
- ? !c.backfill_done
- : true,
- },
- render: (c) => ,
- },
{
key: "tags",
header: t("channels.cols.tags"),
cardLabel: false,
// Bounded width so the cell can't widen the column and reflow the whole table; the control
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
- width: "12rem",
+ width: "9rem",
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
diff --git a/frontend/src/i18n/locales/en/channel.json b/frontend/src/i18n/locales/en/channel.json
index d7d7ec28..b6bab16a 100644
--- a/frontend/src/i18n/locales/en/channel.json
+++ b/frontend/src/i18n/locales/en/channel.json
@@ -12,6 +12,7 @@
"videoCount_other": "{{count}} videos",
"videoCountStored": "{{stored}} / {{total}} videos",
"totalViews": "{{formatted}} views",
+ "videoTypes": "{{normal}} normal / {{short}} shorts / {{live}} live",
"joined": "Joined {{date}}",
"loadingVideos": "Loading this channel's videos…",
"tabVideos": "Videos",
diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json
index 57cdcfda..9eb7826e 100644
--- a/frontend/src/i18n/locales/en/channels.json
+++ b/frontend/src/i18n/locales/en/channels.json
@@ -68,9 +68,6 @@
"subs": "Subscribers",
"lastUpload": "Last upload",
"length": "Length",
- "types": "N/S/L",
- "typesHint": "Videos by type: Normal / Shorts / Live",
- "sync": "Sync",
"tags": "Tags",
"actions": "Actions"
},
@@ -97,20 +94,13 @@
"raisePriority": "Raise priority",
"lowerPriority": "Lower priority",
"editPriority": "Click to type a priority (negatives allowed)",
- "recent": "recent",
- "recentSyncedHint": "Recent uploads synced.",
- "recentNotSyncedHint": "Recent uploads not fetched yet.",
- "full": "full",
"fullHint": "Full history fetched.",
- "fullNotFetchedHint": "Full back-catalog not fetched yet — use the backfill action in the Actions column.",
"fullySynced": "fully synced",
"fullySyncedHint": "Recent uploads and the full back-catalog are both in.",
"backfillThis": "Backfill this channel",
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
"resetBackfill": "Reset & re-fetch this channel",
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
- "fullHistoryQueued": "queued",
- "fullHistoryComing": "coming",
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
diff --git a/frontend/src/i18n/locales/hu/channel.json b/frontend/src/i18n/locales/hu/channel.json
index 77463ac2..ddaee097 100644
--- a/frontend/src/i18n/locales/hu/channel.json
+++ b/frontend/src/i18n/locales/hu/channel.json
@@ -12,6 +12,7 @@
"videoCount_other": "{{count}} videó",
"videoCountStored": "{{stored}} / {{total}} videó",
"totalViews": "{{formatted}} megtekintés",
+ "videoTypes": "{{normal}} normál / {{short}} short / {{live}} élő",
"joined": "Csatlakozott: {{date}}",
"loadingVideos": "A csatorna videóinak betöltése…",
"tabVideos": "Videók",
diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json
index 29df7265..6b392cfc 100644
--- a/frontend/src/i18n/locales/hu/channels.json
+++ b/frontend/src/i18n/locales/hu/channels.json
@@ -68,9 +68,6 @@
"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"
},
@@ -97,20 +94,13 @@
"raisePriority": "Prioritás növelése",
"lowerPriority": "Prioritás csökkentése",
"editPriority": "Kattints a prioritás beírásához (negatív is lehet)",
- "recent": "legutóbbi",
- "recentSyncedHint": "Legutóbbi feltöltések szinkronizálva.",
- "recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",
- "full": "teljes",
"fullHint": "Teljes előzmény letöltve.",
- "fullNotFetchedHint": "A teljes archívum még nincs letöltve — használd a Műveletek oszlop backfill gombját.",
"fullySynced": "teljesen szinkronban",
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
"backfillThis": "Csatorna teljes letöltése",
"backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.",
"resetBackfill": "Csatorna resetelése és újraletöltése",
"resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
- "fullHistoryQueued": "sorban",
- "fullHistoryComing": "úton",
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
diff --git a/frontend/src/lib/api/channels.ts b/frontend/src/lib/api/channels.ts
index b8417f85..c5113e54 100644
--- a/frontend/src/lib/api/channels.ts
+++ b/frontend/src/lib/api/channels.ts
@@ -27,6 +27,9 @@ export interface ChannelDetail {
explored: boolean;
blocked: boolean;
stored_videos: number;
+ count_normal: number;
+ count_short: number;
+ count_live: number;
deep_requested: boolean;
priority: number;
backfill_done: boolean;
@@ -70,9 +73,6 @@ export interface ManagedChannel {
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;
diff --git a/frontend/src/lib/channelDetailPoll.test.ts b/frontend/src/lib/channelDetailPoll.test.ts
new file mode 100644
index 00000000..03c7a446
--- /dev/null
+++ b/frontend/src/lib/channelDetailPoll.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from "vitest";
+import { channelDetailPollMs } from "./channelDetailPoll";
+import { type ChannelDetail } from "./api";
+
+// Only deep_requested + backfill_done drive the decision; cast a partial for the pure predicate.
+const d = (over: Partial): ChannelDetail => over as ChannelDetail;
+
+describe("channelDetailPollMs", () => {
+ it("polls at 4s while THIS user's full-history backfill is running", () => {
+ expect(channelDetailPollMs(d({ deep_requested: true, backfill_done: false }))).toBe(4000);
+ });
+
+ it("stops (false) once the back-catalogue is fully in", () => {
+ expect(channelDetailPollMs(d({ deep_requested: true, backfill_done: true }))).toBe(false);
+ });
+
+ it("stops (false) when the user hasn't requested full history", () => {
+ expect(channelDetailPollMs(d({ deep_requested: false, backfill_done: false }))).toBe(false);
+ });
+
+ it("stops (false) before the detail has loaded", () => {
+ expect(channelDetailPollMs(undefined)).toBe(false);
+ });
+});
diff --git a/frontend/src/lib/channelDetailPoll.ts b/frontend/src/lib/channelDetailPoll.ts
new file mode 100644
index 00000000..0f796e51
--- /dev/null
+++ b/frontend/src/lib/channelDetailPoll.ts
@@ -0,0 +1,8 @@
+import { type ChannelDetail } from "./api";
+
+/** How often the channel page re-polls its detail (via useLiveQuery): every 4s while THIS user's
+ * full-history backfill is running (`deep_requested && !backfill_done`) so the stored count + the
+ * N/S/L breakdown climb on their own, and `false` (stop polling) once the back-catalogue is in or
+ * the user never requested it. Extracted so the poll-or-stop decision is unit-testable. */
+export const channelDetailPollMs = (d: ChannelDetail | undefined): number | false =>
+ d?.deep_requested && !d.backfill_done ? 4000 : false;
diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index 46090461..761ce00f 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -14,6 +14,23 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
+ {
+ version: "0.65.0",
+ date: "2026-08-15",
+ summary:
+ "The channel manager table now fits your screen at any zoom, and asking for a channel's full history fills it in seconds instead of over many minutes.",
+ features: [
+ "Channel manager table redesign: it fits the width at any zoom now (no more sideways scrolling), fully-synced channels get a ✓ next to their stored count, and the crowded N/S/L and Sync columns are gone — the channel-type breakdown moved to the channel page.",
+ "“Get full history” is now immediate: clicking it fills the whole channel's back-catalogue in seconds rather than waiting for the background schedule, and the channel page's counts climb live as it fills — no need to refresh.",
+ "The channel page shows the Normal / Shorts / Live breakdown for the videos it holds.",
+ ],
+ fixes: [
+ "The channel name and description columns now shrink and show “…” (full name on hover) instead of pushing the table off-screen.",
+ ],
+ chores: [
+ "Backend: the immediate full-history backfill coordinates with the background scheduler so the two never duplicate work, its API-quota use is recorded correctly, and it's capped so one burst can't crowd out others.",
+ ],
+ },
{
version: "0.64.0",
date: "2026-08-15",
diff --git a/frontend/src/lib/useLiveQuery.ts b/frontend/src/lib/useLiveQuery.ts
index 69b96fbc..22af46c1 100644
--- a/frontend/src/lib/useLiveQuery.ts
+++ b/frontend/src/lib/useLiveQuery.ts
@@ -9,10 +9,13 @@ export function useLiveQuery(
key: QueryKey,
queryFn: () => Promise,
// intervalMs may be a function of the latest data, so the poll cadence can adapt to it
- // (e.g. poll faster while a job is running). react-query re-evaluates it after each fetch
- // against fresh data — the right place for this, vs. a React state toggle that can lag or
- // stall the live updates.
- opts: { intervalMs?: number | ((data: T | undefined) => number); enabled?: boolean } = {},
+ // (e.g. poll faster while a job is running, and return `false` to STOP once it's done).
+ // react-query re-evaluates it after each fetch against fresh data — the right place for this,
+ // vs. a React state toggle that can lag or stall the live updates.
+ opts: {
+ intervalMs?: number | false | ((data: T | undefined) => number | false);
+ enabled?: boolean;
+ } = {},
) {
const { intervalMs = 4000, enabled = true } = opts;
return useQuery({