diff --git a/frontend/src/components/ui/pressableSelectable.contract.test.ts b/frontend/src/components/ui/pressableSelectable.contract.test.ts index b0ac57fc..5425ae26 100644 --- a/frontend/src/components/ui/pressableSelectable.contract.test.ts +++ b/frontend/src/components/ui/pressableSelectable.contract.test.ts @@ -56,6 +56,7 @@ const PLAIN: Record = { "modules/files/VideoEditor.tsx": [2, "timeline glyphs"], "shell/Welcome.tsx": [1, "a screenshot (zoom)"], "modules/playlists/playlistColumns.tsx": [1, "the thumbnail"], + "modules/channels/WatchedVideosDialog.tsx": [1, "the thumbnail (its title sits beside it)"], "modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"], "modules/plex/plexViews.tsx": [1, "the rating filter (a number)"], "components/ui/ActionMenu.tsx": [1, "a menu row"], diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 3d9b956b..4cc834fa 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -7,6 +7,7 @@ "tabs": { "subscribed": "Subscriptions", "discovery": "Discover from playlists", + "watched": "Discover from your history", "hidden": "Hidden", "blocked": "Blocked" }, @@ -154,5 +155,38 @@ "hint": "Their videos are kept out of your personal Feed, but still appear in the shared Library.", "empty": "No hidden channels. Hide a subscription (the eye icon on its row) to keep its uploads out of your personal Feed without unsubscribing.", "unhide": "Unhide" + }, + "watched": { + "intro": "Channels you often watched on YouTube but don't follow, from the watch history you uploaded. Only videos of five minutes or more count, so Shorts stay out. Follow a channel to get its new uploads in your Feed, or take it off the list.", + "loading": "Finding channels…", + "empty": "No suggestions — you follow every channel you watched five or more longer videos from.", + "notImported": "Upload your YouTube watch history to see the channels you watch but don't follow.", + "openTakeout": "Open YouTube history", + "pending_one": "{{count}} more channel is still being looked at — the list may grow.", + "pending_other": "{{count}} more channels are still being looked at — the list may grow.", + "more_one": "Show {{count}} more", + "more_other": "Show {{count}} more", + "shown": "{{shown}} of {{total}}", + "dismiss": "Not interested", + "dismissHint": "Take this channel off the list. You can undo it right after.", + "dismissed": "{{name}} is off the list", + "cols": { + "watched": "Watched", + "watchedHint_one": "Show the {{count}} video of five minutes or more you watched from this channel", + "watchedHint_other": "Show the {{count}} videos of five minutes or more you watched from this channel", + "last": "Last watched" + }, + "videos": { + "title": "Videos you watched from {{name}}", + "summary_one": "{{count}} video of five minutes or more · last watched {{when}}", + "summary_other": "{{count}} videos of five minutes or more · last watched {{when}}", + "loading": "Getting the videos ready…", + "gone_one": "{{count}} video is no longer on YouTube", + "gone_other": "{{count}} videos are no longer on YouTube", + "empty": "None of these videos is on YouTube any more.", + "colTitle": "Video", + "colLength": "Length", + "colWatched": "Watched" + } } } diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index c5d7d4dd..0b1ae16c 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -115,7 +115,9 @@ "apiToken": { "create": "Couldn't create the token", "revoke": "Couldn't revoke the token" - } + }, + "dismissWatchedChannel": "Couldn't take the channel off the list", + "restoreWatchedChannel": "Couldn't put the channel back on the list" }, "ok": "OK", "offline": { diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index f73a6dd6..5ff9c159 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -7,6 +7,7 @@ "tabs": { "subscribed": "Feliratkozások", "discovery": "Felfedezés a lejátszási listákból", + "watched": "Felfedezés az előzményekből", "hidden": "Elrejtett", "blocked": "Letiltott" }, @@ -154,5 +155,38 @@ "hint": "A videóik kimaradnak a személyes Feededből, de a megosztott Könyvtárban továbbra is megjelennek.", "empty": "Nincs elrejtett csatorna. Egy feliratkozást a sorában (a szem ikonnal) rejthetsz el, hogy kimaradjon a személyes Feededből leiratkozás nélkül.", "unhide": "Felfedés" + }, + "watched": { + "intro": "Csatornák, amelyeket YouTube-on gyakran néztél, de nem követsz — a feltöltött nézési előzményeid alapján. Csak az öt percnél hosszabb videók számítanak, így a Shorts kimarad. Kövesd a csatornát, és az új videói megjelennek a hírfolyamodban, vagy vedd le a listáról.", + "loading": "Csatornák keresése…", + "empty": "Nincs javaslat — minden csatornát követsz, amelyről legalább öt hosszabb videót néztél.", + "notImported": "Töltsd fel a YouTube nézési előzményeidet, és itt látod a csatornákat, amelyeket nézel, de nem követsz.", + "openTakeout": "YouTube-előzmények megnyitása", + "pending_one": "Még {{count}} csatornát vizsgálunk — a lista még bővülhet.", + "pending_other": "Még {{count}} csatornát vizsgálunk — a lista még bővülhet.", + "more_one": "További {{count}}", + "more_other": "További {{count}}", + "shown": "{{shown}} / {{total}}", + "dismiss": "Nem érdekel", + "dismissHint": "A csatorna lekerül a listáról. Utána rögtön visszavonhatod.", + "dismissed": "{{name}} lekerült a listáról", + "cols": { + "watched": "Nézett", + "watchedHint_one": "A csatornáról nézett {{count}}, öt percnél hosszabb videó megnyitása", + "watchedHint_other": "A csatornáról nézett {{count}}, öt percnél hosszabb videó megnyitása", + "last": "Utoljára nézve" + }, + "videos": { + "title": "{{name}}: a videók, amelyeket láttál", + "summary_one": "{{count}} öt percnél hosszabb videó · utoljára nézve: {{when}}", + "summary_other": "{{count}} öt percnél hosszabb videó · utoljára nézve: {{when}}", + "loading": "A videók előkészítése…", + "gone_one": "{{count}} videó már nincs fent a YouTube-on", + "gone_other": "{{count}} videó már nincs fent a YouTube-on", + "empty": "Ezek közül egyik videó sincs már fent a YouTube-on.", + "colTitle": "Videó", + "colLength": "Hossz", + "colWatched": "Nézve" + } } } diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index 39b735b4..b902ced6 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -115,7 +115,9 @@ "apiToken": { "create": "Nem sikerült létrehozni a tokent", "revoke": "Nem sikerült visszavonni a tokent" - } + }, + "dismissWatchedChannel": "Nem sikerült levenni a csatornát a listáról", + "restoreWatchedChannel": "Nem sikerült visszatenni a csatornát a listára" }, "ok": "OK", "offline": { diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index 41a89376..0e53ed30 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -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 => 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 => uploadReq("/api/takeout", file), takeoutStatus: (): Promise => req("/api/takeout"), + watchedChannels: (): Promise => req("/api/takeout/channels"), + watchedChannelVideos: (id: string): Promise => + 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" }), }; diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 22c2c030..3f9b868a 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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 --- diff --git a/frontend/src/lib/subscriptionCache.test.ts b/frontend/src/lib/subscriptionCache.test.ts index ff9f6109..bc5efd25 100644 --- a/frontend/src/lib/subscriptionCache.test.ts +++ b/frontend/src/lib/subscriptionCache.test.ts @@ -20,6 +20,7 @@ describe("a subscription change", () => { qk.feed(), qk.feedSummary(), qk.discoveredChannels(), + qk.watchedChannels(), ]); }); diff --git a/frontend/src/lib/subscriptionCache.ts b/frontend/src/lib/subscriptionCache.ts index 20721000..dff6d221 100644 --- a/frontend/src/lib/subscriptionCache.ts +++ b/frontend/src/lib/subscriptionCache.ts @@ -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 }); } diff --git a/frontend/src/modules/channels/Channels.tsx b/frontend/src/modules/channels/Channels.tsx index a1ee78a0..9358d4aa 100644 --- a/frontend/src/modules/channels/Channels.tsx +++ b/frontend/src/modules/channels/Channels.tsx @@ -57,6 +57,7 @@ import { menuGroups } from "../../components/ui/menuGroups"; import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems"; import { optionsFromColumns } from "../../lib/sortOptions"; import ChannelDiscovery from "./ChannelDiscovery"; +import WatchedChannels from "./WatchedChannels"; import TagManager from "../../features/channel/TagManager"; import { useChannelLayout, @@ -183,6 +184,8 @@ export default function Channels() { onSuccess: () => { qc.invalidateQueries({ queryKey: qk.blockedChannels() }); qc.invalidateQueries({ queryKey: qk.feed() }); + // A blocked channel is off the watched-channels list; unblocked, it may be back on it. + qc.invalidateQueries({ queryKey: qk.watchedChannels() }); }, }); @@ -530,7 +533,7 @@ export default function Channels() { ); - // Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no + // Table / cards density — shared by the subscribed + both discovery tables (blocked is a chip list, no // layout to switch). Rides the same generic ViewSwitcher the feed uses. const layoutOptions: ViewOption[] = CHANNEL_LAYOUTS.map((id) => ({ id, @@ -546,7 +549,7 @@ export default function Channels() { labelClassName="hidden lg:inline" /> ); - const hasLayout = view === "subscribed" || view === "discovery"; + const hasLayout = view === "subscribed" || view === "discovery" || view === "watched"; // In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state // the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks. const sortControl = ( @@ -612,7 +615,7 @@ export default function Channels() { const viewTabs: TabDef[] = useMemo( () => - (["subscribed", "discovery", "hidden", "blocked"] as ChannelsView[]).map((v) => ({ + (["subscribed", "discovery", "watched", "hidden", "blocked"] as ChannelsView[]).map((v) => ({ id: v, label: t(`channels.tabs.${v}`), })), @@ -778,6 +781,13 @@ export default function Channels() { onOpenWizard={onOpenWizard} onViewChannel={onViewChannel} /> + ) : view === "watched" ? ( + ) : view === "blocked" ? ( /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
diff --git a/frontend/src/modules/channels/ChannelsProvider.tsx b/frontend/src/modules/channels/ChannelsProvider.tsx index 0bfc05d1..21581803 100644 --- a/frontend/src/modules/channels/ChannelsProvider.tsx +++ b/frontend/src/modules/channels/ChannelsProvider.tsx @@ -48,7 +48,9 @@ import { // The contexts, their value types and the hooks live in channelsContext.ts. function clampView(v: string | null): ChannelsView { - return v === "discovery" || v === "blocked" || v === "hidden" ? v : "subscribed"; + return v === "discovery" || v === "watched" || v === "blocked" || v === "hidden" + ? v + : "subscribed"; } // One-time carry-forward of the retired single-value sync chip (LS.legacyChannelFilter, vocab diff --git a/frontend/src/modules/channels/WatchedChannels.dom.test.tsx b/frontend/src/modules/channels/WatchedChannels.dom.test.tsx new file mode 100644 index 00000000..bcaef4a0 --- /dev/null +++ b/frontend/src/modules/channels/WatchedChannels.dom.test.tsx @@ -0,0 +1,195 @@ +// @vitest-environment jsdom +// +// The Channel manager's "Discover from your history" tab: what it says before a history was +// uploaded and while the list is still being made, its top twenty and "show more", taking a channel +// off the list with an Undo, and a row's count opening the channel's watched videos to play. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const h = vi.hoisted(() => ({ + notify: vi.fn(), + confirm: vi.fn(async () => true), + setPage: vi.fn(), + focusSettingsTab: vi.fn(), + player: vi.fn(), +})); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + i18n: { language: "en" }, + t: (key: string, o?: Record) => + o && "count" in o ? `${key}:${String(o.count)}` : key, + }), + }; +}); +vi.mock("../../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + notify: (...a: unknown[]) => h.notify(...a), +})); +vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => h.confirm })); +vi.mock("../../features/navigation/navigationContext", () => ({ + useNavigationActions: () => ({ setPage: h.setPage }), +})); +vi.mock("../../lib/settingsTab", () => ({ focusSettingsTab: h.focusSettingsTab })); +vi.mock("../../features/player/PlayerModal", () => ({ + default: (p: { video: { id: string }; queue: { id: string }[]; startIndex: number }) => { + h.player( + p.video.id, + p.queue.map((v) => v.id), + p.startIndex, + ); + return
; + }, +})); + +import WatchedChannels from "./WatchedChannels"; +import { api, type Video, type WatchedChannel, type WatchedChannels as List } from "../../lib/api"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + for (const fn of Object.values(h)) fn.mockReset(); + h.confirm.mockImplementation(async () => true); +}); + +const channel = (n: number, over: Partial = {}): WatchedChannel => ({ + id: `UC${n}`, + title: `Channel ${n}`, + handle: null, + thumbnail_url: null, + subscriber_count: 1000, + video_count: 50, + description: null, + watched: 30 - n, + last_watched_at: "2026-09-24T10:00:00Z", + ...over, +}); + +const video = (id: string): Video & { takeout_watched_at: string } => ({ + id, + title: `Video ${id}`, + channel_id: "UC1", + channel_title: "Channel 1", + channel_thumbnail: null, + channel_url: "", + published_at: null, + thumbnail_url: null, + duration_seconds: 600, + view_count: null, + is_short: false, + live_status: "none", + status: "new", + position_seconds: 0, + saved: false, + watch_url: "", + takeout_watched_at: "2026-09-24T10:00:00Z", +}); + +function mount(list: List) { + vi.spyOn(api, "watchedChannels").mockResolvedValue(list); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + render( + + {}} onViewChannel={() => {}} /> + , + ); + return qc; +} + +describe("WatchedChannels", () => { + it("sends a user with no history uploaded to the upload, not to an empty list", async () => { + mount({ imported: false, pending: 0, channels: [] }); + fireEvent.click(await screen.findByRole("button", { name: "channels.watched.openTakeout" })); + expect(h.focusSettingsTab).toHaveBeenCalledWith("account"); + expect(h.setPage).toHaveBeenCalledWith("settings"); + expect(screen.queryByText("channels.watched.empty")).toBeNull(); + }); + + it("says when more channels may still join, and when there is nothing to suggest", async () => { + mount({ imported: true, pending: 3, channels: [] }); + expect(await screen.findByText("channels.watched.empty")).toBeTruthy(); + expect(screen.getByRole("status").textContent).toBe("channels.watched.pending:3"); + }); + + it("shows the top twenty, the most watched first, and twenty more on asking", async () => { + mount({ + imported: true, + pending: 0, + channels: Array.from({ length: 27 }, (_, i) => channel(i)), + }); + await screen.findByText("Channel 0"); + expect(screen.getByText("Channel 19")).toBeTruthy(); + expect(screen.queryByText("Channel 20")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "channels.watched.more:7" })); + expect(screen.getByText("Channel 26")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /channels\.watched\.more/ })).toBeNull(); + }); + + it("takes a channel off the list with an Undo that puts it back", async () => { + const dismiss = vi.spyOn(api, "dismissWatchedChannel").mockResolvedValue({ ok: true }); + const restore = vi.spyOn(api, "restoreWatchedChannel").mockResolvedValue({ ok: true }); + mount({ imported: true, pending: 0, channels: [channel(1)] }); + fireEvent.click(await screen.findByRole("button", { name: "channels.watched.dismiss" })); + await waitFor(() => expect(h.notify).toHaveBeenCalled()); + expect(dismiss).toHaveBeenCalledWith("UC1"); + const toast = h.notify.mock.calls[0]![0] as { action: { label: string; onClick: () => void } }; + expect(toast.action.label).toBe("common.undo"); + act(() => toast.action.onClick()); + await waitFor(() => expect(restore).toHaveBeenCalledWith("UC1")); + }); + + it("opens a row's watched videos from its count, plays one and plays them all", async () => { + const videos = vi + .spyOn(api, "watchedChannelVideos") + .mockResolvedValue({ ok: true, items: [video("a"), video("b"), video("c")], gone: 2 }); + mount({ imported: true, pending: 0, channels: [channel(1)] }); + fireEvent.click(await screen.findByRole("button", { name: "29" })); + const dialog = await screen.findByRole("dialog"); + await within(dialog).findByText("Video b"); + expect(videos).toHaveBeenCalledWith("UC1"); + expect(dialog.textContent).toContain("channels.watched.videos.gone:2"); + fireEvent.click(within(dialog).getByRole("button", { name: "card.play — Video b" })); + await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("b", ["a", "b", "c"], 1)); + fireEvent.click(within(dialog).getByRole("button", { name: "playlists.playAll" })); + await waitFor(() => expect(h.player).toHaveBeenLastCalledWith("a", ["a", "b", "c"], 0)); + }); + + it("follows a channel from its videos after the confirm, and closes", async () => { + vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({ + ok: true, + items: [video("a")], + gone: 0, + }); + const subscribe = vi.spyOn(api, "subscribeChannel").mockResolvedValue({ ok: true }); + mount({ imported: true, pending: 0, channels: [channel(1)] }); + fireEvent.click(await screen.findByRole("button", { name: "29" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "channels.discovery.subscribe" })); + await waitFor(() => expect(subscribe).toHaveBeenCalledWith("UC1")); + expect(h.confirm).toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("follows nothing and stays open when the confirm is declined", async () => { + vi.spyOn(api, "watchedChannelVideos").mockResolvedValue({ + ok: true, + items: [video("a")], + gone: 0, + }); + const subscribe = vi.spyOn(api, "subscribeChannel").mockResolvedValue({ ok: true }); + h.confirm.mockImplementation(async () => false); + mount({ imported: true, pending: 0, channels: [channel(1)] }); + fireEvent.click(await screen.findByRole("button", { name: "29" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "channels.discovery.subscribe" })); + await waitFor(() => expect(h.confirm).toHaveBeenCalled()); + expect(subscribe).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); +}); diff --git a/frontend/src/modules/channels/WatchedChannels.tsx b/frontend/src/modules/channels/WatchedChannels.tsx new file mode 100644 index 00000000..f8591648 --- /dev/null +++ b/frontend/src/modules/channels/WatchedChannels.tsx @@ -0,0 +1,227 @@ +import { lazy, Suspense, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { EyeOff, History, UserPlus } from "lucide-react"; +import { qk } from "../../lib/queryKeys"; +import { api, type WatchedChannel } from "../../lib/api"; +import { formatDate } from "../../lib/format"; +import { focusSettingsTab } from "../../lib/settingsTab"; +import { useNavigationActions } from "../../features/navigation/navigationContext"; +import Tooltip from "../../components/ui/Tooltip"; +import DataTable, { type Column } from "../../components/ui/DataTable"; +import { LoadingState, StateMessage } from "../../components/ui/QueryState"; +import { Button } from "../../components/ui/Button"; +import ChannelLink from "./ChannelLink"; +import AboutCell from "./AboutCell"; +import ChannelLayoutGrid from "./ChannelLayoutGrid"; +import { subsColumn } from "./channelColumns"; +import { useChannelLayout } from "./channelsContext"; +import { useWatchedChannelActions } from "./useWatchedChannelActions"; + +const WatchedVideosDialog = lazy(() => import("./WatchedVideosDialog")); + +// How many channels the list shows at first, and how many more each "Show more" adds. The list is a +// ranking — the most watched first — so a short top is the point: past it, the reader asks for more. +const WATCHED_PAGE = 20; + +// The Channel manager's "Discover from your history" tab: the channels the user's Takeout watch +// history shows them watching often but not following, the most watched first. A row's count opens +// the videos it counts, ready to play — a quick look at what the channel was, before following it. +export default function WatchedChannels({ + canWrite, + search, + onOpenWizard, + onViewChannel, +}: { + canWrite: boolean; + // Client-side name filter from the shared top SearchBar (matches title + handle). + search: string; + onOpenWizard: () => void; + onViewChannel: (id: string, name: string) => void; +}) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const layout = useChannelLayout(); + const { setPage } = useNavigationActions(); + const [shown, setShown] = useState(WATCHED_PAGE); + const [open, setOpen] = useState(null); + const actions = useWatchedChannelActions(onOpenWizard); + + const query = useQuery({ queryKey: qk.watchedChannels(), queryFn: api.watchedChannels }); + + const q = search.trim().toLowerCase(); + const rows = (query.data?.channels ?? []).filter( + (c) => !q || `${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q), + ); + const visible = rows.slice(0, shown); + + // The order is the ranking itself, so no column sorts: a header sort would only reorder the top + // twenty and hide that it is a top. + const columns: Column[] = [ + { + key: "actions", + header: t("channels.cols.actions"), + align: "left", + nowrap: true, + cardLabel: false, + render: (c) => ( +
+ + + + + + +
+ ), + }, + { + key: "channel", + header: t("channels.cols.channel"), + cardPrimary: true, + render: (c) => ( +
+ onViewChannel(c.id, c.title ?? c.id)} + /> +
+ ), + }, + { + key: "about", + header: t("channels.cols.about"), + hideInCard: true, + render: (c) => , + }, + { ...subsColumn(t, lang), sortable: false }, + { + key: "watched", + header: t("channels.watched.cols.watched"), + align: "right", + nowrap: true, + render: (c) => ( + + + + ), + }, + { + key: "last", + header: t("channels.watched.cols.last"), + align: "right", + nowrap: true, + render: (c) => ( + {formatDate(c.last_watched_at, lang)} + ), + }, + ]; + + const pending = query.data?.pending ?? 0; + const more = Math.min(WATCHED_PAGE, rows.length - visible.length); + const body = + query.isError && !query.data ? ( + query.refetch()}> + {t("common.loadError")} + + ) : query.isLoading ? ( + + ) : !query.data?.imported ? ( + // The message and the way to the upload, one line: StateMessage lays its content in a row. + + {t("channels.watched.notImported")} + + + ) : layout === "table" ? ( + c.id} + pager={false} + emptyText={t("channels.watched.empty")} + keyboardNav + narrowedBy={q} + /> + ) : ( + c.id} + aboutKey="about" + actionsKey="actions" + emptyText={t("channels.watched.empty")} + /> + ); + + return ( +
+

{t("channels.watched.intro")}

+ {pending > 0 && ( +

+ {t("channels.watched.pending", { count: pending })} +

+ )} + {body} + {rows.length > 0 && ( +
+ + {t("channels.watched.shown", { shown: visible.length, total: rows.length })} + + {more > 0 && ( + + )} +
+ )} + {open && ( + + setOpen(null)} + /> + + )} +
+ ); +} diff --git a/frontend/src/modules/channels/WatchedVideosDialog.tsx b/frontend/src/modules/channels/WatchedVideosDialog.tsx new file mode 100644 index 00000000..4f9c435a --- /dev/null +++ b/frontend/src/modules/channels/WatchedVideosDialog.tsx @@ -0,0 +1,205 @@ +import { lazy, Suspense, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { EyeOff, Play, UserPlus } from "lucide-react"; +import { qk } from "../../lib/queryKeys"; +import { api, type Video, type VideoStatus, type WatchedChannel } from "../../lib/api"; +import { formatDate, formatDuration } from "../../lib/format"; +import { notifyActionError } from "../../lib/actionErrors"; +import { useStableCallback } from "../../lib/useLatest"; +import Modal, { ModalFooter } from "../../components/ui/Modal"; +import DataTable, { type Column } from "../../components/ui/DataTable"; +import { LoadingState, StateMessage } from "../../components/ui/QueryState"; +import { Button } from "../../components/ui/Button"; +import { Pressable } from "../../components/ui/Pressable"; +import RemoteImage from "../../components/ui/RemoteImage"; +import Tooltip from "../../components/ui/Tooltip"; +import type { WatchedChannelActions } from "./useWatchedChannelActions"; + +const PlayerModal = lazy(() => import("../../features/player/PlayerModal")); + +type WatchedVideo = Video & { takeout_watched_at: string | null }; + +// The videos of one channel that the watched-channels list counts, last watched first, each ready to +// play — and Play all through them, as on a playlist. Opening it explores the channel for the user +// (the server brings in the videos the catalog lacks as the exploration's), so it is asked for once +// per opening, not refetched behind the reader's back. +export default function WatchedVideosDialog({ + channel, + canWrite, + actions, + onClose, +}: { + channel: WatchedChannel; + canWrite: boolean; + actions: WatchedChannelActions; + onClose: () => void; +}) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const qc = useQueryClient(); + const name = channel.title ?? channel.id; + const key = qk.watchedChannelVideos(channel.id); + const query = useQuery({ + queryKey: key, + queryFn: () => api.watchedChannelVideos(channel.id), + staleTime: Infinity, + refetchOnWindowFocus: false, + retry: false, + }); + const items = query.data?.items ?? []; + const gone = query.data?.gone ?? 0; + const [playing, setPlaying] = useState(null); + + // The player's watched / in-progress marks: saved, then shown on this list and the feed's. + const onState = useStableCallback((id: string, status: VideoStatus) => { + api + .setState(id, status) + .then(() => { + qc.setQueryData( + key, + (old: typeof query.data) => + old && { + ...old, + items: old.items.map((v) => (v.id === id ? { ...v, status } : v)), + }, + ); + void qc.invalidateQueries({ queryKey: qk.feed() }); + }) + .catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); + }); + const closePlayer = useStableCallback(() => setPlaying(null)); + + const columns: Column[] = [ + { + key: "title", + header: t("channels.watched.videos.colTitle"), + width: "100%", + cardPrimary: true, + render: (v) => ( +
+ setPlaying(items.indexOf(v))} + aria-label={`${t("card.play")} — ${v.title ?? v.id}`} + className="relative w-20 h-11.25 rounded-sm overflow-hidden bg-surface shrink-0 group/thumb touch-target" + > + {v.thumbnail_url && ( + + )} + + + + + {v.title ?? v.id} +
+ ), + }, + { + key: "length", + header: t("channels.watched.videos.colLength"), + align: "right", + nowrap: true, + render: (v) => ( + {formatDuration(v.duration_seconds)} + ), + }, + { + key: "watched", + header: t("channels.watched.videos.colWatched"), + align: "right", + nowrap: true, + render: (v) => ( + {formatDate(v.takeout_watched_at, lang)} + ), + }, + ]; + + const body = + query.isError && !query.data ? ( + query.refetch()}> + {t("common.loadError")} + + ) : query.isLoading ? ( + + ) : ( + v.id} + pager={false} + emptyText={t("channels.watched.videos.empty")} + keyboardNav + /> + ); + + return ( + } + onClick={() => setPlaying(0)} + disabled={!items.length} + > + {t("playlists.playAll")} + + } + > +

+ {t("channels.watched.videos.summary", { + count: channel.watched, + when: formatDate(channel.last_watched_at, lang), + })} + {gone > 0 && ` · ${t("channels.watched.videos.gone", { count: gone })}`} +

+ {body} + + + + + + + {playing != null && items[playing] && ( + + + + )} +
+ ); +} diff --git a/frontend/src/modules/channels/channelsContext.ts b/frontend/src/modules/channels/channelsContext.ts index 47828f36..aa729c60 100644 --- a/frontend/src/modules/channels/channelsContext.ts +++ b/frontend/src/modules/channels/channelsContext.ts @@ -7,7 +7,7 @@ import type { FilterMap } from "../../lib/tableColumns"; // components (Fast Refresh). The provider owns the state; this is the read side every consumer imports. // The manager's tabs. -export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked"; +export type ChannelsView = "subscribed" | "discovery" | "watched" | "hidden" | "blocked"; export type ChannelsState = { search: string; diff --git a/frontend/src/modules/channels/useWatchedChannelActions.ts b/frontend/src/modules/channels/useWatchedChannelActions.ts new file mode 100644 index 00000000..8b30bd7e --- /dev/null +++ b/frontend/src/modules/channels/useWatchedChannelActions.ts @@ -0,0 +1,70 @@ +import { useTranslation } from "react-i18next"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { qk } from "../../lib/queryKeys"; +import { invalidateSubscription } from "../../lib/subscriptionCache"; +import { api, type WatchedChannel } from "../../lib/api"; +import { notify } from "../../lib/notifications"; +import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; +import { useConfirm } from "../../components/ui/confirmContext"; + +/** Follow and take-off-the-list for a watched channel: the tab's rows and its videos dialog share + * them, so the confirm, the toasts and the cache refresh are written once. */ +export function useWatchedChannelActions(onOpenWizard: () => void) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + const nameOf = (c: WatchedChannel) => c.title ?? c.id; + + const subscribe = useMutation({ + mutationFn: (c: WatchedChannel) => api.subscribeChannel(c.id), + onSuccess: (_data, c) => { + invalidateSubscription(qc, c.id); + notify({ + level: "success", + title: t("channels.discovery.subscribedTitle"), + message: t("channels.discovery.subscribedBody", { name: nameOf(c) }), + meta: { kind: "channel-subscribed", channelId: c.id, channelName: nameOf(c) }, + }); + }, + onError: (err: unknown) => + notifyYouTubeActionError(err, t("channels.discovery.subscribeFailed"), onOpenWizard), + }); + const restore = useMutation({ + mutationFn: (c: WatchedChannel) => api.restoreWatchedChannel(c.id), + meta: { failTitle: "errors.writeFailed.restoreWatchedChannel" }, + onSuccess: () => qc.invalidateQueries({ queryKey: qk.watchedChannels() }), + }); + // Taken off the list at once, with the toast's Undo as the way back (the same bargain as hiding a + // channel): the next channel steps up into its place. + const dismiss = useMutation({ + mutationFn: (c: WatchedChannel) => api.dismissWatchedChannel(c.id), + meta: { failTitle: "errors.writeFailed.dismissWatchedChannel" }, + onSuccess: (_data, c) => { + void qc.invalidateQueries({ queryKey: qk.watchedChannels() }); + notify({ + level: "success", + message: t("channels.watched.dismissed", { name: nameOf(c) }), + action: { label: t("common.undo"), onClick: () => restore.mutate(c) }, + }); + }, + }); + + // Subscribing changes the user's real YouTube account and spends a little quota — confirmed, as + // on the Discovery tab. Resolves whether it went ahead. + const onSubscribe = async (c: WatchedChannel): Promise => { + const ok = await confirm({ + title: t("channels.discovery.subscribe"), + message: t("channels.discovery.confirmSubscribe", { name: nameOf(c) }), + confirmLabel: t("channels.discovery.subscribe"), + }); + if (ok) subscribe.mutate(c); + return ok; + }; + return { + onSubscribe, + subscribing: subscribe.isPending, + dismiss: (c: WatchedChannel) => dismiss.mutate(c), + }; +} + +export type WatchedChannelActions = ReturnType; diff --git a/frontend/src/modules/feed/ChannelPage.tsx b/frontend/src/modules/feed/ChannelPage.tsx index 0551865d..18147aa9 100644 --- a/frontend/src/modules/feed/ChannelPage.tsx +++ b/frontend/src/modules/feed/ChannelPage.tsx @@ -157,6 +157,8 @@ export default function ChannelPage({ qc.invalidateQueries({ queryKey: qk.channel(channelId) }); qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.blockedChannels() }); + // A blocked channel is off the watched-channels list; unblocked, it may be back on it. + qc.invalidateQueries({ queryKey: qk.watchedChannels() }); }, // No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide // reporter names the failure the error dialog does not cover (a 404 for a channel gone).