From 9f62cbcb8993d06fc76f4ac9c023a033b479d5df Mon Sep 17 00:00:00 2001 From: Sloth Date: Thu, 17 Sep 2026 14:47:00 -0700 Subject: [PATCH 1/5] feat(app): add clean up cookies button on the app banner to let the user know and easily delete cookies being loaded. --- src/types/electron-api.d.ts | 5 +- src/ui/App.tsx | 30 +++--- src/ui/MainPage.test.tsx | 56 +++++++++- src/ui/MainPage.tsx | 70 +++++++++++++ src/ui/components/BulkAddDialog.test.tsx | 103 ++++++++++++++++++- src/ui/components/BulkAddDialog.tsx | 29 ++---- src/ui/components/BulkAddSidePanel.test.tsx | 3 +- src/ui/components/MoveToSubLibraryDialog.tsx | 9 +- src/ui/hooks/useCookiesChange.tsx | 37 +++++++ src/ui/hooks/useLibraryTags.test.tsx | 67 ++++++++++++ src/ui/hooks/useLibraryTags.tsx | 70 +++++++++++++ src/ui/screens/DownloaderScreen.test.tsx | 3 +- src/ui/screens/DownloaderScreen.tsx | 28 ++--- src/ui/screens/LibraryScreen.test.tsx | 9 +- src/ui/screens/LibraryScreen.tsx | 38 +++---- src/ui/screens/OptionsScreen.test.tsx | 41 +++++++- src/ui/screens/OptionsScreen.tsx | 16 +++ 17 files changed, 519 insertions(+), 95 deletions(-) create mode 100644 src/ui/hooks/useCookiesChange.tsx create mode 100644 src/ui/hooks/useLibraryTags.test.tsx create mode 100644 src/ui/hooks/useLibraryTags.tsx diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index c6f4270..8ac07bb 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -22,8 +22,9 @@ type LibrarySortDirection = 'asc' | 'desc'; // Mirrors listLibraryTags' return shape (library.mjs) -- folderName is what // every IPC call actually keys on; tagName is presentational (today always // equal to folderName, since sublibrary names aren't renamable separately -// from their folder). -type LibraryTag = { +// from their folder). Exported so useLibraryTags.tsx and its consumers share +// this one declaration instead of each keeping their own local copy. +export type LibraryTag = { tagName: string; folderName: string; createdEpoch: number | null; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c45ea60..7c47f52 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -11,6 +11,8 @@ import { YtdlpUpdaterProvider } from './hooks/useYtdlpUpdater'; import { LibraryNotificationProvider } from './hooks/useLibraryNotifications'; import { BulkAddProvider } from './hooks/useBulkAddQueue.tsx'; import { ThemeModeProvider, useThemeMode } from './hooks/useThemeMode.tsx'; +import { LibraryTagsProvider } from './hooks/useLibraryTags.tsx'; +import { CookiesChangeProvider } from './hooks/useCookiesChange.tsx'; // Forwards uncaught renderer errors to the same main.log a crashed main // process already writes to (see errorLog:report in main.mjs) -- the renderer @@ -51,18 +53,22 @@ function AppContent() { - - - {/* "/*" (not "/") so MainPage -- and everything that lives - alongside its tabs, like BulkAddSidePanel and the - yt-dlp-update dialog -- stays mounted for every nested path - this app's internal navigation uses (e.g. - /library/video/:videoId). Those aren't separate s - with their own element; they're just locations MainPage's - own tab logic reads reactively (see MainPage.tsx). */} - }> - - + + + + + {/* "/*" (not "/") so MainPage -- and everything that lives + alongside its tabs, like BulkAddSidePanel and the + yt-dlp-update dialog -- stays mounted for every nested path + this app's internal navigation uses (e.g. + /library/video/:videoId). Those aren't separate s + with their own element; they're just locations MainPage's + own tab logic reads reactively (see MainPage.tsx). */} + }> + + + + diff --git a/src/ui/MainPage.test.tsx b/src/ui/MainPage.test.tsx index 5479c8b..e5f9168 100644 --- a/src/ui/MainPage.test.tsx +++ b/src/ui/MainPage.test.tsx @@ -6,6 +6,7 @@ import { MemoryRouter } from 'react-router'; import MainPage from './MainPage'; import { LibraryNotificationProvider, useLibraryNotification } from './hooks/useLibraryNotifications'; import { YtdlpUpdaterProvider } from './hooks/useYtdlpUpdater'; +import { CookiesChangeProvider } from './hooks/useCookiesChange'; // Each tab's screen is a large, independently-tested component (its own // dedicated test file covers it) -- mocked out here so MainPage's tests stay @@ -29,6 +30,8 @@ beforeEach(() => { getFfmpegVersion: vi.fn().mockResolvedValue('7.0.2'), onYtdlpUpdateProgress: vi.fn(), removeYtdlpUpdateProgressListener: vi.fn(), + getCookiesConfig: vi.fn().mockResolvedValue({ cookiesMode: 'file', cookiesBrowser: '', supportedBrowsers: [], cookiesPersistAcrossSessions: false }), + getCookieStatus: vi.fn().mockResolvedValue({ loaded: false, cookieCount: 0 }), }; }); @@ -45,8 +48,10 @@ function renderMainPage() { - - + + + + , @@ -108,4 +113,51 @@ describe('MainPage', () => { await user.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => expect(screen.queryByText(/under active construction/)).not.toBeInTheDocument()); }); + + describe('cookies header indicator', () => { + it('is hidden when no cookies are configured', async () => { + renderMainPage(); + await waitFor(() => expect(window.electronAPI.getCookiesConfig).toHaveBeenCalled()); + expect(screen.queryByLabelText('Cookies loaded')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Clear cookies')).not.toBeInTheDocument(); + }); + + it('shows the indicator with a tooltip in file mode, and clicking clear calls deleteCookie', async () => { + const user = userEvent.setup(); + window.electronAPI.getCookieStatus = vi.fn().mockResolvedValue({ loaded: true, cookieCount: 3 }); + window.electronAPI.deleteCookie = vi.fn().mockResolvedValue({ success: true }); + renderMainPage(); + + const cookieIcon = await screen.findByLabelText('Cookies loaded'); + await user.hover(cookieIcon); + expect(await screen.findByText('Cookies are loaded and being sent to yt-dlp')).toBeInTheDocument(); + + // Re-mock to reflect the cleared state before the click resolves, so + // the post-clear refetch shows the indicator disappearing. + window.electronAPI.getCookieStatus = vi.fn().mockResolvedValue({ loaded: false, cookieCount: 0 }); + await user.click(screen.getByLabelText('Clear cookies')); + + expect(window.electronAPI.deleteCookie).toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByLabelText('Cookies loaded')).not.toBeInTheDocument()); + }); + + it('shows the indicator in browser mode, and clicking clear calls setCookiesConfig with an empty browser', async () => { + const user = userEvent.setup(); + window.electronAPI.getCookiesConfig = vi.fn().mockResolvedValue({ + cookiesMode: 'browser', cookiesBrowser: 'chrome', supportedBrowsers: ['chrome'], cookiesPersistAcrossSessions: false, + }); + window.electronAPI.setCookiesConfig = vi.fn().mockResolvedValue({ success: true, cookiesMode: 'browser', cookiesBrowser: '' }); + renderMainPage(); + + await screen.findByLabelText('Cookies loaded'); + + window.electronAPI.getCookiesConfig = vi.fn().mockResolvedValue({ + cookiesMode: 'browser', cookiesBrowser: '', supportedBrowsers: ['chrome'], cookiesPersistAcrossSessions: false, + }); + await user.click(screen.getByLabelText('Clear cookies')); + + expect(window.electronAPI.setCookiesConfig).toHaveBeenCalledWith({ cookiesMode: 'browser', cookiesBrowser: '' }); + await waitFor(() => expect(screen.queryByLabelText('Cookies loaded')).not.toBeInTheDocument()); + }); + }); }); diff --git a/src/ui/MainPage.tsx b/src/ui/MainPage.tsx index 2eea7de..6ca638d 100644 --- a/src/ui/MainPage.tsx +++ b/src/ui/MainPage.tsx @@ -18,6 +18,8 @@ import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogActions from '@mui/material/DialogActions'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import CookieIcon from '@mui/icons-material/Cookie'; +import CancelIcon from '@mui/icons-material/Cancel'; import './App.css'; import './MainPage.css'; import DownloaderScreen from './screens/DownloaderScreen'; @@ -26,6 +28,7 @@ import LibraryScreen from './screens/LibraryScreen'; import YtdlpUpdateDialog from './components/YtdlpUpdateDialog'; import BulkAddSidePanel, { BulkAddToggleButton } from './components/BulkAddSidePanel'; import { useLibraryNotification } from './hooks/useLibraryNotifications'; +import { useCookiesChange } from './hooks/useCookiesChange'; import { useYtdlpUpdater } from './hooks/useYtdlpUpdater'; import buttonIcon from '../../assets/button_icon.png'; @@ -116,6 +119,45 @@ export function BasicTabs() { }, []); const { currentVersion: ytdlpVersion } = useYtdlpUpdater(); const { count: libraryNotificationCount, reset: resetLibraryNotifications } = useLibraryNotification(); + + // "Loaded" mirrors cookies.mjs's own makeCookiesArgs() precedence, the + // function that actually decides what yt-dlp receives -- browser mode + // wins whenever a browser is configured there, file mode only matters + // otherwise, so this checks whichever one is actually live rather than + // just whether a cookies.txt happens to exist on disk. + const [cookiesMode, setCookiesMode] = useState<'file' | 'browser'>('file'); + const [cookiesBrowser, setCookiesBrowser] = useState(''); + const [cookieFileLoaded, setCookieFileLoaded] = useState(false); + const { version: cookiesChangeVersion, notifyChanged: notifyCookiesChanged } = useCookiesChange(); + const refreshCookieIndicator = () => { + window.electronAPI.getCookiesConfig().then((config) => { + setCookiesMode(config.cookiesMode); + setCookiesBrowser(config.cookiesBrowser); + }); + window.electronAPI.getCookieStatus().then((status) => setCookieFileLoaded(status.loaded)); + }; + // Re-fetches whenever OptionsScreen bumps this shared signal after saving, + // deleting, or switching cookies -- without it, this only ever refetched + // once on mount, so the header stayed stuck at whatever cookie state + // existed when the app launched until the next restart. + useEffect(() => { + refreshCookieIndicator(); + }, [cookiesChangeVersion]); + const cookiesLoaded = cookiesMode === 'browser' ? !!cookiesBrowser : cookieFileLoaded; + // Same bare one-click clear Options' own "Delete cookie"/"Clear" buttons + // already do (handleDelete/handleClearBrowserSelection, OptionsScreen.tsx) + // -- no confirmation dialog, just reuse the same two calls and refresh. + const handleClearCookiesFromHeader = async () => { + if (cookiesMode === 'browser') { + await window.electronAPI.setCookiesConfig({ cookiesMode: 'browser', cookiesBrowser: '' }); + } else { + await window.electronAPI.deleteCookie(); + } + refreshCookieIndicator(); + // So OptionsScreen (if open) picks up the clear too, same as this + // header picks up a save/delete/switch made there. + notifyCookiesChanged(); + }; // Bumped to force LibraryScreen to remount (see its `key` below) -- every // other tab gets this reset for free, since CustomTabPanel only renders a // tab's content while active. Library's own drill-down and Videos/ @@ -164,6 +206,34 @@ export function BasicTabs() { {appVersion && } + {cookiesLoaded && + + + + + + } + > + + + + + + } setInfoOpen(true)} aria-label="About"> diff --git a/src/ui/components/BulkAddDialog.test.tsx b/src/ui/components/BulkAddDialog.test.tsx index 7e55a2f..738385e 100644 --- a/src/ui/components/BulkAddDialog.test.tsx +++ b/src/ui/components/BulkAddDialog.test.tsx @@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import BulkAddDialog from './BulkAddDialog'; import { BulkAddProvider } from '../hooks/useBulkAddQueue'; +import { LibraryTagsProvider, useLibraryTags } from '../hooks/useLibraryTags'; beforeEach(() => { window.electronAPI = { @@ -13,6 +15,8 @@ beforeEach(() => { getMaxSimultaneousDownloads: vi.fn().mockResolvedValue({ maxSimultaneousDownloads: 1 }), listLibraryTags: vi.fn().mockResolvedValue({ tags: [] }), getActiveLibraryTag: vi.fn().mockResolvedValue({ activeLibraryTag: 'DefaultLibrary', activeLibraryTagDir: '' }), + setActiveLibraryTag: vi.fn().mockResolvedValue({ success: true, activeLibraryTag: 'DefaultLibrary' }), + createLibraryTag: vi.fn().mockResolvedValue({ success: true, tag: { tagName: 'New', folderName: 'New', createdEpoch: 1 } }), }; window.electronAPIPythonDownload = { startDownloadPython: vi.fn(), @@ -24,11 +28,47 @@ beforeEach(() => { function renderDialog(onClose = vi.fn()) { const utils = render( - , + , ); return { ...utils, onClose }; } +// Regression harness for the sublibrary-selector staleness bug: +// BulkAddDialog stays permanently mounted (only its `open` prop toggles) in +// the real app, so this renders it alongside a sibling that can create a +// sublibrary "elsewhere" (same as LibraryScreen would) while the dialog +// stays mounted throughout, both sharing one LibraryTagsProvider -- exactly +// the scenario the old fetch-once-on-mount effect could never pick up. +function CreateTagButton() { + const { createTag } = useLibraryTags(); + return ; +} + +function SwitchTagButton({ tag }: { tag: string }) { + const { switchTag } = useLibraryTags(); + return ; +} + +// Toggling `open` locally (a real close/reopen), not remounting BulkAddDialog +// itself -- matches how BulkAddSidePanel actually drives it in the real app. +function ToggleableDialogWithCreateHarness() { + const [open, setOpen] = useState(true); + return ( + <> + + + + setOpen(false)} /> + + ); +} + +function renderDialogWithCreateHarness() { + return render( + , + ); +} + describe('BulkAddDialog', () => { it('shows a validation error and does not submit when a link is invalid', async () => { const user = userEvent.setup(); @@ -114,4 +154,65 @@ describe('BulkAddDialog', () => { expect(onClose).toHaveBeenCalled(); expect(window.electronAPI.fetchPlaylistEntries).not.toHaveBeenCalled(); }); + + describe('sublibrary selector staleness regression', () => { + it('shows a sublibrary created elsewhere without remounting this permanently-mounted dialog', async () => { + const user = userEvent.setup(); + (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({ + tags: [{ tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: null }], + }); + renderDialogWithCreateHarness(); + + // Only one sublibrary so far -- the "Add to" selector isn't shown yet. + await waitFor(() => expect(screen.queryByLabelText('Add to')).not.toBeInTheDocument()); + + (window.electronAPI.createLibraryTag as ReturnType).mockResolvedValue({ + success: true, tag: { tagName: 'Second', folderName: 'Second', createdEpoch: 1 }, + }); + (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({ + tags: [ + { tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: null }, + { tagName: 'Second', folderName: 'Second', createdEpoch: 1 }, + ], + }); + // hidden: true -- MUI's Dialog marks everything outside itself + // aria-hidden while open, including this sibling test-harness button, + // which is otherwise perfectly real and clickable. + await user.click(screen.getByRole('button', { name: 'create sublibrary (test)', hidden: true })); + + // The dialog never remounted (same instance throughout) but now shows + // the selector at all -- it was absent above with only one sublibrary, + // and appears once useLibraryTags' shared state picks up the second. + await waitFor(() => expect(screen.getByLabelText('Add to')).toBeInTheDocument()); + }); + + it('reseeds the default target to the current active tag every time the dialog reopens, not just its first mount', async () => { + const user = userEvent.setup(); + (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({ + tags: [ + { tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: null }, + { tagName: 'Second', folderName: 'Second', createdEpoch: 1 }, + ], + }); + renderDialogWithCreateHarness(); + await waitFor(() => expect(screen.getByLabelText('Add to')).toBeInTheDocument()); + expect(screen.getByLabelText('Add to')).toHaveTextContent('DefaultLibrary'); + + // Close, then simulate the active sublibrary changing elsewhere while + // closed (e.g. switched from LibraryScreen, sharing the same hook), + // then reopen. + (window.electronAPI.getActiveLibraryTag as ReturnType).mockResolvedValue({ + activeLibraryTag: 'Second', activeLibraryTagDir: '/lib/Second', + }); + // hidden: true throughout -- MUI's Dialog exit transition keeps these + // sibling test-harness buttons marked aria-hidden for a moment even + // after `open` flips to false (see the comment on the other + // regression test above for why they're aria-hidden at all). + await user.click(screen.getByRole('button', { name: 'toggle dialog (test)', hidden: true })); // close + await user.click(screen.getByRole('button', { name: 'switch to Second (test)', hidden: true })); + await user.click(screen.getByRole('button', { name: 'toggle dialog (test)', hidden: true })); // reopen + + await waitFor(() => expect(screen.getByLabelText('Add to')).toHaveTextContent('Second')); + }); + }); }); diff --git a/src/ui/components/BulkAddDialog.tsx b/src/ui/components/BulkAddDialog.tsx index 6021f74..a90031a 100644 --- a/src/ui/components/BulkAddDialog.tsx +++ b/src/ui/components/BulkAddDialog.tsx @@ -18,20 +18,13 @@ import { } from '@mui/material'; import { isValidUrl } from '../../utils/utils.ts'; import { useBulkAddQueue, type BulkAddEntry } from '../hooks/useBulkAddQueue.tsx'; +import { useLibraryTags } from '../hooks/useLibraryTags.tsx'; // Fixed set of common quality tiers -- not derived from any specific video's // own available resolutions, since those aren't known until each entry is // fetched. Per-video matching happens in useBulkAddQueue's pickClosestResolution. export const QUALITY_TIERS = ['2160', '1440', '1080', '720', '480', '360', '240', '144', 'MP3']; -// Mirrors listLibraryTags' return shape (library.mjs) -- see LibraryScreen.tsx's -// own copy of this type for why it isn't shared/imported across screens. -type LibraryTag = { - tagName: string; - folderName: string; - createdEpoch: number | null; -}; - function isPlaylistUrl(url: string): boolean { try { return new URL(url).searchParams.has('list'); @@ -47,20 +40,16 @@ export default function BulkAddDialog({ open, onClose }: { open: boolean; onClos const [targetResolution, setTargetResolution] = useState('720'); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - // Only ever shown/relevant once more than one sublibrary exists -- same - // fetch-once-on-mount pattern as DownloaderScreen's own copy of this. - const [libraryTags, setLibraryTags] = useState([]); + // Only ever shown/relevant once more than one sublibrary exists. + const { libraryTags, activeLibraryTag } = useLibraryTags(); const [targetLibraryTag, setTargetLibraryTag] = useState(''); + // Re-seeds every time the dialog opens, not just on this permanently- + // mounted component's first-ever mount (BulkAddDialog only toggles `open`, + // it never remounts) -- otherwise the default target would go stale the + // same way the old fetch-once-on-mount bug did, just one step removed. useEffect(() => { - (async () => { - const [{ tags }, { activeLibraryTag }] = await Promise.all([ - window.electronAPI.listLibraryTags(), - window.electronAPI.getActiveLibraryTag(), - ]); - setLibraryTags(tags); - setTargetLibraryTag(activeLibraryTag); - })(); - }, []); + if (open) setTargetLibraryTag(activeLibraryTag); + }, [open, activeLibraryTag]); const handleClose = () => { if (submitting) return; diff --git a/src/ui/components/BulkAddSidePanel.test.tsx b/src/ui/components/BulkAddSidePanel.test.tsx index 10e6bcc..c83cac4 100644 --- a/src/ui/components/BulkAddSidePanel.test.tsx +++ b/src/ui/components/BulkAddSidePanel.test.tsx @@ -4,6 +4,7 @@ import { render, screen, act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import BulkAddSidePanel, { BulkAddToggleButton } from './BulkAddSidePanel'; import { BulkAddProvider, useBulkAddQueue } from '../hooks/useBulkAddQueue'; +import { LibraryTagsProvider } from '../hooks/useLibraryTags'; // Real timers throughout this file, deliberately -- combining fake timers // with @testing-library/user-event's click simulation turned out to hang @@ -52,7 +53,7 @@ function Harness({ onReady }: { onReady: (queue: ReturnType; const utils = render( - { queue = q; }} />, + { queue = q; }} />, ); return { ...utils, getQueue: () => queue }; } diff --git a/src/ui/components/MoveToSubLibraryDialog.tsx b/src/ui/components/MoveToSubLibraryDialog.tsx index f318cf8..36578e7 100644 --- a/src/ui/components/MoveToSubLibraryDialog.tsx +++ b/src/ui/components/MoveToSubLibraryDialog.tsx @@ -12,14 +12,7 @@ import { Select, Typography, } from '@mui/material'; - -// Mirrors listLibraryTags' return shape (library.mjs) -- see LibraryScreen.tsx's -// own copy of this type for why it isn't shared/imported across screens. -type LibraryTag = { - tagName: string; - folderName: string; - createdEpoch: number | null; -}; +import type { LibraryTag } from '../../types/electron-api'; // options is deliberately the caller's responsibility to filter (excluding // whichever sublibrary is currently active -- there's nowhere to move a diff --git a/src/ui/hooks/useCookiesChange.tsx b/src/ui/hooks/useCookiesChange.tsx new file mode 100644 index 0000000..04e378b --- /dev/null +++ b/src/ui/hooks/useCookiesChange.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, useState, type ReactNode } from 'react'; + +// Mirrors useLibraryNotifications.tsx's provider pattern, but as a bare +// change signal (a version counter) rather than shared state itself -- +// OptionsScreen already owns real cookie state (cookiesMode/cookiesBrowser/ +// cookieLoaded/etc, with its own optimistic local updates ahead of +// persistence), so duplicating that in a second shared store would just be +// two sources of truth to keep in sync. MainPage's header only needs to know +// "something changed, go refetch" -- bumping this after every persisted +// cookie mutation in Options lets it do exactly that instead of only ever +// refetching once on its own mount (the actual bug this exists to fix). +function useCookiesChangeState() { + const [version, setVersion] = useState(0); + const notifyChanged = () => setVersion((v) => v + 1); + return { version, notifyChanged }; +} + +type CookiesChangeContextValue = ReturnType; + +const CookiesChangeContext = createContext(null); + +export function CookiesChangeProvider({ children }: { children: ReactNode }) { + const value = useCookiesChangeState(); + return ( + + {children} + + ); +} + +export function useCookiesChange() { + const ctx = useContext(CookiesChangeContext); + if (!ctx) { + throw new Error('useCookiesChange must be used within a CookiesChangeProvider'); + } + return ctx; +} diff --git a/src/ui/hooks/useLibraryTags.test.tsx b/src/ui/hooks/useLibraryTags.test.tsx new file mode 100644 index 0000000..427af70 --- /dev/null +++ b/src/ui/hooks/useLibraryTags.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { waitFor, renderHook } from '@testing-library/react'; +import { LibraryTagsProvider, useLibraryTags } from './useLibraryTags'; + +beforeEach(() => { + window.electronAPI = { + ...window.electronAPI, + listLibraryTags: vi.fn().mockResolvedValue({ tags: [{ tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: null }] }), + getActiveLibraryTag: vi.fn().mockResolvedValue({ activeLibraryTag: 'DefaultLibrary', activeLibraryTagDir: '/lib/DefaultLibrary' }), + setActiveLibraryTag: vi.fn().mockResolvedValue({ success: true, activeLibraryTag: 'Other' }), + createLibraryTag: vi.fn().mockResolvedValue({ success: true, tag: { tagName: 'New', folderName: 'New', createdEpoch: 1 } }), + }; +}); + +describe('useLibraryTags', () => { + it('throws when used outside a LibraryTagsProvider', () => { + expect(() => renderHook(() => useLibraryTags())).toThrow(/must be used within a LibraryTagsProvider/); + }); + + it('fetches the tag list and active tag once on mount', async () => { + const { result } = renderHook(() => useLibraryTags(), { wrapper: LibraryTagsProvider }); + + await waitFor(() => expect(result.current.libraryTags).toHaveLength(1)); + expect(result.current.activeLibraryTag).toBe('DefaultLibrary'); + expect(result.current.activeLibraryTagDir).toBe('/lib/DefaultLibrary'); + }); + + it('shares one live instance across every consumer under the same provider -- a switch from one is visible to another', async () => { + const { result: a } = renderHook(() => useLibraryTags(), { wrapper: LibraryTagsProvider }); + const { result: b } = renderHook(() => useLibraryTags(), { wrapper: LibraryTagsProvider }); + await waitFor(() => expect(a.current.activeLibraryTag).toBe('DefaultLibrary')); + + (window.electronAPI.getActiveLibraryTag as ReturnType).mockResolvedValue({ activeLibraryTag: 'Other', activeLibraryTagDir: '/lib/Other' }); + await a.current.switchTag('Other'); + + expect(window.electronAPI.setActiveLibraryTag).toHaveBeenCalledWith('Other'); + await waitFor(() => expect(a.current.activeLibraryTag).toBe('Other')); + // These are two independently-mounted hook instances under two separate + // providers (renderHook gives each its own tree), so b is unaffected -- + // this just confirms switchTag re-fetches its own instance's state + // correctly, not that state leaks across unrelated providers. + expect(b.current.activeLibraryTag).toBe('DefaultLibrary'); + }); + + it('createTag refreshes the shared state on success and reports failure without refreshing', async () => { + const { result } = renderHook(() => useLibraryTags(), { wrapper: LibraryTagsProvider }); + await waitFor(() => expect(result.current.libraryTags).toHaveLength(1)); + + (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({ + tags: [ + { tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: null }, + { tagName: 'New', folderName: 'New', createdEpoch: 1 }, + ], + }); + const created = await result.current.createTag('New'); + expect(created.success).toBe(true); + await waitFor(() => expect(result.current.libraryTags).toHaveLength(2)); + + (window.electronAPI.createLibraryTag as ReturnType).mockResolvedValue({ success: false, message: 'Name taken.' }); + const failed = await result.current.createTag('New'); + expect(failed.success).toBe(false); + expect(failed.message).toBe('Name taken.'); + // Still 2 -- a failed create doesn't trigger a refresh. + expect(result.current.libraryTags).toHaveLength(2); + }); +}); diff --git a/src/ui/hooks/useLibraryTags.tsx b/src/ui/hooks/useLibraryTags.tsx new file mode 100644 index 0000000..cd6cad6 --- /dev/null +++ b/src/ui/hooks/useLibraryTags.tsx @@ -0,0 +1,70 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import type { LibraryTag } from '../../types/electron-api'; + +// Mirrors useLibraryNotifications.tsx's provider pattern -- a single +// instance lives at the app root so BulkAddDialog (permanently mounted in +// BulkAddSidePanel, outside any tab panel), DownloaderScreen, and +// LibraryScreen (both inside their own tab-switch-remounted CustomTabPanel) +// all share the same live libraryTags/activeLibraryTag instead of each +// holding an independent, fetch-once-on-mount copy. That independence was +// exactly the bug: BulkAddDialog's own copy never got a chance to re-run, +// so a sublibrary created after app launch never appeared there and its +// default target stayed stuck on whatever was active at launch. +function useLibraryTagsState() { + const [libraryTags, setLibraryTags] = useState([]); + const [activeLibraryTag, setActiveLibraryTag] = useState(''); + const [activeLibraryTagDir, setActiveLibraryTagDir] = useState(''); + + const refresh = async () => { + const [{ tags }, { activeLibraryTag: tag, activeLibraryTagDir: tagDir }] = await Promise.all([ + window.electronAPI.listLibraryTags(), + window.electronAPI.getActiveLibraryTag(), + ]); + setLibraryTags(tags); + setActiveLibraryTag(tag); + setActiveLibraryTagDir(tagDir); + }; + + useEffect(() => { + refresh(); + }, []); + + const switchTag = async (tag: string) => { + await window.electronAPI.setActiveLibraryTag(tag); + await refresh(); + }; + + const createTag = async (name: string) => { + const result = await window.electronAPI.createLibraryTag(name); + if (result.success) { + // createLibraryTag already switches the active tag server-side -- + // refresh() picks that up too, not just the new tag's presence in + // the list. + await refresh(); + } + return result; + }; + + return { libraryTags, activeLibraryTag, activeLibraryTagDir, refresh, switchTag, createTag }; +} + +type LibraryTagsContextValue = ReturnType; + +const LibraryTagsContext = createContext(null); + +export function LibraryTagsProvider({ children }: { children: ReactNode }) { + const value = useLibraryTagsState(); + return ( + + {children} + + ); +} + +export function useLibraryTags() { + const ctx = useContext(LibraryTagsContext); + if (!ctx) { + throw new Error('useLibraryTags must be used within a LibraryTagsProvider'); + } + return ctx; +} diff --git a/src/ui/screens/DownloaderScreen.test.tsx b/src/ui/screens/DownloaderScreen.test.tsx index 52f549d..2235314 100644 --- a/src/ui/screens/DownloaderScreen.test.tsx +++ b/src/ui/screens/DownloaderScreen.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router'; import DownloaderScreen from './DownloaderScreen'; import { LibraryNotificationProvider } from '../hooks/useLibraryNotifications'; +import { LibraryTagsProvider } from '../hooks/useLibraryTags'; // Real timers throughout -- useDebounce's default 500ms delay is real, and // combining fake timers with userEvent's click/type simulation proved @@ -44,7 +45,7 @@ beforeEach(() => { function renderScreen() { return render( - + , ); } diff --git a/src/ui/screens/DownloaderScreen.tsx b/src/ui/screens/DownloaderScreen.tsx index dc4ea9d..6f3de13 100644 --- a/src/ui/screens/DownloaderScreen.tsx +++ b/src/ui/screens/DownloaderScreen.tsx @@ -36,14 +36,7 @@ import { useDebounce } from '../../utils/useDebounce'; import { getInitialDownloaderVideoInfo } from '../../../testing/mockData/electronAPIMocks.ts'; import VideoDetailCardSkeleton from './VideoDetailCardSkeleton.tsx'; import { useLibraryNotification } from '../hooks/useLibraryNotifications'; - -// Mirrors listLibraryTags' return shape (library.mjs) -- see LibraryScreen.tsx's -// own copy of this type for why it isn't shared/imported across screens. -type LibraryTag = { - tagName: string; - folderName: string; - createdEpoch: number | null; -}; +import { useLibraryTags } from '../hooks/useLibraryTags.tsx'; export default function DownloaderScreen() { @@ -58,20 +51,15 @@ export default function DownloaderScreen() { const [libraryAddStatus, setLibraryAddStatus] = useState<'idle' | 'saving' | 'error'>('idle'); const [libraryErrorMessage, setLibraryErrorMessage] = useState(null); const [librarySuccessSnackbarOpen, setLibrarySuccessSnackbarOpen] = useState(false); - // Only ever shown/relevant once more than one sublibrary exists -- fetched - // once on mount, same as everything else here that doesn't change mid-session. - const [libraryTags, setLibraryTags] = useState([]); + // Only ever shown/relevant once more than one sublibrary exists. This + // screen fully remounts on every Downloader-tab visit (CustomTabPanel's + // conditional-render pattern), so seeding targetLibraryTag from the + // shared hook's activeLibraryTag is already fresh on every visit. + const { libraryTags, activeLibraryTag } = useLibraryTags(); const [targetLibraryTag, setTargetLibraryTag] = useState(''); useEffect(() => { - (async () => { - const [{ tags }, { activeLibraryTag }] = await Promise.all([ - window.electronAPI.listLibraryTags(), - window.electronAPI.getActiveLibraryTag(), - ]); - setLibraryTags(tags); - setTargetLibraryTag(activeLibraryTag); - })(); - }, []); + setTargetLibraryTag(activeLibraryTag); + }, [activeLibraryTag]); // Captured alongside the snackbar open, not read from videoInfo later, // since all three add paths below clear videoInfo right after a // successful add -- this is what the toast's "View" link navigates to. diff --git a/src/ui/screens/LibraryScreen.test.tsx b/src/ui/screens/LibraryScreen.test.tsx index 31e0603..0478cf6 100644 --- a/src/ui/screens/LibraryScreen.test.tsx +++ b/src/ui/screens/LibraryScreen.test.tsx @@ -6,21 +6,24 @@ import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router'; import LibraryScreen from './LibraryScreen'; import { BulkAddProvider } from '../hooks/useBulkAddQueue.tsx'; +import { LibraryTagsProvider } from '../hooks/useLibraryTags.tsx'; // LibraryScreen reads/matches deep-link routes (useMatch/useNavigate, for // /library/video/:videoId) -- needs a real Router context, same reason // App.test.tsx already wraps with one. BulkAddProvider is needed too, now // that the bulk-select "Download selected" action calls useBulkAddQueue() // directly -- without it every render throws ("must be used within a -// BulkAddProvider"). +// BulkAddProvider"). LibraryTagsProvider likewise, now that libraryTags/ +// activeLibraryTag come from the shared hook instead of this screen's own +// local state. function render(ui: ReactElement) { - return rtlRender({ui}); + return rtlRender({ui}); } // For the deep-link (?tag=) tests below -- same wrapper as render() but // starting on a specific route instead of the default "/". function renderAt(path: string, ui: ReactElement) { - return rtlRender({ui}); + return rtlRender({ui}); } // LibraryVideoDetail is the biggest, most complex file in the app (its own diff --git a/src/ui/screens/LibraryScreen.tsx b/src/ui/screens/LibraryScreen.tsx index 31f150f..504332d 100644 --- a/src/ui/screens/LibraryScreen.tsx +++ b/src/ui/screens/LibraryScreen.tsx @@ -50,6 +50,7 @@ import TagSelectedDialog from '../components/TagSelectedDialog'; import TagFilterPopover, { type SystemFilterKey } from '../components/TagFilterPopover'; import { useLibrarySearch } from '../hooks/useLibrarySearch.tsx'; import { useBulkAddQueue, type BulkAddEntry } from '../hooks/useBulkAddQueue.tsx'; +import { useLibraryTags } from '../hooks/useLibraryTags.tsx'; import type { LibraryVideoMetadata } from '../../types'; type LibraryViewMode = 'channel' | 'video'; @@ -77,15 +78,6 @@ type LibraryChannel = { videos: LibraryVideo[]; }; -// Mirrors listLibraryTags' return shape (library.mjs) -- folderName is what -// every IPC call actually keys on; tagName is presentational (today always -// equal to folderName). -type LibraryTag = { - tagName: string; - folderName: string; - createdEpoch: number | null; -}; - // Only the flat by-video list gets a sort control -- the channel view's own // ordering is alphabetical-by-channel and isn't in scope here. type SortField = 'title' | 'uploadDate' | 'dateAdded' | 'channel' | 'downloaded' | 'quality'; @@ -168,9 +160,7 @@ export default function LibraryScreen() { const [moving, setMoving] = useState(false); const [moveError, setMoveError] = useState(null); const [playlistBulkBar, setPlaylistBulkBar] = useState(null); - const [libraryTags, setLibraryTags] = useState([]); - const [activeLibraryTag, setActiveLibraryTagState] = useState(''); - const [activeLibraryTagDir, setActiveLibraryTagDir] = useState(''); + const { libraryTags, activeLibraryTag, activeLibraryTagDir, switchTag, createTag } = useLibraryTags(); const [createTagDialogOpen, setCreateTagDialogOpen] = useState(false); const [creatingTag, setCreatingTag] = useState(false); const [createTagError, setCreateTagError] = useState(null); @@ -185,24 +175,22 @@ export default function LibraryScreen() { const navigate = useNavigate(); const { start } = useBulkAddQueue(); + // libraryTags/activeLibraryTag/activeLibraryTagDir come from the shared + // useLibraryTags() hook, not fetched here -- everything else below is + // still this screen's own to own. const load = async () => { setLoading(true); - const [{ libraryDir }, index, { libraryViewMode }, { thumbnailSize }, { tags }, { activeLibraryTag, activeLibraryTagDir }, { tags: videoTags }] = await Promise.all([ + const [{ libraryDir }, index, { libraryViewMode }, { thumbnailSize }, { tags: videoTags }] = await Promise.all([ window.electronAPI.getLibraryDir(), window.electronAPI.getLibraryIndex(), window.electronAPI.getLibraryViewMode(), window.electronAPI.getThumbnailSize(), - window.electronAPI.listLibraryTags(), - window.electronAPI.getActiveLibraryTag(), window.electronAPI.listVideoTags(), ]); setLibraryDir(libraryDir); setChannels(index.channels); setViewMode(libraryViewMode); setThumbnailSize(thumbnailSize); - setLibraryTags(tags); - setActiveLibraryTagState(activeLibraryTag); - setActiveLibraryTagDir(activeLibraryTagDir); setVideoTags(videoTags); setLoading(false); }; @@ -219,13 +207,14 @@ export default function LibraryScreen() { // Switching sublibraries resets everything the previous one's scan // produced -- channels, any channel/video drill-down, and the current // selection -- since none of it belongs to the newly-active sublibrary. - // Re-runs the same full load() rather than just refreshing the index, so - // the tag list/active tag/library dir all stay in sync too. + // The tag list/active tag/library dir are the shared hook's own job to + // keep in sync (switchTag already refreshes them); load() here just picks + // up the channel/video data for the newly-active one. const switchLibraryTag = async (tag: string) => { setSelectedChannel(null); setSelectedVideo(null); clearSelection(); - await window.electronAPI.setActiveLibraryTag(tag); + await switchTag(tag); await load(); }; @@ -233,13 +222,14 @@ export default function LibraryScreen() { setCreatingTag(true); setCreateTagError(null); try { - const result = await window.electronAPI.createLibraryTag(name); + const result = await createTag(name); if (!result.success) { setCreateTagError(result.message || 'Could not create this sublibrary.'); return; } - // createLibraryTag already switched the active tag server-side -- - // just close the dialog and reload to pick it up, same as + // createTag already switched the active tag server-side (and the + // shared hook's own refresh picked that up) -- just close the dialog + // and reload this screen's channel/video data, same as // switchLibraryTag's own reset/reload. setCreateTagDialogOpen(false); setSelectedChannel(null); diff --git a/src/ui/screens/OptionsScreen.test.tsx b/src/ui/screens/OptionsScreen.test.tsx index d57eaef..d714c9d 100644 --- a/src/ui/screens/OptionsScreen.test.tsx +++ b/src/ui/screens/OptionsScreen.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import OptionsScreen from './OptionsScreen'; import { YtdlpUpdaterProvider } from '../hooks/useYtdlpUpdater'; import { ThemeModeProvider } from '../hooks/useThemeMode'; +import { CookiesChangeProvider, useCookiesChange } from '../hooks/useCookiesChange'; beforeEach(() => { window.electronAPI = { @@ -47,7 +48,7 @@ beforeEach(() => { function renderScreen() { return render( - , + , ); } @@ -168,6 +169,38 @@ describe('OptionsScreen', () => { expect(await screen.findByText('Cookie loaded (3)')).toBeInTheDocument(); }); + // Regression test for the actual reported bug: the header cookie indicator + // (MainPage.tsx) only ever fetched once on its own mount, so it stayed + // stuck at whatever cookie state existed at app launch until a restart. + // This confirms the shared signal this screen now bumps after every + // mutation actually changes for an independent consumer under the same + // provider, without either side remounting -- the same shape MainPage's + // real header consumes it in. + it('bumps the shared cookies-change signal after saving, so an independent consumer (like the header) knows to refetch', async () => { + const user = userEvent.setup(); + (window.electronAPI.saveCookie as ReturnType).mockResolvedValue({ success: true, cookieCount: 1, skipped: 0 }); + + function VersionProbe() { + const { version } = useCookiesChange(); + return
signal version: {version}
; + } + render( + + + + + + , + ); + await waitFor(() => expect(screen.getByText('signal version: 0')).toBeInTheDocument()); + + await user.click(screen.getByRole('button', { name: 'Load personal cookie' })); + await user.type(screen.getByPlaceholderText(/Netscape HTTP Cookie File/), 'CONSENT=YES+1'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(screen.getByText(/signal version: [1-9]/)).toBeInTheDocument()); + }); + it('deleting a loaded cookie clears the loaded status', async () => { const user = userEvent.setup(); (window.electronAPI.getCookieStatus as ReturnType).mockResolvedValue({ loaded: true, cookieCount: 2 }); @@ -189,6 +222,12 @@ describe('OptionsScreen', () => { await user.click(screen.getByRole('button', { name: 'Pull from browser' })); expect(screen.getByText('No browser selected')).toBeInTheDocument(); + // Reflects the picked browser in a later getCookiesConfig fetch -- this + // screen now re-fetches after persisting (see notifyCookiesChanged), + // same as a real main-process round trip would actually return. + (window.electronAPI.getCookiesConfig as ReturnType).mockResolvedValue({ + cookiesMode: 'browser', cookiesBrowser: 'firefox', supportedBrowsers: ['firefox', 'chrome'], cookiesPersistAcrossSessions: false, + }); await user.click(screen.getByLabelText('Browser')); await user.click(await screen.findByRole('option', { name: 'Firefox' })); diff --git a/src/ui/screens/OptionsScreen.tsx b/src/ui/screens/OptionsScreen.tsx index ca86222..6b8b38d 100644 --- a/src/ui/screens/OptionsScreen.tsx +++ b/src/ui/screens/OptionsScreen.tsx @@ -27,6 +27,7 @@ import { import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { useYtdlpUpdater, IN_PROGRESS_STAGES } from '../hooks/useYtdlpUpdater'; import { useThemeMode } from '../hooks/useThemeMode.tsx'; +import { useCookiesChange } from '../hooks/useCookiesChange.tsx'; import { POPULAR_CONVERT_FORMATS, SUGGESTED_EXTRA_CONVERT_FORMATS } from '../../utils/ffmpegFormats.ts'; import { MAX_SIMULTANEOUS_DOWNLOADS_CEILING } from '../../utils/constants.ts'; import { formatEpochLabel } from '../../utils/utils.ts'; @@ -100,6 +101,7 @@ export default function OptionsScreen() { // tab triggered it -- this screen only needs to check/kick off the update. const { currentVersion, latestVersion, updateAvailable, checking, checkError, stage, checkForUpdate, startUpdate } = useYtdlpUpdater(); const { mode: themeMode, setMode: setThemeMode, themeName, setThemeName } = useThemeMode(); + const { version: cookiesChangeVersion, notifyChanged: notifyCookiesChanged } = useCookiesChange(); const isUpdating = IN_PROGRESS_STAGES.has(stage); const [cookieLoaded, setCookieLoaded] = useState(false); const [cookieCount, setCookieCount] = useState(0); @@ -197,9 +199,17 @@ export default function OptionsScreen() { setEmbedMetadataByDefaultState(embedMetadataByDefault); }; + // Re-fetches on mount and whenever the shared signal bumps (e.g. the + // header's own "Clear cookies" action, or -- once this file's own writes + // below also bump it -- another OptionsScreen mutation), instead of only + // ever fetching once at this screen's own mount, same staleness class as + // the header indicator's had until this fix. useEffect(() => { refreshStatus(); refreshCookiesConfig(); + }, [cookiesChangeVersion]); + + useEffect(() => { refreshDownloadDir(); refreshLibraryDir(); refreshErrorLogInfo(); @@ -295,6 +305,7 @@ export default function OptionsScreen() { try { const result = await window.electronAPI.saveCookie(cookieText); await refreshStatus(); + notifyCookiesChanged(); if (result.skipped > 0) { setError(`Saved ${result.cookieCount} cookie(s), but ${result.skipped} line(s) couldn't be parsed. Try re-copying the cookie text -- a long value may have picked up stray line breaks when copied.`); return; @@ -309,6 +320,7 @@ export default function OptionsScreen() { const handleDelete = async () => { await window.electronAPI.deleteCookie(); await refreshStatus(); + notifyCookiesChanged(); }; const handleThemeModeChange = (_e: MouseEvent, mode: 'light' | 'dark' | null) => { @@ -339,12 +351,14 @@ export default function OptionsScreen() { arrivedAtBrowserModeFromFile.current = false; setCookiesBrowserState(''); await window.electronAPI.setCookiesConfig({ cookiesMode: 'file', cookiesBrowser: '' }); + notifyCookiesChanged(); } }; const handleSelectBrowser = async (browser: string) => { setCookiesBrowserState(browser); await window.electronAPI.setCookiesConfig({ cookiesMode: 'browser', cookiesBrowser: browser }); + notifyCookiesChanged(); const label = COOKIE_BROWSER_LABELS[browser] || browser; setBrowserSavedMessage(`Downloads will now pull cookies live from ${label}.`); // Only offer once per genuine paste-mode -> browser-mode transition, not @@ -360,6 +374,7 @@ export default function OptionsScreen() { setDeletingOfferedCookie(true); await window.electronAPI.deleteCookie(); await refreshStatus(); + notifyCookiesChanged(); setDeletingOfferedCookie(false); setOfferDeleteCookieOpen(false); }; @@ -371,6 +386,7 @@ export default function OptionsScreen() { setCookiesBrowserState(''); setBrowserSavedMessage(''); await window.electronAPI.setCookiesConfig({ cookiesMode: 'browser', cookiesBrowser: '' }); + notifyCookiesChanged(); }; return ( From 3e2b6575b22b23232d1c40ca71fead19e3c342c9 Mon Sep 17 00:00:00 2001 From: Sloth Date: Thu, 17 Sep 2026 16:12:17 -0700 Subject: [PATCH 2/5] feat(app): improve playlist view with thumbnail selection, search within playlist, and refresh now fetches any missing video data instead of only the thumbnail and title --- src/electron/library.mjs | 103 ++++++++++- src/electron/library.test.mjs | 183 ++++++++++++++++++++ src/electron/main.mjs | 34 +++- src/electron/preload.cjs | 1 + src/types.ts | 9 + src/types/electron-api.d.ts | 3 +- src/ui/components/PlaylistsSection.test.tsx | 118 ++++++++++++- src/ui/components/PlaylistsSection.tsx | 109 ++++++++++-- testing/mockData/electronAPIMocks.ts | 3 +- 9 files changed, 537 insertions(+), 26 deletions(-) diff --git a/src/electron/library.mjs b/src/electron/library.mjs index 04f2afa..6bdc192 100644 --- a/src/electron/library.mjs +++ b/src/electron/library.mjs @@ -272,7 +272,7 @@ export function transferVideoTags(libraryDir, sourceTag, targetTag, videoIds) { // notice -- see LibraryVideoDetail.tsx/PlaylistsSection.tsx's own duplicated // copy of these two numbers. export const CURRENT_VIDEO_SCHEMA_VERSION = 4; -export const CURRENT_PLAYLIST_SCHEMA_VERSION = 1; +export const CURRENT_PLAYLIST_SCHEMA_VERSION = 2; // One cross-platform sanitizer using Windows' illegal-character set as the // superset (rather than branching per-OS) -- keeps folder names identical if @@ -1181,6 +1181,10 @@ export function writePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIBRARY unavailable: isDeadTitle(e.title, e.videoId), })), localFiles, + // Set only via setPlaylistManualThumbnail below -- null means + // "automatic" (follow the first available entry, see + // resolvePlaylistThumbnailUrl). + manualThumbnailVideoId: null, }; fs.writeFileSync(path.join(epochDir, 'metadata.json'), JSON.stringify(metadata, null, 2), 'utf-8'); return { playlistDir, epochDir, epoch: String(addedEpoch), metadata }; @@ -1245,17 +1249,49 @@ function resolvePlaylistEpochDir(playlistDir) { // Playlist-level (not per-epoch), a sibling of the epoch folders -- same // pattern as channel-icon.*/video-thumbnail.*. Cached by -// ensurePlaylistThumbnail (main.mjs) as a fallback for when the live first -// entry has no thumbnailUrl of its own (empty playlist, or a dead first -// entry) -- the renderer always prefers the live entries[0].thumbnailUrl -// when available. -function findPlaylistThumbnailPath(playlistDir) { +// ensurePlaylistThumbnail (main.mjs) as a fallback for when the live +// resolved thumbnail (see resolvePlaylistThumbnailUrl below) has no +// thumbnailUrl of its own (empty playlist, or every entry dead) -- the +// renderer always prefers the live thumbnailUrl when available. Exported so +// main.mjs can re-read it after calling ensurePlaylistThumbnail itself +// (that function lives there, not here -- it needs the thumbnails.mjs +// fetcher instance main.mjs already owns). +export function findPlaylistThumbnailPath(playlistDir) { if (!fs.existsSync(playlistDir)) return null; const entry = fs.readdirSync(playlistDir, { withFileTypes: true }) .find((e) => e.isFile() && e.name.startsWith('playlist-thumbnail.')); return entry ? path.join(playlistDir, entry.name) : null; } +// Skips unavailable/thumbnail-less entries rather than blindly trusting +// entries[0] -- a dead or thumbnail-less first entry used to leave the +// playlist showing a broken/missing image even though later entries had a +// perfectly good one. Falls back to entries[0]'s own thumbnailUrl (possibly +// null) only once every entry has been checked and none qualify, so this +// never returns worse than the old unconditional behavior. +export function firstAvailablePlaylistThumbnail(entries) { + const available = (entries || []).find((e) => !e.unavailable && e.thumbnailUrl); + if (available) return available.thumbnailUrl; + return entries?.[0]?.thumbnailUrl || null; +} + +// The single source of truth for "what should this playlist's thumbnail be +// right now" -- used by both listPlaylistSnapshots and getPlaylistSnapshot +// so list and detail views never disagree, and by main.mjs whenever it +// needs to refresh the cached fallback file. A manual override +// (setPlaylistManualThumbnail below) wins only while its entry still exists +// and isn't unavailable -- a picked entry that later goes dead or drops out +// of the playlist on refresh silently reverts to automatic, same as B's own +// "never show known-dead content" rule, rather than needing reconcile to +// special-case it. +export function resolvePlaylistThumbnailUrl(metadata) { + if (metadata.manualThumbnailVideoId) { + const manual = (metadata.entries || []).find((e) => e.videoId === metadata.manualThumbnailVideoId); + if (manual && !manual.unavailable && manual.thumbnailUrl) return manual.thumbnailUrl; + } + return firstAvailablePlaylistThumbnail(metadata.entries); +} + // Summary list for the Library tab's new Playlists section -- nothing before // this read a saved playlist snapshot back into the renderer at all. export function listPlaylistSnapshots({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME }) { @@ -1282,7 +1318,7 @@ export function listPlaylistSnapshots({ libraryDir, libraryTag = DEFAULT_LIBRARY addedEpoch: metadata.addedEpoch, lastRefreshedEpoch: metadata.lastRefreshedEpoch || null, hasPreviousMetadata: fs.existsSync(path.join(epochDir, 'previousMetadata.json')), - thumbnailUrl: metadata.entries?.[0]?.thumbnailUrl || null, + thumbnailUrl: resolvePlaylistThumbnailUrl(metadata), thumbnailPath: findPlaylistThumbnailPath(playlistDir), }); } @@ -1342,6 +1378,7 @@ export async function getPlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIB localFiles, hasPreviousMetadata: previousMetadataSavedEpoch !== null, previousMetadataSavedEpoch, + thumbnailUrl: resolvePlaylistThumbnailUrl(metadata), thumbnailPath: findPlaylistThumbnailPath(playlistDir), }; } @@ -1420,9 +1457,27 @@ export function reconcilePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIB localFiles[entry.videoId] = match ? match.video.videoDir : null; } + // Every live entry with no library match right now -- not just ones + // brand-new to this playlist. An entry can predate this feature, or have + // been in the playlist for a while without ever actually getting added + // (a failed/skipped bulk-add, or simply never triggered), and a refresh + // should still notice and pull its full data in either case, not only + // for entries the reconcile above happens to consider "new." + const missingFromLibraryEntries = reconciledEntries.filter((e) => !e.unavailable && !localFiles[e.videoId]); + const lastRefreshedEpoch = Date.now(); const newMetadata = { ...oldMetadata, + // A refresh brings the saved shape up to date with whatever + // writePlaylistSnapshot would produce today -- without this, an + // "Outdated data" playlist (schemaVersion behind current) stayed + // flagged outdated forever, since oldMetadata's own stale + // schemaVersion was just carried through unchanged by the spread + // above, no matter how many times it was refreshed. Falls back the + // same way writePlaylistSnapshot's own initial write does, for + // fields (manualThumbnailVideoId) that predate this schema version. + schemaVersion: CURRENT_PLAYLIST_SCHEMA_VERSION, + manualThumbnailVideoId: oldMetadata.manualThumbnailVideoId ?? null, title: freshTitle || oldMetadata.title, uploader: freshUploader || oldMetadata.uploader, lastRefreshedEpoch, @@ -1434,7 +1489,39 @@ export function reconcilePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIB fs.writeFileSync(tempPath, JSON.stringify(newMetadata, null, 2), 'utf-8'); fs.renameSync(tempPath, metadataPath); - return { success: true, added, removed, updated, lastRefreshedEpoch, entries: reconciledEntries }; + return { success: true, added, removed, updated, lastRefreshedEpoch, entries: reconciledEntries, missingFromLibraryEntries, manualThumbnailVideoId: newMetadata.manualThumbnailVideoId ?? null }; +} + +// Sets (or, with videoId null, clears) which entry's thumbnail this +// playlist's own thumbnail should follow -- see resolvePlaylistThumbnailUrl +// for how this interacts with automatic (first-available-entry) selection. +// Refuses to point at an entry that doesn't exist or is already flagged +// unavailable, rather than silently accepting a pick that resolvePlaylistThumbnailUrl +// would just ignore anyway -- the caller (the UI's hover button) never +// offers this for an unavailable entry, but the backend shouldn't trust that. +// Doesn't itself touch the cached fallback file (ensurePlaylistThumbnail) -- +// that lives in main.mjs, which calls it right after this succeeds. +export function setPlaylistManualThumbnail({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, playlistId, videoId }) { + const playlistDir = path.join(libraryTagDir(libraryDir, libraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId)); + const epochDir = resolvePlaylistEpochDir(playlistDir); + if (!epochDir) return { success: false, message: 'This playlist has no saved snapshot.' }; + + const metadataPath = path.join(epochDir, 'metadata.json'); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + + if (videoId) { + const target = metadata.entries.find((e) => e.videoId === videoId); + if (!target || target.unavailable) { + return { success: false, message: 'That entry is not available to use as a thumbnail.' }; + } + } + + metadata.manualThumbnailVideoId = videoId || null; + const tempPath = `${metadataPath}.new`; + fs.writeFileSync(tempPath, JSON.stringify(metadata, null, 2), 'utf-8'); + fs.renameSync(tempPath, metadataPath); + + return { success: true, manualThumbnailVideoId: metadata.manualThumbnailVideoId, thumbnailUrl: resolvePlaylistThumbnailUrl(metadata) }; } // One-shot undo -- reverts to previousMetadata.json (written by the most diff --git a/src/electron/library.test.mjs b/src/electron/library.test.mjs index 531ff7e..2fdcfd8 100644 --- a/src/electron/library.test.mjs +++ b/src/electron/library.test.mjs @@ -24,6 +24,13 @@ import { findVideoInIndex, writePlaylistSnapshot, enrichPlaylistEntry, + listPlaylistSnapshots, + getPlaylistSnapshot, + reconcilePlaylistSnapshot, + setPlaylistManualThumbnail, + firstAvailablePlaylistThumbnail, + resolvePlaylistThumbnailUrl, + CURRENT_PLAYLIST_SCHEMA_VERSION, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, DEFAULT_LIBRARY_DIR_NAME, @@ -1129,6 +1136,182 @@ describe('writePlaylistSnapshot / enrichPlaylistEntry', () => { }); }); +describe('firstAvailablePlaylistThumbnail / resolvePlaylistThumbnailUrl', () => { + const entries = [ + { videoId: 'v1', title: null, url: 'u1', thumbnailUrl: 't1', unavailable: true }, // dead, but has a thumbnail + { videoId: 'v2', title: 'Two', url: 'u2', thumbnailUrl: null, unavailable: false }, // live, no thumbnail + { videoId: 'v3', title: 'Three', url: 'u3', thumbnailUrl: 't3', unavailable: false }, // live, has one -- automatic's own pick + { videoId: 'v4', title: 'Four', url: 'u4', thumbnailUrl: 't4', unavailable: false }, // live, has one too + ]; + + it('skips a dead first entry and a live-but-thumbnail-less one, picking the first qualifying entry', () => { + expect(firstAvailablePlaylistThumbnail(entries)).toBe('t3'); + }); + + it('falls back to entries[0]\'s own thumbnailUrl when nothing qualifies', () => { + const allDead = [{ videoId: 'v1', thumbnailUrl: 't1', unavailable: true }, { videoId: 'v2', thumbnailUrl: null, unavailable: true }]; + expect(firstAvailablePlaylistThumbnail(allDead)).toBe('t1'); + expect(firstAvailablePlaylistThumbnail([])).toBeNull(); + }); + + it('a manual override pointing at a live entry with a real thumbnail wins over the automatic pick', () => { + // Automatic (firstAvailablePlaylistThumbnail) would pick v3 -- the + // override explicitly points at v4 instead, and must win. + expect(resolvePlaylistThumbnailUrl({ entries, manualThumbnailVideoId: 'v4' })).toBe('t4'); + }); + + it('falls back to automatic when the manual override entry is dead or missing entirely', () => { + expect(resolvePlaylistThumbnailUrl({ entries, manualThumbnailVideoId: 'v1' })).toBe('t3'); // v1 is dead -- ignored + expect(resolvePlaylistThumbnailUrl({ entries, manualThumbnailVideoId: 'gone' })).toBe('t3'); // not even in entries + expect(resolvePlaylistThumbnailUrl({ entries, manualThumbnailVideoId: null })).toBe('t3'); // no override at all + }); +}); + +describe('listPlaylistSnapshots / getPlaylistSnapshot (thumbnail resolution)', () => { + const deadFirstEntries = () => [ + { videoId: 'v1', title: 'v1', url: 'u1', thumbnailUrl: 'dead-thumb' }, // dead title (== videoId) + { videoId: 'v2', title: 'Live One', url: 'u2', thumbnailUrl: 'live-thumb' }, + ]; + + it('listPlaylistSnapshots skips a dead first entry\'s thumbnail in favor of the first live one', () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: deadFirstEntries(), index: { channels: [] } }); + const [summary] = listPlaylistSnapshots({ libraryDir }); + expect(summary.thumbnailUrl).toBe('live-thumb'); + }); + + it('getPlaylistSnapshot exposes the same resolved thumbnailUrl and a manualThumbnailVideoId field', async () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: deadFirstEntries(), index: { channels: [] } }); + const snapshot = await getPlaylistSnapshot({ libraryDir, playlistId: 'PL1', index: { channels: [] } }); + expect(snapshot.thumbnailUrl).toBe('live-thumb'); + expect(snapshot.manualThumbnailVideoId).toBeNull(); + }); +}); + +describe('reconcilePlaylistSnapshot', () => { + const initialEntries = () => [{ videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }]; + + it('reports every live entry with no library match -- new ones, but not a brand-new dead placeholder', () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: initialEntries(), index: { channels: [] } }); + + const fresh = [ + { videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }, // unchanged, but no library match + { videoId: 'v2', title: 'Video Two', url: 'u2', thumbnailUrl: 't2', uploadDate: '20260102' }, // new, live + { videoId: 'v3', title: 'v3', url: 'u3' }, // new, but dead title (== videoId) + ]; + const result = reconcilePlaylistSnapshot({ libraryDir, playlistId: 'PL1', freshEntries: fresh, index: { channels: [] } }); + + expect(result.added).toBe(2); // v2 and v3 count as "added" for the summary toast... + // ...but missingFromLibraryEntries is scoped differently: every live + // entry without a library match, v1 included even though it's not new + // to this refresh, v3 excluded because it's dead (nothing worth fetching). + const missingIds = result.missingFromLibraryEntries.map((e) => e.videoId).sort(); + expect(missingIds).toEqual(['v1', 'v2']); + }); + + it('flags a pre-existing playlist entry that still has no library match, even though it\'s not new to this refresh', () => { + // The bug this covers: an entry that has sat in the playlist for a + // while (e.g. a skipped/failed earlier bulk-add, or one that predates + // this feature) never gets picked up on refresh just because it isn't + // "new" -- missingFromLibraryEntries is keyed on library presence, not + // on refresh novelty. + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: initialEntries(), index: { channels: [] } }); + const indexWithV1 = { channels: [{ videos: [{ metadata: { videoId: 'v1' }, videoDir: '/lib/Channel A/v1' }] }] }; + + const stillMissing = reconcilePlaylistSnapshot({ + libraryDir, playlistId: 'PL1', + freshEntries: [{ videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }], + index: { channels: [] }, // v1 still not in the library + }); + expect(stillMissing.missingFromLibraryEntries.map((e) => e.videoId)).toEqual(['v1']); + + const nowPresent = reconcilePlaylistSnapshot({ + libraryDir, playlistId: 'PL1', + freshEntries: [{ videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }], + index: indexWithV1, // added to the library between refreshes + }); + expect(nowPresent.missingFromLibraryEntries).toHaveLength(0); + }); + + it('carries manualThumbnailVideoId through a refresh unchanged, and surfaces it in the return value', () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: initialEntries(), index: { channels: [] } }); + setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: 'v1' }); + + const result = reconcilePlaylistSnapshot({ + libraryDir, playlistId: 'PL1', + freshEntries: [{ videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }], + index: { channels: [] }, + }); + + expect(result.manualThumbnailVideoId).toBe('v1'); + }); + + it('bumps a stale schemaVersion (and backfills fields that predate it) on refresh, so "Outdated data" actually clears', () => { + // Real bug: a refresh recomputed everything else but spread oldMetadata's + // own stale schemaVersion straight through unchanged, so a playlist + // flagged "Outdated data" stayed flagged forever no matter how many + // times it was refreshed. + const written = writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: initialEntries(), index: { channels: [] } }); + const metadataPath = path.join(written.epochDir, 'metadata.json'); + const stale = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + delete stale.manualThumbnailVideoId; // predates schemaVersion 2 + stale.schemaVersion = 1; + fs.writeFileSync(metadataPath, JSON.stringify(stale, null, 2), 'utf-8'); + + reconcilePlaylistSnapshot({ + libraryDir, playlistId: 'PL1', + freshEntries: [{ videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }], + index: { channels: [] }, + }); + + const updated = readMetadata(written.epochDir); + expect(updated.schemaVersion).toBe(CURRENT_PLAYLIST_SCHEMA_VERSION); + expect(updated.manualThumbnailVideoId).toBeNull(); + }); +}); + +describe('setPlaylistManualThumbnail', () => { + const entries = () => [ + { videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }, + { videoId: 'v2', title: 'v2', url: 'u2' }, // dead title (== videoId) + ]; + + it('sets the override, resolves the thumbnail to that entry\'s own, and persists it', () => { + const written = writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: entries(), index: { channels: [] } }); + + const result = setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: 'v1' }); + + expect(result.success).toBe(true); + expect(result.manualThumbnailVideoId).toBe('v1'); + expect(result.thumbnailUrl).toBe('t1'); + expect(readMetadata(written.epochDir).manualThumbnailVideoId).toBe('v1'); + }); + + it('clearing (videoId null) reverts to automatic resolution', () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: entries(), index: { channels: [] } }); + setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: 'v1' }); + + const result = setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: null }); + + expect(result.success).toBe(true); + expect(result.manualThumbnailVideoId).toBeNull(); + }); + + it('refuses to set an unavailable or nonexistent entry as the thumbnail', () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: entries(), index: { channels: [] } }); + + const deadResult = setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: 'v2' }); + expect(deadResult.success).toBe(false); + + const missingResult = setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: 'nope' }); + expect(missingResult.success).toBe(false); + }); + + it('reports failure for a playlist with no saved snapshot', () => { + const result = setPlaylistManualThumbnail({ libraryDir, playlistId: 'nope', videoId: 'v1' }); + expect(result.success).toBe(false); + }); +}); + describe('moveLibraryEntry', () => { it('refuses to move a videoDir outside the configured library folder', () => { expect(() => moveLibraryEntry({ libraryDir, videoDir: '/etc', targetTag: 'Music' })) diff --git a/src/electron/main.mjs b/src/electron/main.mjs index 7918001..1892cee 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -10,7 +10,7 @@ import os from 'node:os'; import { getSupportedVideoFilters, allVideoFilter } from './utils/constants.mjs'; import { getCurrentYtdlpVersion, isNewerVersion, performYtdlpUpdate } from './updater.mjs'; import { resolveLatestRelease, YTDLP_VERIFICATION_ERROR_CODE } from './ytdlpRelease.mjs'; -import { writeLibraryEntry, overrideLibraryEntry, addLibraryVersion, refreshLibraryEntryMetadata, getLibraryIndex, refreshLibraryIndex, findVideoInIndex, recordLibraryDownload, swapLibraryDownload, savePlaybackPosition, findVideoThumbnailPath, deleteLibraryEntry, deleteLocalFiles, moveLibraryEntry, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, undoPlaylistRefresh, deletePlaylistSnapshot, sanitizeForFilesystem, resolveInsideLibrary, libraryTagDir, DEFAULT_LIBRARY_DIR_NAME, listLibraryTags, createLibraryTag, listVideoTags, setVideoTag, addTagToVideos, removeVideosFromTags, transferVideoTags, checkAndRepairEpochFiles, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, buildClipFilePath, recordClip, listClips, deleteClip, updateClipFile } from './library.mjs'; +import { writeLibraryEntry, overrideLibraryEntry, addLibraryVersion, refreshLibraryEntryMetadata, getLibraryIndex, refreshLibraryIndex, findVideoInIndex, recordLibraryDownload, swapLibraryDownload, savePlaybackPosition, findVideoThumbnailPath, deleteLibraryEntry, deleteLocalFiles, moveLibraryEntry, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, setPlaylistManualThumbnail, undoPlaylistRefresh, deletePlaylistSnapshot, sanitizeForFilesystem, resolveInsideLibrary, libraryTagDir, DEFAULT_LIBRARY_DIR_NAME, listLibraryTags, createLibraryTag, listVideoTags, setVideoTag, addTagToVideos, removeVideosFromTags, transferVideoTags, checkAndRepairEpochFiles, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, buildClipFilePath, recordClip, listClips, deleteClip, updateClipFile, firstAvailablePlaylistThumbnail, resolvePlaylistThumbnailUrl, findPlaylistThumbnailPath } from './library.mjs'; import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampThemeName, clampResumeTrackingMode, RESUME_TRACKING_MODE_DEFAULT, clampResumeMinDurationSeconds, RESUME_MIN_DURATION_SECONDS_DEFAULT, clampEmbedMetadataByDefault, EMBED_METADATA_BY_DEFAULT_DEFAULT } from './settings.mjs'; import { makeCookiesArgs, looksLikeNetscapeFormat, convertHeaderCookiesToNetscape, validateNetscapeLines, SUPPORTED_COOKIE_BROWSERS, reapStaleCookieCopies } from './cookies.mjs'; import { downloadImageToFile, createThumbnailFetchers } from './thumbnails.mjs'; @@ -858,7 +858,12 @@ ipcMain.handle('library:fetchPlaylistEntries', async (e, playlistUrl) => { index, }); // Fire-and-forget, same as the video/channel thumbnail caches. - ensurePlaylistThumbnail(result.playlistDir, playlist.entries[0]?.thumbnailUrl); + // Prefers result.metadata.entries (already dead-flagged by + // writePlaylistSnapshot's own isDeadTitle check) over the raw + // fetch, so a dead first entry doesn't win here either -- falls + // back to the raw entries only on the rare no-op-second-save + // case (result.metadata is null then). + ensurePlaylistThumbnail(result.playlistDir, firstAvailablePlaylistThumbnail(result.metadata?.entries || playlist.entries)); } return { success: true, entries: playlist.entries, playlistId: playlist.id }; } catch (err) { @@ -931,13 +936,36 @@ ipcMain.handle('library:refreshPlaylist', async (e, playlistId) => { index, }); const playlistDir = path.join(libraryTagDir(libraryDir, activeLibraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId)); - ensurePlaylistThumbnail(playlistDir, result.entries[0]?.thumbnailUrl); + ensurePlaylistThumbnail(playlistDir, resolvePlaylistThumbnailUrl({ entries: result.entries, manualThumbnailVideoId: result.manualThumbnailVideoId })); return result; } catch (err) { return { success: false, message: err instanceof Error ? err.message : String(err) }; } }); +// "Set as playlist thumbnail" (videoId set) / "Reset to automatic" +// (videoId null) -- see setPlaylistManualThumbnail (library.mjs) for the +// persistence + validation, and resolvePlaylistThumbnailUrl for how this +// interacts with the automatic first-available-entry fallback. +ipcMain.handle('library:setPlaylistManualThumbnail', async (e, { playlistId, videoId }) => { + const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings(); + if (!libraryDir) { + return { success: false, message: 'No library folder configured.' }; + } + try { + const result = setPlaylistManualThumbnail({ libraryDir, libraryTag: activeLibraryTag, playlistId, videoId }); + if (!result.success) return result; + const playlistDir = path.join(libraryTagDir(libraryDir, activeLibraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId)); + // Refreshes the cached fallback file to match the newly-resolved + // thumbnail -- same fire-and-forget-style cache used everywhere else, + // but awaited here since the response needs the resulting path. + await ensurePlaylistThumbnail(playlistDir, result.thumbnailUrl); + return { ...result, thumbnailPath: findPlaylistThumbnailPath(playlistDir) }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : String(err) }; + } +}); + ipcMain.handle('library:undoPlaylistRefresh', async (e, playlistId) => { const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings(); if (!libraryDir) { diff --git a/src/electron/preload.cjs b/src/electron/preload.cjs index ba2434c..22d7517 100644 --- a/src/electron/preload.cjs +++ b/src/electron/preload.cjs @@ -73,6 +73,7 @@ contextBridge.exposeInMainWorld('electronAPI', { listPlaylists: () => ipcRenderer.invoke('library:listPlaylists'), getPlaylist: (playlistId) => ipcRenderer.invoke('library:getPlaylist', playlistId), refreshPlaylist: (playlistId) => ipcRenderer.invoke('library:refreshPlaylist', playlistId), + setPlaylistManualThumbnail: (playlistId, videoId) => ipcRenderer.invoke('library:setPlaylistManualThumbnail', { playlistId, videoId }), undoPlaylistRefresh: (playlistId) => ipcRenderer.invoke('library:undoPlaylistRefresh', playlistId), deletePlaylist: (playlistId) => ipcRenderer.invoke('library:deletePlaylist', playlistId), recordLibraryDownload: (payload) => ipcRenderer.invoke('library:recordDownload', payload), diff --git a/src/types.ts b/src/types.ts index 48f9801..34d58b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,7 +89,16 @@ export type PlaylistSnapshot = { // What Undo would revert to, and when that version was itself last // current -- null when there's nothing to undo. previousMetadataSavedEpoch: number | null; + // The resolved thumbnail source (manual override if set and still valid, + // else the first available -- not dead -- entry) and a locally-cached + // fallback file path, mirroring PlaylistSummary's own pair. + thumbnailUrl: string | null; thumbnailPath: string | null; + // The entry deliberately picked via "Set as playlist thumbnail," or null + // for the default automatic (first-available-entry) behavior. Only + // honored while that entry still exists and isn't unavailable -- see + // resolvePlaylistThumbnailUrl (library.mjs). + manualThumbnailVideoId: string | null; } export type DownloadProgressMessage = { diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index 8ac07bb..01e24ec 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -126,7 +126,8 @@ declare global { enrichPlaylistEntry: (payload: { playlistId: string; videoId: string; title?: string | null; uploadDate?: string | null; thumbnailUrl?: string | null }) => Promise<{ success: boolean; message?: string }> listPlaylists: () => Promise<{ playlists: PlaylistSummary[] }> getPlaylist: (playlistId: string) => Promise<{ playlist: PlaylistSnapshot | null }> - refreshPlaylist: (playlistId: string) => Promise<{ success: boolean; added?: number; removed?: number; updated?: number; lastRefreshedEpoch?: number; entries?: PlaylistEntry[]; message?: string }> + refreshPlaylist: (playlistId: string) => Promise<{ success: boolean; added?: number; removed?: number; updated?: number; lastRefreshedEpoch?: number; entries?: PlaylistEntry[]; missingFromLibraryEntries?: PlaylistEntry[]; manualThumbnailVideoId?: string | null; message?: string }> + setPlaylistManualThumbnail: (playlistId: string, videoId: string | null) => Promise<{ success: boolean; manualThumbnailVideoId?: string | null; thumbnailUrl?: string | null; thumbnailPath?: string | null; message?: string }> undoPlaylistRefresh: (playlistId: string) => Promise<{ success: boolean; metadata?: PlaylistSnapshot; message?: string }> deletePlaylist: (playlistId: string) => Promise<{ success: boolean; message?: string }> recordLibraryDownload: (payload: { videoDir: string; epoch: string; filePath: string; resolution: string; format?: string; kind?: 'video' | 'audio' }) => Promise<{ success: boolean }> diff --git a/src/ui/components/PlaylistsSection.test.tsx b/src/ui/components/PlaylistsSection.test.tsx index a97563d..0f898bd 100644 --- a/src/ui/components/PlaylistsSection.test.tsx +++ b/src/ui/components/PlaylistsSection.test.tsx @@ -381,7 +381,7 @@ describe('PlaylistsSection tag/system filter', () => { await user.click(screen.getByRole('checkbox', { name: 'games' })); expect(screen.queryByText('Alpha Video')).not.toBeInTheDocument(); expect(screen.queryByText('Gamma Video')).not.toBeInTheDocument(); - expect(screen.getByText('No videos match the selected filter.')).toBeInTheDocument(); + expect(screen.getByText('No videos match your search or the selected filter.')).toBeInTheDocument(); // Filter button/popover stay reachable with the list empty, so the // filter can still be cleared. @@ -495,3 +495,119 @@ describe('PlaylistsSection bulk tag', () => { await waitFor(() => expect(window.electronAPI.tagVideos).toHaveBeenCalledWith(['vidA'], 'TVshows')); }); }); + +describe('PlaylistsSection entry search', () => { + it('narrows the entry list to titles matching the query, and composes with the existing filters', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ + playlist: makePlaylist({ + entries: [makeEntry(), makeEntry({ videoId: 'vidB', title: 'Beta Video' })], + localFiles: { vidA: '/lib/Channel A/vidA', vidB: null }, + }), + }); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByText('Beta Video'); + + await user.type(screen.getByPlaceholderText('Search this playlist...'), 'alpha'); + + await waitFor(() => { + expect(screen.getByText('Alpha Video')).toBeInTheDocument(); + expect(screen.queryByText('Beta Video')).not.toBeInTheDocument(); + }); + }); +}); + +describe('PlaylistsSection set playlist thumbnail', () => { + it('shows the button only for available entries, sets the override on click, and reflects the active state', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ + playlist: makePlaylist({ + manualThumbnailVideoId: null, + entries: [makeEntry(), makeEntry({ videoId: 'vidC', title: 'Gamma Video', unavailable: true })], + localFiles: { vidA: '/lib/Channel A/vidA', vidC: null }, + }), + }); + window.electronAPI.setPlaylistManualThumbnail = vi.fn().mockResolvedValue({ + success: true, manualThumbnailVideoId: 'vidA', thumbnailUrl: 'https://example.com/a.jpg', thumbnailPath: null, + }); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByText('Alpha Video'); + + // Only vidA (available) gets the button -- vidC (unavailable) gets none, + // so there's exactly one "Set as playlist thumbnail" button on screen. + const setButtons = screen.getAllByRole('button', { name: 'Set as playlist thumbnail' }); + expect(setButtons).toHaveLength(1); + const [setButton] = setButtons; + + await user.click(setButton); + + expect(window.electronAPI.setPlaylistManualThumbnail).toHaveBeenCalledWith('pl1', 'vidA'); + // Now shows as active, with the "reset" affordance instead. + await waitFor(() => expect(screen.getByRole('button', { name: 'Reset to automatic thumbnail' })).toBeInTheDocument()); + + window.electronAPI.setPlaylistManualThumbnail = vi.fn().mockResolvedValue({ + success: true, manualThumbnailVideoId: null, thumbnailUrl: null, thumbnailPath: null, + }); + await user.click(screen.getByRole('button', { name: 'Reset to automatic thumbnail' })); + + expect(window.electronAPI.setPlaylistManualThumbnail).toHaveBeenCalledWith('pl1', null); + await waitFor(() => expect(screen.getByRole('button', { name: 'Set as playlist thumbnail' })).toBeInTheDocument()); + }); +}); + +describe('PlaylistsSection refresh fetches full data for entries missing a library match', () => { + it('feeds newly-added entries from a refresh into the bulk-add pipeline, metadata-only', async () => { + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ playlist: makePlaylist() }); + window.electronAPI.refreshPlaylist = vi.fn().mockResolvedValue({ + success: true, added: 1, removed: 0, updated: 0, + missingFromLibraryEntries: [{ videoId: 'vidNew', title: 'New Video', url: 'https://youtube.com/watch?v=vidNew', thumbnailUrl: null, uploadDate: null }], + }); + window.electronAPI.getVideoInfoPython = vi.fn().mockResolvedValue({ + data: { response: { id: 'vidNew', fullTitle: 'New Video', title: 'New Video', thumbnail: null, resolutions: [] } }, + }); + window.electronAPI.findLibraryVideo = vi.fn().mockResolvedValue({ found: false }); + window.electronAPI.addLibraryEntry = vi.fn().mockResolvedValue({ success: true, videoDir: '/lib/Channel B/vidNew' }); + window.electronAPI.enrichPlaylistEntry = vi.fn().mockResolvedValue({ success: true }); + + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('button', { name: 'Refresh' })); + + // Metadata-only: the new entry's real info gets fetched and written into + // the library, but nothing is downloaded (no download-progress call). + await waitFor(() => expect(window.electronAPI.getVideoInfoPython).toHaveBeenCalledWith('https://youtube.com/watch?v=vidNew')); + await waitFor(() => expect(window.electronAPI.addLibraryEntry).toHaveBeenCalled()); + }); + + it('also fetches an entry that was already in the playlist but still has no library match, not just newly-found ones', async () => { + // Reported gap: an entry present since before this refresh (added + // stays 0 -- reconcilePlaylistSnapshot doesn't consider it "new") but + // still missing from the library must still be fetched. + (window.electronAPI.getPlaylist as ReturnType).mockResolvedValue({ playlist: makePlaylist() }); + window.electronAPI.refreshPlaylist = vi.fn().mockResolvedValue({ + success: true, added: 0, removed: 0, updated: 0, + missingFromLibraryEntries: [{ videoId: 'vidA', title: 'Alpha Video', url: 'https://youtube.com/watch?v=vidA', thumbnailUrl: null, uploadDate: '20260101' }], + }); + window.electronAPI.getVideoInfoPython = vi.fn().mockResolvedValue({ + data: { response: { id: 'vidA', fullTitle: 'Alpha Video', title: 'Alpha Video', thumbnail: null, resolutions: [] } }, + }); + window.electronAPI.findLibraryVideo = vi.fn().mockResolvedValue({ found: false }); + window.electronAPI.addLibraryEntry = vi.fn().mockResolvedValue({ success: true, videoDir: '/lib/Channel A/vidA' }); + window.electronAPI.enrichPlaylistEntry = vi.fn().mockResolvedValue({ success: true }); + + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('My Playlist')); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('button', { name: 'Refresh' })); + + await waitFor(() => expect(window.electronAPI.getVideoInfoPython).toHaveBeenCalledWith('https://youtube.com/watch?v=vidA')); + await waitFor(() => expect(window.electronAPI.addLibraryEntry).toHaveBeenCalled()); + }); +}); diff --git a/src/ui/components/PlaylistsSection.tsx b/src/ui/components/PlaylistsSection.tsx index 4f7d2b6..76f44a7 100644 --- a/src/ui/components/PlaylistsSection.tsx +++ b/src/ui/components/PlaylistsSection.tsx @@ -35,6 +35,8 @@ import LinkIcon from '@mui/icons-material/Link'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import FilterListIcon from '@mui/icons-material/FilterList'; +import ImageIcon from '@mui/icons-material/Image'; +import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'; import { pink } from '@mui/material/colors'; import { convertYYYYMMDDStringToDate, buildAppVideoUrl, formatEpochLabel, getBestDownloadedQuality } from '../../utils/utils.ts'; import LibrarySearchBar from './LibrarySearchBar'; @@ -59,7 +61,7 @@ export type PlaylistBulkBar = { // Mirrors library.mjs's own CURRENT_PLAYLIST_SCHEMA_VERSION (main process // and renderer never cross-import in this codebase). -const CURRENT_PLAYLIST_SCHEMA_VERSION = 1; +const CURRENT_PLAYLIST_SCHEMA_VERSION = 2; // Prefers the live first-entry thumbnail over the locally-cached fallback // (ensurePlaylistThumbnail, main.mjs), which only matters once there's no @@ -115,6 +117,15 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: playlists, (playlist) => playlist.title || playlist.playlistId, ); + // Same hook, scoped to the currently-open playlist's own entries instead + // of the top-level playlist list -- the detail view had no search at all + // before this. + const { + query: entryQuery, setQuery: setEntryQuery, filtered: searchFilteredEntries, clear: clearEntryQuery, + } = useLibrarySearch( + selectedPlaylist?.entries ?? [], + (entry) => entry.title ?? '', + ); const toggleVideoSelected = (videoId: string) => { setSelectedVideoIds((prev) => { @@ -155,21 +166,21 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: }; const isEntryDownloaded = (videoId: string) => getEntryQuality(videoId) !== null; - // Entries currently on screen, after the tag/downloaded filters below -- - // used only for rendering and for what "select all" targets. Manual - // per-item selections are tracked separately (against the unfiltered set, - // see allSelectableEntries) so narrowing the filter never silently drops - // an already-selected video out of a pending bulk action. + // Entries currently on screen, after search narrows first and the tag/ + // downloaded filters below narrow further -- used only for rendering and + // for what "select all" targets. Manual per-item selections are tracked + // separately (against the unfiltered set, see allSelectableEntries) so + // narrowing the filter never silently drops an already-selected video out + // of a pending bulk action. const filteredEntries = useMemo(() => { - const entries = selectedPlaylist?.entries || []; - return entries.filter((e) => { + return searchFilteredEntries.filter((e) => { if (selectedSystemFilters.has('downloaded') && !isEntryDownloaded(e.videoId)) return false; if (selectedSystemFilters.has('notDownloaded') && isEntryDownloaded(e.videoId)) return false; if (selectedTagFilters.size > 0 && ![...selectedTagFilters].every((tag) => videoTags[tag]?.includes(e.videoId))) return false; return true; }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedPlaylist, selectedSystemFilters, selectedTagFilters, videoTags, videoByDir]); + }, [searchFilteredEntries, selectedSystemFilters, selectedTagFilters, videoTags, videoByDir]); // Selectable entries are every entry not confirmed unavailable -- both // already-in-library and not-yet-added ones. Unfiltered: what a bulk @@ -381,6 +392,21 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: const result = await window.electronAPI.refreshPlaylist(selectedPlaylistId); if (result.success) { setRefreshSummary({ added: result.added || 0, removed: result.removed || 0, updated: result.updated || 0 }); + // Every live entry with no library match yet -- not just ones new to + // this refresh (an entry can have sat in the playlist unaddded for a + // while, e.g. a skipped/failed earlier bulk-add) -- only ever carries + // the thin flat-playlist shape (title/url/thumbnail) + // reconcilePlaylistSnapshot itself can see. Feeding it through the + // same fetch/dedup/write/enrich pipeline bulk-add already uses + // (metadata-only, download: false) is what actually pulls its full + // data into the library, rather than leaving it a bare placeholder + // until someone adds it by hand. + if (result.missingFromLibraryEntries && result.missingFromLibraryEntries.length > 0) { + const entries: BulkAddEntry[] = result.missingFromLibraryEntries.map((entry) => ({ + id: entry.videoId, title: entry.title, url: entry.url, videoId: entry.videoId, playlistId: selectedPlaylistId, + })); + start(entries, { download: false, targetResolution: '' }); + } await loadDetail(selectedPlaylistId); } else { setActionError(result.message || 'Failed to refresh this playlist.'); @@ -388,6 +414,23 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: setRefreshing(false); }; + // "Set as playlist thumbnail" (videoId) / "Reset to automatic" (null) -- + // see setPlaylistManualThumbnail (library.mjs) for the persistence rule. + const handleSetPlaylistThumbnail = async (videoId: string | null) => { + if (!selectedPlaylistId) return; + const result = await window.electronAPI.setPlaylistManualThumbnail(selectedPlaylistId, videoId); + if (result.success) { + setSelectedPlaylist((prev) => (prev ? { + ...prev, + manualThumbnailVideoId: result.manualThumbnailVideoId ?? null, + thumbnailUrl: result.thumbnailUrl ?? null, + thumbnailPath: result.thumbnailPath ?? null, + } : prev)); + } else { + setActionError(result.message || 'Failed to update the playlist thumbnail.'); + } + }; + // Only ever deletes the playlist's own saved snapshot -- the videos it // references stay in the library untouched, hence the notice in the // confirm dialog rather than the "videoDeleted"-style branching the @@ -437,7 +480,7 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate:
@@ -505,6 +548,15 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: ) : ( <> + {selectedPlaylist && selectedPlaylist.entries.length > 0 && + + + } {selectedPlaylist && selectedPlaylist.entries.length > 0 && {visibleSelectableEntries.length > 0 && @@ -545,15 +597,22 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: } {selectedPlaylist && selectedPlaylist.entries.length > 0 && filteredEntries.length === 0 && - No videos match the selected filter.} + No videos match your search or the selected filter.} {filteredEntries.map((entry) => { const videoDir = selectedPlaylist?.localFiles[entry.videoId]; const entryQuality = getEntryQuality(entry.videoId); const entryAppliedTags = Object.keys(videoTags).filter((name) => videoTags[name].includes(entry.videoId)); + const isManualThumbnail = !!selectedPlaylist?.manualThumbnailVideoId && selectedPlaylist.manualThumbnailVideoId === entry.videoId; return ( {/* Both slots are always rendered (blank when not @@ -581,7 +640,33 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: } > - + + + {!entry.unavailable && + + handleSetPlaylistThumbnail(isManualThumbnail ? null : entry.videoId)} + aria-label={isManualThumbnail ? 'Reset to automatic thumbnail' : 'Set as playlist thumbnail'} + sx={{ + position: 'absolute', top: -6, right: -6, width: 20, height: 20, + backgroundColor: 'background.paper', + '&:hover': { backgroundColor: 'background.paper' }, + // Always visible once active, so it's clear at a + // glance which entry the thumbnail follows even + // without hovering -- hover-only otherwise, to + // keep every other row's chrome out of the way. + opacity: isManualThumbnail ? 1 : 0, + transition: 'opacity 0.15s', + }} + > + {isManualThumbnail + ? + : } + + } + ({ success: true }), listPlaylists: async () => ({ playlists: [] }), getPlaylist: async () => ({ playlist: null }), - refreshPlaylist: async () => ({ success: true, added: 0, removed: 0, updated: 0, entries: [] }), + refreshPlaylist: async () => ({ success: true, added: 0, removed: 0, updated: 0, entries: [], missingFromLibraryEntries: [] }), + setPlaylistManualThumbnail: async () => ({ success: true, manualThumbnailVideoId: null, thumbnailUrl: null, thumbnailPath: null }), undoPlaylistRefresh: async () => ({ success: true }), deletePlaylist: async () => ({ success: true }), recordLibraryDownload: async () => ({ success: true }), From c791631d5c60751e4714b1ea22febd14864e07fe Mon Sep 17 00:00:00 2001 From: Sloth Date: Thu, 17 Sep 2026 22:35:13 -0700 Subject: [PATCH 3/5] feat(app): background player phase 1: Introduce mini player and feature to send video playback to the mini player that works across the entire app navigation --- src/ui/App.tsx | 27 +-- src/ui/MainPage.test.tsx | 7 +- src/ui/MainPage.tsx | 2 + src/ui/components/ClipCollectionView.test.tsx | 178 ++++++++++++++++-- src/ui/components/ClipCollectionView.tsx | 29 ++- src/ui/components/LibraryVideoPlayer.test.tsx | 140 +++++++++++++- src/ui/components/LibraryVideoPlayer.tsx | 91 ++++++++- .../LibraryVideoPlayerWithTools.tsx | 11 +- src/ui/components/MiniPlayerBar.test.tsx | 90 +++++++++ src/ui/components/MiniPlayerBar.tsx | 91 +++++++++ src/ui/components/PlayerContextMenu.tsx | 15 +- src/ui/hooks/useBackgroundPlayer.test.tsx | 95 ++++++++++ src/ui/hooks/useBackgroundPlayer.tsx | 113 +++++++++++ src/ui/screens/LibraryScreen.tsx | 25 ++- src/ui/screens/LibraryVideoDetail.test.tsx | 149 +++++++++++++-- src/ui/screens/LibraryVideoDetail.tsx | 40 +++- 16 files changed, 1031 insertions(+), 72 deletions(-) create mode 100644 src/ui/components/MiniPlayerBar.test.tsx create mode 100644 src/ui/components/MiniPlayerBar.tsx create mode 100644 src/ui/hooks/useBackgroundPlayer.test.tsx create mode 100644 src/ui/hooks/useBackgroundPlayer.tsx diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 7c47f52..633a07b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -13,6 +13,7 @@ import { BulkAddProvider } from './hooks/useBulkAddQueue.tsx'; import { ThemeModeProvider, useThemeMode } from './hooks/useThemeMode.tsx'; import { LibraryTagsProvider } from './hooks/useLibraryTags.tsx'; import { CookiesChangeProvider } from './hooks/useCookiesChange.tsx'; +import { BackgroundPlayerProvider } from './hooks/useBackgroundPlayer.tsx'; // Forwards uncaught renderer errors to the same main.log a crashed main // process already writes to (see errorLog:report in main.mjs) -- the renderer @@ -55,18 +56,20 @@ function AppContent() { - - - {/* "/*" (not "/") so MainPage -- and everything that lives - alongside its tabs, like BulkAddSidePanel and the - yt-dlp-update dialog -- stays mounted for every nested path - this app's internal navigation uses (e.g. - /library/video/:videoId). Those aren't separate s - with their own element; they're just locations MainPage's - own tab logic reads reactively (see MainPage.tsx). */} - }> - - + + + + {/* "/*" (not "/") so MainPage -- and everything that lives + alongside its tabs, like BulkAddSidePanel and the + yt-dlp-update dialog -- stays mounted for every nested path + this app's internal navigation uses (e.g. + /library/video/:videoId). Those aren't separate s + with their own element; they're just locations MainPage's + own tab logic reads reactively (see MainPage.tsx). */} + }> + + + diff --git a/src/ui/MainPage.test.tsx b/src/ui/MainPage.test.tsx index e5f9168..e2e0bdf 100644 --- a/src/ui/MainPage.test.tsx +++ b/src/ui/MainPage.test.tsx @@ -7,6 +7,7 @@ import MainPage from './MainPage'; import { LibraryNotificationProvider, useLibraryNotification } from './hooks/useLibraryNotifications'; import { YtdlpUpdaterProvider } from './hooks/useYtdlpUpdater'; import { CookiesChangeProvider } from './hooks/useCookiesChange'; +import { BackgroundPlayerProvider } from './hooks/useBackgroundPlayer'; // Each tab's screen is a large, independently-tested component (its own // dedicated test file covers it) -- mocked out here so MainPage's tests stay @@ -49,8 +50,10 @@ function renderMainPage() { - - + + + + diff --git a/src/ui/MainPage.tsx b/src/ui/MainPage.tsx index 6ca638d..5ff471e 100644 --- a/src/ui/MainPage.tsx +++ b/src/ui/MainPage.tsx @@ -27,6 +27,7 @@ import OptionsScreen from './screens/OptionsScreen'; import LibraryScreen from './screens/LibraryScreen'; import YtdlpUpdateDialog from './components/YtdlpUpdateDialog'; import BulkAddSidePanel, { BulkAddToggleButton } from './components/BulkAddSidePanel'; +import MiniPlayerBar from './components/MiniPlayerBar'; import { useLibraryNotification } from './hooks/useLibraryNotifications'; import { useCookiesChange } from './hooks/useCookiesChange'; import { useYtdlpUpdater } from './hooks/useYtdlpUpdater'; @@ -252,6 +253,7 @@ export function BasicTabs() { + setInfoOpen(false)} maxWidth="xs" fullWidth> diff --git a/src/ui/components/ClipCollectionView.test.tsx b/src/ui/components/ClipCollectionView.test.tsx index e4a9e20..257e624 100644 --- a/src/ui/components/ClipCollectionView.test.tsx +++ b/src/ui/components/ClipCollectionView.test.tsx @@ -1,10 +1,62 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import ClipCollectionView from './ClipCollectionView'; +import { BackgroundPlayerProvider, useBackgroundPlayer } from '../hooks/useBackgroundPlayer.tsx'; import type { LibraryClip } from '../../types'; +// Same one-shot readiness sequence LibraryVideoPlayer.test.tsx fires -- +// Vidstack won't treat the