feat(channels): a "Discover from your history" tab for the channels watched but not followed

The Channel manager lists the channels the Takeout history shows watched
often but not followed, the most watched first: twenty at a time, the
channel's About on hover, its name to its page, Follow, and Not interested
with an Undo. A row's count opens the videos it counts, ready to play one
or all. Before a history is uploaded it points to the upload; while the
import still asks YouTube it says more channels may join.
This commit is contained in:
2026-10-24 00:00:50 +02:00
parent bd2340c204
commit b5f9cf14db
17 changed files with 831 additions and 9 deletions
+34
View File
@@ -195,6 +195,33 @@ export interface TakeoutStatus {
watch_later?: TakeoutCounts;
}
// A channel the user's Takeout history shows them watching often but not following
// (`GET /api/takeout/channels`): `watched` counts its videos of five minutes or more they watched.
export interface WatchedChannel {
id: string;
title: string | null;
handle: string | null;
thumbnail_url: string | null;
subscriber_count: number | null;
video_count: number | null;
description: string | null;
watched: number;
last_watched_at: string | null;
}
export interface WatchedChannels {
/** Whether a watch history was uploaded at all ("nothing to suggest" vs "nothing to go on"). */
imported: boolean;
/** How many more channels may join once the import has asked YouTube for their videos' lengths. */
pending: number;
channels: WatchedChannel[];
}
/** A channel's counted videos, ready to play, last watched first; `gone`: those YouTube lost. */
export interface WatchedChannelVideos {
ok: true;
items: (Video & { takeout_watched_at: string | null })[];
gone: number;
}
export const feedApi = {
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
@@ -260,4 +287,11 @@ export const feedApi = {
// One file of a Google Takeout export, its bytes as they are; the server tells the two apart.
takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file),
takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"),
watchedChannels: (): Promise<WatchedChannels> => req("/api/takeout/channels"),
watchedChannelVideos: (id: string): Promise<WatchedChannelVideos> =>
req(`/api/takeout/channels/${encodeURIComponent(id)}/videos`, { method: "POST" }),
dismissWatchedChannel: (id: string) =>
req(`/api/takeout/channels/${encodeURIComponent(id)}/dismissal`, { method: "PUT" }),
restoreWatchedChannel: (id: string) =>
req(`/api/takeout/channels/${encodeURIComponent(id)}/dismissal`, { method: "DELETE" }),
};
+2
View File
@@ -37,6 +37,8 @@ export const qk = {
channels: () => ["channels"] as const,
channel: (id: string) => ["channel", id] as const,
discoveredChannels: () => ["discovered-channels"] as const,
watchedChannels: () => ["watched-channels"] as const,
watchedChannelVideos: (id: string) => ["watched-channels", "videos", id] as const,
blockedChannels: () => ["blocked-channels"] as const,
// --- playlists ---
@@ -20,6 +20,7 @@ describe("a subscription change", () => {
qk.feed(),
qk.feedSummary(),
qk.discoveredChannels(),
qk.watchedChannels(),
]);
});
+3 -2
View File
@@ -3,8 +3,8 @@ import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
/** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its
* counts, the feed and its facets (the channel's videos come or go), and discovery (a subscribed
* channel leaves it). ONE set for every site that makes the change: each used to keep
* counts, the feed and its facets (the channel's videos come or go), and the two discovery lists (a
* subscribed channel leaves them). ONE set for every site that makes the change: each used to keep
* its own list, and each list missed something. */
export function invalidateSubscription(qc: QueryClient, channelId: string): void {
for (const queryKey of [
@@ -14,6 +14,7 @@ export function invalidateSubscription(qc: QueryClient, channelId: string): void
qk.feed(),
qk.feedSummary(),
qk.discoveredChannels(),
qk.watchedChannels(),
])
void qc.invalidateQueries({ queryKey });
}