+
+
+
@@ -181,6 +217,9 @@ export default function Channels({
onView={() => onViewChannel(c.id, c.title ?? "This channel")}
onPriority={(d) => patch.mutate({ id: c.id, body: { priority: c.priority + d } })}
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
+ onDeep={() =>
+ patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })
+ }
onToggleTag={(tagId) =>
c.tag_ids.includes(tagId)
? detach.mutate({ id: c.id, tagId })
@@ -232,6 +271,7 @@ function ChannelRow({
onView,
onPriority,
onHide,
+ onDeep,
onToggleTag,
}: {
c: ManagedChannel;
@@ -239,6 +279,7 @@ function ChannelRow({
onView: () => void;
onPriority: (delta: number) => void;
onHide: () => void;
+ onDeep: () => void;
onToggleTag: (tagId: number) => void;
}) {
return (
@@ -279,11 +320,29 @@ function ChannelRow({
label="recent"
hint={c.recent_synced ? "Recent uploads synced." : "Recent uploads not fetched yet."}
/>
-
+ {c.backfill_done ? (
+
+ ) : (
+
+
+
+ )}
{userTags.map((t) => {
const on = c.tag_ids.includes(t.id);
return (
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index 51e01a2..eb53106 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Bell, Monitor, Pause, Play, RefreshCw, User, X } from "lucide-react";
+import { Bell, History, Monitor, Pause, Play, RefreshCw, User, X } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api";
+import { formatEta } from "../lib/format";
import {
configureNotifications,
getNotifSettings,
@@ -335,6 +336,18 @@ function Sync({ me }: { me: Me }) {
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: ["my-status"] }),
});
+ const deepAll = useMutation({
+ mutationFn: () => api.deepAll(true),
+ onSuccess: (r: { updated?: number }) => {
+ qc.invalidateQueries({ queryKey: ["my-status"] });
+ qc.invalidateQueries({ queryKey: ["channels"] });
+ notify({
+ level: "success",
+ message: `Full history requested for ${r.updated ?? 0} channels`,
+ });
+ },
+ onError: () => notify({ level: "error", message: "Couldn't request full history" }),
+ });
return (
<>
@@ -352,10 +365,18 @@ function Sync({ me }: { me: Me }) {
- {`${s.channels_deep_done}/${s.channels_total}`}
+ {`${s.channels_deep_done}/${s.channels_deep_requested}`}
+ {s.deep_pending_count > 0 && (
+
+ {formatEta(s.deep_eta_seconds)}
+
+ )}
{s.my_videos.toLocaleString()}
@@ -382,6 +403,16 @@ function Sync({ me }: { me: Me }) {
Sync subscriptions
+
+
+
{me.role === "admin" && s && (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index ea3426a..022a594 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -144,6 +144,9 @@ export interface MyStatus {
channels_deep_done: number;
channels_recent_pending: number;
channels_deep_pending: number;
+ channels_deep_requested: number;
+ deep_pending_count: number;
+ deep_eta_seconds: number;
my_videos: number;
quota_used_today: number;
quota_remaining_today: number;
@@ -160,6 +163,7 @@ export interface ManagedChannel {
stored_videos: number;
priority: number;
hidden: boolean;
+ deep_requested: boolean;
tag_ids: number[];
details_synced: boolean;
recent_synced: boolean;
@@ -184,8 +188,12 @@ export const api = {
// --- channel manager ---
myStatus: (): Promise => req("/api/sync/my-status"),
channels: (): Promise => req("/api/channels"),
- updateChannel: (id: string, patch: { priority?: number; hidden?: boolean }) =>
- req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
+ updateChannel: (
+ id: string,
+ patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
+ ) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
+ deepAll: (on = true) =>
+ req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
attachChannelTag: (id: string, tagId: number) =>
req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
detachChannelTag: (id: string, tagId: number) =>
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 4f7b89f..440e6db 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -34,6 +34,19 @@ export function formatDuration(sec: number | null): string {
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
+/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). */
+export function formatEta(seconds: number): string {
+ if (seconds <= 0) return "done";
+ const hours = seconds / 3600;
+ if (hours < 1) return "< 1 hour";
+ if (hours < 24) {
+ const h = Math.round(hours);
+ return `~${h} hour${h === 1 ? "" : "s"}`;
+ }
+ const d = Math.round(hours / 24);
+ return `~${d} day${d === 1 ? "" : "s"}`;
+}
+
export function formatViews(n: number | null): string {
if (n == null) return "";
if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`;