diff --git a/CLAUDE.md b/CLAUDE.md index 7b212a6ae..da0199e80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `components/Settings/`: Settings page wrapper and splash index (`SettingsIndex.tsx`) listing the per-section routes under `/settings/`. `MaintenanceSection.tsx` hosts the manual filesystem rescan trigger and the "Deleted and missing videos" card (`Settings/components/RemovedVideosCard.tsx` + `EmptyTrashDialog.tsx`, data through `Settings/hooks/useRemovedVideos.ts`: the counts, "Mark all missing as deleted", and "Empty trash..." with its which-videos / download-archive choices and an explicit cannot-be-undone acknowledgement; the missing choices are disabled with the reason while the downloads folder looks unmounted, driven by `missingActionsBlocked`, except that for an empty folder Mark all stays available once the user confirms they emptied it, sending `ignoreLibraryCheck`, so "Rescan anyway" is never a dead end; Mark all is disabled while a rescan runs). The Videos page has the matching per-selection "Mark as deleted" action (`VideosPage/components/MarkDeletedDialog.tsx`, `VideosPage/hooks/useMarkDeleted.ts`; the shared video modal offers it for one missing video, from any page), a `Deleted` filter chip resting at `exclude` and remembered in localStorage (`shared/VideoList/usePersistedChipFilter.ts`; status chips with a `defaultValue` other than off count as active, and show a `Show: ...` chip, only when moved off it), cleanup hints (`VideosPage/components/HiddenDeletedNote.tsx`: the "N deleted videos hidden." note with Show and Clean up buttons and an info tooltip, on the Library heading's line on wide screens and under it on phones; `LibraryCleanupHints.tsx`: a line under the Missing or Deleted filter when it shows only those videos; each links to the Maintenance card at `/settings/maintenance#deleted-and-missing`, which `MaintenanceSection` scrolls to), and the unmounted-folder warning banner. `shared/AvailabilityChip` renders Available / Deleted (neutral) / Missing (red) with the plain-language reason as its tooltip; on coarse-pointer devices (the query Tooltip uses to open on tap) a tap on it opens the tooltip without reaching the row underneath; mouse clicks, and taps on fine-pointer touchscreens, pass through, for rows that expand on click. `/settings/watch-status` routes to `Configuration/sections/WatchStatusSection.tsx`, which surfaces the sync toggle and a manual "Sync Now" trigger through `Configuration/hooks/useWatchStatusSync.ts` (polls `/api/mediaservers/watch-status` while a sync is running, whether started here or by the scheduler; a sync-in-progress state is shown only for a `running` 409 refusal from Sync Now, not other refusals such as no media server configured). `/settings/scheduling` routes to `Configuration/sections/SchedulingSection.tsx`: every cron schedule from the `Configuration/schedules.ts` registry (grouped, with `ScheduleEditor` daily/preset/custom modes, and a per-task `frequentRunWarning` from `schedules.ts` shown when any task other than automatic downloads is set to run more than once an hour; `describeSchedule` renders custom expressions in words through cronstrue, but only when `isSupportedCronSyntax` passes, a client mirror of the server's field rules and node-cron's parser, so `10/11`, `L`, `#`, and other syntax the save would reject are echoed instead of described. The expression handed to cronstrue is rebuilt from node-cron 3's own reading of each field (canonical numbers, reversed ranges low to high, the first `7` in the weekday field rewritten to `0` before the range is read, as node-cron does, and a step kept only where standard cron expands it to the same values, otherwise the explicit values), so `0/15` reads as hourly, `1-31/10` as days 10, 20, 30, and `5-7` as Sunday through Friday; anything cronstrue still rejects falls back to the echo instead of throwing during render) plus live scheduler state from `GET /api/schedules` through `Configuration/hooks/useScheduleStatus.ts` (a one-line summary - task count, running, failed last run, next run - and per task the next run, the last recorded run, a `runNow` availability, and a warning when the saved expression could not be scheduled; the hook polls every 60 s, every 30 s while a task is running, pauses while the tab is hidden, refreshes on return, refetches on relevant WebSocket events (`scheduledTaskStatus`, `rescanStatus`, `jobsUpdated`, `downloadComplete`, `downloadPauseChanged`, `connectionRestored`, debounced 300 ms so a burst of download events costs one refetch; skipped while the tab is hidden, refetching once it becomes visible again), and times a refetch just after a blocked task's `availableAt` passes so Run now re-enables on schedule; every timestamp is rendered in the server's zone via `utils/formatters.formatDateTimeInZone`). Tasks are collapsed accordion rows (`sections/components/ScheduleTaskRow.tsx`): the header reads at a glance (status dot and label from `scheduleDisplay.ts`, the schedule in words including unsaved edits, relative next/last run ticked by `hooks/useNow` and corrected by the hook's `clockOffsetMs`, how long the last finished run took, and a short reason when Run now is blocked; phones get two lines, `lg` lines the times up in columns), and `ScheduleTaskDetails` holds the description, settings link, `ScheduleRunStatus`, the full `runNowHint.ts` hint, and the editor. Rows open on a `#` deep link and when a save error or failed Run now newly lands on them; `savedConfig` (Settings' `initialConfig`) drives the Unsaved tag. Run lengths come from each task's `lastFinishedRun` (`GET /api/schedules`, via `scheduledTaskRuns.getLatestFinishedRuns`: the newest success/error run, so a skipped or interrupted latest run does not hide the previous length, shown as "previous run took") ; a last run marked interrupted (only the startup pass does that) shows "by a server restart" in place of a length. Each row's `RunNowControl` (icon-only on phones, and a sibling of the Radix trigger, never inside it) starts a run through `Configuration/hooks/useRunScheduledTask.ts` (`POST /api/schedules/:key/run`, tracking pending/error state per task and refetching status on completion); automatic video cleanup confirms first, since Run now deletes files. The other settings pages link to it through `ScheduleSummary`. `layout/ScheduledTaskIndicator.tsx` (header, beside the download indicator) shows while any scheduled task except automatic downloads runs, via `hooks/useRunningScheduledTasks.ts` (`GET /api/schedules` once, then on `scheduledTaskStatus`/`connectionRestored` broadcasts and tab return, polling every 30 s only while something runs), and links to `/settings/scheduling`. - `components/Configuration/sections/LoggingSection.tsx` (`/settings/logging`): log level select ('' = LOG_LEVEL), the current level's source from the saved setting plus `/getconfig`'s `logging.envLevel` (via `useConfig().loggingStatus`), log file limits or an unwritable-folder warning, and Download logs through `Configuration/hooks/useLogDownload.ts` (axios blob, since a link cannot send `x-access-token`). - `components/FindChannels/`: channel search page at `/subscriptions/find`; each result card has an Open in YouTube link below its clickable area; clicking an unsubscribed card opens the shared `AddChannelDialog`, whose confirm hands off to `Subscriptions` via `location.state.addChannelUrl` and auto-runs the add flow. +- `components/FindVideos/`: video search page at `/videos/find`. The server returns YouTube's relevance order; `sortResults.ts` sorts the loaded set client-side (relevance, published, views, duration, title, channel; nulls last either way), through the desktop table's headers (natural direction, reversed, then back to relevance) or the shared `VideoList/VideoListSortButton` on phones and in grid view (its `SortOption.undirected` marks relevance, which has no arrow). `hooks/useFindVideosPreferences.ts` remembers view (table by default), sort, and minimum duration in localStorage; `hooks/useVideoSearch.ts` keeps the last search in sessionStorage and restores it when the page opens with the same `?q=`, which the page writes after every search (a `?q=` with nothing remembered runs the search). `components/resultParts.tsx` holds the pieces the table, phone list, and cards share: relative published date with the exact date as tooltip (yt-dlp dates are approximate), compact view count, a Short badge at 60 s or less, a status chip only for states other than never-downloaded, the per-row download button (`hooks/useResultDownload.ts` owns the dialog for either one row or the selection, never touching the selection for a single download), and the channel name through the shared `ChannelNameDisplay` (link when `channelSubscribed`, plus a Subscribed chip; otherwise the add-channel affordance opening `AddChannelDialog`). - `themes/`: theme definitions (`playful`, `linear`, `flat`), shared layout policy (`layoutPolicy.ts`), and the `ALL_THEMES` registry. New themes add an entry here and implement the required token surface. - `hooks/`: app-wide custom hooks for data fetching and state. - `contexts/` and `providers/`: React Context for cross-cutting concerns (auth token, WebSocket, theme). `contexts/ThemeEngineContext.tsx` owns the active theme mode, resolves the layout policy for the current viewport, and injects theme CSS variables onto the document root. @@ -76,7 +77,8 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - Collation is `utf8mb4_unicode_ci` on every table from migration `20260907000000-normalize-utf8mb4-unicode-collation` onward (older installs had `utf8mb4_general_ci` legacy tables beside explicitly `unicode_ci` newer ones, which breaks any SQL comparing string columns across them). Migrations sorted before it must not compare string columns across tables; `migrations/lib/unicodeCollation.js` is the lowercase-table-name collation helper, `jobsUuidCollation.js` is legacy. The helper drops the foreign keys on the jobs UUID chain before converting and recreates them (stored names and rules, read from information_schema, never assumed) after restoring `utf8mb4_bin`, because MariaDB 10.4.31+/10.5.22+/10.6.15+/10.11.5+/11.x/12.x refuse to alter a foreign-key column even with `FOREIGN_KEY_CHECKS=0`; a key missing after an interrupted run is recreated from the canonical definition as `_job_id_fk`. MariaDB 12.1+ keeps `
_ibfk_N` names across renames and auto-names new constraints `1`, `2`, so Elfhosted-style databases carry mixed-case constraint names on lowercase tables. Test migrations that alter foreign-key columns against `mariadb:12.3` as well as the bundled 10.3. - `server/modules/scheduleConfig.js`: registry of the eight configurable schedules (config key, label, default), `getScheduleError` (syntax via node-cron plus the 15-minute minimum interval, computed structurally with node-cron's own step semantics: a step keeps values divisible by it; the across-midnight case asks the matcher whether two firing days are consecutive, so a Sundays-only `5,55 0,23` schedule is not rejected), `violatesMinimumInterval` with `normalizeToMinimumInterval` (what `configModule` applies at startup: runs closer than 15 minutes apart are dropped, never added, and the hour and calendar fields are kept, so `0,5 2 * * 0` becomes `0 2 * * 0` and pre-floor hand edits keep running instead of blocking every Settings save), and `getNextRun` (steps node-cron's internal time matcher over the minutes/hours the expression can fire on, so predictions match what actually fires, and adds the repeated period when clocks fall back, derived from the offset change so thirty-minute zones such as Lord Howe work, because `setHours` only yields an ambiguous time's first occurrence). `scheduledTaskManager.js` owns the timers: `updateTask({ id, expression, enabled, run, isRunning, getRunBlocker, manualCooldownMs, manualRunRequiresEnabled })` replaces a timer only when the expression changes, keeps the previous timer on an invalid edit, skips a scheduled occurrence while the previous run is still active, and records every run through the recorder injected by `setRunRecorder` (wired in server.js once the DB is ready); `manualRunRequiresEnabled: false` (channel downloads) means the feature switch only stops the timer, not a manual run. A task whose work outlives it may resolve with `finalRecord` (a promise of the run record): the lock is released as usual and only the history row waits for it (`finishWhenSettled`; a rejection closes the row as an error, a row still open at shutdown becomes interrupted), so a slow or stuck `finalRecord` can never hold a task running. `run` is always called as `run({ trigger, ...args })`. `runNow(id, { trigger, args, enforceEnabled, enforceCooldown })` is the single entry point for manual runs -- Scheduling's Run now via `routes/runNowResponse.js` and `POST /api/schedules/:key/run`, the Maintenance rescan, Watch Status Sync Now, the YT-DLP update button, and Download New -- returning `{ started, completion }` or a blocker, checked in order: running (the manager's own flag or the task's `isRunning` probe) -> the task's `getRunBlocker` -> turned off -> the manual-run cooldown (`MANUAL_RUN_COOLDOWN_MS`, kept in memory, applied to channel video counts only; channel downloads are refused only while a sweep is running). `getTaskSnapshot(id)` reads the run blocker and the task's status together, after the run history has been read, so `running` and `runNow` on `GET /api/schedules` never disagree; `scheduledTaskStatus` broadcasts (`{ key }`) tell open Scheduling pages to refetch, and `announceRun(id, promise)` gives the same broadcast to startup passes that run outside the manager (library repair, the yt-dlp channel re-apply, the channel-count refresh). Tasks resolve to `{ status: success|error|skipped, outcome, message, details }`; the feature tasks map their own results through adapters (`mediaServers/watchStatusRunSummary`, `channelAutoDownload`, `backfillFromCompleteList`, `rescanRunSummary`, `ytdlpUpdateRunSummary`) so an overlapped, deferred, or partially failed run is never recorded as a success; a run whose work partly failed (some deletions or empty-folder removals, some archive record writes, the playlist half of the download sweep or any individual playlist in it, one media server of a sync) records as `error` with outcome `partial` and counts in its message. `scheduledTaskRuns.js` is that recorder: a bounded history (20 rows per task, never pruning a running row or the newest row of each outcome, so "last installed" survives any number of later checks) in `scheduled_task_runs`, best-effort so a DB problem never reaches the task, with `markInterruptedRuns()` at startup flagging rows a crashed process left running. `rescanRunSummary.js` and `ytdlpUpdateRunSummary.js` map the rescan and yt-dlp update results to run records and back; startup passes (the filesystem rescan, library repair, and the channel-count refresh) record themselves, while every scheduled and manual run, including the rescan's, is recorded by `scheduledTaskManager` through `runNow`. `jobModule.runStartupBackfill` records the archive repair's startup pass the same way (one finished row, because it starts before `markInterruptedRuns()`), and their last-run displays (Maintenance page, YT-DLP page via `/api/ytdlp/latest-version`) read the history, falling back to the legacy `rescanLastRun` / `ytdlpLast*` config keys, which are no longer written, until a run has been recorded. -- `server/modules/videoLocalStatus.js`: shared local download/file metadata lookup for search results and the local-status endpoint. Also sets `inArchive` on each result: the ID is in `config/complete.list` (one read per call through `archiveModule.filterArchivedVideoIds`, which returns an empty set when the file cannot be read) but has no Videos row, so yt-dlp would skip it. Channel-ignored videos are excluded, because ignoring writes the archive entry on purpose. That lookup is `modules/archiveOnlyIds.js`, shared with channel Download all and playlist download selection; it requires only the ChannelVideo model, not the models index, because `downloadModule.test.js` replaces model files with plain objects and the index's association setup throws on them when `playlistDownloadModule` loads it. The channel listing sets the same flag on the page it returns (`channelVideosService.applyArchiveFlag`, called from `buildChannelVideosResponse` so the date probe that precedes each listing never reads the archive). The channel page, video modal, and Find Videos count such videos as previously downloaded when opening the download dialog, which turns on "Allow re-downloading"; the channel page and Find Videos pass the flag into the modal, whose own local-status lookup then keeps it current. +- `server/modules/videoLocalStatus.js`: shared local download/file metadata lookup for search results and the local-status endpoint. Also sets `inArchive` on each result: the ID is in `config/complete.list` (one read per call through `archiveModule.filterArchivedVideoIds`, which returns an empty set when the file cannot be read) but has no Videos row, so yt-dlp would skip it. Channel-ignored videos are excluded, because ignoring writes the archive entry on purpose. That lookup is `modules/archiveOnlyIds.js`, shared with channel Download all and playlist download selection; it requires only the ChannelVideo model, not the models index, because `downloadModule.test.js` replaces model files with plain objects and the index's association setup throws on them when `playlistDownloadModule` loads it. The channel listing sets the same flag on the page it returns (`channelVideosService.applyArchiveFlag`, called from `buildChannelVideosResponse` so the date probe that precedes each listing never reads the archive). The channel page, video modal, and Find Videos count such videos as previously downloaded when opening the download dialog, which turns on "Allow re-downloading"; the channel page and Find Videos pass the flag into the modal, whose own local-status lookup then keeps it current. It also sets `channelSubscribed` (an enabled Channel row for the result's `channelId`) on results that carry a channel id, i.e. search results but not the local-status endpoint's, whose refresh therefore never clears the flag. +- `server/modules/videoSearchModule.js`: `POST /api/videos/search` (YouTube Data API when a key is set, else yt-dlp `ytsearch`). Results stay in the source's order, YouTube's relevance ranking, which the client cannot recompute; every other order is applied client-side (`FindVideos/sortResults.ts`). The yt-dlp path drops `live_status` `is_live`/`is_upcoming` entries, as the API path already did, because a download would record the stream; a VOD with a null duration is kept. - `server/modules/download/videoActivity.js`: transient per-video queued/downloading registry, duplicate admission claims, and revisioned snapshots. - `client/src/providers/VideoActivityProvider.tsx`: shared authenticated activity snapshots; `useLocalVideoStatus` refreshes persisted metadata independently of activity revisions. diff --git a/client/src/components/FindChannels/components/ChannelCard.tsx b/client/src/components/FindChannels/components/ChannelCard.tsx index 1eb5a3001..c5cb28792 100644 --- a/client/src/components/FindChannels/components/ChannelCard.tsx +++ b/client/src/components/FindChannels/components/ChannelCard.tsx @@ -2,18 +2,13 @@ import React from 'react'; import { Avatar, Box, Card, CardActionArea, Chip, Typography } from '../../ui'; import { ChannelSearchResult } from '../types'; import OpenInYouTubeLink from '../../shared/OpenInYouTubeLink'; +import { formatCompactCount as formatCount } from '../../../utils/formatters'; interface ChannelCardProps { result: ChannelSearchResult; onClick: () => void; } -function formatCount(count: number): string { - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; - if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, '')}K`; - return count.toLocaleString(); -} - export default function ChannelCard({ result, onClick }: ChannelCardProps) { return ( diff --git a/client/src/components/FindVideos/__tests__/FindVideos.test.tsx b/client/src/components/FindVideos/__tests__/FindVideos.test.tsx index 8eda786f9..57a084b87 100644 --- a/client/src/components/FindVideos/__tests__/FindVideos.test.tsx +++ b/client/src/components/FindVideos/__tests__/FindVideos.test.tsx @@ -1,9 +1,12 @@ // Local metadata refresh is covered by the hook tests; isolate action/search requests here. -jest.mock('../../../hooks/useLocalVideoStatus', () => ({ useLocalVideoStatus: () => ({}) })); +const mockLocalStatus = jest.fn((): Record => ({})); +jest.mock('../../../hooks/useLocalVideoStatus', () => ({ + useLocalVideoStatus: () => mockLocalStatus(), +})); import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; jest.mock('axios', () => ({ post: jest.fn(), @@ -22,6 +25,15 @@ jest.mock('../../shared/VideoModal', () => ({ }, })); +jest.mock('../../shared/AddChannelDialog', () => ({ + __esModule: true, + default: function MockAddChannelDialog(props: { open: boolean; channelName: string }) { + const React = require('react'); + if (!props.open) return null; + return React.createElement('div', { 'data-testid': 'mock-add-channel-dialog' }, props.channelName); + }, +})); + jest.mock('../../../hooks/useConfig', () => ({ useConfig: () => ({ config: { preferredResolution: '1080' } }), })); @@ -60,8 +72,18 @@ jest.mock('../../DownloadManager/ManualDownload/DownloadSettingsDialog', () => ( const axios = require('axios'); const FindVideos = require('../index').default; -function renderWithRouter(ui: React.ReactElement) { - return render({ui}); +function LocationProbe() { + const location = useLocation(); + return
{location.search}
; +} + +function renderWithRouter(ui: React.ReactElement, initialEntries: string[] = ['/videos/find']) { + return render( + + {ui} + + + ); } function mockMatchMedia(matches: boolean) { @@ -80,62 +102,128 @@ function mockMatchMedia(matches: boolean) { }); } +function result(overrides: Record = {}) { + return { + youtubeId: 'abc12345678', title: 'Hello World', channelName: 'Chan', + channelId: null, duration: 120, thumbnailUrl: null, publishedAt: null, + viewCount: null, status: 'never_downloaded', + ...overrides, + }; +} + +async function searchFor(query: string, resultsData: Record[]) { + axios.post.mockResolvedValueOnce({ data: { results: resultsData } }); + fireEvent.change(screen.getByRole('textbox'), { target: { value: query } }); + fireEvent.click(screen.getByRole('button', { name: /^search$/i })); + await screen.findByText(String(resultsData[0].title)); +} + +const rowTitles = () => + screen.getAllByRole('row', { name: /^open /i }).map((row) => row.getAttribute('aria-label')!.replace('Open ', '')); + describe('FindVideos page', () => { beforeEach(() => { jest.clearAllMocks(); mockTriggerDownloads.mockResolvedValue(true); + mockLocalStatus.mockReturnValue({}); mockMatchMedia(false); window.localStorage.clear(); + window.sessionStorage.clear(); }); test('submitting a search renders results', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [{ - youtubeId: 'abc12345678', title: 'Hello World', channelName: 'Chan', - channelId: null, duration: 120, thumbnailUrl: null, publishedAt: null, - viewCount: null, status: 'never_downloaded', - }] }, - }); + renderWithRouter(); + await searchFor('hello', [result({ title: 'Hello World' })]); + expect(screen.getByText('Hello World')).toBeInTheDocument(); + }); + test('results open in a table by default on desktop; a row click opens the modal', async () => { renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); + await searchFor('x', [result({ title: 'Row Pick' })]); - expect(await screen.findByText('Hello World')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('row', { name: /open row pick/i })); + expect(screen.getByTestId('mock-video-modal')).toHaveTextContent('Row Pick'); }); - test('clicking a result opens the VideoModal', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [{ - youtubeId: 'abc12345678', title: 'Pick me', channelName: 'Chan', - channelId: null, duration: 120, thumbnailUrl: null, publishedAt: null, - viewCount: null, status: 'never_downloaded', - }] }, - }); + test('Grid View switches to cards and the choice is remembered', async () => { + renderWithRouter(); + await searchFor('x', [result({ title: 'Card Pick' })]); + fireEvent.click(screen.getByRole('button', { name: /grid view/i })); + expect(screen.queryByRole('row', { name: /open card pick/i })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /open card pick/i })); + expect(screen.getByTestId('mock-video-modal')).toHaveTextContent('Card Pick'); + expect(window.localStorage.getItem('findVideos.viewMode')).toBe('grid'); + }); + + test('puts the query in the URL once a search runs', async () => { renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'x' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - expect(await screen.findByText('Pick me')).toBeInTheDocument(); + await searchFor('hello world', [result()]); + expect(screen.getByTestId('location-search')).toHaveTextContent('?q=hello+world'); + }); + + test('restores the remembered results when opened with their query in the URL', async () => { + window.sessionStorage.setItem('findVideos.lastSearch', JSON.stringify({ + query: 'minecraft', count: 50, results: [result({ title: 'Cached result' })], + })); + renderWithRouter(, ['/videos/find?q=minecraft']); + + expect(await screen.findByText('Cached result')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveValue('minecraft'); + expect(axios.post).not.toHaveBeenCalled(); + }); + + test('runs the search from the URL when nothing is remembered for it', async () => { + axios.post.mockResolvedValueOnce({ data: { results: [result({ title: 'Fresh result' })] } }); + renderWithRouter(, ['/videos/find?q=terraria']); + + expect(await screen.findByText('Fresh result')).toBeInTheDocument(); + expect(axios.post).toHaveBeenCalledWith('/api/videos/search', { query: 'terraria', count: 25 }, expect.anything()); + expect(screen.getByRole('textbox')).toHaveValue('terraria'); + }); + + test('clicking a column header sorts, clicking again flips, a third click restores relevance', async () => { + renderWithRouter(); + await searchFor('x', [ + result({ youtubeId: 'few00000001', title: 'Few views', viewCount: 10 }), + result({ youtubeId: 'many0000001', title: 'Many views', viewCount: 1000 }), + ]); + expect(rowTitles()).toEqual(['Few views', 'Many views']); + + const viewsHeader = () => screen.getByRole('button', { name: /^views$/i }); + fireEvent.click(viewsHeader()); + expect(rowTitles()).toEqual(['Many views', 'Few views']); + fireEvent.click(viewsHeader()); + expect(rowTitles()).toEqual(['Few views', 'Many views']); + expect(screen.getByRole('columnheader', { name: /views/i })).toHaveAttribute('aria-sort', 'ascending'); + fireEvent.click(viewsHeader()); + expect(screen.getByRole('columnheader', { name: /views/i })).not.toHaveAttribute('aria-sort'); + expect(JSON.parse(window.localStorage.getItem('findVideos.sort') ?? '')).toEqual({ key: 'relevance', direction: 'desc' }); + }); - fireEvent.click(screen.getByRole('button', { name: /open pick me/i })); - expect(screen.getByTestId('mock-video-modal')).toHaveTextContent('Pick me'); + test('the sort menu orders cards in grid view', async () => { + window.localStorage.setItem('findVideos.viewMode', 'grid'); + renderWithRouter(); + await searchFor('x', [ + result({ youtubeId: 'old00000001', title: 'Older', publishedAt: '2020-01-01T00:00:00Z' }), + result({ youtubeId: 'new00000001', title: 'Newer', publishedAt: '2025-01-01T00:00:00Z' }), + ]); + + fireEvent.click(screen.getByTestId('video-list-sort-button')); + fireEvent.click(await screen.findByRole('menuitem', { name: /published/i })); + + const cards = screen.getAllByRole('button', { name: /^open /i }); + expect(cards.map((c) => c.getAttribute('aria-label'))).toEqual(['Open Newer', 'Open Older']); }); test('applies persisted minimum duration filter and shows "X of Y" count', async () => { window.localStorage.setItem('findVideos.minDuration', '60'); - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'short000001', title: 'Tiny Short', channelName: 'C', channelId: null, duration: 30, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - { youtubeId: 'long0000001', title: 'Long Video', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, - }); - renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); + await searchFor('q', [ + result({ youtubeId: 'long0000001', title: 'Long Video', duration: 600 }), + result({ youtubeId: 'short000001', title: 'Tiny Short', duration: 30 }), + ]); - expect(await screen.findByText('Long Video')).toBeInTheDocument(); expect(screen.queryByText('Tiny Short')).not.toBeInTheDocument(); expect(screen.getByText(/showing 1 of 2 results/i)).toBeInTheDocument(); }); @@ -144,8 +232,8 @@ describe('FindVideos page', () => { window.localStorage.setItem('findVideos.minDuration', '1200'); axios.post.mockResolvedValueOnce({ data: { results: [ - { youtubeId: 'short000001', title: 'Short A', channelName: 'C', channelId: null, duration: 30, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - { youtubeId: 'short000002', title: 'Short B', channelName: 'C', channelId: null, duration: 45, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, + result({ youtubeId: 'short000001', title: 'Short A', duration: 30 }), + result({ youtubeId: 'short000002', title: 'Short B', duration: 45 }), ] }, }); @@ -160,53 +248,34 @@ describe('FindVideos page', () => { test('keeps results with null duration even when filter is active', async () => { window.localStorage.setItem('findVideos.minDuration', '60'); - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'unknownmeta', title: 'Unknown duration', channelName: 'C', channelId: null, duration: null, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - { youtubeId: 'short000001', title: 'Tiny', channelName: 'C', channelId: null, duration: 10, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, - }); - renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - - expect(await screen.findByText('Unknown duration')).toBeInTheDocument(); + await searchFor('q', [ + result({ youtubeId: 'unknownmeta', title: 'Unknown duration', duration: null }), + result({ youtubeId: 'short000001', title: 'Tiny', duration: 10 }), + ]); expect(screen.queryByText('Tiny')).not.toBeInTheDocument(); }); test('eligible videos render a select checkbox; downloaded videos do not', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'never000001', title: 'Never', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - { youtubeId: 'missing0001', title: 'Missing', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'missing' }, - { youtubeId: 'downloaded1', title: 'Done', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'downloaded' }, - ] }, - }); - renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - expect(await screen.findByText('Never')).toBeInTheDocument(); - - expect(screen.getByTestId('select-never000001')).toBeInTheDocument(); - expect(screen.getByTestId('select-missing0001')).toBeInTheDocument(); - expect(screen.queryByTestId('select-downloaded1')).not.toBeInTheDocument(); + await searchFor('q', [ + result({ youtubeId: 'never000001', title: 'Never', duration: 600 }), + result({ youtubeId: 'missing0001', title: 'Missing', duration: 600, status: 'missing' }), + result({ youtubeId: 'downloaded1', title: 'Done', duration: 600, status: 'downloaded' }), + ]); + + expect(screen.getByTestId('select-row-never000001')).toBeInTheDocument(); + expect(screen.getByTestId('select-row-missing0001')).toBeInTheDocument(); + expect(screen.queryByTestId('select-row-downloaded1')).not.toBeInTheDocument(); }); test('a video Youtarr deleted is selectable and counts as previously downloaded', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'deleted0001', title: 'Gone video', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'deleted', removedReason: 'user_deleted' }, - ] }, - }); - renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('Gone video'); + await searchFor('q', [ + result({ youtubeId: 'deleted0001', title: 'Gone video', duration: 600, status: 'deleted', removedReason: 'user_deleted' }), + ]); - fireEvent.click(screen.getByTestId('select-deleted0001')); + fireEvent.click(screen.getByTestId('select-row-deleted0001')); fireEvent.click(screen.getByTestId('video-list-selection-pill')); fireEvent.click(await screen.findByTestId('selection-menu-download')); @@ -215,20 +284,14 @@ describe('FindVideos page', () => { }); test('selecting eligible videos and confirming dialog calls triggerDownloads with URLs', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'never000001', title: 'Never', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - { youtubeId: 'missing0001', title: 'Missing', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'missing' }, - ] }, - }); - renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('Never'); + await searchFor('q', [ + result({ youtubeId: 'never000001', title: 'Never', duration: 600 }), + result({ youtubeId: 'missing0001', title: 'Missing', duration: 600, status: 'missing' }), + ]); - fireEvent.click(screen.getByTestId('select-never000001')); - fireEvent.click(screen.getByTestId('select-missing0001')); + fireEvent.click(screen.getByTestId('select-row-never000001')); + fireEvent.click(screen.getByTestId('select-row-missing0001')); fireEvent.click(screen.getByTestId('video-list-selection-pill')); fireEvent.click(await screen.findByTestId('selection-menu-download')); @@ -249,20 +312,36 @@ describe('FindVideos page', () => { }); }); - test('failed download queue keeps selection and shows an error', async () => { - mockTriggerDownloads.mockResolvedValueOnce(false); - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'never000001', title: 'Never', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, + test('the row download button queues that one video and leaves the selection alone', async () => { + renderWithRouter(); + await searchFor('q', [ + result({ youtubeId: 'first000001', title: 'First', duration: 600 }), + result({ youtubeId: 'second00001', title: 'Second', duration: 600 }), + result({ youtubeId: 'third000001', title: 'Third', duration: 600 }), + ]); + + fireEvent.click(screen.getByTestId('select-row-first000001')); + fireEvent.click(screen.getByTestId('select-row-second00001')); + fireEvent.click(screen.getByRole('button', { name: 'Download Third' })); + + expect(await screen.findByTestId('mock-download-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('dialog-video-count')).toHaveTextContent('1'); + fireEvent.click(screen.getByTestId('dialog-confirm')); + + await waitFor(() => expect(mockTriggerDownloads).toHaveBeenCalledTimes(1)); + expect(mockTriggerDownloads).toHaveBeenCalledWith({ + urls: ['https://www.youtube.com/watch?v=third000001'], + overrideSettings: undefined, }); + expect(screen.getByTestId('video-list-selection-pill')).toBeInTheDocument(); + }); + test('failed download queue keeps selection and shows an error', async () => { + mockTriggerDownloads.mockResolvedValueOnce(false); renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('Never'); + await searchFor('q', [result({ youtubeId: 'never000001', title: 'Never', duration: 600 })]); - fireEvent.click(screen.getByTestId('select-never000001')); + fireEvent.click(screen.getByTestId('select-row-never000001')); fireEvent.click(screen.getByTestId('video-list-selection-pill')); fireEvent.click(await screen.findByTestId('selection-menu-download')); fireEvent.click(await screen.findByTestId('dialog-confirm')); @@ -272,23 +351,17 @@ describe('FindVideos page', () => { }); test('selected hidden missing videos still count as missing in the download dialog', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'missing0001', title: 'Missing', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'missing' }, - { youtubeId: 'long0000001', title: 'Long', channelName: 'C', channelId: null, duration: 1800, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, - }); - renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByTestId('select-missing0001'); + await searchFor('q', [ + result({ youtubeId: 'missing0001', title: 'Gone from disk', duration: 600, status: 'missing' }), + result({ youtubeId: 'long0000001', title: 'Long', duration: 1800 }), + ]); - fireEvent.click(screen.getByTestId('select-missing0001')); + fireEvent.click(screen.getByTestId('select-row-missing0001')); fireEvent.mouseDown(screen.getByText('Any length')); fireEvent.click(await screen.findByText('20+ min')); - expect(screen.queryByTestId('select-missing0001')).not.toBeInTheDocument(); + expect(screen.queryByTestId('select-row-missing0001')).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId('video-list-selection-pill')); fireEvent.click(await screen.findByTestId('selection-menu-download')); @@ -297,20 +370,14 @@ describe('FindVideos page', () => { }); test('a selected never-downloaded video in the download archive counts as missing in the download dialog', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'archived001', title: 'Archived', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded', inArchive: true }, - { youtubeId: 'never000001', title: 'Never', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded', inArchive: false }, - ] }, - }); - renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('Archived'); + await searchFor('q', [ + result({ youtubeId: 'archived001', title: 'Archived', duration: 600, inArchive: true }), + result({ youtubeId: 'never000001', title: 'Never', duration: 600, inArchive: false }), + ]); - fireEvent.click(screen.getByTestId('select-archived001')); - fireEvent.click(screen.getByTestId('select-never000001')); + fireEvent.click(screen.getByTestId('select-row-archived001')); + fireEvent.click(screen.getByTestId('select-row-never000001')); fireEvent.click(screen.getByTestId('video-list-selection-pill')); fireEvent.click(await screen.findByTestId('selection-menu-download')); @@ -318,18 +385,31 @@ describe('FindVideos page', () => { expect(screen.getByTestId('dialog-missing-count')).toHaveTextContent('1'); }); - test('mobile selection action opens the download dialog', async () => { - mockMatchMedia(true); - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'never000001', title: 'Never', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, + test('keeps the Subscribed marker from the search when the local status refresh answers without it', async () => { + mockLocalStatus.mockReturnValue({ + abc12345678: { youtubeId: 'abc12345678', status: 'never_downloaded', inArchive: false }, }); + renderWithRouter(); + await searchFor('q', [result({ title: 'Subscribed video', channelId: 'UC1', channelSubscribed: true })]); + const row = screen.getByRole('row', { name: /open subscribed video/i }); + expect(within(row).getByText('Subscribed')).toBeInTheDocument(); + expect(within(row).getByRole('link', { name: 'Chan' })).toHaveAttribute('href', '/channel/UC1'); + }); + + test('clicking an unsubscribed channel name offers to add the channel', async () => { renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('Never'); + await searchFor('q', [result({ title: 'New channel video', channelId: 'UC2', channelSubscribed: false })]); + + fireEvent.click(screen.getByRole('button', { name: 'Chan' })); + expect(screen.getByTestId('mock-add-channel-dialog')).toHaveTextContent('Chan'); + expect(screen.queryByTestId('mock-video-modal')).not.toBeInTheDocument(); + }); + + test('mobile selection action opens the download dialog', async () => { + mockMatchMedia(true); + renderWithRouter(); + await searchFor('q', [result({ youtubeId: 'never000001', title: 'Never', duration: 600 })]); fireEvent.click(screen.getByTestId('select-mobile-never000001')); fireEvent.click(screen.getByTestId('selection-action-download')); @@ -338,50 +418,20 @@ describe('FindVideos page', () => { expect(screen.getByTestId('dialog-video-count')).toHaveTextContent('1'); }); - test('starting a new search clears prior selection so stale ids cannot be downloaded', async () => { - axios.post - .mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'first0000001', title: 'First', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, - }) - .mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'second000001', title: 'Second', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, - }); + test('mobile shows the sort menu even in the list layout', async () => { + mockMatchMedia(true); + renderWithRouter(); + expect(screen.getByTestId('video-list-sort-button')).toBeInTheDocument(); + }); + test('starting a new search clears prior selection so stale ids cannot be downloaded', async () => { renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'one' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('First'); - fireEvent.click(screen.getByTestId('select-first0000001')); + await searchFor('one', [result({ youtubeId: 'first0000001', title: 'First', duration: 600 })]); + fireEvent.click(screen.getByTestId('select-row-first0000001')); expect(screen.getByTestId('video-list-selection-pill')).toBeInTheDocument(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'two' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('Second'); + await searchFor('two', [result({ youtubeId: 'second000001', title: 'Second', duration: 600 })]); expect(screen.queryByTestId('video-list-selection-pill')).not.toBeInTheDocument(); }); - - test('toggling to Table View switches results to a table and row click opens modal', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [{ - youtubeId: 'abc12345678', title: 'Row Pick', channelName: 'Chan', - channelId: null, duration: 120, thumbnailUrl: null, publishedAt: null, - viewCount: null, status: 'never_downloaded', - }] }, - }); - - renderWithRouter(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'x' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - expect(await screen.findByText('Row Pick')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: /table view/i })); - const row = screen.getByRole('row', { name: /open row pick/i }); - fireEvent.click(row); - expect(screen.getByTestId('mock-video-modal')).toHaveTextContent('Row Pick'); - }); }); diff --git a/client/src/components/FindVideos/__tests__/sortResults.test.ts b/client/src/components/FindVideos/__tests__/sortResults.test.ts new file mode 100644 index 000000000..c2cdb0314 --- /dev/null +++ b/client/src/components/FindVideos/__tests__/sortResults.test.ts @@ -0,0 +1,76 @@ +import { SearchResult } from '../types'; +import { DEFAULT_SORT, SORT_OPTIONS, defaultDirectionFor, sortResults } from '../sortResults'; + +function makeResult(overrides: Partial & { youtubeId: string }): SearchResult { + return { + title: 'Title', + channelName: 'Channel', + channelId: null, + duration: null, + thumbnailUrl: null, + publishedAt: null, + viewCount: null, + status: 'never_downloaded', + ...overrides, + }; +} + +const ids = (results: SearchResult[]) => results.map((r) => r.youtubeId); + +describe('sortResults', () => { + const results = [ + makeResult({ youtubeId: 'b', title: 'Bravo', channelName: 'Zed', duration: 300, publishedAt: '2024-01-01T00:00:00Z', viewCount: 10 }), + makeResult({ youtubeId: 'n', title: 'Null', channelName: 'Mid' }), + makeResult({ youtubeId: 'a', title: 'alpha', channelName: 'Alpha', duration: 30, publishedAt: '2026-01-01T00:00:00Z', viewCount: 1000 }), + ]; + + test('relevance keeps the input order in both directions', () => { + expect(ids(sortResults(results, 'relevance', 'desc'))).toEqual(['b', 'n', 'a']); + expect(ids(sortResults(results, 'relevance', 'asc'))).toEqual(['b', 'n', 'a']); + }); + + test('does not mutate the input', () => { + sortResults(results, 'title', 'asc'); + expect(ids(results)).toEqual(['b', 'n', 'a']); + }); + + test('sorts titles case-insensitively', () => { + expect(ids(sortResults(results, 'title', 'asc'))).toEqual(['a', 'b', 'n']); + expect(ids(sortResults(results, 'title', 'desc'))).toEqual(['n', 'b', 'a']); + }); + + test('sorts by channel name', () => { + expect(ids(sortResults(results, 'channel', 'asc'))).toEqual(['a', 'n', 'b']); + }); + + test('puts a missing date, duration, or view count last in either direction', () => { + expect(ids(sortResults(results, 'published', 'desc'))).toEqual(['a', 'b', 'n']); + expect(ids(sortResults(results, 'published', 'asc'))).toEqual(['b', 'a', 'n']); + expect(ids(sortResults(results, 'duration', 'desc'))).toEqual(['b', 'a', 'n']); + expect(ids(sortResults(results, 'views', 'asc'))).toEqual(['b', 'a', 'n']); + }); + + test('is stable for equal keys', () => { + const tied = [ + makeResult({ youtubeId: 'one', viewCount: 5 }), + makeResult({ youtubeId: 'two', viewCount: 5 }), + makeResult({ youtubeId: 'three', viewCount: 5 }), + ]; + expect(ids(sortResults(tied, 'views', 'desc'))).toEqual(['one', 'two', 'three']); + }); +}); + +describe('sort defaults', () => { + test('text columns start ascending, numeric and date columns descending', () => { + expect(defaultDirectionFor('title')).toBe('asc'); + expect(defaultDirectionFor('channel')).toBe('asc'); + expect(defaultDirectionFor('published')).toBe('desc'); + expect(defaultDirectionFor('duration')).toBe('desc'); + expect(defaultDirectionFor('views')).toBe('desc'); + }); + + test('relevance is the default and the only undirected option', () => { + expect(DEFAULT_SORT).toEqual({ key: 'relevance', direction: 'desc' }); + expect(SORT_OPTIONS.filter((o) => o.undirected).map((o) => o.key)).toEqual(['relevance']); + }); +}); diff --git a/client/src/components/FindVideos/components/ResultCard.tsx b/client/src/components/FindVideos/components/ResultCard.tsx index 3b982286f..f19a1198b 100644 --- a/client/src/components/FindVideos/components/ResultCard.tsx +++ b/client/src/components/FindVideos/components/ResultCard.tsx @@ -1,32 +1,41 @@ import React from 'react'; import { Box, Card, CardActionArea, Checkbox, Typography, Chip } from '../../ui'; -import { CalendarToday as CalendarTodayIcon } from '../../../lib/icons'; -import { - getStatusColor, - getStatusIcon, - getStatusLabel, - getStatusChipVariant, - getStatusChipStyle, - VideoStatus, -} from '../../../utils/videoStatus'; -import { SHARED_THEMED_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; import { formatDurationClock } from '../../../utils'; import { isSelectableForDownload, ResultSelection, SearchResult } from '../types'; +import { + AddChannelHandler, + DownloadButton, + PublishedDate, + ResultChannel, + ResultStatusChip, + ShortBadge, + ViewCount, +} from './resultParts'; interface ResultCardProps { result: SearchResult; onClick: () => void; selection?: ResultSelection; + onAddChannel?: AddChannelHandler; + onDownloadOne?: (result: SearchResult) => void; } -export default function ResultCard({ result, onClick, selection }: ResultCardProps) { - const status: VideoStatus = result.status; +const THUMB_OVERLAY_STYLE: React.CSSProperties = { + position: 'absolute', + top: 4, + padding: 4, + borderRadius: 'var(--radius-ui)', + backgroundColor: 'var(--media-overlay-background)', + zIndex: 3, +}; + +export default function ResultCard({ result, onClick, selection, onAddChannel, onDownloadOne }: ResultCardProps) { const durationLabel = formatDurationClock(result.duration); const selectable = Boolean(selection) && isSelectableForDownload(result.status); const checked = selectable && selection!.isChecked(result.youtubeId); return ( - + e.stopPropagation()} - style={{ - position: 'absolute', - top: 4, - left: 4, - padding: 4, - borderRadius: 'var(--radius-ui)', - backgroundColor: 'var(--media-overlay-background)', - zIndex: 3, - }} + style={{ ...THUMB_OVERLAY_STYLE, left: 4 }} > {result.title} - {result.channelName} - - {result.publishedAt && ( - - - {new Date(result.publishedAt).toLocaleDateString()} - - )} - + + + + + + + {/* Outside the action area so the button is not nested in the card's own button role. */} + {onDownloadOne && isSelectableForDownload(result.status) && ( + + + + )} ); } diff --git a/client/src/components/FindVideos/components/ResultsGrid.tsx b/client/src/components/FindVideos/components/ResultsGrid.tsx index cb32ca4fa..15593eed6 100644 --- a/client/src/components/FindVideos/components/ResultsGrid.tsx +++ b/client/src/components/FindVideos/components/ResultsGrid.tsx @@ -1,10 +1,16 @@ import React from 'react'; import { Box, Typography, Alert, Button, Skeleton } from '../../ui'; import { useMediaQuery } from '../../../hooks/useMediaQuery'; -import { ResultSelection, SearchResult, PageSize, ViewMode } from '../types'; +import { ResultSort, SortKey } from '../sortResults'; +import { ResultSelection, SearchResult, PageSize, ViewMode, THUMB_WIDTH, THUMB_HEIGHT } from '../types'; import ResultCard from './ResultCard'; import ResultsTable from './ResultsTable'; import ResultsListMobile from './ResultsListMobile'; +import { AddChannelHandler } from './resultParts'; + +// Placeholder rows beyond the first screen are never seen; the grid keeps +// its one-per-result shape because cards are short enough to fill it. +const ROW_SKELETON_MAX = 10; interface ResultsGridProps { results: SearchResult[]; @@ -17,25 +23,61 @@ interface ResultsGridProps { onResultClick: (result: SearchResult) => void; onRetry: () => void; selection?: ResultSelection; + sort?: ResultSort; + onSortChange?: (key: SortKey) => void; + onAddChannel?: AddChannelHandler; + onDownloadOne?: (result: SearchResult) => void; +} + +function GridSkeleton({ count }: { count: number }) { + return ( + + {Array.from({ length: count }).map((_, i) => ( + + + + + + ))} + + ); +} + +function RowSkeleton({ count, wide, testId }: { count: number; wide: boolean; testId: string }) { + return ( + + {Array.from({ length: count }).map((_, i) => ( + + + + + + + {wide && ( + <> + + + + + )} + + ))} + + ); } export default function ResultsGrid({ - results, loading, error, hasSearched, lastQuery, pageSize, viewMode, onResultClick, onRetry, selection, + results, loading, error, hasSearched, lastQuery, pageSize, viewMode, + onResultClick, onRetry, selection, sort, onSortChange, onAddChannel, onDownloadOne, }: ResultsGridProps) { const isMobile = useMediaQuery('(max-width: 767px)'); if (loading) { - return ( - - {Array.from({ length: pageSize }).map((_, i) => ( - - - - - - ))} - - ); + if (viewMode === 'grid') return ; + const rows = Math.min(pageSize, ROW_SKELETON_MAX); + return isMobile + ? + : ; } if (error) { @@ -67,8 +109,26 @@ export default function ResultsGrid({ if (viewMode === 'table') { return isMobile - ? - : ; + ? ( + + ) + : ( + + ); } return ( @@ -79,6 +139,8 @@ export default function ResultsGrid({ result={r} onClick={() => onResultClick(r)} selection={selection} + onAddChannel={onAddChannel} + onDownloadOne={onDownloadOne} /> ))} diff --git a/client/src/components/FindVideos/components/ResultsListMobile.tsx b/client/src/components/FindVideos/components/ResultsListMobile.tsx index f1a234f9b..76fbaf4a0 100644 --- a/client/src/components/FindVideos/components/ResultsListMobile.tsx +++ b/client/src/components/FindVideos/components/ResultsListMobile.tsx @@ -1,15 +1,5 @@ import React from 'react'; import { Checkbox, Typography, Chip } from '../../ui'; -import { CalendarToday as CalendarTodayIcon } from '../../../lib/icons'; -import { - getStatusColor, - getStatusIcon, - getStatusLabel, - getStatusChipVariant, - getStatusChipStyle, - VideoStatus, -} from '../../../utils/videoStatus'; -import { SHARED_THEMED_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; import { formatDurationClock } from '../../../utils'; import { isSelectableForDownload, @@ -18,44 +8,33 @@ import { THUMB_WIDTH, THUMB_HEIGHT, } from '../types'; - -function StatusChip({ status }: { status: VideoStatus }) { - return ( - - ); -} +import { + AddChannelHandler, + DownloadButton, + PublishedDate, + ResultChannel, + ResultStatusChip, + ShortBadge, + ViewCount, +} from './resultParts'; interface ResultsListMobileProps { results: SearchResult[]; onResultClick: (result: SearchResult) => void; selection?: ResultSelection; + onAddChannel?: AddChannelHandler; + onDownloadOne?: (result: SearchResult) => void; } -export default function ResultsListMobile({ results, onResultClick, selection }: ResultsListMobileProps) { +export default function ResultsListMobile({ + results, onResultClick, selection, onAddChannel, onDownloadOne, +}: ResultsListMobileProps) { return (
{results.map((result) => { - const status: VideoStatus = result.status; const durationLabel = formatDurationClock(result.duration); const selectable = Boolean(selection) && isSelectableForDownload(result.status); const checked = selectable && selection!.isChecked(result.youtubeId); - const published = result.publishedAt - ? new Date(result.publishedAt).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: '2-digit', - }) - : null; return (
{result.title} - - {result.channelName} - -
- {published && ( - - - {published} - - )} - + onAddChannel={onAddChannel} + /> +
+ + + +
+ {onDownloadOne && ( +
+ +
+ )}
); })} diff --git a/client/src/components/FindVideos/components/ResultsTable.tsx b/client/src/components/FindVideos/components/ResultsTable.tsx index 010abd5ed..a63a31783 100644 --- a/client/src/components/FindVideos/components/ResultsTable.tsx +++ b/client/src/components/FindVideos/components/ResultsTable.tsx @@ -2,25 +2,16 @@ import React from 'react'; import { Checkbox, Typography, - Chip, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + TableSortLabel, } from '../../ui'; -import { CalendarToday as CalendarTodayIcon } from '../../../lib/icons'; -import { - getStatusColor, - getStatusIcon, - getStatusLabel, - getStatusChipVariant, - getStatusChipStyle, - VideoStatus, -} from '../../../utils/videoStatus'; -import { SHARED_THEMED_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; import { formatDurationClock } from '../../../utils'; +import { defaultDirectionFor, ResultSort, SortKey } from '../sortResults'; import { isSelectableForDownload, ResultSelection, @@ -28,47 +19,79 @@ import { THUMB_WIDTH, THUMB_HEIGHT, } from '../types'; +import { + AddChannelHandler, + DownloadButton, + PublishedDate, + ResultChannel, + ResultStatusChip, + ShortBadge, + ViewCount, +} from './resultParts'; -function StatusChip({ status }: { status: VideoStatus }) { - return ( - - ); -} +type ColumnKey = Exclude; interface ResultsTableProps { results: SearchResult[]; onResultClick: (result: SearchResult) => void; selection?: ResultSelection; + sort?: ResultSort; + onSortChange?: (key: SortKey) => void; + onAddChannel?: AddChannelHandler; + onDownloadOne?: (result: SearchResult) => void; +} + +interface SortableHeaderProps { + column: ColumnKey; + label: string; + className: string; + sort?: ResultSort; + onSortChange?: (key: SortKey) => void; +} + +function SortableHeader({ column, label, className, sort, onSortChange }: SortableHeaderProps) { + const active = sort?.key === column; + const direction = active ? sort.direction : defaultDirectionFor(column); + return ( + + {onSortChange ? ( + onSortChange(column)}> + {label} + + ) : ( + label + )} + + ); } -export default function ResultsTable({ results, onResultClick, selection }: ResultsTableProps) { +export default function ResultsTable({ + results, onResultClick, selection, sort, onSortChange, onAddChannel, onDownloadOne, +}: ResultsTableProps) { + const headerProps = { sort, onSortChange }; return ( -
+ {/* Minimum widths keep the title readable at tablet widths; the container scrolls instead. */} +
{selection && } Thumbnail - Title - Channel - Published - Duration - Status + + + + + + Status + {onDownloadOne && } {results.map((result) => { - const status: VideoStatus = result.status; const selectable = Boolean(selection) && isSelectableForDownload(result.status); const checked = selectable && selection!.isChecked(result.youtubeId); return ( @@ -115,26 +138,33 @@ export default function ResultsTable({ results, onResultClick, selection }: Resu - - {result.channelName} - + - {result.publishedAt ? ( - - - {new Date(result.publishedAt).toLocaleDateString()} - - ) : ( - 'N/A' - )} + - {formatDurationClock(result.duration) || '-'} + + {formatDurationClock(result.duration) || '-'} + + + + + {result.viewCount === null ? '-' : } - + + {onDownloadOne && ( + + + + )} ); })} diff --git a/client/src/components/FindVideos/components/SearchBar.tsx b/client/src/components/FindVideos/components/SearchBar.tsx index fdf21f513..a2a7e4677 100644 --- a/client/src/components/FindVideos/components/SearchBar.tsx +++ b/client/src/components/FindVideos/components/SearchBar.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { Box, Button, TextField, Select, MenuItem } from '../../ui'; import { Loader2, LayoutGrid as ViewModuleIcon, Rows as TableChartIcon } from '../../../lib/icons'; +import VideoListSortButton from '../../shared/VideoList/VideoListSortButton'; +import { SortConfig } from '../../shared/VideoList/types'; import { PAGE_SIZES, PageSize, @@ -16,6 +18,7 @@ interface SearchBarProps { minDuration: MinDuration; loading: boolean; viewMode: ViewMode; + sort?: SortConfig; onQueryChange: (q: string) => void; onPageSizeChange: (s: PageSize) => void; onMinDurationChange: (d: MinDuration) => void; @@ -24,15 +27,46 @@ interface SearchBarProps { onCancel: () => void; } +// Phones: the field and Search share the first row, the two selects the second, +// view toggle and sort the third. Wider screens: one wrapping row, with Search +// after the selects. Flex order does the rearranging so nothing renders twice. +const HALF_ROW = 'basis-[calc(50%-0.25rem)] sm:basis-auto'; + +function ViewToggle({ viewMode, onViewModeChange }: Pick) { + return ( + + {(['table', 'grid'] as const).map((mode) => { + const isActive = viewMode === mode; + const Icon = mode === 'table' ? TableChartIcon : ViewModuleIcon; + const label = mode === 'table' ? 'Table View' : 'Grid View'; + return ( + + ); + })} + + ); +} + export default function SearchBar({ - query, pageSize, minDuration, loading, viewMode, + query, pageSize, minDuration, loading, viewMode, sort, onQueryChange, onPageSizeChange, onMinDurationChange, onViewModeChange, onSearch, onCancel, }: SearchBarProps) { const trimmed = query.trim(); const canSearch = !loading && trimmed.length > 0; return ( - + - - - {loading ? ( - - - - - ) : ( - - )} - - {(['table', 'grid'] as const).map((mode) => { - const isActive = viewMode === mode; - const Icon = mode === 'table' ? TableChartIcon : ViewModuleIcon; - const label = mode === 'table' ? 'Table View' : 'Grid View'; - return ( - - ); - })} + + {loading ? ( + + + + + ) : ( + + )} + + + + + + + + + + {sort && } ); diff --git a/client/src/components/FindVideos/components/__tests__/ResultCard.test.tsx b/client/src/components/FindVideos/components/__tests__/ResultCard.test.tsx new file mode 100644 index 000000000..02f205970 --- /dev/null +++ b/client/src/components/FindVideos/components/__tests__/ResultCard.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import ResultCard from '../ResultCard'; +import { SearchResult } from '../../types'; + +function makeResult(overrides: Partial = {}): SearchResult { + return { + youtubeId: 'abc12345678', + title: 'Default Title', + channelName: 'Default Channel', + channelId: null, + duration: null, + thumbnailUrl: null, + publishedAt: null, + viewCount: null, + status: 'never_downloaded', + ...overrides, + }; +} + +function renderCard(result: SearchResult, props: Partial> = {}) { + return render( + + {}} {...props} /> + + ); +} + +describe('ResultCard', () => { + test('clicking the card opens it', () => { + const onClick = jest.fn(); + renderCard(makeResult({ title: 'Pick me' }), { onClick }); + fireEvent.click(screen.getByRole('button', { name: /open pick me/i })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test('shows the relative date, view count, and Short badge', () => { + const publishedAt = new Date(Date.now() - 400 * 86_400_000).toISOString(); + renderCard(makeResult({ publishedAt, viewCount: 850, duration: 59 })); + expect(screen.getByText('1 year ago')).toBeInTheDocument(); + expect(screen.getByText('850')).toBeInTheDocument(); + expect(screen.getByText('Short')).toBeInTheDocument(); + }); + + test('shows no status chip when never downloaded, and one when downloaded', () => { + const { rerender } = renderCard(makeResult()); + expect(screen.queryByText('Not Downloaded')).not.toBeInTheDocument(); + rerender( + + {}} /> + + ); + expect(screen.getByText('Downloaded')).toBeInTheDocument(); + }); + + test('the download button queues the video without opening the card', () => { + const onClick = jest.fn(); + const onDownloadOne = jest.fn(); + const result = makeResult({ title: 'Alpha' }); + renderCard(result, { onClick, onDownloadOne }); + fireEvent.click(screen.getByRole('button', { name: 'Download Alpha' })); + expect(onDownloadOne).toHaveBeenCalledWith(result); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/FindVideos/components/__tests__/ResultsGrid.test.tsx b/client/src/components/FindVideos/components/__tests__/ResultsGrid.test.tsx index 1a06e4bfd..7c5ba396b 100644 --- a/client/src/components/FindVideos/components/__tests__/ResultsGrid.test.tsx +++ b/client/src/components/FindVideos/components/__tests__/ResultsGrid.test.tsx @@ -85,6 +85,12 @@ describe('ResultsGrid', () => { expect(screen.getAllByTestId('skeleton')).toHaveLength(30); }); + test('loading in table mode on desktop renders a table-shaped skeleton', () => { + render(); + expect(screen.getByTestId('results-skeleton-table')).toBeInTheDocument(); + expect(screen.queryByTestId('results-skeleton-list')).not.toBeInTheDocument(); + }); + test('error: renders message and Try again triggers onRetry', () => { const onRetry = jest.fn(); render(); @@ -172,6 +178,12 @@ describe('ResultsGrid', () => { }); }); + test('loading in table mode renders a list-shaped skeleton', () => { + render(); + expect(screen.getByTestId('results-skeleton-list')).toBeInTheDocument(); + expect(screen.queryByTestId('results-skeleton-table')).not.toBeInTheDocument(); + }); + test('viewMode=table renders ResultsListMobile instead of ResultsTable', () => { const onResultClick = jest.fn(); const results = [ diff --git a/client/src/components/FindVideos/components/__tests__/ResultsListMobile.test.tsx b/client/src/components/FindVideos/components/__tests__/ResultsListMobile.test.tsx index 223586341..f2c2ab262 100644 --- a/client/src/components/FindVideos/components/__tests__/ResultsListMobile.test.tsx +++ b/client/src/components/FindVideos/components/__tests__/ResultsListMobile.test.tsx @@ -75,4 +75,29 @@ describe('ResultsListMobile', () => { const card = screen.getByRole('button', { name: /open alpha/i }); expect(within(card).queryByText('-')).not.toBeInTheDocument(); }); + + test('shows the relative date, view count, and Short badge in the meta line', () => { + const publishedAt = new Date(Date.now() - 14 * 86_400_000).toISOString(); + const results = [makeResult({ youtubeId: 'a', title: 'Alpha', publishedAt, viewCount: 2_000_000, duration: 30 })]; + render( {}} />); + const card = screen.getByRole('button', { name: /open alpha/i }); + expect(within(card).getByText('2 weeks ago')).toBeInTheDocument(); + expect(within(card).getByText('2M')).toBeInTheDocument(); + expect(within(card).getByText('Short')).toBeInTheDocument(); + }); + + test('shows no Not Downloaded chip', () => { + render( {}} />); + expect(screen.queryByText('Not Downloaded')).not.toBeInTheDocument(); + }); + + test('the download button queues the video without opening it', () => { + const onDownloadOne = jest.fn(); + const onResultClick = jest.fn(); + const results = [makeResult({ youtubeId: 'a', title: 'Alpha' })]; + render(); + fireEvent.click(screen.getByRole('button', { name: 'Download Alpha' })); + expect(onDownloadOne).toHaveBeenCalledWith(results[0]); + expect(onResultClick).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/components/FindVideos/components/__tests__/ResultsTable.test.tsx b/client/src/components/FindVideos/components/__tests__/ResultsTable.test.tsx index cd199e339..848754195 100644 --- a/client/src/components/FindVideos/components/__tests__/ResultsTable.test.tsx +++ b/client/src/components/FindVideos/components/__tests__/ResultsTable.test.tsx @@ -43,12 +43,77 @@ describe('ResultsTable', () => { expect(onResultClick).toHaveBeenCalledWith(results[0]); }); - test('shows N/A when publishedAt is null and - when duration is null', () => { + test('shows N/A when publishedAt is null and - when duration and views are null', () => { const results = [makeResult({ youtubeId: 'a', title: 'Alpha' })]; render( {}} />); const row = screen.getByRole('row', { name: /open alpha/i }); expect(within(row).getByText('N/A')).toBeInTheDocument(); - expect(within(row).getByText('-')).toBeInTheDocument(); + expect(within(row).getAllByText('-')).toHaveLength(2); + }); + + test('shows the published date relative to today and the view count abbreviated', () => { + const publishedAt = new Date(Date.now() - 3 * 86_400_000).toISOString(); + const results = [makeResult({ youtubeId: 'a', title: 'Alpha', publishedAt, viewCount: 12_500 })]; + render( {}} />); + const row = screen.getByRole('row', { name: /open alpha/i }); + expect(within(row).getByText('3 days ago')).toBeInTheDocument(); + expect(within(row).getByText('12.5K')).toBeInTheDocument(); + }); + + test('clicking a column header asks to sort by that column', () => { + const onSortChange = jest.fn(); + render( {}} onSortChange={onSortChange} />); + fireEvent.click(screen.getByRole('button', { name: /^views$/i })); + expect(onSortChange).toHaveBeenCalledWith('views'); + }); + + test('marks the active column with its direction', () => { + render( + {}} + sort={{ key: 'published', direction: 'asc' }} + onSortChange={() => {}} + /> + ); + expect(screen.getByRole('columnheader', { name: /published/i })).toHaveAttribute('aria-sort', 'ascending'); + expect(screen.getByRole('columnheader', { name: /views/i })).not.toHaveAttribute('aria-sort'); + }); + + test('shows no status chip for a never-downloaded row but does for a downloaded one', () => { + const results = [ + makeResult({ youtubeId: 'new', title: 'New', status: 'never_downloaded' }), + makeResult({ youtubeId: 'have', title: 'Have', status: 'downloaded' }), + ]; + render( {}} />); + expect(screen.queryByText('Not Downloaded')).not.toBeInTheDocument(); + expect(within(screen.getByRole('row', { name: /open have/i })).getByText('Downloaded')).toBeInTheDocument(); + }); + + test('the row download button queues that video without opening the row', () => { + const onDownloadOne = jest.fn(); + const onResultClick = jest.fn(); + const results = [makeResult({ youtubeId: 'a', title: 'Alpha' })]; + render(); + fireEvent.click(screen.getByRole('button', { name: 'Download Alpha' })); + expect(onDownloadOne).toHaveBeenCalledWith(results[0]); + expect(onResultClick).not.toHaveBeenCalled(); + }); + + test('offers no download button for a video already downloaded', () => { + render( + {}} + onDownloadOne={jest.fn()} + /> + ); + expect(screen.queryByRole('button', { name: 'Download Have' })).not.toBeInTheDocument(); + }); + + test('marks a Short', () => { + render( {}} />); + expect(within(screen.getByRole('row', { name: /open tiny/i })).getByText('Short')).toBeInTheDocument(); }); test('formats duration as m:ss when under an hour and h:mm:ss otherwise', () => { diff --git a/client/src/components/FindVideos/components/__tests__/SearchBar.test.tsx b/client/src/components/FindVideos/components/__tests__/SearchBar.test.tsx index 0c5add9ff..5e9e1f020 100644 --- a/client/src/components/FindVideos/components/__tests__/SearchBar.test.tsx +++ b/client/src/components/FindVideos/components/__tests__/SearchBar.test.tsx @@ -60,4 +60,17 @@ describe('SearchBar', () => { fireEvent.click(screen.getByRole('button', { name: /table view/i })); expect(onViewModeChange).toHaveBeenCalledWith('table'); }); + + test('renders the sort menu only when a sort is given', () => { + const sort = { + options: [{ key: 'relevance', label: 'Relevance', undirected: true }], + activeKey: 'relevance', + direction: 'desc' as const, + onChange: jest.fn(), + }; + const { rerender } = render(); + expect(screen.getByTestId('video-list-sort-button')).toHaveTextContent('Sort: Relevance'); + rerender(); + expect(screen.queryByTestId('video-list-sort-button')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/FindVideos/components/__tests__/resultParts.test.tsx b/client/src/components/FindVideos/components/__tests__/resultParts.test.tsx new file mode 100644 index 000000000..3f175f46d --- /dev/null +++ b/client/src/components/FindVideos/components/__tests__/resultParts.test.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { + DownloadButton, + PublishedDate, + ResultChannel, + ResultStatusChip, + ShortBadge, + ViewCount, +} from '../resultParts'; +import { SearchResult } from '../../types'; + +function makeResult(overrides: Partial = {}): SearchResult { + return { + youtubeId: 'abc12345678', + title: 'Default Title', + channelName: 'Chan', + channelId: null, + duration: null, + thumbnailUrl: null, + publishedAt: null, + viewCount: null, + status: 'never_downloaded', + ...overrides, + }; +} + +const daysAgo = (days: number) => new Date(Date.now() - days * 86_400_000).toISOString(); + +describe('ResultStatusChip', () => { + test('renders nothing for a video that was never downloaded', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + test('labels every other state', () => { + render(); + expect(screen.getByText('Downloaded')).toBeInTheDocument(); + }); +}); + +describe('ResultChannel', () => { + test('links a subscribed channel to its page and marks it Subscribed', () => { + render( + + + + ); + expect(screen.getByRole('link', { name: 'Chan' })).toHaveAttribute('href', '/channel/UC1'); + expect(screen.getByText('Subscribed')).toBeInTheDocument(); + }); + + test('offers to add an unsubscribed channel', () => { + const onAddChannel = jest.fn(); + render( + + + + ); + fireEvent.click(screen.getByRole('button', { name: /chan/i })); + expect(onAddChannel).toHaveBeenCalledWith('Chan', 'https://www.youtube.com/channel/UC1'); + expect(screen.queryByText('Subscribed')).not.toBeInTheDocument(); + }); + + test('shows a plain name when the channel id is unknown', () => { + render( + + + + ); + expect(screen.getByText('Chan')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); + +describe('PublishedDate', () => { + test('shows the date relative to today with the exact date as its tooltip', () => { + const publishedAt = daysAgo(3); + render(); + const label = screen.getByText('3 days ago'); + expect(label).toHaveAttribute('title', new Date(publishedAt).toLocaleDateString()); + }); + + test('shows the fallback when the date is unknown', () => { + render(); + expect(screen.getByText('N/A')).toBeInTheDocument(); + }); +}); + +describe('ViewCount', () => { + test('abbreviates the count', () => { + render(); + expect(screen.getByText('554.3K')).toBeInTheDocument(); + expect(screen.getByTitle('554,299 views')).toBeInTheDocument(); + }); + + test('renders nothing when the count is unknown', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('ShortBadge', () => { + test('marks videos of a minute or less as Shorts', () => { + render(); + expect(screen.getByText('Short')).toBeInTheDocument(); + }); + + test('renders nothing for longer videos or an unknown length', () => { + const { container, rerender } = render(); + expect(container).toBeEmptyDOMElement(); + rerender(); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('DownloadButton', () => { + test('asks to download the video without opening it', () => { + const onDownload = jest.fn(); + const onOpen = jest.fn(); + const result = makeResult({ title: 'Pick me' }); + render( +
+ +
+ ); + fireEvent.click(screen.getByRole('button', { name: 'Download Pick me' })); + expect(onDownload).toHaveBeenCalledWith(result); + expect(onOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/FindVideos/components/resultParts.tsx b/client/src/components/FindVideos/components/resultParts.tsx new file mode 100644 index 000000000..5043f98e1 --- /dev/null +++ b/client/src/components/FindVideos/components/resultParts.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { Chip, IconButton } from '../../ui'; +import { + CalendarToday as CalendarTodayIcon, + Download as DownloadIcon, + Eye as EyeIcon, +} from '../../../lib/icons'; +import ChannelNameDisplay from '../../shared/ChannelNameDisplay'; +import { + getStatusColor, + getStatusIcon, + getStatusLabel, + getStatusChipVariant, + getStatusChipStyle, + VideoStatus, +} from '../../../utils/videoStatus'; +import { SHARED_THEMED_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; +import { formatCompactCount, formatRelativeDate } from '../../../utils/formatters'; +import { isSelectableForDownload, isShort, SearchResult } from '../types'; + +export type AddChannelHandler = (channelName: string, channelUrl: string) => void; + +const noopAddChannel: AddChannelHandler = () => {}; + +// Nearly every search result is new, so "Not Downloaded" is the silent default +// and only the states worth noticing get a chip. +export function ResultStatusChip({ status }: { status: VideoStatus }) { + if (status === 'never_downloaded') return null; + return ( + + ); +} + +interface ResultChannelProps { + result: SearchResult; + variant: 'caption' | 'body2'; + className?: string; + style?: React.CSSProperties; + onAddChannel?: AddChannelHandler; +} + +export function ResultChannel({ result, variant, className, style, onAddChannel }: ResultChannelProps) { + const subscribed = Boolean(result.channelSubscribed && result.channelId); + return ( + + + {subscribed && ( + + )} + + ); +} + +interface PublishedDateProps { + publishedAt: string | null; + iconSize?: number; + fallback?: React.ReactNode; + className?: string; +} + +// Relative, like YouTube: the yt-dlp path's dates come from "5 years ago" +// text, so an exact-looking date would claim precision the data lacks. +export function PublishedDate({ publishedAt, iconSize = 12, fallback = null, className = '' }: PublishedDateProps) { + const relative = formatRelativeDate(publishedAt); + if (!relative || !publishedAt) return <>{fallback}; + return ( + + + {relative} + + ); +} + +interface ViewCountProps { + viewCount: number | null | undefined; + iconSize?: number; + icon?: boolean; + className?: string; +} + +export function ViewCount({ viewCount, iconSize = 12, icon = true, className = '' }: ViewCountProps) { + if (viewCount === null || viewCount === undefined) return null; + return ( + + {icon && } + {formatCompactCount(viewCount)} + + ); +} + +export function ShortBadge({ duration }: { duration: number | null }) { + if (!isShort(duration)) return null; + return ( + + ); +} + +interface DownloadButtonProps { + result: SearchResult; + onDownload: (result: SearchResult) => void; + className?: string; +} + +export function DownloadButton({ result, onDownload, className }: DownloadButtonProps) { + if (!isSelectableForDownload(result.status)) return null; + return ( + { + e.stopPropagation(); + onDownload(result); + }} + > + + + ); +} diff --git a/client/src/components/FindVideos/hooks/__tests__/useFindVideosPreferences.test.ts b/client/src/components/FindVideos/hooks/__tests__/useFindVideosPreferences.test.ts new file mode 100644 index 000000000..98f9e369e --- /dev/null +++ b/client/src/components/FindVideos/hooks/__tests__/useFindVideosPreferences.test.ts @@ -0,0 +1,47 @@ +import { act, renderHook } from '@testing-library/react'; +import { useFindVideosPreferences } from '../useFindVideosPreferences'; + +describe('useFindVideosPreferences', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + test('defaults to the table view, relevance order, and no minimum duration', () => { + const { result } = renderHook(() => useFindVideosPreferences()); + expect(result.current.viewMode).toBe('table'); + expect(result.current.sort).toEqual({ key: 'relevance', direction: 'desc' }); + expect(result.current.minDuration).toBe(0); + }); + + test('restores stored choices', () => { + window.localStorage.setItem('findVideos.viewMode', 'grid'); + window.localStorage.setItem('findVideos.sort', JSON.stringify({ key: 'views', direction: 'asc' })); + window.localStorage.setItem('findVideos.minDuration', '300'); + const { result } = renderHook(() => useFindVideosPreferences()); + expect(result.current.viewMode).toBe('grid'); + expect(result.current.sort).toEqual({ key: 'views', direction: 'asc' }); + expect(result.current.minDuration).toBe(300); + }); + + test('falls back to defaults for values it does not recognise', () => { + window.localStorage.setItem('findVideos.viewMode', 'carousel'); + window.localStorage.setItem('findVideos.sort', '{"key":"colour","direction":"up"}'); + window.localStorage.setItem('findVideos.minDuration', '42'); + const { result } = renderHook(() => useFindVideosPreferences()); + expect(result.current.viewMode).toBe('table'); + expect(result.current.sort).toEqual({ key: 'relevance', direction: 'desc' }); + expect(result.current.minDuration).toBe(0); + }); + + test('persists each change', () => { + const { result } = renderHook(() => useFindVideosPreferences()); + act(() => { + result.current.setViewMode('grid'); + result.current.setSort({ key: 'published', direction: 'desc' }); + result.current.setMinDuration(600); + }); + expect(window.localStorage.getItem('findVideos.viewMode')).toBe('grid'); + expect(JSON.parse(window.localStorage.getItem('findVideos.sort') ?? '')).toEqual({ key: 'published', direction: 'desc' }); + expect(window.localStorage.getItem('findVideos.minDuration')).toBe('600'); + }); +}); diff --git a/client/src/components/FindVideos/hooks/__tests__/useResultDownload.test.ts b/client/src/components/FindVideos/hooks/__tests__/useResultDownload.test.ts new file mode 100644 index 000000000..546e49e82 --- /dev/null +++ b/client/src/components/FindVideos/hooks/__tests__/useResultDownload.test.ts @@ -0,0 +1,141 @@ +import { act, renderHook } from '@testing-library/react'; +import { SearchResult } from '../../types'; +import { VideoSelectionState } from '../../../shared/VideoList/hooks/useVideoSelection'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +const mockTriggerDownloads = jest.fn(); +jest.mock('../../../../hooks/useTriggerDownloads', () => ({ + useTriggerDownloads: () => ({ triggerDownloads: mockTriggerDownloads, loading: false, error: null }), +})); + +const { useResultDownload } = require('../useResultDownload'); + +function makeResult(overrides: Partial & { youtubeId: string }): SearchResult { + return { + title: overrides.youtubeId, + channelName: 'C', + channelId: null, + duration: null, + thumbnailUrl: null, + publishedAt: null, + viewCount: null, + status: 'never_downloaded', + ...overrides, + }; +} + +function makeSelection(selectedIds: string[]): VideoSelectionState & { clear: jest.Mock } { + return { + selectedIds, + isSelected: (id) => selectedIds.includes(id), + toggle: jest.fn(), + set: jest.fn(), + add: jest.fn(), + selectAll: jest.fn(), + clear: jest.fn(), + hasSelection: selectedIds.length > 0, + count: selectedIds.length, + actions: [], + menuAnchor: null, + openMenu: jest.fn(), + closeMenu: jest.fn(), + }; +} + +const results = [ + makeResult({ youtubeId: 'aaaaaaaaaaa' }), + makeResult({ youtubeId: 'bbbbbbbbbbb', status: 'missing' }), + makeResult({ youtubeId: 'ccccccccccc', inArchive: true }), +]; + +describe('useResultDownload', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockTriggerDownloads.mockResolvedValue(true); + }); + + test('opening for one video targets just that video', () => { + const selection = makeSelection(['aaaaaaaaaaa']); + const { result } = renderHook(() => useResultDownload({ token: 't', results, selection, activeVideos: {} })); + + act(() => { result.current.openForOne(results[1]); }); + + expect(result.current.dialogOpen).toBe(true); + expect(result.current.targetIds).toEqual(['bbbbbbbbbbb']); + }); + + test('counts removed and archive-only targets as previously downloaded', () => { + const selection = makeSelection(['aaaaaaaaaaa', 'bbbbbbbbbbb', 'ccccccccccc']); + const { result } = renderHook(() => useResultDownload({ token: 't', results, selection, activeVideos: {} })); + + act(() => { result.current.openForSelection(); }); + + expect(result.current.targetIds).toEqual(['aaaaaaaaaaa', 'bbbbbbbbbbb', 'ccccccccccc']); + expect(result.current.missingVideoCount).toBe(2); + }); + + test('confirming a selection queues its URLs, clears the selection, and opens the activity page', async () => { + const selection = makeSelection(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + const { result } = renderHook(() => useResultDownload({ token: 't', results, selection, activeVideos: {} })); + + act(() => { result.current.openForSelection(); }); + await act(async () => { await result.current.confirm(null); }); + + expect(mockTriggerDownloads).toHaveBeenCalledWith({ + urls: ['https://www.youtube.com/watch?v=aaaaaaaaaaa', 'https://www.youtube.com/watch?v=bbbbbbbbbbb'], + overrideSettings: undefined, + }); + expect(selection.clear).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/downloads/activity'); + expect(result.current.dialogOpen).toBe(false); + }); + + test('confirming a single video leaves the selection alone', async () => { + const selection = makeSelection(['aaaaaaaaaaa']); + const { result } = renderHook(() => useResultDownload({ token: 't', results, selection, activeVideos: {} })); + + act(() => { result.current.openForOne(results[1]); }); + await act(async () => { await result.current.confirm(null); }); + + expect(mockTriggerDownloads).toHaveBeenCalledWith({ + urls: ['https://www.youtube.com/watch?v=bbbbbbbbbbb'], + overrideSettings: undefined, + }); + expect(selection.clear).not.toHaveBeenCalled(); + }); + + test('skips targets that are already queued or downloading', async () => { + const selection = makeSelection(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + const { result } = renderHook(() => useResultDownload({ + token: 't', results, selection, activeVideos: { aaaaaaaaaaa: { state: 'queued' } }, + })); + + act(() => { result.current.openForSelection(); }); + await act(async () => { await result.current.confirm(null); }); + + expect(mockTriggerDownloads).toHaveBeenCalledWith(expect.objectContaining({ + urls: ['https://www.youtube.com/watch?v=bbbbbbbbbbb'], + })); + }); + + test('a failed queue keeps the selection and reports an error', async () => { + mockTriggerDownloads.mockResolvedValueOnce(false); + const selection = makeSelection(['aaaaaaaaaaa']); + const { result } = renderHook(() => useResultDownload({ token: 't', results, selection, activeVideos: {} })); + + act(() => { result.current.openForSelection(); }); + await act(async () => { await result.current.confirm(null); }); + + expect(result.current.error).toMatch(/failed to queue/i); + expect(selection.clear).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + + act(() => { result.current.clearError(); }); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/client/src/components/FindVideos/hooks/__tests__/useVideoSearch.test.ts b/client/src/components/FindVideos/hooks/__tests__/useVideoSearch.test.ts index 8b78c9588..a86eae753 100644 --- a/client/src/components/FindVideos/hooks/__tests__/useVideoSearch.test.ts +++ b/client/src/components/FindVideos/hooks/__tests__/useVideoSearch.test.ts @@ -122,4 +122,56 @@ describe('useVideoSearch', () => { unmount(); expect(signalRef?.aborted).toBe(true); }); + + describe('last search cache', () => { + beforeEach(() => { window.sessionStorage.clear(); }); + + test('remembers a successful search for the session', async () => { + axios.post.mockResolvedValueOnce({ data: { results: [{ youtubeId: 'a', title: 'A' }] } }); + const { result } = renderHook(() => useVideoSearch('token')); + await act(async () => { await result.current.search('minecraft', 50); }); + + expect(JSON.parse(window.sessionStorage.getItem('findVideos.lastSearch') ?? '')).toEqual({ + query: 'minecraft', count: 50, results: [{ youtubeId: 'a', title: 'A' }], + }); + }); + + test('restores the remembered results without a request when asked for the same query', () => { + window.sessionStorage.setItem('findVideos.lastSearch', JSON.stringify({ + query: 'minecraft', count: 10, results: [{ youtubeId: 'a', title: 'A' }], + })); + const { result } = renderHook(() => useVideoSearch('token', { restoreQuery: 'minecraft' })); + + expect(result.current.results).toEqual([{ youtubeId: 'a', title: 'A' }]); + expect(result.current.restored).toEqual({ query: 'minecraft', count: 10 }); + expect(axios.post).not.toHaveBeenCalled(); + }); + + test('ignores the remembered results for a different query', () => { + window.sessionStorage.setItem('findVideos.lastSearch', JSON.stringify({ + query: 'minecraft', count: 10, results: [{ youtubeId: 'a', title: 'A' }], + })); + const { result } = renderHook(() => useVideoSearch('token', { restoreQuery: 'terraria' })); + + expect(result.current.results).toEqual([]); + expect(result.current.restored).toBeNull(); + }); + + test('ignores the remembered results when no query is asked for', () => { + window.sessionStorage.setItem('findVideos.lastSearch', JSON.stringify({ + query: 'minecraft', count: 10, results: [{ youtubeId: 'a', title: 'A' }], + })); + const { result } = renderHook(() => useVideoSearch('token')); + + expect(result.current.results).toEqual([]); + expect(result.current.restored).toBeNull(); + }); + + test('ignores a remembered entry it cannot read', () => { + window.sessionStorage.setItem('findVideos.lastSearch', '{not json'); + const { result } = renderHook(() => useVideoSearch('token', { restoreQuery: 'minecraft' })); + expect(result.current.results).toEqual([]); + expect(result.current.restored).toBeNull(); + }); + }); }); diff --git a/client/src/components/FindVideos/hooks/useFindVideosPreferences.ts b/client/src/components/FindVideos/hooks/useFindVideosPreferences.ts new file mode 100644 index 000000000..d84ac30a4 --- /dev/null +++ b/client/src/components/FindVideos/hooks/useFindVideosPreferences.ts @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { DEFAULT_SORT, ResultSort, isSortKey } from '../sortResults'; +import { + DEFAULT_MIN_DURATION, + MIN_DURATIONS, + MIN_DURATION_STORAGE_KEY, + MinDuration, + ViewMode, +} from '../types'; + +export const VIEW_MODE_STORAGE_KEY = 'findVideos.viewMode'; +export const SORT_STORAGE_KEY = 'findVideos.sort'; +const DEFAULT_VIEW_MODE: ViewMode = 'table'; + +function readStored(key: string, parse: (raw: string) => T | null, fallback: T): T { + try { + const raw = window.localStorage.getItem(key); + if (raw === null) return fallback; + return parse(raw) ?? fallback; + } catch { + return fallback; + } +} + +function writeStored(key: string, value: string) { + try { + window.localStorage.setItem(key, value); + } catch { + // localStorage unavailable (private mode, quota); the choice still holds in-session. + } +} + +function parseViewMode(raw: string): ViewMode | null { + return raw === 'table' || raw === 'grid' ? raw : null; +} + +function parseSort(raw: string): ResultSort | null { + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + const { key, direction } = parsed as { key?: unknown; direction?: unknown }; + if (!isSortKey(key)) return null; + if (direction !== 'asc' && direction !== 'desc') return null; + return { key, direction }; + } catch { + return null; + } +} + +function parseMinDuration(raw: string): MinDuration | null { + const parsed = Number(raw) as MinDuration; + return MIN_DURATIONS.includes(parsed) ? parsed : null; +} + +export interface FindVideosPreferences { + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; + sort: ResultSort; + setSort: (sort: ResultSort) => void; + minDuration: MinDuration; + setMinDuration: (minDuration: MinDuration) => void; +} + +// The page's remembered choices: view, sort, and minimum duration survive reloads. +export function useFindVideosPreferences(): FindVideosPreferences { + const [viewMode, setViewMode] = useState( + () => readStored(VIEW_MODE_STORAGE_KEY, parseViewMode, DEFAULT_VIEW_MODE) + ); + const [sort, setSort] = useState(() => readStored(SORT_STORAGE_KEY, parseSort, DEFAULT_SORT)); + const [minDuration, setMinDuration] = useState( + () => readStored(MIN_DURATION_STORAGE_KEY, parseMinDuration, DEFAULT_MIN_DURATION) + ); + + useEffect(() => { writeStored(VIEW_MODE_STORAGE_KEY, viewMode); }, [viewMode]); + useEffect(() => { writeStored(SORT_STORAGE_KEY, JSON.stringify(sort)); }, [sort]); + useEffect(() => { writeStored(MIN_DURATION_STORAGE_KEY, String(minDuration)); }, [minDuration]); + + return { viewMode, setViewMode, sort, setSort, minDuration, setMinDuration }; +} diff --git a/client/src/components/FindVideos/hooks/useResultDownload.ts b/client/src/components/FindVideos/hooks/useResultDownload.ts new file mode 100644 index 000000000..de98e5c9b --- /dev/null +++ b/client/src/components/FindVideos/hooks/useResultDownload.ts @@ -0,0 +1,104 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTriggerDownloads } from '../../../hooks/useTriggerDownloads'; +import { isRemovedStatus } from '../../../utils/videoStatus'; +import { DownloadSettings } from '../../DownloadManager/ManualDownload/types'; +import { VideoSelectionState } from '../../shared/VideoList/hooks/useVideoSelection'; +import { SearchResult } from '../types'; + +const QUEUE_FAILED_MESSAGE = 'Failed to queue selected videos. Please try again.'; + +type ActiveVideos = Record; + +// Which videos the dialog is for: the multi-selection, or one row's button. +type Target = { kind: 'selection' } | { kind: 'one'; youtubeId: string }; + +interface UseResultDownloadOptions { + token: string | null; + results: SearchResult[]; + selection: VideoSelectionState; + activeVideos: ActiveVideos; +} + +export interface ResultDownload { + dialogOpen: boolean; + targetIds: string[]; + missingVideoCount: number; + error: string | null; + clearError: () => void; + openForSelection: () => void; + openForOne: (result: SearchResult) => void; + close: () => void; + confirm: (settings: DownloadSettings | null) => Promise; +} + +function toOverrideSettings(settings: DownloadSettings | null) { + if (!settings) return undefined; + return { + resolution: settings.resolution, + allowRedownload: settings.allowRedownload, + subfolder: settings.subfolder, + audioFormat: settings.audioFormat, + rating: settings.rating, + skipVideoFolder: settings.skipVideoFolder, + }; +} + +export function useResultDownload({ token, results, selection, activeVideos }: UseResultDownloadOptions): ResultDownload { + const navigate = useNavigate(); + const { triggerDownloads } = useTriggerDownloads(token); + const [target, setTarget] = useState(null); + const [error, setError] = useState(null); + + const targetIds = useMemo(() => { + if (!target) return []; + return target.kind === 'one' ? [target.youtubeId] : selection.selectedIds; + }, [target, selection.selectedIds]); + + // Removed or archive-only videos need "Allow re-downloading" or yt-dlp skips them. + const missingVideoCount = useMemo(() => { + const targets = new Set(targetIds); + return results.filter((r) => targets.has(r.youtubeId) && (isRemovedStatus(r.status) || r.inArchive)).length; + }, [results, targetIds]); + + const openForSelection = useCallback(() => { + setError(null); + setTarget({ kind: 'selection' }); + }, []); + + const openForOne = useCallback((result: SearchResult) => { + setError(null); + setTarget({ kind: 'one', youtubeId: result.youtubeId }); + }, []); + + const close = useCallback(() => setTarget(null), []); + + const confirm = useCallback(async (settings: DownloadSettings | null) => { + const current = target; + setTarget(null); + if (!current) return; + const ids = targetIds.filter((id) => !activeVideos[id]?.state); + if (ids.length === 0) return; + const urls = ids.map((id) => `https://www.youtube.com/watch?v=${id}`); + const success = await triggerDownloads({ urls, overrideSettings: toOverrideSettings(settings) }); + if (success === null) return; + if (!success) { + setError(QUEUE_FAILED_MESSAGE); + return; + } + if (current.kind === 'selection') selection.clear(); + navigate('/downloads/activity'); + }, [target, targetIds, activeVideos, triggerDownloads, selection, navigate]); + + return { + dialogOpen: target !== null, + targetIds, + missingVideoCount, + error, + clearError: () => setError(null), + openForSelection, + openForOne, + close, + confirm, + }; +} diff --git a/client/src/components/FindVideos/hooks/useVideoSearch.ts b/client/src/components/FindVideos/hooks/useVideoSearch.ts index 6d82c3475..6ac68e2f5 100644 --- a/client/src/components/FindVideos/hooks/useVideoSearch.ts +++ b/client/src/components/FindVideos/hooks/useVideoSearch.ts @@ -1,21 +1,67 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import axios from 'axios'; import { decodeHtml } from '../../../utils/formatters'; -import { SearchResult, PageSize } from '../types'; +import { SearchResult, PageSize, PAGE_SIZES } from '../types'; + +export const LAST_SEARCH_STORAGE_KEY = 'findVideos.lastSearch'; + +interface LastSearch { + query: string; + count: PageSize; + results: SearchResult[]; +} + +export interface RestoredSearch { + query: string; + count: PageSize; +} + +interface UseVideoSearchOptions { + // The query the page was opened with (`?q=`): when it matches the remembered + // search, its results are shown again without spending another request. + restoreQuery?: string | null; +} interface UseVideoSearchResult { results: SearchResult[]; loading: boolean; error: string | null; + restored: RestoredSearch | null; search: (query: string, count: PageSize) => Promise; cancel: () => void; } -export function useVideoSearch(token: string | null): UseVideoSearchResult { - const [results, setResults] = useState([]); +function readLastSearch(query: string | null | undefined): LastSearch | null { + if (!query) return null; + try { + const raw = window.sessionStorage.getItem(LAST_SEARCH_STORAGE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + const { query: storedQuery, count, results } = parsed as Partial; + if (storedQuery !== query || !Array.isArray(results)) return null; + if (!PAGE_SIZES.includes(count as PageSize)) return null; + return { query: storedQuery, count: count as PageSize, results }; + } catch { + return null; + } +} + +function writeLastSearch(entry: LastSearch) { + try { + window.sessionStorage.setItem(LAST_SEARCH_STORAGE_KEY, JSON.stringify(entry)); + } catch { + // sessionStorage unavailable or full; the results are still on screen. + } +} + +export function useVideoSearch(token: string | null, options: UseVideoSearchOptions = {}): UseVideoSearchResult { + const [restoredSearch] = useState(() => readLastSearch(options.restoreQuery)); + const [results, setResults] = useState(() => restoredSearch?.results ?? []); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const controllerRef = useRef(null); + const restored = restoredSearch ? { query: restoredSearch.query, count: restoredSearch.count } : null; useEffect(() => { return () => { controllerRef.current?.abort(); }; @@ -53,6 +99,7 @@ export function useVideoSearch(token: string | null): UseVideoSearchResult { channelName: r.channelName ? decodeHtml(r.channelName) : r.channelName, })); setResults(decoded); + writeLastSearch({ query, count, results: decoded }); } catch (err: unknown) { if (axios.isCancel(err)) { // user canceled or superseded; do not surface as error @@ -73,5 +120,5 @@ export function useVideoSearch(token: string | null): UseVideoSearchResult { controllerRef.current?.abort(); }, []); - return { results, loading, error, search, cancel }; + return { results, loading, error, restored, search, cancel }; } diff --git a/client/src/components/FindVideos/index.tsx b/client/src/components/FindVideos/index.tsx index eac2dafb2..f6a18da26 100644 --- a/client/src/components/FindVideos/index.tsx +++ b/client/src/components/FindVideos/index.tsx @@ -1,47 +1,34 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useVideoActivity } from '../../providers/VideoActivityProvider'; import { useLocalVideoStatus } from '../../hooks/useLocalVideoStatus'; -import { useNavigate } from 'react-router-dom'; -import { isRemovedStatus } from '../../utils/videoStatus'; import { removedState } from '../../utils/videoRemoval'; import { Alert, Box, Typography } from '../ui'; import { Download as DownloadIcon } from '../../lib/icons'; import VideoModal from '../shared/VideoModal'; import { VideoModalData } from '../shared/VideoModal/types'; +import AddChannelDialog from '../shared/AddChannelDialog'; import { useMediaQuery } from '../../hooks/useMediaQuery'; import { useConfig } from '../../hooks/useConfig'; -import { useTriggerDownloads } from '../../hooks/useTriggerDownloads'; import { useVideoSelection } from '../shared/VideoList/hooks/useVideoSelection'; import VideoListSelectionPill from '../shared/VideoList/VideoListSelectionPill'; -import { SelectionAction } from '../shared/VideoList/types'; +import { SelectionAction, SortConfig } from '../shared/VideoList/types'; import DownloadSettingsDialog from '../DownloadManager/ManualDownload/DownloadSettingsDialog'; -import { DownloadSettings } from '../DownloadManager/ManualDownload/types'; import SearchBar from './components/SearchBar'; import ResultsGrid from './components/ResultsGrid'; import { useVideoSearch } from './hooks/useVideoSearch'; +import { useFindVideosPreferences } from './hooks/useFindVideosPreferences'; +import { useResultDownload } from './hooks/useResultDownload'; +import { DEFAULT_SORT, defaultDirectionFor, isSortKey, SORT_OPTIONS, SortKey, sortResults } from './sortResults'; import { - DEFAULT_MIN_DURATION, DEFAULT_PAGE_SIZE, isSelectableForDownload, - MIN_DURATIONS, - MIN_DURATION_STORAGE_KEY, - MinDuration, PageSize, ResultSelection, SearchResult, - ViewMode, } from './types'; -function readStoredMinDuration(): MinDuration { - try { - const raw = window.localStorage.getItem(MIN_DURATION_STORAGE_KEY); - if (raw === null) return DEFAULT_MIN_DURATION; - const parsed = Number(raw) as MinDuration; - return MIN_DURATIONS.includes(parsed) ? parsed : DEFAULT_MIN_DURATION; - } catch { - return DEFAULT_MIN_DURATION; - } -} +const QUERY_PARAM = 'q'; interface FindVideosProps { token: string | null; @@ -78,19 +65,18 @@ function toModalData(r: SearchResult): VideoModalData { export default function FindVideos({ token }: FindVideosProps) { const isMobile = useMediaQuery('(max-width: 767px)'); - const navigate = useNavigate(); - const [query, setQuery] = useState(''); - const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const [minDuration, setMinDuration] = useState(readStoredMinDuration); - const [hasSearched, setHasSearched] = useState(false); - const [viewMode, setViewMode] = useState(isMobile ? 'table' : 'grid'); + const [searchParams, setSearchParams] = useSearchParams(); + const urlQuery = searchParams.get(QUERY_PARAM); + const prefs = useFindVideosPreferences(); + const { results: searchResults, loading, error, restored, search, cancel } = useVideoSearch(token, { restoreQuery: urlQuery }); + const [query, setQuery] = useState(restored?.query ?? urlQuery ?? ''); + const [pageSize, setPageSize] = useState(restored?.count ?? DEFAULT_PAGE_SIZE); + const [hasSearched, setHasSearched] = useState(Boolean(restored)); const [modalVideo, setModalVideo] = useState(null); - const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); - const [downloadError, setDownloadError] = useState(null); - const lastQueryRef = useRef(''); - const lastPageSizeRef = useRef(DEFAULT_PAGE_SIZE); + const [addChannel, setAddChannel] = useState<{ name: string; url: string } | null>(null); + const lastQueryRef = useRef(restored?.query ?? ''); + const lastPageSizeRef = useRef(restored?.count ?? DEFAULT_PAGE_SIZE); - const { results: searchResults, loading, error, search, cancel } = useVideoSearch(token); const { snapshot } = useVideoActivity(); const localStatuses = useLocalVideoStatus(searchResults.map(r => r.youtubeId), token); const results = useMemo(() => searchResults.map(result => ({ @@ -100,41 +86,54 @@ export default function FindVideos({ token }: FindVideosProps) { status: snapshot.videos[result.youtubeId]?.state || localStatuses[result.youtubeId]?.status || result.status, })), [searchResults, localStatuses, snapshot]); const { config } = useConfig(token); - const { triggerDownloads } = useTriggerDownloads(token); - useEffect(() => { - try { - window.localStorage.setItem(MIN_DURATION_STORAGE_KEY, String(minDuration)); - } catch { - // localStorage unavailable (private mode, quota); filter still works in-session. - } - }, [minDuration]); + const runSearch = useCallback((trimmed: string, count: PageSize) => { + lastQueryRef.current = trimmed; + lastPageSizeRef.current = count; + setHasSearched(true); + setSearchParams({ [QUERY_PARAM]: trimmed }, { replace: true }); + search(trimmed, count); + }, [search, setSearchParams]); - const filteredResults = useMemo(() => { - if (minDuration === 0) return results; - return results.filter((r) => r.duration === null || r.duration >= minDuration); - }, [results, minDuration]); + // Opened with ?q= and nothing remembered for it (a fresh tab, a shared link): + // run that search once. Only the query the page was opened with counts; the + // ones it writes to the URL itself must not trigger a second search. + const openedWithQuery = useRef(urlQuery); + useEffect(() => { + const initial = openedWithQuery.current?.trim(); + if (restored || !initial) return; + runSearch(initial, pageSize); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const doSearch = useCallback(() => { const trimmed = query.trim(); if (!trimmed) return; - setDownloadError(null); - lastQueryRef.current = trimmed; - lastPageSizeRef.current = pageSize; - setHasSearched(true); - search(trimmed, pageSize); - }, [query, pageSize, search]); + runSearch(trimmed, pageSize); + }, [query, pageSize, runSearch]); const onRetry = useCallback(() => { if (!lastQueryRef.current) return; search(lastQueryRef.current, lastPageSizeRef.current); }, [search]); + const filteredResults = useMemo(() => { + if (prefs.minDuration === 0) return results; + return results.filter((r) => r.duration === null || r.duration >= prefs.minDuration); + }, [results, prefs.minDuration]); + const sortedResults = useMemo( + () => sortResults(filteredResults, prefs.sort.key, prefs.sort.direction), + [filteredResults, prefs.sort] + ); + const showFilterCount = - hasSearched && !loading && !error && minDuration !== 0 && results.length > 0; + hasSearched && !loading && !error && prefs.minDuration !== 0 && results.length > 0; const allFilteredOut = showFilterCount && results.length > 0 && filteredResults.length === 0; + // The selection's Download action opens the dialog, and the dialog hook needs + // the selection: the ref breaks the loop. + const openForSelectionRef = useRef<() => void>(() => {}); const downloadActions: SelectionAction[] = useMemo( () => [ { @@ -142,16 +141,15 @@ export default function FindVideos({ token }: FindVideosProps) { label: 'Download', icon: , intent: 'success', - onClick: () => { - setDownloadError(null); - setDownloadDialogOpen(true); - }, + onClick: () => openForSelectionRef.current(), }, ], [] ); const selection = useVideoSelection({ actions: downloadActions }); + const download = useResultDownload({ token, results, selection, activeVideos: snapshot.videos }); + openForSelectionRef.current = download.openForSelection; // Drop selected ids that are no longer eligible (e.g. their status changed, // or a new search wiped them from the results list). @@ -172,40 +170,31 @@ export default function FindVideos({ token }: FindVideosProps) { [selection.isSelected, selection.toggle] ); - const handleDownloadConfirm = useCallback( - async (settings: DownloadSettings | null) => { - setDownloadDialogOpen(false); - const ids = selection.selectedIds.filter(id => !snapshot.videos[id]?.state); - if (ids.length === 0) return; - const urls = ids.map((id) => `https://www.youtube.com/watch?v=${id}`); - const overrideSettings = settings - ? { - resolution: settings.resolution, - allowRedownload: settings.allowRedownload, - subfolder: settings.subfolder, - audioFormat: settings.audioFormat, - rating: settings.rating, - skipVideoFolder: settings.skipVideoFolder, - } - : undefined; - const success = await triggerDownloads({ urls, overrideSettings }); - if (success === null) return; - if (!success) { - setDownloadError('Failed to queue selected videos. Please try again.'); - return; - } - selection.clear(); - navigate('/downloads/activity'); - }, - [selection, triggerDownloads, navigate, snapshot] - ); + // Column headers cycle: the column's natural direction, the other, then back to relevance. + const handleHeaderSort = useCallback((key: SortKey) => { + const { sort, setSort } = prefs; + if (sort.key !== key) { + setSort({ key, direction: defaultDirectionFor(key) }); + } else if (sort.direction === defaultDirectionFor(key)) { + setSort({ key, direction: sort.direction === 'asc' ? 'desc' : 'asc' }); + } else { + setSort(DEFAULT_SORT); + } + }, [prefs]); + + // The desktop table sorts through its headers; every other layout gets the menu. + const showSortMenu = isMobile || prefs.viewMode === 'grid'; + const sortConfig: SortConfig | undefined = showSortMenu + ? { + options: SORT_OPTIONS, + activeKey: prefs.sort.key, + direction: prefs.sort.direction, + onChange: (key, direction) => { + if (isSortKey(key)) prefs.setSort({ key, direction }); + }, + } + : undefined; - const missingVideoCount = useMemo( - () => results.filter( - (r) => selection.isSelected(r.youtubeId) && (isRemovedStatus(r.status) || r.inArchive) - ).length, - [results, selection.isSelected] - ); const defaultResolution = config.preferredResolution || '1080'; return ( @@ -214,13 +203,14 @@ export default function FindVideos({ token }: FindVideosProps) { @@ -230,9 +220,9 @@ export default function FindVideos({ token }: FindVideosProps) { {results.length - filteredResults.length} hidden by minimum duration filter) )} - {downloadError && ( - setDownloadError(null)}> - {downloadError} + {download.error && ( + + {download.error} )} {allFilteredOut ? ( @@ -242,30 +232,42 @@ export default function FindVideos({ token }: FindVideosProps) { ) : ( setModalVideo(toModalData(r))} onRetry={onRetry} selection={resultSelection} + sort={prefs.sort} + onSortChange={handleHeaderSort} + onAddChannel={(name, url) => setAddChannel({ name, url })} + onDownloadOne={download.openForOne} /> )} setDownloadDialogOpen(false)} - onConfirm={handleDownloadConfirm} - videoCount={selection.selectedIds.length} - missingVideoCount={missingVideoCount} + open={download.dialogOpen} + onClose={download.close} + onConfirm={download.confirm} + videoCount={download.targetIds.length} + missingVideoCount={download.missingVideoCount} defaultResolution={defaultResolution} defaultResolutionSource="global" mode="manual" token={token} /> + {addChannel && ( + setAddChannel(null)} + channelName={addChannel.name} + channelUrl={addChannel.url} + /> + )} {modalVideo && ( ): Comparable { + switch (key) { + case 'title': + return result.title.toLocaleLowerCase(); + case 'channel': + return result.channelName.toLocaleLowerCase(); + case 'published': + return result.publishedAt ? Date.parse(result.publishedAt) : null; + case 'duration': + return result.duration; + case 'views': + return result.viewCount; + } +} + +function compare(a: Comparable, b: Comparable): number { + if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); + return (a as number) - (b as number); +} + +// Stable; a missing value sorts last whichever way the rest is ordered. +export function sortResults(results: T[], key: SortKey, direction: SortDirection): T[] { + if (key === 'relevance') return results.slice(); + const sign = direction === 'asc' ? 1 : -1; + return results + .map((result, index) => ({ result, index, value: valueFor(result, key) })) + .sort((a, b) => { + if (a.value === null && b.value === null) return a.index - b.index; + if (a.value === null) return 1; + if (b.value === null) return -1; + return sign * compare(a.value, b.value) || a.index - b.index; + }) + .map((entry) => entry.result); +} diff --git a/client/src/components/FindVideos/types.ts b/client/src/components/FindVideos/types.ts index 95d63bb8f..ec57d2d1a 100644 --- a/client/src/components/FindVideos/types.ts +++ b/client/src/components/FindVideos/types.ts @@ -12,6 +12,9 @@ export interface SearchResult { publishedAt: string | null; viewCount: number | null; status: SearchResultStatus; + // The video's channel is an enabled subscription (set by the search, not by + // the local-status refresh, which has no channel id to answer with). + channelSubscribed?: boolean; // Listed in the download archive without a database record, so a download // skips it unless re-downloading is allowed. inArchive?: boolean; @@ -44,7 +47,7 @@ export const MIN_DURATIONS: MinDuration[] = [0, 60, 180, 300, 600, 900, 1200]; export const DEFAULT_MIN_DURATION: MinDuration = 0; export const MIN_DURATION_LABELS: Record = { 0: 'Any length', - 60: '1+ min', + 60: '1+ min (hides Shorts)', 180: '3+ min', 300: '5+ min', 600: '10+ min', @@ -54,6 +57,14 @@ export const MIN_DURATION_LABELS: Record = { export const MIN_DURATION_STORAGE_KEY = 'findVideos.minDuration'; +// YouTube's original Shorts limit; search results carry no vertical-video flag, +// so length is the only signal. +export const SHORT_MAX_SECONDS = 60; + +export function isShort(duration: number | null): boolean { + return duration !== null && duration <= SHORT_MAX_SECONDS; +} + export const THUMB_WIDTH = 120; export const THUMB_HEIGHT = 67; diff --git a/client/src/components/VideosPage/components/VideoCard.tsx b/client/src/components/VideosPage/components/VideoCard.tsx index 3d6e3457d..3a9b88b52 100644 --- a/client/src/components/VideosPage/components/VideoCard.tsx +++ b/client/src/components/VideosPage/components/VideoCard.tsx @@ -22,7 +22,7 @@ import AvailabilityChip from '../../shared/AvailabilityChip'; import { SHARED_STATUS_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; import { DELETED_OVERLAY_BACKGROUND, getRemovalState } from '../../../utils/videoRemoval'; import RemovalDescription from '../../shared/RemovalDescription'; -import ChannelNameDisplay from './ChannelNameDisplay'; +import ChannelNameDisplay from '../../shared/ChannelNameDisplay'; import WatchedChip from '../../shared/WatchedChip'; export interface VideoCardProps { diff --git a/client/src/components/VideosPage/components/VideosListMobile.tsx b/client/src/components/VideosPage/components/VideosListMobile.tsx index b9fcadbab..d4277dff0 100644 --- a/client/src/components/VideosPage/components/VideosListMobile.tsx +++ b/client/src/components/VideosPage/components/VideosListMobile.tsx @@ -16,7 +16,7 @@ import ThumbnailClickOverlay from '../../shared/ThumbnailClickOverlay'; import AvailabilityChip from '../../shared/AvailabilityChip'; import WatchedChip from '../../shared/WatchedChip'; import { SHARED_STATUS_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; -import ChannelNameDisplay from './ChannelNameDisplay'; +import ChannelNameDisplay from '../../shared/ChannelNameDisplay'; export interface VideosListMobileProps { videos: VideoData[]; diff --git a/client/src/components/VideosPage/components/VideosTable.tsx b/client/src/components/VideosPage/components/VideosTable.tsx index 8122c23e8..116b5df63 100644 --- a/client/src/components/VideosPage/components/VideosTable.tsx +++ b/client/src/components/VideosPage/components/VideosTable.tsx @@ -35,7 +35,7 @@ import AvailabilityChip from '../../shared/AvailabilityChip'; import { SHARED_STATUS_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; import { DELETED_OVERLAY_BACKGROUND, getRemovalState } from '../../../utils/videoRemoval'; import RemovalDescription from '../../shared/RemovalDescription'; -import ChannelNameDisplay from './ChannelNameDisplay'; +import ChannelNameDisplay from '../../shared/ChannelNameDisplay'; import WatchedChip from '../../shared/WatchedChip'; export interface VideosTableProps { diff --git a/client/src/components/VideosPage/components/ChannelNameDisplay.tsx b/client/src/components/shared/ChannelNameDisplay.tsx similarity index 96% rename from client/src/components/VideosPage/components/ChannelNameDisplay.tsx rename to client/src/components/shared/ChannelNameDisplay.tsx index c8575677f..506078a06 100644 --- a/client/src/components/VideosPage/components/ChannelNameDisplay.tsx +++ b/client/src/components/shared/ChannelNameDisplay.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import { Typography } from '../../ui'; -import { Plus } from '../../../lib/icons'; +import { Typography } from '../ui'; +import { Plus } from '../../lib/icons'; interface ChannelNameDisplayProps { channelName: string; diff --git a/client/src/components/shared/VideoList/VideoListSortButton.tsx b/client/src/components/shared/VideoList/VideoListSortButton.tsx new file mode 100644 index 000000000..98e1e1e0c --- /dev/null +++ b/client/src/components/shared/VideoList/VideoListSortButton.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { Button, Menu, MenuItem, ListItemIcon, ListItemText } from '../../ui'; +import { + ArrowUpward as ArrowUpIcon, + ArrowDownward as ArrowDownIcon, + Check as CheckIcon, +} from '../../../lib/icons'; +import { SortConfig } from './types'; + +export interface VideoListSortButtonProps { + sort: SortConfig; +} + +function DirectionIcon({ direction }: { direction: 'asc' | 'desc' }) { + const Icon = direction === 'asc' ? ArrowUpIcon : ArrowDownIcon; + return ; +} + +function VideoListSortButton({ sort }: VideoListSortButtonProps) { + const [anchorEl, setAnchorEl] = useState(null); + + const active = sort.options.find((o) => o.key === sort.activeKey); + const label = active ? active.label : 'Sort'; + const activeIsDirected = !active?.undirected; + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + {sort.options.map((option) => { + const isActive = option.key === sort.activeKey; + return ( + { + if (!isActive) { + sort.onChange(option.key, 'desc'); + } else if (!option.undirected) { + sort.onChange(option.key, sort.direction === 'asc' ? 'desc' : 'asc'); + } + setAnchorEl(null); + }} + > + {option.label} + + {isActive && !option.undirected ? ( + + ) : ( + + )} + + + ); + })} + + + ); +} + +export default VideoListSortButton; diff --git a/client/src/components/shared/VideoList/VideoListToolbar.tsx b/client/src/components/shared/VideoList/VideoListToolbar.tsx index 1577d2172..fe2ee449d 100644 --- a/client/src/components/shared/VideoList/VideoListToolbar.tsx +++ b/client/src/components/shared/VideoList/VideoListToolbar.tsx @@ -1,21 +1,8 @@ -import React, { useState } from 'react'; -import { - Badge, - Button, - Menu, - MenuItem, - TextField, - ListItemIcon, - ListItemText, -} from '../../ui'; -import { - Search as SearchIcon, - ListFilter as FilterListIcon, - ArrowUpward as ArrowUpIcon, - ArrowDownward as ArrowDownIcon, - Check as CheckIcon, -} from '../../../lib/icons'; +import React from 'react'; +import { Badge, Button, TextField } from '../../ui'; +import { Search as SearchIcon, ListFilter as FilterListIcon } from '../../../lib/icons'; import VideoListViewToggle from './VideoListViewToggle'; +import VideoListSortButton from './VideoListSortButton'; import { VideoListViewMode, SortConfig } from './types'; import { VideoListState } from './hooks/useVideoListState'; @@ -32,69 +19,6 @@ export interface VideoListToolbarProps { isMobile: boolean; } -function SortButton({ sort }: { sort: SortConfig }) { - const [anchorEl, setAnchorEl] = useState(null); - - const active = sort.options.find((o) => o.key === sort.activeKey); - const label = active ? active.label : 'Sort'; - const DirectionIcon = sort.direction === 'asc' ? ArrowUpIcon : ArrowDownIcon; - - return ( - <> - - setAnchorEl(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} - > - {sort.options.map((option) => { - const isActive = option.key === sort.activeKey; - return ( - { - if (isActive) { - sort.onChange(option.key, sort.direction === 'asc' ? 'desc' : 'asc'); - } else { - sort.onChange(option.key, 'desc'); - } - setAnchorEl(null); - }} - > - {option.label} - {isActive && ( - - {sort.direction === 'asc' ? ( - - ) : ( - - )} - - )} - {!isActive && ( - - - - )} - - ); - })} - - - ); -} - function VideoListToolbar({ state, viewModes, @@ -145,7 +69,7 @@ function VideoListToolbar({
{filtersButton} - {sort && } + {sort && }
{rightActions}
@@ -180,7 +104,7 @@ function VideoListToolbar({ /> {filtersButton} - {sort && } + {sort && } {toolbarExtras}
{rightActions} diff --git a/client/src/components/shared/VideoList/__tests__/VideoListSortButton.test.tsx b/client/src/components/shared/VideoList/__tests__/VideoListSortButton.test.tsx new file mode 100644 index 000000000..4f7e75a80 --- /dev/null +++ b/client/src/components/shared/VideoList/__tests__/VideoListSortButton.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import VideoListSortButton from '../VideoListSortButton'; +import { SortConfig } from '../types'; +import { renderWithProviders } from '../../../../test-utils'; + +function buildSort(overrides: Partial = {}): SortConfig { + return { + options: [ + { key: 'relevance', label: 'Relevance', undirected: true }, + { key: 'published', label: 'Published' }, + ], + activeKey: 'published', + direction: 'desc', + onChange: jest.fn(), + ...overrides, + }; +} + +describe('VideoListSortButton', () => { + test('shows the active label with a direction arrow for a directional sort', () => { + renderWithProviders(); + const button = screen.getByTestId('video-list-sort-button'); + expect(button).toHaveTextContent('Sort: Published'); + expect(screen.getByTestId('sort-direction-desc')).toBeInTheDocument(); + }); + + test('hides the direction arrow while an undirected sort is active', () => { + renderWithProviders(); + expect(screen.getByTestId('video-list-sort-button')).toHaveTextContent('Sort: Relevance'); + expect(screen.queryByTestId('sort-direction-desc')).not.toBeInTheDocument(); + expect(screen.queryByTestId('sort-direction-asc')).not.toBeInTheDocument(); + }); + + test('choosing a different option sorts it descending', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders(); + await user.click(screen.getByTestId('video-list-sort-button')); + await user.click(await screen.findByRole('menuitem', { name: /published/i })); + expect(onChange).toHaveBeenCalledWith('published', 'desc'); + }); + + test('choosing the active directional option flips its direction', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders(); + await user.click(screen.getByTestId('video-list-sort-button')); + await user.click(await screen.findByRole('menuitem', { name: /published/i })); + expect(onChange).toHaveBeenCalledWith('published', 'asc'); + }); + + test('choosing the active undirected option changes nothing', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders(); + await user.click(screen.getByTestId('video-list-sort-button')); + await user.click(await screen.findByRole('menuitem', { name: /relevance/i })); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/shared/VideoList/index.ts b/client/src/components/shared/VideoList/index.ts index 77480d546..5049a6994 100644 --- a/client/src/components/shared/VideoList/index.ts +++ b/client/src/components/shared/VideoList/index.ts @@ -1,6 +1,7 @@ export { default as VideoListContainer } from './VideoListContainer'; export { default as VideoListToolbar } from './VideoListToolbar'; export { default as VideoListViewToggle } from './VideoListViewToggle'; +export { default as VideoListSortButton } from './VideoListSortButton'; export { default as VideoListFilterPanel } from './VideoListFilterPanel'; export { default as VideoListFilterChips, countActiveFilters, hasActiveFilters, clearAllFilters } from './VideoListFilterChips'; export { default as VideoListSelectionPill } from './VideoListSelectionPill'; diff --git a/client/src/components/shared/VideoList/types.ts b/client/src/components/shared/VideoList/types.ts index 7b83f53c9..28703381b 100644 --- a/client/src/components/shared/VideoList/types.ts +++ b/client/src/components/shared/VideoList/types.ts @@ -87,6 +87,9 @@ export type FilterConfig = export interface SortOption { key: string; label: string; + // An order with no direction (e.g. the source's own ranking): the button + // shows no arrow for it and re-choosing it changes nothing. + undirected?: boolean; } export interface SortConfig { diff --git a/client/src/components/shared/VideoModal/components/VideoMetadata.tsx b/client/src/components/shared/VideoModal/components/VideoMetadata.tsx index 6784d4aad..74cae49e1 100644 --- a/client/src/components/shared/VideoModal/components/VideoMetadata.tsx +++ b/client/src/components/shared/VideoModal/components/VideoMetadata.tsx @@ -11,7 +11,7 @@ import { FileDownload as DownloadedIcon, Plus as AddIcon, } from '../../../../lib/icons'; -import { formatDate, formatDateTime } from '../../../../utils/formatters'; +import { formatCompactCount as formatCount, formatDate, formatDateTime } from '../../../../utils/formatters'; import { VideoModalData, VideoExtendedMetadata } from '../types'; interface VideoMetadataProps { @@ -24,16 +24,6 @@ interface VideoMetadataProps { const DESCRIPTION_PREVIEW_LENGTH = 300; const VISIBLE_TAGS_COUNT = 6; -function formatCount(count: number): string { - if (count >= 1_000_000) { - return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; - } - if (count >= 1_000) { - return `${(count / 1_000).toFixed(1).replace(/\.0$/, '')}K`; - } - return count.toLocaleString(); -} - function formatDuration(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); diff --git a/client/src/components/VideosPage/components/__tests__/ChannelNameDisplay.test.tsx b/client/src/components/shared/__tests__/ChannelNameDisplay.test.tsx similarity index 100% rename from client/src/components/VideosPage/components/__tests__/ChannelNameDisplay.test.tsx rename to client/src/components/shared/__tests__/ChannelNameDisplay.test.tsx diff --git a/client/src/utils/__tests__/formatters.test.ts b/client/src/utils/__tests__/formatters.test.ts index 443920a0d..a7e58cbe5 100644 --- a/client/src/utils/__tests__/formatters.test.ts +++ b/client/src/utils/__tests__/formatters.test.ts @@ -5,9 +5,60 @@ import { formatAddedDate, formatAddedDateTime, formatAddedDateParts, + formatCompactCount, + formatRelativeDate, uploadDateToIso, } from '../formatters'; +describe('formatCompactCount', () => { + test('shows small counts in full with locale grouping', () => { + expect(formatCompactCount(850)).toBe('850'); + expect(formatCompactCount(0)).toBe('0'); + }); + + test('abbreviates thousands to one decimal, dropping a trailing .0', () => { + expect(formatCompactCount(12_500)).toBe('12.5K'); + expect(formatCompactCount(12_000)).toBe('12K'); + }); + + test('abbreviates millions', () => { + expect(formatCompactCount(1_500_000)).toBe('1.5M'); + expect(formatCompactCount(554_299)).toBe('554.3K'); + }); +}); + +describe('formatRelativeDate', () => { + const now = new Date('2026-10-20T12:00:00Z'); + const daysAgo = (days: number) => new Date(now.getTime() - days * 86_400_000).toISOString(); + + test('returns null for missing or unparseable input', () => { + expect(formatRelativeDate(null, now)).toBeNull(); + expect(formatRelativeDate('garbage', now)).toBeNull(); + }); + + test('describes the last two days in words', () => { + expect(formatRelativeDate(daysAgo(0), now)).toBe('today'); + expect(formatRelativeDate(daysAgo(1), now)).toBe('yesterday'); + }); + + test('counts days under a week, weeks under a month, months under a year', () => { + expect(formatRelativeDate(daysAgo(3), now)).toBe('3 days ago'); + expect(formatRelativeDate(daysAgo(7), now)).toBe('1 week ago'); + expect(formatRelativeDate(daysAgo(20), now)).toBe('2 weeks ago'); + expect(formatRelativeDate(daysAgo(31), now)).toBe('1 month ago'); + expect(formatRelativeDate(daysAgo(200), now)).toBe('6 months ago'); + }); + + test('counts whole years beyond that', () => { + expect(formatRelativeDate(daysAgo(365), now)).toBe('1 year ago'); + expect(formatRelativeDate(daysAgo(5 * 365 + 3), now)).toBe('5 years ago'); + }); + + test('treats a date in the future (clock skew, approximate dates) as today', () => { + expect(formatRelativeDate(daysAgo(-2), now)).toBe('today'); + }); +}); + describe('formatters date helpers', () => { describe('parseDate', () => { test('returns null for null', () => { diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index 4a04bf8ab..5963576fa 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -139,3 +139,38 @@ export function formatAddedDate(iso: string | null | undefined): string { const time = date.toLocaleTimeString([], ADDED_TIME_OPTIONS); return `${day} ${time}`; } + +// "12.5K", "1.5M": YouTube-style abbreviation for view, like, and subscriber counts. +export function formatCompactCount(count: number): string { + if (count >= 1_000_000) { + return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + } + if (count >= 1_000) { + return `${(count / 1_000).toFixed(1).replace(/\.0$/, '')}K`; + } + return count.toLocaleString(); +} + +const DAY_MS = 86_400_000; +const DAYS_PER_WEEK = 7; +const DAYS_PER_MONTH = 30; +const DAYS_PER_YEAR = 365; + +function plural(count: number, unit: string): string { + return `${count} ${unit}${count === 1 ? '' : 's'} ago`; +} + +// "3 days ago", "2 weeks ago", "5 years ago": the precision YouTube itself +// shows, which also matches what yt-dlp's approximate search dates carry. +export function formatRelativeDate(iso: string | null | undefined, now: Date = new Date()): string | null { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + const days = Math.floor((now.getTime() - date.getTime()) / DAY_MS); + if (days <= 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < DAYS_PER_WEEK) return plural(days, 'day'); + if (days < DAYS_PER_MONTH) return plural(Math.floor(days / DAYS_PER_WEEK), 'week'); + if (days < DAYS_PER_YEAR) return plural(Math.floor(days / DAYS_PER_MONTH), 'month'); + return plural(Math.floor(days / DAYS_PER_YEAR), 'year'); +} diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index b266ce5ea..3e3be59f3 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -524,16 +524,23 @@ Search YouTube from inside Youtarr and see which results you already have, which 1. **Open Find Videos on YouTube** - In the sidebar, expand **Videos** and click **Find Videos on YouTube** - Enter a search query (up to 200 characters) - - Pick a result count (10, 25, 50, or 100), optionally choose a minimum duration, and click Search + - Pick a result count (10, 25, 50, or 100), optionally choose a minimum duration (**1+ min** hides Shorts), and click Search + - The query is kept in the page address, so the browser's Back button returns you to the same results without searching again 2. **Results** - - Sorted newest-to-oldest by YouTube's approximate publish date (accurate to within a day or two, same fidelity as the channel videos page) + - Listed in YouTube's own relevance order by default. Sort by published date, views, duration, title, or channel from the table's column headers on desktop (click a header again to reverse it, a third time to return to relevance) or from the **Sort** menu on phones and in grid view + - Desktop opens results in a table; the toggle beside the Search button switches to a grid of cards. The choice, the sort, and the minimum duration are remembered + - Each result shows the channel, duration, how long ago it was published (the exact date on hover), and the view count; videos of a minute or less carry a **Short** badge + - Live and upcoming streams are left out, since a download would record the stream - If a minimum duration is selected, shorter results are hidden in the browser and the page shows how many results were filtered out - - Each card shows the channel, duration, upload date, and a status chip: + - A status chip appears only when there is something to know: - **Downloaded**: already in your library - - **Missing**: previously downloaded but removed from disk - - **Not Downloaded**: new result, not yet saved - - Click a result to open the video detail modal, where you can download, play (if downloaded), or ignore it + - **Deleted**: previously downloaded and removed by Youtarr + - **Missing**: previously downloaded but gone from disk + - **Queued** / **Downloading**: on its way + - Channel names link to the channel page for channels you follow (marked **Subscribed**); other channel names offer to add the channel + - The download icon on a row queues that one video; tick several and use the selection bar to download them together + - Click a result to open the video detail modal, where you can download, play (if downloaded), or open it on YouTube 3. **Notes** - Rate-limited to 10 searches per minute per session; server-side timeout is 60 seconds diff --git a/server/modules/__tests__/videoLocalStatus.test.js b/server/modules/__tests__/videoLocalStatus.test.js index 7697203af..0c873c5c3 100644 --- a/server/modules/__tests__/videoLocalStatus.test.js +++ b/server/modules/__tests__/videoLocalStatus.test.js @@ -1,8 +1,8 @@ /* eslint-env jest */ -jest.mock('../../models', () => ({ Video: { findAll: jest.fn() } })); +jest.mock('../../models', () => ({ Video: { findAll: jest.fn() }, Channel: { findAll: jest.fn() } })); jest.mock('../../models/channelvideo', () => ({ findAll: jest.fn() })); jest.mock('../archiveModule', () => ({ filterArchivedVideoIds: jest.fn() })); -const { Video } = require('../../models'); +const { Video, Channel } = require('../../models'); const ChannelVideo = require('../../models/channelvideo'); const archiveModule = require('../archiveModule'); const { applyLocalVideoStatus } = require('../videoLocalStatus'); @@ -12,6 +12,7 @@ const pending = (...youtubeIds) => youtubeIds.map(youtubeId => ({ youtubeId, sta beforeEach(() => { jest.clearAllMocks(); Video.findAll.mockResolvedValue([]); + Channel.findAll.mockResolvedValue([]); ChannelVideo.findAll.mockResolvedValue([]); archiveModule.filterArchivedVideoIds.mockReturnValue(new Set()); }); @@ -104,3 +105,30 @@ describe('inArchive', () => { expect(ChannelVideo.findAll).not.toHaveBeenCalled(); }); }); + +describe('channelSubscribed', () => { + const withChannel = (youtubeId, channelId) => ({ youtubeId, channelId, status: 'never_downloaded' }); + + it('is true for results whose channel is an enabled subscription and false otherwise', async () => { + Channel.findAll.mockResolvedValue([{ channel_id: 'UCsubscribed' }]); + const results = [withChannel('aaaaaaaaaaa', 'UCsubscribed'), withChannel('bbbbbbbbbbb', 'UCother')]; + await applyLocalVideoStatus(results); + expect(results.map(r => r.channelSubscribed)).toEqual([true, false]); + }); + + it('asks only for enabled channels among the distinct channel ids in the results', async () => { + const results = [withChannel('aaaaaaaaaaa', 'UCone'), withChannel('bbbbbbbbbbb', 'UCone'), withChannel('ccccccccccc', null)]; + await applyLocalVideoStatus(results); + expect(Channel.findAll).toHaveBeenCalledWith({ + where: { channel_id: ['UCone'], enabled: true }, + attributes: ['channel_id'], + }); + }); + + it('leaves the flag unset and skips the lookup when no result carries a channel id', async () => { + const results = pending('aaaaaaaaaaa'); + await applyLocalVideoStatus(results); + expect(Channel.findAll).not.toHaveBeenCalled(); + expect(results[0]).not.toHaveProperty('channelSubscribed'); + }); +}); diff --git a/server/modules/__tests__/videoSearchModule.test.js b/server/modules/__tests__/videoSearchModule.test.js index 441e47f1e..e855ca9db 100644 --- a/server/modules/__tests__/videoSearchModule.test.js +++ b/server/modules/__tests__/videoSearchModule.test.js @@ -4,6 +4,7 @@ jest.mock('../download/ytdlpCommandBuilder', () => ({ })); jest.mock('../../models', () => ({ Video: { findAll: jest.fn().mockResolvedValue([]) }, + Channel: { findAll: jest.fn().mockResolvedValue([]) }, })); jest.mock('../../models/channelvideo', () => ({ findAll: jest.fn().mockResolvedValue([]) })); jest.mock('../archiveModule', () => ({ filterArchivedVideoIds: jest.fn(() => new Set()) })); @@ -244,8 +245,8 @@ describe('videoSearchModule', () => { }); }); - describe('sorting', () => { - test('sorts results newest-to-oldest by publishedAt, with nulls last', async () => { + describe('ordering', () => { + test('keeps results in the order yt-dlp returned them (YouTube relevance), whatever their dates', async () => { const ndjson = JSON.stringify({ id: 'old', title: 'Old', timestamp: 1_600_000_000 }) + '\n' + JSON.stringify({ id: 'nodate', title: 'NoDate' }) + '\n' + @@ -254,7 +255,21 @@ describe('videoSearchModule', () => { ytDlpRunner.run.mockResolvedValueOnce(ndjson); const results = await videoSearchModule.searchVideos('test', 10, {}); - expect(results.map(r => r.youtubeId)).toEqual(['new', 'mid', 'old', 'nodate']); + expect(results.map(r => r.youtubeId)).toEqual(['old', 'nodate', 'new', 'mid']); + }); + }); + + describe('live streams', () => { + test('drops live and upcoming streams but keeps a video that merely lacks a duration', async () => { + const ndjson = + JSON.stringify({ id: 'live0000000', title: 'Radio', live_status: 'is_live', duration: null }) + '\n' + + JSON.stringify({ id: 'soon0000000', title: 'Premiere', live_status: 'is_upcoming', duration: null }) + '\n' + + JSON.stringify({ id: 'nodur000000', title: 'No duration', live_status: null, duration: null }) + '\n' + + JSON.stringify({ id: 'vod00000000', title: 'VOD', live_status: 'was_live', duration: 3600 }) + '\n'; + ytDlpRunner.run.mockResolvedValueOnce(ndjson); + + const results = await videoSearchModule.searchVideos('radio', 10, {}); + expect(results.map(r => r.youtubeId)).toEqual(['nodur000000', 'vod00000000']); }); }); }); @@ -285,6 +300,7 @@ describe('videoSearchModule.searchVideos - API-first path', () => { jest.doMock('../configModule', () => ({ getCookiesPath: jest.fn(() => null) })); jest.doMock('../../models', () => ({ Video: { findAll: jest.fn().mockResolvedValue([]) }, + Channel: { findAll: jest.fn().mockResolvedValue([]) }, })); jest.doMock('../../logger', () => ({ info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn(), @@ -294,6 +310,20 @@ describe('videoSearchModule.searchVideos - API-first path', () => { videoSearchModule = require('../videoSearchModule'); }); + test('keeps the API results in the order YouTube ranked them', async () => { + const apiResult = (youtubeId, publishedAt) => ({ + youtubeId, title: youtubeId, channelName: 'C', channelId: null, duration: 60, + thumbnailUrl: null, publishedAt, viewCount: null, status: 'never_downloaded', + }); + youtubeApi.client.searchVideos.mockResolvedValueOnce([ + apiResult('older000000', '2020-01-01T00:00:00.000Z'), + apiResult('newer000000', '2025-01-01T00:00:00.000Z'), + ]); + + const results = await videoSearchModule.searchVideos('test', 10); + expect(results.map(r => r.youtubeId)).toEqual(['older000000', 'newer000000']); + }); + test('uses API when available; yt-dlp is not called', async () => { const ytDlpRunner = require('../ytDlpRunner'); youtubeApi.client.searchVideos.mockResolvedValueOnce([ diff --git a/server/modules/videoLocalStatus.js b/server/modules/videoLocalStatus.js index 30f4fb240..78487f2af 100644 --- a/server/modules/videoLocalStatus.js +++ b/server/modules/videoLocalStatus.js @@ -1,12 +1,29 @@ -const { Video } = require('../models'); +const { Video, Channel } = require('../models'); const { findArchiveOnlyIds } = require('./archiveOnlyIds'); const { REMOVAL_STATES, removalState } = require('./videoRemovalState'); const toIso = (value) => (value ? new Date(value).toISOString() : null); +// Search results carry a channelId; the local-status endpoint's do not, so the +// flag is only set where it can be answered and a refresh never clears it. +async function applyChannelSubscribed(results) { + const withChannel = results.filter(r => typeof r.channelId === 'string' && r.channelId); + if (withChannel.length === 0) return; + const channelIds = [...new Set(withChannel.map(r => r.channelId))]; + const enabled = await Channel.findAll({ + where: { channel_id: channelIds, enabled: true }, + attributes: ['channel_id'], + }); + const subscribed = new Set(enabled.map(c => c.channel_id)); + for (const r of withChannel) { + r.channelSubscribed = subscribed.has(r.channelId); + } +} + async function applyLocalVideoStatus(results) { const youtubeIds = results.map(r => r.youtubeId).filter(Boolean); if (youtubeIds.length === 0) return; + await applyChannelSubscribed(results); const existing = await Video.findAll({ where: { youtubeId: youtubeIds }, attributes: [ diff --git a/server/modules/videoSearchModule.js b/server/modules/videoSearchModule.js index 6d3b5258a..465b06096 100644 --- a/server/modules/videoSearchModule.js +++ b/server/modules/videoSearchModule.js @@ -6,6 +6,7 @@ const youtubeApi = require('./youtubeApi'); const SEARCH_TIMEOUT_MS = 60_000; const ALLOWED_COUNTS = [10, 25, 50, 100]; +const LIVE_STATUSES_TO_SKIP = new Set(['is_live', 'is_upcoming']); class SearchCanceledError extends Error { constructor() { super('Search canceled'); this.name = 'SearchCanceledError'; } @@ -54,8 +55,10 @@ class VideoSearchModule { source = 'yt-dlp'; } + // Results stay in the order the source returned them: YouTube's relevance + // ranking is the one thing a client cannot recompute, so every other order + // is the client's to apply. if (results.length > 0) await this._applyLocalStatus(results); - this._sortByPublishedAtDesc(results); logger.info({ query, count, resultCount: results.length, source }, 'video search complete'); return results; } @@ -69,6 +72,8 @@ class VideoSearchModule { if (!trimmed) continue; try { const entry = JSON.parse(trimmed); + // The API path drops these too; a download would record a live stream indefinitely. + if (LIVE_STATUSES_TO_SKIP.has(entry.live_status)) continue; const normalized = this._normalize(entry); if (normalized.youtubeId && seenIds.has(normalized.youtubeId)) continue; if (normalized.youtubeId) seenIds.add(normalized.youtubeId); @@ -120,17 +125,6 @@ class VideoSearchModule { async _applyLocalStatus(results) { await applyLocalVideoStatus(results); } - - _sortByPublishedAtDesc(results) { - results.sort((a, b) => { - if (a.publishedAt && b.publishedAt) { - return b.publishedAt.localeCompare(a.publishedAt); - } - if (a.publishedAt) return -1; - if (b.publishedAt) return 1; - return 0; - }); - } } module.exports = new VideoSearchModule(); diff --git a/server/routes/videoSearch.js b/server/routes/videoSearch.js index d7cfaf527..64818e1ef 100644 --- a/server/routes/videoSearch.js +++ b/server/routes/videoSearch.js @@ -39,7 +39,7 @@ function createVideoSearchRoutes({ verifyToken, videoSearchModule }) { * /api/videos/search: * post: * summary: Search YouTube by free text - * description: Search YouTube via yt-dlp and return a list of matching videos, sorted newest-to-oldest by publishedAt (entries without a timestamp sort last). Results are ephemeral; nothing is persisted. Each result includes a `status` field (`downloaded`, `missing`, or `never_downloaded`) describing the video's state in the local Video table. + * description: Search YouTube (YouTube Data API when a key is configured, otherwise yt-dlp) and return matching videos in YouTube's relevance order. Live and upcoming streams are left out. Results are ephemeral; nothing is persisted. Each result includes a `status` field (`downloaded`, `deleted`, `missing`, or `never_downloaded`) describing the video's state in the local Video table, and `channelSubscribed` says whether the video's channel is an enabled subscription. * tags: [Videos] * requestBody: * required: true @@ -84,7 +84,8 @@ function createVideoSearchRoutes({ verifyToken, videoSearchModule }) { * viewCount: { type: integer, nullable: true } * status: * type: string - * enum: [downloaded, missing, never_downloaded] + * enum: [downloaded, deleted, missing, never_downloaded] + * channelSubscribed: { type: boolean, description: "True when the video's channel is an enabled subscription." } * databaseId: { type: integer, nullable: true, description: "Video row id when status is downloaded or missing." } * filePath: { type: string, nullable: true } * fileSize: { type: integer, nullable: true }