From 84fb3c891af4eb71064b1be48f0ea36b95146a1c Mon Sep 17 00:00:00 2001 From: dialmaster Date: Tue, 20 Oct 2026 08:25:18 -0700 Subject: [PATCH 1/3] feat: add library cleanup hints and path tooltips Once videos are marked deleted or missing, the Videos page hid them without saying where to clean them up, and a missing file's path was buried inside a sentence in a narrow tooltip. - Show "N deleted videos hidden" beside the Library heading on wide screens (under it on phones) with Show, Clean up and an explanation, and add a line under the Missing or Deleted filter when it isolates those videos. Each links to the Maintenance card at /settings/maintenance#deleted-and-missing, which the Maintenance page now scrolls to. - Render removed-video tooltips through a shared RemovalDescription that sets a missing file's path apart in a monospace pill, with a wider tooltip so it wraps less. The video modal keeps the plain sentence through the same describeMissing helper. - Move InfoHint to shared, since the Videos page now uses it too. Refs: #670 --- CLAUDE.md | 4 +- .../src/components/ChannelPage/VideoCard.tsx | 8 +- .../components/ChannelPage/VideoListItem.tsx | 8 +- .../components/ChannelPage/VideoTableView.tsx | 8 +- .../DownloadManager/DownloadHistory.tsx | 7 +- .../components/LibraryDownloadsGroup.tsx | 2 +- .../components/MediaServerSyncGroup.tsx | 2 +- .../components/PlaylistVideoCard.tsx | 3 +- .../components/PlaylistVideoTable.tsx | 3 +- .../__tests__/playlistVideoHelpers.test.tsx | 30 ++++---- .../components/playlistVideoHelpers.tsx | 17 ++++- .../Settings/MaintenanceSection.tsx | 23 ++++-- .../__tests__/MaintenanceSection.test.tsx | 39 +++++++++- .../components/HiddenDeletedNote.tsx | 34 +++++++++ .../components/LibraryCleanupHints.tsx | 45 +++++++++++ .../VideosPage/components/VideoCard.tsx | 5 +- .../components/VideosListMobile.tsx | 5 +- .../VideosPage/components/VideosTable.tsx | 5 +- .../__tests__/HiddenDeletedNote.test.tsx | 73 ++++++++++++++++++ .../__tests__/LibraryCleanupHints.test.tsx | 74 +++++++++++++++++++ client/src/components/VideosPage/index.tsx | 21 ++++-- .../components/__tests__/VideosPage.test.tsx | 9 +++ .../components/shared/AvailabilityChip.tsx | 7 +- .../components => shared}/InfoHint.tsx | 4 +- .../components/shared/RemovalDescription.tsx | 36 +++++++++ .../__tests__/AvailabilityChip.test.tsx | 13 +++- .../__tests__}/InfoHint.test.tsx | 2 +- .../__tests__/RemovalDescription.test.tsx | 49 ++++++++++++ client/src/test-utils.tsx | 4 +- .../src/utils/__tests__/videoRemoval.test.ts | 15 +++- client/src/utils/videoRemoval.ts | 31 ++++++-- 31 files changed, 517 insertions(+), 69 deletions(-) create mode 100644 client/src/components/VideosPage/components/HiddenDeletedNote.tsx create mode 100644 client/src/components/VideosPage/components/LibraryCleanupHints.tsx create mode 100644 client/src/components/VideosPage/components/__tests__/HiddenDeletedNote.test.tsx create mode 100644 client/src/components/VideosPage/components/__tests__/LibraryCleanupHints.test.tsx rename client/src/components/{PlaylistPage/components => shared}/InfoHint.tsx (90%) create mode 100644 client/src/components/shared/RemovalDescription.tsx rename client/src/components/{PlaylistPage/components => shared/__tests__}/InfoHint.test.tsx (97%) create mode 100644 client/src/components/shared/__tests__/RemovalDescription.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index af907e3cf..7b212a6ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/storageGuard.js`: the storage download pause (issues #196/#217). Optional `downloadPauseUsageLimit` (storageUsage total) and `downloadPauseMinFreeSpace` (df) triggers, both off by default; a failed measurement never pauses (fail open). `assertDownloadsAllowed()` throws `DownloadsPausedError` (code `DOWNLOADS_PAUSED`) from `downloadModule`'s fresh-request entry points (`doChannelAndPlaylistDownloads`, `doChannelDownloads`, `doSpecificDownloads`, `doPlaylistDownloads`; queued jobs started with isNextJob skip it), which routes map to 409 via the injected `storageGuard.isPausedError` (Download New reaches `doChannelAndPlaylistDownloads` through the scheduler's `channelDownloadFrequency` task rather than calling it directly, so its own pause check runs first, inside `autoDownloadScheduler.getRunBlocker`, and a paused Download New request gets the `downloads-paused` reason instead of a bare 409); the scheduled sweep records a skipped run first, and a pause hit mid-sweep stops the playlist loop and is reported as `pausedReason` (not a failure); `jobModule.startNextJob` refreshes the guard (so every finished job re-checks) and holds Pending jobs while paused, restarting the queue on the guard's `resumed` event. `startNextJob` coalesces overlapping calls into one in-flight scan (the `resumed` event fires inside the refresh a scan awaits, and a channel job stays Pending while it builds groups, so two scans would start it twice) and catches the unawaited job action's rejection; a job whose action rejects while it is still Pending is marked `Error` by `failUnstartedJob`, which waits for the launching scan to settle and then scans again, so the queue never stalls behind it (the single-group channel job's pre-spawn failure likewise reports a `jobIssue` to its run and calls `startNextJob`). `subscriptionImport` calls `startNextJob` when an import finishes (the import occupies the queue but completes outside the download path, so jobs queued or held meanwhile would otherwise stay Pending). The downloaded-size SUM runs only when a usage limit is set; `GET /api/jobs/download-pause` opts in with `refresh({ includeUsage: true })` for the settings page. `/updateconfig` rejects size values other than blank or `MB|GB|TB`; `configModule.normalizeStorageSizeFields` corrects or clears hand-edited ones on load (startup and file-watch reload), because the client saves the full config and one bad value would otherwise block every Settings save. On a paused-state flip it broadcasts `downloadPauseChanged`, sends `notificationModule.sendDownloadPauseNotification` (paused and resumed), and re-checks every 5 min while paused; deletions re-check when paused. `GET /api/jobs/download-pause` serves the status; the client shows it through `hooks/useDownloadPauseStatus`, `layout/DownloadPauseBanner` (every page, dismissible per pause except on `/downloads`), and `shared/DownloadPauseAlert`, with settings at `/settings/storage-limits` (`Configuration/sections/StorageLimitsSection.tsx`). - `modules/playlistDownloadModule.js`: shared eligibility for scheduled, download-all, explicit-id, preview, batch queueing, and download/following count paths, with dependencies passed at call time. Requests persist for scheduled retry only when auto-download is enabled and a baseline exists; queue failures are logged with playlist id and selected count. Successful downloads clear their request flags. Scheduled selection returns disjoint discovery and older saved-retry pools, each capped at the configured limit. Requested discoveries belong only to the discovery pool. Older requests rotate by `playlistvideos.auto_download_last_attempt_at` (DATETIME(3), nullable), recorded before queue submission on explicit saved batches and scheduled retry selection, outside the pure selector. Null attempts remain in the bounded pool. The original batch queues in full. Discovery jobs are admitted before any retry settings group; retry jobs use `Playlist Retry: ` (displayed as Saved playlist retries), retaining URL-list processing, notifications, and run aggregation. Already-active entries are excluded before selection. `following_requested_count` reports eligible outstanding selections in the header without claiming they failed. Explicit-id downloads can override ignored and allowRedownload retains its existing meaning. Without allowRedownload, entries the download archive lists without a Videos row (`archiveOnlyIds.findArchiveOnlyIds`; channel-ignored videos excepted, since a download removes their entry) are not eligible on any path: yt-dlp would skip them, so a sweep would queue them again every run. - `modules/download/archiveAccess.js`: coordinates archive-removing purges with `downloadExecutor.doDownload`, including preflight and finalization. Such purges are refused with 409 `archive-busy` while any download holds access; new downloads wait for an already-running purge to release it. Purges that keep archive entries can run during downloads. -- `modules/videoRemovalState.js` + `modules/libraryRootCheck.js` + `modules/videoTrashModule.js` (+ `routes/videoTrash.js`): deleted vs. missing videos (issue #670). `videos.removed` stays the single "no file" flag every query reads; `removed_reason` (`user_deleted`, `auto_removal` with the rule in `removed_detail`, e.g. "older than 30 days", `not_found`, `marked_deleted`) and `removed_at` say why, and `videoRemovalState` derives `present | deleted | missing` (missing = removed with `not_found`; every other removed row, including the NULL reason of rows removed before the columns existed, is deleted; `removedColumns(reason)` / `presentColumns()` are the only writers, `stateWhere` / `notStateWhere` the only filters). Deleted stays deleted: the page-load file check (`fileCheckModule`) and the rescan (`rescanRowUpdate`) only mark present rows missing, and a file that reappears clears all three columns in every writer (download persistence, job recovery, library repair, file check, rescan). `libraryRootCheck.checkLibraryRoot(baseDir)` guards every `removed: true` write against an unmounted drive or share: the downloads root must exist and hold something, and so must each `__subfolder` in it (`missing | empty | unreadable | subfolder-empty | subfolder-unreadable`; a root failure is reported alone, while every failing subfolder is reported, in `paths`, with the strictest reason (unreadable beats empty, so an unreadable share is never looked past as emptied on purpose) and `path` its first subfolder; `message` names no path: the root is "the downloads folder" and a subfolder goes by its own name, because the container path means nothing on the host). Entries Youtarr creates itself never count (`.youtarr_tmp`, `__playlists__`, leftover `.youtarr_healthcheck_*` files, named in `filesystem/constants.js`): a sweep or a restart creates them on the bare mount point while a share is down, and they would otherwise make it look mounted for good. `isUnderLibraryCheckPath(check, filePath)` says whether a failing check covers a file (root reasons cover every file, subfolder reasons only the files under the failing subfolders), `isRowUnderLibraryCheck(check, row)` whether it covers either of a row's files, and `libraryCheckCoversRows(check, Video, where)` whether it covers any row the filter matches (a `LIKE` on both path columns, wildcards escaped), so an empty `__subfolder` no matching row is stored in (for example one a channel was moved out of) blocks nothing. The file check runs the check lazily, once per pass and only when a row would be marked missing, holds back the rows it covers, and returns them as `libraryWarning` (`{ reason, path, message, heldBack }`), which `/getVideos` passes through for the Videos page banner (the channel page gets the suppression only; its listing query reads plain rows, `raw: true`, because the check spreads them and a Sequelize instance loses its getters when spread); its writes carry a `removed` predicate, so only a present row becomes missing and only a removed row is restored even if a delete lands inside the request. The rescan checks first, and only counts a failure that covers present rows: a failing root with present rows ends the run as `error` ("Nothing was marked missing; is the drive mounted?"), an empty or unreadable `__subfolder` lets it finish but hold back missing marks for the rows stored in it (`notMarkedMissing`, outcome `partial`, named in the message), and with no present rows (a fresh install) it runs normally. A manual rescan may skip the check: `POST /api/maintenance/rescan-files` with `ignoreLibraryCheck: true` ("Rescan anyway" in the Maintenance card, offered only for the `empty` reasons, for a folder the user emptied on purpose); the scheduled task never passes it, and the run record says the check was skipped. Automatic cleanup (`videoDeletionModule.performAutomaticCleanup`) and the orphan folder sweep record a `skipped` run with the check's message while the check covers present rows (`_libraryBlock`; a dry run never checks; once the sweep runs, it removes an empty `__subfolder` nothing is stored in, which clears the check), and manual deletes (`deleteVideos`, `deleteVideosByYoutubeIds`: one check per batch, fail-open if the check itself throws) refuse each covered video with the message rather than stamping a row whose file may sit on an unmounted share; job recovery likewise leaves the removal columns alone when it cannot find the file. `/getVideos` filters: `missingFilter` is strictly missing, `deletedFilter` (default `off`; the Videos page sends `exclude` and persists the user's choice in localStorage) hides or isolates deleted rows and returns `hiddenDeletedCount` while excluding; the channel listing takes the same `deletedFilter`, appended as the last positional argument through `channelModule` -> `channelVideosService` -> `channelVideoQuery`. The client mirrors the states in `utils/videoRemoval.ts` (`getRemovalState`, `describeRemoval`, the plain-language sentence shown as the chip tooltip and the modal's File Details Status row) and `utils/videoStatus.tsx` (`deleted` status, neutral style, `isRemovedStatus`). `videoTrashModule` serves `GET /api/videos/removed-summary` (deleted, missing, legacy counts, the library check (reported as passing when it covers no present or missing row, so the card's warning and Rescan anyway match the Videos page banner), `missingActionsBlocked` (it covers missing rows, the only rows the trash actions touch), and `downloadsActive` from `archiveAccess.isDownloading()`, which the Empty trash dialog uses to disable archive removal before the user confirms) and `POST /api/videos/mark-deleted` (`{ videoIds }` or `{ all: true }`: only missing rows change, to `marked_deleted`; `all` is refused with 409 `library-unavailable` while the check covers missing rows, unless it also sends `ignoreLibraryCheck: true` and the reason is `empty` or `subfolder-empty` (a folder the user emptied on purpose; never `missing` or unreadable), and a selection is never refused; the video modal marks one missing video through it), and `POST /api/videos/purge-removed` (`{ states: ['deleted'|'missing'], removeFromArchive }`, "Empty trash" in Settings > Maintenance): one purge at a time (409 `running`), refused for `missing` while the check covers missing rows; in chunks of 500, each in a transaction, it deletes `video_watch_status` and `jobvideos` rows (the latter has a restricting FK) before the `videos` row and nulls `playlistvideos.downloaded_at`, then unlinks `jobs/info/<id>.info.json` and the cached `videothumb-<id>*.jpg` (library repair would otherwise recreate the row as missing from the archive), removes the `complete.list` lines only when asked (`archiveModule.removeVideosFromArchive`, an atomic file replacement per chunk before the database transaction commits; keeping them means automatic downloads keep skipping the videos), refreshes the affected in-memory jobs through the injected `jobModule.reloadJobVideos` (the route hands it in) and only then broadcasts `videosUpdated` + `jobsUpdated`, so a Download History refetch never shows ghosts; each chunk is re-read under lock with the state predicate inside its transaction, so a video re-downloaded or restored between the select and the delete is kept and its fresh info.json left alone. An archive rewrite failure rolls back that chunk so its rows remain selectable for retry; earlier committed chunks still get the job reload and broadcasts before the error is rethrown. If the database commit fails after a successful archive rewrite, retrying the retained rows safely removes already-absent entries. Nothing on disk under the downloads folder is touched. +- `modules/videoRemovalState.js` + `modules/libraryRootCheck.js` + `modules/videoTrashModule.js` (+ `routes/videoTrash.js`): deleted vs. missing videos (issue #670). `videos.removed` stays the single "no file" flag every query reads; `removed_reason` (`user_deleted`, `auto_removal` with the rule in `removed_detail`, e.g. "older than 30 days", `not_found`, `marked_deleted`) and `removed_at` say why, and `videoRemovalState` derives `present | deleted | missing` (missing = removed with `not_found`; every other removed row, including the NULL reason of rows removed before the columns existed, is deleted; `removedColumns(reason)` / `presentColumns()` are the only writers, `stateWhere` / `notStateWhere` the only filters). Deleted stays deleted: the page-load file check (`fileCheckModule`) and the rescan (`rescanRowUpdate`) only mark present rows missing, and a file that reappears clears all three columns in every writer (download persistence, job recovery, library repair, file check, rescan). `libraryRootCheck.checkLibraryRoot(baseDir)` guards every `removed: true` write against an unmounted drive or share: the downloads root must exist and hold something, and so must each `__subfolder` in it (`missing | empty | unreadable | subfolder-empty | subfolder-unreadable`; a root failure is reported alone, while every failing subfolder is reported, in `paths`, with the strictest reason (unreadable beats empty, so an unreadable share is never looked past as emptied on purpose) and `path` its first subfolder; `message` names no path: the root is "the downloads folder" and a subfolder goes by its own name, because the container path means nothing on the host). Entries Youtarr creates itself never count (`.youtarr_tmp`, `__playlists__`, leftover `.youtarr_healthcheck_*` files, named in `filesystem/constants.js`): a sweep or a restart creates them on the bare mount point while a share is down, and they would otherwise make it look mounted for good. `isUnderLibraryCheckPath(check, filePath)` says whether a failing check covers a file (root reasons cover every file, subfolder reasons only the files under the failing subfolders), `isRowUnderLibraryCheck(check, row)` whether it covers either of a row's files, and `libraryCheckCoversRows(check, Video, where)` whether it covers any row the filter matches (a `LIKE` on both path columns, wildcards escaped), so an empty `__subfolder` no matching row is stored in (for example one a channel was moved out of) blocks nothing. The file check runs the check lazily, once per pass and only when a row would be marked missing, holds back the rows it covers, and returns them as `libraryWarning` (`{ reason, path, message, heldBack }`), which `/getVideos` passes through for the Videos page banner (the channel page gets the suppression only; its listing query reads plain rows, `raw: true`, because the check spreads them and a Sequelize instance loses its getters when spread); its writes carry a `removed` predicate, so only a present row becomes missing and only a removed row is restored even if a delete lands inside the request. The rescan checks first, and only counts a failure that covers present rows: a failing root with present rows ends the run as `error` ("Nothing was marked missing; is the drive mounted?"), an empty or unreadable `__subfolder` lets it finish but hold back missing marks for the rows stored in it (`notMarkedMissing`, outcome `partial`, named in the message), and with no present rows (a fresh install) it runs normally. A manual rescan may skip the check: `POST /api/maintenance/rescan-files` with `ignoreLibraryCheck: true` ("Rescan anyway" in the Maintenance card, offered only for the `empty` reasons, for a folder the user emptied on purpose); the scheduled task never passes it, and the run record says the check was skipped. Automatic cleanup (`videoDeletionModule.performAutomaticCleanup`) and the orphan folder sweep record a `skipped` run with the check's message while the check covers present rows (`_libraryBlock`; a dry run never checks; once the sweep runs, it removes an empty `__subfolder` nothing is stored in, which clears the check), and manual deletes (`deleteVideos`, `deleteVideosByYoutubeIds`: one check per batch, fail-open if the check itself throws) refuse each covered video with the message rather than stamping a row whose file may sit on an unmounted share; job recovery likewise leaves the removal columns alone when it cannot find the file. `/getVideos` filters: `missingFilter` is strictly missing, `deletedFilter` (default `off`; the Videos page sends `exclude` and persists the user's choice in localStorage) hides or isolates deleted rows and returns `hiddenDeletedCount` while excluding; the channel listing takes the same `deletedFilter`, appended as the last positional argument through `channelModule` -> `channelVideosService` -> `channelVideoQuery`. The client mirrors the states in `utils/videoRemoval.ts` (`getRemovalState`, `describeRemoval`, the plain-language sentence shown as the modal's File Details Status row; chip tooltips render it through `shared/RemovalDescription`, which sets a missing file's path apart in a monospace pill, using `describeMissing` so both share the wording) and `utils/videoStatus.tsx` (`deleted` status, neutral style, `isRemovedStatus`). `videoTrashModule` serves `GET /api/videos/removed-summary` (deleted, missing, legacy counts, the library check (reported as passing when it covers no present or missing row, so the card's warning and Rescan anyway match the Videos page banner), `missingActionsBlocked` (it covers missing rows, the only rows the trash actions touch), and `downloadsActive` from `archiveAccess.isDownloading()`, which the Empty trash dialog uses to disable archive removal before the user confirms) and `POST /api/videos/mark-deleted` (`{ videoIds }` or `{ all: true }`: only missing rows change, to `marked_deleted`; `all` is refused with 409 `library-unavailable` while the check covers missing rows, unless it also sends `ignoreLibraryCheck: true` and the reason is `empty` or `subfolder-empty` (a folder the user emptied on purpose; never `missing` or unreadable), and a selection is never refused; the video modal marks one missing video through it), and `POST /api/videos/purge-removed` (`{ states: ['deleted'|'missing'], removeFromArchive }`, "Empty trash" in Settings > Maintenance): one purge at a time (409 `running`), refused for `missing` while the check covers missing rows; in chunks of 500, each in a transaction, it deletes `video_watch_status` and `jobvideos` rows (the latter has a restricting FK) before the `videos` row and nulls `playlistvideos.downloaded_at`, then unlinks `jobs/info/<id>.info.json` and the cached `videothumb-<id>*.jpg` (library repair would otherwise recreate the row as missing from the archive), removes the `complete.list` lines only when asked (`archiveModule.removeVideosFromArchive`, an atomic file replacement per chunk before the database transaction commits; keeping them means automatic downloads keep skipping the videos), refreshes the affected in-memory jobs through the injected `jobModule.reloadJobVideos` (the route hands it in) and only then broadcasts `videosUpdated` + `jobsUpdated`, so a Download History refetch never shows ghosts; each chunk is re-read under lock with the state predicate inside its transaction, so a video re-downloaded or restored between the select and the delete is kept and its fresh info.json left alone. An archive rewrite failure rolls back that chunk so its rows remain selectable for retry; earlier committed chunks still get the job reload and broadcasts before the error is rethrown. If the database commit fails after a successful archive rewrite, retrying the retained rows safely removes already-absent entries. Nothing on disk under the downloads folder is touched. - `modules/playlistOutputRefresh.js`: rebuilds the `.m3u` file and re-syncs media servers (`mediaServerSync.syncPlaylist`) for every enabled playlist that includes (not excluded) any of the given videos, one playlist at a time. `videoDeletionModule` calls it in the background after every delete path (manual by id or YouTube id, and automatic cleanup's strategies), so neither keeps listing a deleted file; downloads use `downloadModule.afterDownloadHook` instead. - `modules/playlistConstants.js`: backend playlist fetch/batch limits, preview default, and shared refresh-conflict text. Client feature defaults live in `components/PlaylistPage/playlistConstants.ts`. - First-setup recovery persists `playlists.auto_download_setup_error`: legacy scheduled incomplete snapshots stay enabled and retry with a visible waiting notice; oversized playlists are disabled with an explanation. Recovery refreshes the cache without setting a baseline or clearing requests and cannot become a successful idle sweep. Successful setup clears the error. Subscribe/restore returns 201 with a warning for expected following failures, disables auto-download for size/completeness failures, and preserves settings on a concurrent refresh. Warnings travel through the subscription hook and router state to the page. @@ -62,7 +62,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `components/shared/`: reusable components used across multiple features (e.g. `VideoModal/` for the video detail modal, `ThumbnailClickOverlay` for clickable thumbnail hotspots, `DeleteVideosDialog`, `AddChannelDialog` for confirming a channel add from search results or an unsubscribed channel name click, `OpenInYouTubeLink` for the link out to YouTube on the channel and playlist page headers and Find Channels result cards). - `components/ui/`: theme-neutral UI primitives (Button, Card, Dialog, Select, etc.) built on Radix and styled via CSS variables + Tailwind. Use these instead of Material-UI imports in new code. `AccordionContent` stays mounted when collapsed (tests can query it) but is `invisible`, so its controls leave the tab order and accessibility tree. - `components/layout/`: app shell and navigation chrome. `AppShell.tsx` is the outer frame; `NavSidebar.tsx` / `NavHeader.tsx` own desktop and mobile nav; `navLayoutConstants.ts` holds shared sidebar/header sizing constants; `layoutFallback.css` provides fallback CSS variables for themes that skip layout overrides. -- `components/Settings/`: Settings page wrapper and splash index (`SettingsIndex.tsx`) listing the per-section routes under `/settings/<key>`. `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), an "N deleted videos hidden. Show" note, 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 `#<scheduleKey>` 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/Settings/`: Settings page wrapper and splash index (`SettingsIndex.tsx`) listing the per-section routes under `/settings/<key>`. `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 `#<scheduleKey>` 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. - `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. diff --git a/client/src/components/ChannelPage/VideoCard.tsx b/client/src/components/ChannelPage/VideoCard.tsx index 95cee01e4..1c56a6cbb 100644 --- a/client/src/components/ChannelPage/VideoCard.tsx +++ b/client/src/components/ChannelPage/VideoCard.tsx @@ -13,7 +13,7 @@ import { formatDuration } from '../../utils'; import { ChannelVideo } from '../../types/ChannelVideo'; import { decodeHtml, formatAddedDateTime } from '../../utils/formatters'; import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus'; -import { describeRow } from '../../utils/videoRemoval'; +import RemovalDescription, { REMOVAL_TOOLTIP_CLASS } from '../shared/RemovalDescription'; import StillLiveDot from './StillLiveDot'; import RatingBadge from '../shared/RatingBadge'; import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; @@ -391,7 +391,11 @@ function VideoCard({ style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE, flexShrink: 0 }} /> <WatchedChip watchedBy={video.watchedBy || []} /> - <Tooltip title={isRemovedStatus(status) ? describeRow(video) : ''} enterTouchDelay={0}> + <Tooltip + title={isRemovedStatus(status) ? <RemovalDescription row={video} /> : ''} + enterTouchDelay={0} + className={REMOVAL_TOOLTIP_CLASS} + > <Chip icon={getStatusIcon(status)} label={statusLabel} diff --git a/client/src/components/ChannelPage/VideoListItem.tsx b/client/src/components/ChannelPage/VideoListItem.tsx index 0365a9f21..b7ceaf36b 100644 --- a/client/src/components/ChannelPage/VideoListItem.tsx +++ b/client/src/components/ChannelPage/VideoListItem.tsx @@ -15,7 +15,7 @@ import { decodeHtml, formatAddedDate } from '../../utils/formatters'; import { SHARED_STATUS_CHIP_SMALL_STYLE, SHARED_THEMED_CHIP_SMALL_STYLE } from '../shared/chipStyles'; import { getPublishedDateDisplay } from './publishedDateDisplay'; import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus'; -import { describeRow } from '../../utils/videoRemoval'; +import RemovalDescription, { REMOVAL_TOOLTIP_CLASS } from '../shared/RemovalDescription'; import StillLiveDot from './StillLiveDot'; import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; import ProtectionShieldButton from '../shared/ProtectionShieldButton'; @@ -371,7 +371,11 @@ function VideoListItem({ style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE }} /> <WatchedChip watchedBy={video.watchedBy || []} /> - <Tooltip title={isRemovedStatus(status) ? describeRow(video) : ''} enterTouchDelay={0}> + <Tooltip + title={isRemovedStatus(status) ? <RemovalDescription row={video} /> : ''} + enterTouchDelay={0} + className={REMOVAL_TOOLTIP_CLASS} + > <Chip icon={getStatusIcon(status)} label={statusLabel} diff --git a/client/src/components/ChannelPage/VideoTableView.tsx b/client/src/components/ChannelPage/VideoTableView.tsx index 690a07c62..f78d55198 100644 --- a/client/src/components/ChannelPage/VideoTableView.tsx +++ b/client/src/components/ChannelPage/VideoTableView.tsx @@ -12,7 +12,7 @@ import { formatDuration } from '../../utils'; import { ChannelVideo } from '../../types/ChannelVideo'; import { decodeHtml, formatAddedDateParts } from '../../utils/formatters'; import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus'; -import { describeRow } from '../../utils/videoRemoval'; +import RemovalDescription, { REMOVAL_TOOLTIP_CLASS } from '../shared/RemovalDescription'; import StillLiveDot from './StillLiveDot'; import RatingBadge from '../shared/RatingBadge'; import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; @@ -354,7 +354,11 @@ function VideoTableView({ /> )} <WatchedChip watchedBy={video.watchedBy || []} /> - <Tooltip title={isRemovedStatus(status) ? describeRow(video) : ''} enterTouchDelay={0}> + <Tooltip + title={isRemovedStatus(status) ? <RemovalDescription row={video} /> : ''} + enterTouchDelay={0} + className={REMOVAL_TOOLTIP_CLASS} + > <Chip icon={getStatusIcon(status)} label={statusLabel} diff --git a/client/src/components/DownloadManager/DownloadHistory.tsx b/client/src/components/DownloadManager/DownloadHistory.tsx index 5a04bfbe7..d5ce99bf3 100644 --- a/client/src/components/DownloadManager/DownloadHistory.tsx +++ b/client/src/components/DownloadManager/DownloadHistory.tsx @@ -30,7 +30,8 @@ import VideoModal from '../shared/VideoModal'; import { VideoModalData } from '../shared/VideoModal/types'; import VideoThumbnail from './VideoThumbnail'; import AvailabilityChip from '../shared/AvailabilityChip'; -import { describeRow, getRemovalState, removalFromRow, removedState } from '../../utils/videoRemoval'; +import { getRemovalState, removalFromRow, removedState } from '../../utils/videoRemoval'; +import RemovalDescription from '../shared/RemovalDescription'; import FailedVideoChip from './FailedVideoChip'; import FailedDownloadsDetail from './FailedDownloadsDetail'; import { jobTypeLabel } from '../../utils/jobTypeLabel'; @@ -401,7 +402,7 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ {video.youTubeChannelName} </Typography> <VideoActivityChip youtubeId={video.youtubeId} /> - {video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={describeRow(video)} />} + {video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={<RemovalDescription row={video} />} />} </Box> </Box> ))} @@ -571,7 +572,7 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ {video.youTubeVideoName} </Link> <VideoActivityChip youtubeId={video.youtubeId} /> - {video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={describeRow(video)} />} + {video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={<RemovalDescription row={video} />} />} </Box> <Typography variant="caption" color="secondary" className="block">{video.youTubeChannelName}</Typography> </TableCell> diff --git a/client/src/components/PlaylistPage/components/LibraryDownloadsGroup.tsx b/client/src/components/PlaylistPage/components/LibraryDownloadsGroup.tsx index 560ce46c7..257424245 100644 --- a/client/src/components/PlaylistPage/components/LibraryDownloadsGroup.tsx +++ b/client/src/components/PlaylistPage/components/LibraryDownloadsGroup.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Alert, Box, Button, FormControlLabel, Switch, Typography } from '../../ui'; import { Refresh as RefreshIcon, Download as DownloadIcon } from '../../../lib/icons'; -import InfoHint from './InfoHint'; +import InfoHint from '../../shared/InfoHint'; import { Playlist } from '../../../types/playlist'; import { MAX_PLAYLIST_VIDEOS } from '../playlistConstants'; diff --git a/client/src/components/PlaylistPage/components/MediaServerSyncGroup.tsx b/client/src/components/PlaylistPage/components/MediaServerSyncGroup.tsx index db717fc72..539511a2f 100644 --- a/client/src/components/PlaylistPage/components/MediaServerSyncGroup.tsx +++ b/client/src/components/PlaylistPage/components/MediaServerSyncGroup.tsx @@ -11,7 +11,7 @@ import { } from '../../ui'; import { Refresh as RefreshIcon, MoreHorizontal } from '../../../lib/icons'; import { MediaServerStatus, MediaServerType, Playlist } from '../../../types/playlist'; -import InfoHint from './InfoHint'; +import InfoHint from '../../shared/InfoHint'; import PlaylistSyncChips from './PlaylistSyncChips'; interface MediaServerSyncGroupProps { diff --git a/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx b/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx index 2f8e542e4..90e3b63c1 100644 --- a/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx +++ b/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx @@ -6,6 +6,7 @@ import { formatDurationClock } from '../../../utils'; import { isSelectable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers'; import DownloadFormatIndicator from '../../shared/DownloadFormatIndicator'; import WatchedChip from '../../shared/WatchedChip'; +import { REMOVAL_TOOLTIP_CLASS } from '../../shared/RemovalDescription'; import { SHARED_STATUS_CHIP_SMALL_STYLE, SHARED_COMPACT_CHIP_OVERRIDES } from '../../shared/chipStyles'; const compactStatusChipStyle: React.CSSProperties = { @@ -89,7 +90,7 @@ const PlaylistVideoCard: React.FC<PlaylistVideoCardProps> = ({ )} </> ) : status.tooltip ? ( - <Tooltip title={status.tooltip} enterTouchDelay={0}> + <Tooltip title={status.tooltip} enterTouchDelay={0} className={REMOVAL_TOOLTIP_CLASS}> <Chip label={status.label} color={status.color} size="small" style={compactStatusChipStyle} /> </Tooltip> ) : ( diff --git a/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx b/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx index 0ae4dc4fa..6dfffed4b 100644 --- a/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx +++ b/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx @@ -18,6 +18,7 @@ import { Block as BlockIcon, CheckCircleOutline as CheckCircleOutlineIcon } from import { isSelectable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers'; import DownloadFormatIndicator from '../../shared/DownloadFormatIndicator'; import WatchedChip from '../../shared/WatchedChip'; +import { REMOVAL_TOOLTIP_CLASS } from '../../shared/RemovalDescription'; const THUMB_WIDTH = 120; const THUMB_HEIGHT = 67; @@ -171,7 +172,7 @@ const PlaylistVideoTable: React.FC<PlaylistVideoTableProps> = ({ )} </> ) : status.tooltip ? ( - <Tooltip title={status.tooltip} enterTouchDelay={0}> + <Tooltip title={status.tooltip} enterTouchDelay={0} className={REMOVAL_TOOLTIP_CLASS}> <Chip label={status.label} color={status.color} size="small" /> </Tooltip> ) : ( diff --git a/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx b/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx index 637af3323..c397a099a 100644 --- a/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx +++ b/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx @@ -90,26 +90,28 @@ describe('statusLabel', () => { color: 'error', }); }); - test('returns Missing for a previously-downloaded video whose file was not found, explaining it', () => { - expect(statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'not_found', file_path: '/d/v.mp4' }))).toEqual({ + test('returns Missing for a previously-downloaded video whose file was not found', () => { + expect(statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'not_found', file_path: '/d/v.mp4' }))).toMatchObject({ label: 'Missing', color: 'error', - tooltip: 'Youtarr expected a file at /d/v.mp4 and could not find it. If the drive is mounted and the file is really gone, mark it as deleted.', }); }); - test('returns a neutral Deleted for a previously-downloaded video Youtarr removed', () => { - expect(statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'auto_removal', removed_detail: 'older than 30 days' }))).toEqual({ - label: 'Deleted', - color: 'default', - tooltip: 'Deleted by automatic cleanup (older than 30 days). It can be downloaded again.', - }); + test('explains a missing video with the path Youtarr looked at', () => { + const { tooltip } = statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'not_found', file_path: '/d/v.mp4' })); + render(<>{tooltip}</>); + expect(screen.getByRole('code')).toHaveTextContent('/d/v.mp4'); + }); + test('returns a neutral Deleted for a previously-downloaded video Youtarr removed, explaining it', () => { + const status = statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'auto_removal', removed_detail: 'older than 30 days' })); + expect(status).toMatchObject({ label: 'Deleted', color: 'default' }); + const { container } = render(<>{status.tooltip}</>); + expect(container).toHaveTextContent('Deleted by automatic cleanup (older than 30 days). It can be downloaded again.'); }); test('treats a previously-downloaded video without a recorded reason as Deleted', () => { - expect(statusLabel(makeVideo({ previously_downloaded: true }))).toEqual({ - label: 'Deleted', - color: 'default', - tooltip: 'Removed before Youtarr recorded why. It can be downloaded again.', - }); + const status = statusLabel(makeVideo({ previously_downloaded: true })); + expect(status).toMatchObject({ label: 'Deleted', color: 'default' }); + const { container } = render(<>{status.tooltip}</>); + expect(container).toHaveTextContent('Removed before Youtarr recorded why. It can be downloaded again.'); }); test('returns Tracked for a plain tracked video', () => { expect(statusLabel(makeVideo())).toEqual({ label: 'Tracked', color: 'default' }); diff --git a/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx b/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx index e7442175c..35e86ec3a 100644 --- a/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx +++ b/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx @@ -3,7 +3,8 @@ import { CalendarToday as CalendarTodayIcon } from '../../../lib/icons'; import { PlaylistSortOrder } from '../../../hooks/usePlaylistDetail'; import { PlaylistVideo } from '../../../types/playlist'; import { VideoModalData } from '../../shared/VideoModal/types'; -import { describeRemoval, removalFromRow, removedState } from '../../../utils/videoRemoval'; +import { removalFromRow, removedState } from '../../../utils/videoRemoval'; +import RemovalDescription from '../../shared/RemovalDescription'; export type PlaylistVideoStatusColor = 'success' | 'default' | 'warning' | 'error' | 'info'; @@ -11,7 +12,7 @@ export interface PlaylistVideoStatus { label: string; color: PlaylistVideoStatusColor; // Plain-language explanation for a removed video, shown on hover or tap. - tooltip?: string; + tooltip?: React.ReactNode; } // Ignored videos stay downloadable: explicitly selecting one is an intentional @@ -89,7 +90,17 @@ export function statusLabel(v: PlaylistVideo): PlaylistVideoStatus { if (v.previously_downloaded) { // Missing (file not found) is a warning; deleted (removed by Youtarr) is not. const state = removedState(v.removed_reason); - const tooltip = describeRemoval(state, removalFromRow(v), v.file_path); + const tooltip = ( + <RemovalDescription + row={{ + removed: true, + removed_reason: v.removed_reason, + removed_at: v.removed_at, + removed_detail: v.removed_detail, + filePath: v.file_path, + }} + /> + ); return state === 'missing' ? { label: 'Missing', color: 'error', tooltip } : { label: 'Deleted', color: 'default', tooltip }; diff --git a/client/src/components/Settings/MaintenanceSection.tsx b/client/src/components/Settings/MaintenanceSection.tsx index f9a49a491..ca5b99c7f 100644 --- a/client/src/components/Settings/MaintenanceSection.tsx +++ b/client/src/components/Settings/MaintenanceSection.tsx @@ -1,10 +1,12 @@ import { ScheduleSummary } from '../Configuration/sections/components/ScheduleSummary'; import { ConfigState } from '../Configuration/types'; -import React from 'react'; +import React, { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; import { Alert, Button, CircularProgress, Typography } from '../ui'; import { ConfigurationCard } from '../Configuration/common/ConfigurationCard'; import { useRescanStatus } from '../../hooks/useRescanStatus'; import { formatDateTime } from '../../utils/formatters'; +import { REMOVED_VIDEOS_ANCHOR } from '../../utils/videoRemoval'; import RemovedVideosCard from './components/RemovedVideosCard'; interface MaintenanceSectionProps { @@ -16,6 +18,13 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) { const { running, lastRun, loading, error, triggerRescan } = useRescanStatus(token); const persistentError = !running && lastRun?.status === 'error' ? lastRun.errorMessage : null; const transientError = error && error !== persistentError ? error : null; + const { hash } = useLocation(); + + // Links from the Videos page land on the deleted and missing videos card. + useEffect(() => { + if (hash !== `#${REMOVED_VIDEOS_ANCHOR}`) return; + document.getElementById(REMOVED_VIDEOS_ANCHOR)?.scrollIntoView?.({ block: 'start' }); + }, [hash]); let statusLine: React.ReactNode; if (running) { @@ -80,11 +89,13 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) { </div> </ConfigurationCard> - <RemovedVideosCard - token={token} - onRescanAnyway={() => triggerRescan({ ignoreLibraryCheck: true })} - rescanRunning={running} - /> + <section id={REMOVED_VIDEOS_ANCHOR} aria-label="Deleted and missing videos" className="scroll-mt-24"> + <RemovedVideosCard + token={token} + onRescanAnyway={() => triggerRescan({ ignoreLibraryCheck: true })} + rescanRunning={running} + /> + </section> </div> ); } diff --git a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx index 026a2ef0f..06bca2915 100644 --- a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx +++ b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx @@ -24,7 +24,8 @@ const { useRemovedVideos } = require('../hooks/useRemovedVideos') as { function setup( overrides: Partial<UseRescanStatusReturn> = {}, - libraryCheck: RemovedVideosLibraryCheck = { ok: true, reason: null, path: null, message: null } + libraryCheck: RemovedVideosLibraryCheck = { ok: true, reason: null, path: null, message: null }, + route = '/settings/maintenance' ) { const triggerRescan = jest.fn().mockResolvedValue({ started: true }); useRescanStatus.mockReturnValue({ @@ -45,7 +46,7 @@ function setup( actionPending: false, actionError: null, }); - renderWithProviders(<MaintenanceSection token="tok" config={DEFAULT_CONFIG} />); + renderWithProviders(<MaintenanceSection token="tok" config={DEFAULT_CONFIG} />, { initialEntries: [route] }); return { triggerRescan }; } @@ -64,6 +65,40 @@ describe('MaintenanceSection', () => { expect(screen.getByText('Deleted: 2. Missing: 1.')).toBeInTheDocument(); }); + describe('linked from the Videos page', () => { + const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView'); + let scrolledTo: Element[]; + + beforeEach(() => { + scrolledTo = []; + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + writable: true, + value: function scrollIntoView(this: Element) { + scrolledTo.push(this); + }, + }); + }); + + afterEach(() => { + if (original) { + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', original); + } else { + delete (HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView; + } + }); + + test('scrolls to the deleted and missing videos card', () => { + setup({}, undefined, '/settings/maintenance#deleted-and-missing'); + expect(scrolledTo).toEqual([screen.getByRole('region', { name: 'Deleted and missing videos' })]); + }); + + test('stays at the top of the page without the link', () => { + setup(); + expect(scrolledTo).toEqual([]); + }); + }); + test('wires Rescan anyway to a rescan that skips the downloads folder check', async () => { const user = userEvent.setup(); const { triggerRescan } = setup({}, { ok: false, reason: 'empty', path: '/d', message: 'The downloads folder is empty.' }); diff --git a/client/src/components/VideosPage/components/HiddenDeletedNote.tsx b/client/src/components/VideosPage/components/HiddenDeletedNote.tsx new file mode 100644 index 000000000..031d8cc3a --- /dev/null +++ b/client/src/components/VideosPage/components/HiddenDeletedNote.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button, Typography } from '../../ui'; +import InfoHint from '../../shared/InfoHint'; +import { REMOVED_VIDEOS_SETTINGS_PATH } from '../../../utils/videoRemoval'; + +interface HiddenDeletedNoteProps { + // Deleted videos the current filters hide; null when deleted videos are not hidden. + hiddenDeletedCount: number | null; + onShowDeleted: () => void; +} + +// "N deleted videos hidden." with Show, Clean up (Settings > Maintenance) and +// an explanation. Sits beside the Library heading on wide screens and under it +// on phones, so it annotates the total without taking a line of its own. +function HiddenDeletedNote({ hiddenDeletedCount, onShowDeleted }: HiddenDeletedNoteProps) { + const navigate = useNavigate(); + if (hiddenDeletedCount === null || hiddenDeletedCount <= 0) return null; + + return ( + <Typography variant="caption" color="text.secondary" component="p" className="flex flex-wrap items-center justify-center gap-1"> + <span>{hiddenDeletedCount} deleted {hiddenDeletedCount === 1 ? 'video' : 'videos'} hidden.</span> + <Button variant="text" size="sm" onClick={onShowDeleted}>Show</Button> + <span aria-hidden="true">ยท</span> + <Button variant="text" size="sm" onClick={() => navigate(REMOVED_VIDEOS_SETTINGS_PATH)}>Clean up</Button> + <InfoHint label="About deleted videos"> + Deleted videos stay in your library so they can be re-downloaded. In Settings > Maintenance you can + mark missing videos as deleted, or empty the trash to remove deleted videos for good. + </InfoHint> + </Typography> + ); +} + +export default HiddenDeletedNote; diff --git a/client/src/components/VideosPage/components/LibraryCleanupHints.tsx b/client/src/components/VideosPage/components/LibraryCleanupHints.tsx new file mode 100644 index 000000000..14db088c6 --- /dev/null +++ b/client/src/components/VideosPage/components/LibraryCleanupHints.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button, Typography } from '../../ui'; +import type { ChipFilterMode } from '../../shared/VideoList'; +import { REMOVED_VIDEOS_SETTINGS_PATH } from '../../../utils/videoRemoval'; + +interface LibraryCleanupHintsProps { + missingFilter: ChipFilterMode; + deletedFilter: ChipFilterMode; + // Whether the current page lists any video, so a hint never sits over an empty list. + hasVideos: boolean; +} + +const INLINE_LINK_CLASS = 'h-auto p-0 align-baseline text-xs'; + +// Points the Videos page at Settings > Maintenance, where missing videos can be +// marked as deleted and deleted ones removed for good, under the Missing or +// Deleted filter when it shows only those videos. +function LibraryCleanupHints({ missingFilter, deletedFilter, hasVideos }: LibraryCleanupHintsProps) { + const navigate = useNavigate(); + const maintenanceLink = (label: string) => ( + <Button variant="link" className={INLINE_LINK_CLASS} onClick={() => navigate(REMOVED_VIDEOS_SETTINGS_PATH)}> + {label} + </Button> + ); + + return ( + <> + {hasVideos && missingFilter === 'only' && ( + <Typography variant="caption" color="text.secondary" component="p" align="center"> + If these files are really gone, select them and choose Mark as deleted, or{' '} + {maintenanceLink('mark them all in Maintenance')}. + </Typography> + )} + {hasVideos && deletedFilter === 'only' && ( + <Typography variant="caption" color="text.secondary" component="p" align="center"> + Deleted videos stay listed so they can be re-downloaded. To remove them for good,{' '} + {maintenanceLink('empty the trash in Maintenance')}. + </Typography> + )} + </> + ); +} + +export default LibraryCleanupHints; diff --git a/client/src/components/VideosPage/components/VideoCard.tsx b/client/src/components/VideosPage/components/VideoCard.tsx index 1754c70e2..3d6e3457d 100644 --- a/client/src/components/VideosPage/components/VideoCard.tsx +++ b/client/src/components/VideosPage/components/VideoCard.tsx @@ -20,7 +20,8 @@ import ProtectionShieldButton from '../../shared/ProtectionShieldButton'; import ThumbnailClickOverlay from '../../shared/ThumbnailClickOverlay'; import AvailabilityChip from '../../shared/AvailabilityChip'; import { SHARED_STATUS_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; -import { DELETED_OVERLAY_BACKGROUND, describeRow, getRemovalState } from '../../../utils/videoRemoval'; +import { DELETED_OVERLAY_BACKGROUND, getRemovalState } from '../../../utils/videoRemoval'; +import RemovalDescription from '../../shared/RemovalDescription'; import ChannelNameDisplay from './ChannelNameDisplay'; import WatchedChip from '../../shared/WatchedChip'; @@ -318,7 +319,7 @@ function VideoCard({ size="small" /> {video.removed ? ( - <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={describeRow(video)} /> + <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={<RemovalDescription row={video} />} /> ) : video.fileSize ? ( <AvailabilityChip state="present" /> ) : null} diff --git a/client/src/components/VideosPage/components/VideosListMobile.tsx b/client/src/components/VideosPage/components/VideosListMobile.tsx index 33307efa1..b9fcadbab 100644 --- a/client/src/components/VideosPage/components/VideosListMobile.tsx +++ b/client/src/components/VideosPage/components/VideosListMobile.tsx @@ -2,7 +2,8 @@ import React from 'react'; import VideoActivityChip from '../../shared/VideoActivityChip'; import { Box, Typography, Chip, Checkbox, Stack } from '../../ui'; import { AlertCircle as ErrorOutlineIcon, Trash2 as DeletedIcon } from 'lucide-react'; -import { DELETED_OVERLAY_BACKGROUND, describeRow, getRemovalState } from '../../../utils/videoRemoval'; +import { DELETED_OVERLAY_BACKGROUND, getRemovalState } from '../../../utils/videoRemoval'; +import RemovalDescription from '../../shared/RemovalDescription'; import { formatDuration, formatYTDate } from '../../../utils'; import { formatAddedDateTime, formatFileSize } from '../../../utils/formatters'; import { getMediaTypeInfo } from '../../../utils/videoStatus'; @@ -310,7 +311,7 @@ function VideosListMobile({ style={compactRatingChipStyle} /> {video.removed ? ( - <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={describeRow(video)} compact /> + <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={<RemovalDescription row={video} />} compact /> ) : video.fileSize ? ( <AvailabilityChip state="present" compact /> ) : null} diff --git a/client/src/components/VideosPage/components/VideosTable.tsx b/client/src/components/VideosPage/components/VideosTable.tsx index 9f2d0eed9..8122c23e8 100644 --- a/client/src/components/VideosPage/components/VideosTable.tsx +++ b/client/src/components/VideosPage/components/VideosTable.tsx @@ -33,7 +33,8 @@ import ProtectionShieldButton from '../../shared/ProtectionShieldButton'; import ThumbnailClickOverlay from '../../shared/ThumbnailClickOverlay'; import AvailabilityChip from '../../shared/AvailabilityChip'; import { SHARED_STATUS_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; -import { DELETED_OVERLAY_BACKGROUND, describeRow, getRemovalState } from '../../../utils/videoRemoval'; +import { DELETED_OVERLAY_BACKGROUND, getRemovalState } from '../../../utils/videoRemoval'; +import RemovalDescription from '../../shared/RemovalDescription'; import ChannelNameDisplay from './ChannelNameDisplay'; import WatchedChip from '../../shared/WatchedChip'; @@ -323,7 +324,7 @@ function VideosTable({ /> )} {video.removed ? ( - <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={describeRow(video)} /> + <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={<RemovalDescription row={video} />} /> ) : video.fileSize ? ( <AvailabilityChip state="present" /> ) : null} diff --git a/client/src/components/VideosPage/components/__tests__/HiddenDeletedNote.test.tsx b/client/src/components/VideosPage/components/__tests__/HiddenDeletedNote.test.tsx new file mode 100644 index 000000000..b1b2dcc54 --- /dev/null +++ b/client/src/components/VideosPage/components/__tests__/HiddenDeletedNote.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import HiddenDeletedNote from '../HiddenDeletedNote'; + +function CurrentLocation() { + const location = useLocation(); + return <div data-testid="location">{`${location.pathname}${location.hash}`}</div>; +} + +function renderNote(hiddenDeletedCount: number | null, onShowDeleted: () => void = jest.fn()) { + return render( + <MemoryRouter initialEntries={['/videos']}> + <Routes> + <Route + path="/videos" + element={<HiddenDeletedNote hiddenDeletedCount={hiddenDeletedCount} onShowDeleted={onShowDeleted} />} + /> + <Route path="/settings/maintenance" element={<CurrentLocation />} /> + </Routes> + </MemoryRouter> + ); +} + +describe('HiddenDeletedNote', () => { + test('says how many deleted videos are hidden', () => { + renderNote(3); + expect(screen.getByText('3 deleted videos hidden.')).toBeInTheDocument(); + }); + + test('uses the singular for one hidden video', () => { + renderNote(1); + expect(screen.getByText('1 deleted video hidden.')).toBeInTheDocument(); + }); + + test('shows the deleted videos when Show is clicked', async () => { + const onShowDeleted = jest.fn(); + renderNote(3, onShowDeleted); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Show' })); + + expect(onShowDeleted).toHaveBeenCalledTimes(1); + }); + + test('Clean up opens the deleted and missing videos card in Maintenance', async () => { + renderNote(3); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Clean up' })); + + expect(screen.getByTestId('location')).toHaveTextContent('/settings/maintenance#deleted-and-missing'); + }); + + test('explains what Maintenance can do from the info icon', async () => { + renderNote(3); + + await userEvent.setup().hover(screen.getByRole('button', { name: 'About deleted videos' })); + + expect(await screen.findByRole('tooltip')).toHaveTextContent('empty the trash to remove deleted videos for good'); + }); + + test('shows nothing when no deleted video is hidden', () => { + renderNote(0); + expect(screen.queryByText(/deleted videos? hidden/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Clean up' })).not.toBeInTheDocument(); + }); + + test('shows nothing while deleted videos are not hidden', () => { + renderNote(null); + expect(screen.queryByRole('button', { name: 'Show' })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/VideosPage/components/__tests__/LibraryCleanupHints.test.tsx b/client/src/components/VideosPage/components/__tests__/LibraryCleanupHints.test.tsx new file mode 100644 index 000000000..2c57e7ca0 --- /dev/null +++ b/client/src/components/VideosPage/components/__tests__/LibraryCleanupHints.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import LibraryCleanupHints from '../LibraryCleanupHints'; +import type { ChipFilterMode } from '../../../shared/VideoList'; + +function CurrentLocation() { + const location = useLocation(); + return <div data-testid="location">{`${location.pathname}${location.hash}`}</div>; +} + +interface Props { + missingFilter?: ChipFilterMode; + deletedFilter?: ChipFilterMode; + hasVideos?: boolean; +} + +function renderHints({ missingFilter = 'off', deletedFilter = 'exclude', hasVideos = true }: Props = {}) { + return render( + <MemoryRouter initialEntries={['/videos']}> + <Routes> + <Route + path="/videos" + element={<LibraryCleanupHints missingFilter={missingFilter} deletedFilter={deletedFilter} hasVideos={hasVideos} />} + /> + <Route path="/settings/maintenance" element={<CurrentLocation />} /> + </Routes> + </MemoryRouter> + ); +} + +describe('LibraryCleanupHints', () => { + describe('missing filter', () => { + test('points at Mark as deleted while showing only missing videos', () => { + renderHints({ missingFilter: 'only' }); + expect(screen.getByText(/select them and choose Mark as deleted/)).toBeInTheDocument(); + }); + + test('links to marking them all in Maintenance', async () => { + renderHints({ missingFilter: 'only' }); + + await userEvent.setup().click(screen.getByRole('button', { name: 'mark them all in Maintenance' })); + + expect(screen.getByTestId('location')).toHaveTextContent('/settings/maintenance#deleted-and-missing'); + }); + + test('stays out of the way while missing videos are not isolated', () => { + renderHints({ missingFilter: 'off' }); + expect(screen.queryByText(/select them and choose Mark as deleted/)).not.toBeInTheDocument(); + }); + + test('says nothing over an empty list', () => { + renderHints({ missingFilter: 'only', hasVideos: false }); + expect(screen.queryByText(/select them and choose Mark as deleted/)).not.toBeInTheDocument(); + }); + }); + + describe('deleted filter', () => { + test('links to emptying the trash while showing only deleted videos', async () => { + renderHints({ deletedFilter: 'only' }); + + await userEvent.setup().click(screen.getByRole('button', { name: 'empty the trash in Maintenance' })); + + expect(screen.getByTestId('location')).toHaveTextContent('/settings/maintenance#deleted-and-missing'); + }); + + test('says nothing over an empty list', () => { + renderHints({ deletedFilter: 'only', hasVideos: false }); + expect(screen.queryByRole('button', { name: 'empty the trash in Maintenance' })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/VideosPage/index.tsx b/client/src/components/VideosPage/index.tsx index 030540900..cd3f905b1 100644 --- a/client/src/components/VideosPage/index.tsx +++ b/client/src/components/VideosPage/index.tsx @@ -20,6 +20,8 @@ import VideoModal from '../shared/VideoModal'; import { VideoModalData } from '../shared/VideoModal/types'; import { getRemovalState, removalFromRow, removedState, type RemovalState } from '../../utils/videoRemoval'; import MarkDeletedDialog from './components/MarkDeletedDialog'; +import LibraryCleanupHints from './components/LibraryCleanupHints'; +import HiddenDeletedNote from './components/HiddenDeletedNote'; import { useMarkDeleted } from './hooks/useMarkDeleted'; import DownloadSettingsDialog from '../DownloadManager/ManualDownload/DownloadSettingsDialog'; import { DownloadSettings } from '../DownloadManager/ManualDownload/types'; @@ -574,15 +576,18 @@ function VideosPage({ token }: VideosPageProps) { const headerSlot = ( <div style={{ padding: '12px 16px 0 16px' }}> - <Typography variant={isMobile ? 'h6' : 'h5'} component="h2" gutterBottom align="center"> - Library ({totalVideos} total) - </Typography> - {hiddenDeletedCount !== null && hiddenDeletedCount > 0 && ( - <Typography variant="caption" color="text.secondary" component="p" align="center" className="flex items-center justify-center gap-1"> - <span>{hiddenDeletedCount} deleted {hiddenDeletedCount === 1 ? 'video' : 'videos'} hidden.</span> - <Button variant="text" size="sm" onClick={showDeleted}>Show</Button> + {/* Wide screens put the hidden-deleted note on the heading's line; phones stack it. */} + <div className={isMobile ? undefined : 'flex flex-wrap items-baseline justify-center gap-x-4'}> + <Typography variant={isMobile ? 'h6' : 'h5'} component="h2" gutterBottom align="center"> + Library ({totalVideos} total) </Typography> - )} + <HiddenDeletedNote hiddenDeletedCount={hiddenDeletedCount} onShowDeleted={showDeleted} /> + </div> + <LibraryCleanupHints + missingFilter={missingFilter} + deletedFilter={deletedFilter} + hasVideos={totalVideos > 0} + /> {libraryWarning && libraryWarning.message !== dismissedLibraryWarning && ( <Alert severity="warning" className="mt-2 break-words" onClose={() => setDismissedLibraryWarning(libraryWarning.message)}> {libraryWarning.message} Until that changes, videos stored there are not being marked missing; check diff --git a/client/src/components/__tests__/VideosPage.test.tsx b/client/src/components/__tests__/VideosPage.test.tsx index e4516865b..127e5460a 100644 --- a/client/src/components/__tests__/VideosPage.test.tsx +++ b/client/src/components/__tests__/VideosPage.test.tsx @@ -937,6 +937,15 @@ describe('VideosPage Component', () => { }); }); + test('still says how many deleted videos are hidden', async () => { + axios.get.mockResolvedValueOnce({ data: { ...mockPaginatedResponse(mockVideos), hiddenDeletedCount: 2 } }); + + render(<VideosPage token={mockToken} />); + + expect(await screen.findByText('2 deleted videos hidden.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clean up' })).toBeInTheDocument(); + }); + test('shows 6 videos per page in mobile view', async () => { const manyVideos = Array.from({ length: 10 }, (_, i) => ({ id: i, diff --git a/client/src/components/shared/AvailabilityChip.tsx b/client/src/components/shared/AvailabilityChip.tsx index 2e5b18092..7faa34055 100644 --- a/client/src/components/shared/AvailabilityChip.tsx +++ b/client/src/components/shared/AvailabilityChip.tsx @@ -8,13 +8,14 @@ import { import { SHARED_THEMED_CHIP_SMALL_STYLE, SHARED_COMPACT_CHIP_OVERRIDES } from './chipStyles'; import { useMediaQuery } from '../../hooks/useMediaQuery'; import type { RemovalState } from '../../utils/videoRemoval'; +import { REMOVAL_TOOLTIP_CLASS } from './RemovalDescription'; export interface AvailabilityChipProps { // present = Available; missing = red, a file was expected and not found; // deleted = neutral, Youtarr removed the file (or the user marked it deleted). state: RemovalState; - // Plain-language explanation (see utils/videoRemoval describeRow); shown on hover or tap. - tooltip?: string; + // Plain-language explanation (see RemovalDescription); shown on hover or tap. + tooltip?: React.ReactNode; // Replaces the state's label, e.g. a "2 missing" summary count. label?: string; compact?: boolean; @@ -79,7 +80,7 @@ function AvailabilityChip({ state, tooltip, label, compact = false }: Availabili const tapOpensTooltip = useMediaQuery('(pointer: coarse)'); return ( - <Tooltip title={tooltip ?? variant.tooltip} enterTouchDelay={0}> + <Tooltip title={tooltip ?? variant.tooltip} enterTouchDelay={0} className={REMOVAL_TOOLTIP_CLASS}> <span className="inline-flex" onPointerDown={(event) => { lastPointerType.current = event.pointerType; }} diff --git a/client/src/components/PlaylistPage/components/InfoHint.tsx b/client/src/components/shared/InfoHint.tsx similarity index 90% rename from client/src/components/PlaylistPage/components/InfoHint.tsx rename to client/src/components/shared/InfoHint.tsx index cbea62365..714ce9fc3 100644 --- a/client/src/components/PlaylistPage/components/InfoHint.tsx +++ b/client/src/components/shared/InfoHint.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Tooltip } from '../../ui'; -import { Info as InfoIcon } from '../../../lib/icons'; +import { Tooltip } from '../ui'; +import { Info as InfoIcon } from '../../lib/icons'; interface InfoHintProps { label: string; diff --git a/client/src/components/shared/RemovalDescription.tsx b/client/src/components/shared/RemovalDescription.tsx new file mode 100644 index 000000000..af1fb859a --- /dev/null +++ b/client/src/components/shared/RemovalDescription.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { + describeMissing, + describeRow, + getRemovalState, + removalFromRow, + type RemovalRow, +} from '../../utils/videoRemoval'; + +// Tooltips showing a RemovalDescription widen past the default so a file path +// wraps less. Touch tooltips keep their own width cap. +export const REMOVAL_TOOLTIP_CLASS = 'max-w-md'; + +interface RemovalDescriptionProps { + row: RemovalRow; +} + +// Why a video has no file, for a chip tooltip. A missing file's path sits on +// its own line in a monospace pill so it stands apart from the explanation; +// a color would not, since the tooltip inverts between light and dark themes. +function RemovalDescription({ row }: RemovalDescriptionProps) { + const { path, rest } = describeMissing(removalFromRow(row), row.filePath); + if (getRemovalState(row.removed, row.removed_reason) !== 'missing' || !path) { + return <>{describeRow(row)}</>; + } + + return ( + <> + Youtarr expected a file at:{' '} + <code className="my-1 block break-all rounded bg-background/15 px-1.5 py-0.5 font-mono">{path}</code>{' '} + {rest} + </> + ); +} + +export default RemovalDescription; diff --git a/client/src/components/shared/__tests__/AvailabilityChip.test.tsx b/client/src/components/shared/__tests__/AvailabilityChip.test.tsx index e41439835..2c09be1f5 100644 --- a/client/src/components/shared/__tests__/AvailabilityChip.test.tsx +++ b/client/src/components/shared/__tests__/AvailabilityChip.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import AvailabilityChip from '../AvailabilityChip'; @@ -104,6 +104,17 @@ describe('AvailabilityChip', () => { expect(await screen.findByRole('tooltip')).toHaveTextContent('Deleted by automatic cleanup on 10/3/2026 (older than 30 days).'); }); + test('shows a formatted explanation in its tooltip', async () => { + const user = userEvent.setup(); + renderWithProviders( + <AvailabilityChip state="missing" tooltip={<>Youtarr expected a file at: <code>/d/v.mp4</code></>} /> + ); + + await user.hover(screen.getByText('Missing')); + + expect(within(await screen.findByRole('tooltip')).getByRole('code')).toHaveTextContent('/d/v.mp4'); + }); + test('takes a label override for summary counts', () => { renderWithProviders(<AvailabilityChip state="missing" label="2 missing" />); expect(screen.getByText('2 missing')).toBeInTheDocument(); diff --git a/client/src/components/PlaylistPage/components/InfoHint.test.tsx b/client/src/components/shared/__tests__/InfoHint.test.tsx similarity index 97% rename from client/src/components/PlaylistPage/components/InfoHint.test.tsx rename to client/src/components/shared/__tests__/InfoHint.test.tsx index 9049acdb0..03b6a0f3c 100644 --- a/client/src/components/PlaylistPage/components/InfoHint.test.tsx +++ b/client/src/components/shared/__tests__/InfoHint.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; -import InfoHint from './InfoHint'; +import InfoHint from '../InfoHint'; describe('InfoHint', () => { test('renders an info trigger labelled for assistive tech', () => { diff --git a/client/src/components/shared/__tests__/RemovalDescription.test.tsx b/client/src/components/shared/__tests__/RemovalDescription.test.tsx new file mode 100644 index 000000000..33010591b --- /dev/null +++ b/client/src/components/shared/__tests__/RemovalDescription.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import RemovalDescription from '../RemovalDescription'; +import { formatDate } from '../../../utils/formatters'; + +const AT = '2026-10-19T12:00:00.000Z'; + +describe('RemovalDescription', () => { + test('sets a missing file\'s path apart from the explanation', () => { + render(<RemovalDescription row={{ removed: true, removed_reason: 'not_found', filePath: '/mnt/nas/Channel/video.mp4' }} />); + + expect(screen.getByRole('code')).toHaveTextContent(/^\/mnt\/nas\/Channel\/video\.mp4$/); + }); + + test('shows the path as the user knows it, without the container prefix', () => { + render(<RemovalDescription row={{ removed: true, removed_reason: 'not_found', filePath: '/usr/src/app/data/Channel/v.mp4' }} />); + + expect(screen.getByText('Channel/v.mp4')).toBeInTheDocument(); + }); + + test('reads as one sentence around the path, with when it was checked', () => { + const { container } = render( + <RemovalDescription row={{ removed: true, removed_reason: 'not_found', removed_at: AT, filePath: '/d/v.mp4' }} /> + ); + + expect(container).toHaveTextContent( + `Youtarr expected a file at: /d/v.mp4 and could not find it (checked ${formatDate(AT)}). If the drive is mounted and the file is really gone, mark it as deleted.` + ); + }); + + test('describes a missing file with no stored path in plain text', () => { + const { container } = render(<RemovalDescription row={{ removed: true, removed_reason: 'not_found' }} />); + + expect(container).toHaveTextContent( + 'Youtarr expected a file and could not find it. If the drive is mounted and the file is really gone, mark it as deleted.' + ); + expect(screen.queryByRole('code')).not.toBeInTheDocument(); + }); + + test('explains a deleted video without showing its path', () => { + const { container } = render( + <RemovalDescription row={{ removed: true, removed_reason: 'user_deleted', removed_at: AT, filePath: '/d/v.mp4' }} /> + ); + + expect(container).toHaveTextContent(`Deleted in Youtarr on ${formatDate(AT)}. It can be downloaded again.`); + expect(screen.queryByText('/d/v.mp4')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/test-utils.tsx b/client/src/test-utils.tsx index ccca0d30a..baa533815 100644 --- a/client/src/test-utils.tsx +++ b/client/src/test-utils.tsx @@ -21,13 +21,13 @@ export function createMockWebSocketContext(): NonNullable<WebSocketValue> { export function renderWithProviders( ui: React.ReactElement, - opts?: { websocketValue?: WebSocketValue } + opts?: { websocketValue?: WebSocketValue; initialEntries?: string[] } ) { const value = opts?.websocketValue ?? createMockWebSocketContext(); function Wrapper({ children }: { children: React.ReactNode }) { return ( - <MemoryRouter> + <MemoryRouter initialEntries={opts?.initialEntries}> <ThemeEngineProvider> <WebSocketContext.Provider value={value}> <TooltipProvider> diff --git a/client/src/utils/__tests__/videoRemoval.test.ts b/client/src/utils/__tests__/videoRemoval.test.ts index 755a2cc33..02a45dad1 100644 --- a/client/src/utils/__tests__/videoRemoval.test.ts +++ b/client/src/utils/__tests__/videoRemoval.test.ts @@ -1,4 +1,4 @@ -import { describeRemoval, describeRow, getRemovalState, removalFromRow } from '../videoRemoval'; +import { describeMissing, describeRemoval, describeRow, getRemovalState, removalFromRow } from '../videoRemoval'; import { formatDate } from '../formatters'; const AT = '2026-10-03T12:00:00.000Z'; @@ -67,6 +67,19 @@ describe('describeRemoval', () => { }); }); +describe('describeMissing', () => { + test('splits the missing-file sentence around the path', () => { + expect(describeMissing({ reason: 'not_found', at: AT, detail: null }, '/usr/src/app/data/Channel/v.mp4')).toEqual({ + path: 'Channel/v.mp4', + rest: `and could not find it (checked ${formatDate(AT)}). If the drive is mounted and the file is really gone, mark it as deleted.`, + }); + }); + + test('has no path when none is stored', () => { + expect(describeMissing({ reason: 'not_found', at: null, detail: null }, null).path).toBeNull(); + }); +}); + describe('row helpers', () => { test('removalFromRow reads the snake_case columns, defaulting to null', () => { expect(removalFromRow({ removed_reason: 'auto_removal', removed_at: AT, removed_detail: 'older than 30 days' })).toEqual({ diff --git a/client/src/utils/videoRemoval.ts b/client/src/utils/videoRemoval.ts index fa45d9543..19730e900 100644 --- a/client/src/utils/videoRemoval.ts +++ b/client/src/utils/videoRemoval.ts @@ -49,17 +49,38 @@ export function removalFromRow(row: RemovalRow): RemovalInfo { const CAN_REDOWNLOAD = 'It can be downloaded again.'; +// Where Settings > Maintenance cleans up deleted and missing videos. +export const REMOVED_VIDEOS_ANCHOR = 'deleted-and-missing'; +export const REMOVED_VIDEOS_SETTINGS_PATH = `/settings/maintenance#${REMOVED_VIDEOS_ANCHOR}`; + +export interface MissingDescription { + // The path as the user knows it, or null when none is stored. + path: string | null; + // Everything after the path: "and could not find it ...". + rest: string; +} + +// The missing-file sentence in pieces, so a tooltip can set the path apart. +export function describeMissing(info: RemovalInfo = NO_REMOVAL, filePath?: string | null): MissingDescription { + const date = info.at ? formatDate(info.at) : null; + const checked = date ? ` (checked ${date})` : ''; + return { + path: filePath ? stripInternalPath(filePath) : null, + rest: `and could not find it${checked}. If the drive is mounted and the file is really gone, mark it as deleted.`, + }; +} + export function describeRemoval(state: RemovalState, info: RemovalInfo = NO_REMOVAL, filePath?: string | null): string { if (state === 'present') return 'Video file exists on disk'; - const date = info.at ? formatDate(info.at) : null; - const on = date ? ` on ${date}` : ''; if (state === 'missing') { - const where = filePath ? ` at ${stripInternalPath(filePath)}` : ''; - const checked = date ? ` (checked ${date})` : ''; - return `Youtarr expected a file${where} and could not find it${checked}. If the drive is mounted and the file is really gone, mark it as deleted.`; + const { path, rest } = describeMissing(info, filePath); + return `Youtarr expected a file${path ? ` at ${path}` : ''} ${rest}`; } + const date = info.at ? formatDate(info.at) : null; + const on = date ? ` on ${date}` : ''; + switch (info.reason) { case 'user_deleted': return `Deleted in Youtarr${on}. ${CAN_REDOWNLOAD}`; From b16e7f4649cec8d91c0893a0b4304a7b6b941557 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Tue, 20 Oct 2026 09:01:26 -0700 Subject: [PATCH 2/3] fix: keep video list button text readable on hover On hover, the Filters button on the Videos page and channel pages showed its label in nearly the same color as the hover background in some themes (Bold Flat light, Playful dark), so the text was hard to read. The Sort button and the channel filter button in the filter panel used the same hover classes. Switch them to the Button's neutral outlined style (color="inherit"), which already uses the foreground color on hover, and keep only the border highlight as an extra class. The Filters button still uses the primary fill while filters are active. --- .../shared/VideoList/VideoListToolbar.tsx | 10 ++--- .../__tests__/VideoListToolbar.test.tsx | 41 +++++++++++++++++++ .../VideoList/filters/ChannelFilter.tsx | 3 +- .../filters/__tests__/ChannelFilter.test.tsx | 13 ++++++ 4 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 client/src/components/shared/VideoList/filters/__tests__/ChannelFilter.test.tsx diff --git a/client/src/components/shared/VideoList/VideoListToolbar.tsx b/client/src/components/shared/VideoList/VideoListToolbar.tsx index ad72cc351..1577d2172 100644 --- a/client/src/components/shared/VideoList/VideoListToolbar.tsx +++ b/client/src/components/shared/VideoList/VideoListToolbar.tsx @@ -43,9 +43,10 @@ function SortButton({ sort }: { sort: SortConfig }) { <> <Button variant="outlined" + color="inherit" size="small" onClick={(e) => setAnchorEl(e.currentTarget)} - className="text-foreground border-border hover:bg-muted hover:border-foreground" + className="hover:border-foreground" endIcon={<DirectionIcon size={14} />} data-testid="video-list-sort-button" > @@ -109,6 +110,7 @@ function VideoListToolbar({ const filtersButton = onFiltersClick ? ( <Button variant={filtersButtonActive ? 'contained' : 'outlined'} + color={filtersButtonActive ? 'primary' : 'inherit'} size="small" onClick={onFiltersClick} startIcon={ @@ -120,11 +122,7 @@ function VideoListToolbar({ <FilterListIcon size={16} /> </Badge> } - className={ - filtersButtonActive - ? undefined - : 'text-foreground border-border hover:bg-muted hover:border-foreground' - } + className={filtersButtonActive ? undefined : 'hover:border-foreground'} data-testid="video-list-filters-button" > Filters diff --git a/client/src/components/shared/VideoList/__tests__/VideoListToolbar.test.tsx b/client/src/components/shared/VideoList/__tests__/VideoListToolbar.test.tsx index 571c6d21e..4468be5c9 100644 --- a/client/src/components/shared/VideoList/__tests__/VideoListToolbar.test.tsx +++ b/client/src/components/shared/VideoList/__tests__/VideoListToolbar.test.tsx @@ -98,6 +98,47 @@ describe('VideoListToolbar', () => { expect(onFiltersClick).toHaveBeenCalledTimes(1); }); + test('inactive filters button keeps foreground text on hover', () => { + renderWithProviders( + <VideoListToolbar + state={buildState()} + viewModes={VIEW_MODES} + onFiltersClick={jest.fn()} + isMobile={false} + /> + ); + const filtersButton = screen.getByTestId('video-list-filters-button'); + expect(filtersButton).toHaveClass('hover:text-foreground'); + expect(filtersButton).not.toHaveClass('hover:text-primary-foreground'); + }); + + test('active filters button uses the primary fill', () => { + renderWithProviders( + <VideoListToolbar + state={buildState()} + viewModes={VIEW_MODES} + onFiltersClick={jest.fn()} + filtersButtonActive + isMobile={false} + /> + ); + expect(screen.getByTestId('video-list-filters-button')).toHaveClass('bg-primary'); + }); + + test('sort button keeps foreground text on hover', () => { + renderWithProviders( + <VideoListToolbar + state={buildState()} + viewModes={VIEW_MODES} + sort={buildSort()} + isMobile={false} + /> + ); + const sortButton = screen.getByTestId('video-list-sort-button'); + expect(sortButton).toHaveClass('hover:text-foreground'); + expect(sortButton).not.toHaveClass('hover:text-primary-foreground'); + }); + test('omits the sort button when sort prop is missing', () => { renderWithProviders( <VideoListToolbar diff --git a/client/src/components/shared/VideoList/filters/ChannelFilter.tsx b/client/src/components/shared/VideoList/filters/ChannelFilter.tsx index 882120308..d74791462 100644 --- a/client/src/components/shared/VideoList/filters/ChannelFilter.tsx +++ b/client/src/components/shared/VideoList/filters/ChannelFilter.tsx @@ -39,10 +39,11 @@ function ChannelFilter({ value, options, onChange, buttonLabel }: ChannelFilterP <> <Button variant="outlined" + color="inherit" size="small" startIcon={<FilterListIcon size={16} data-testid="FilterListIcon" />} onClick={(event) => setAnchorEl(event.currentTarget)} - className="text-foreground border-border hover:bg-muted hover:border-foreground" + className="hover:border-foreground" > {label} </Button> diff --git a/client/src/components/shared/VideoList/filters/__tests__/ChannelFilter.test.tsx b/client/src/components/shared/VideoList/filters/__tests__/ChannelFilter.test.tsx new file mode 100644 index 000000000..126c4c88d --- /dev/null +++ b/client/src/components/shared/VideoList/filters/__tests__/ChannelFilter.test.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ChannelFilter from '../ChannelFilter'; + +describe('ChannelFilter', () => { + test('button keeps foreground text on hover', () => { + render(<ChannelFilter value="" options={['Channel A']} onChange={jest.fn()} />); + const button = screen.getByRole('button', { name: 'Filter by Channel' }); + expect(button).toHaveClass('hover:text-foreground'); + expect(button).not.toHaveClass('hover:text-primary-foreground'); + }); +}); From 630c1758d4cdbc6e24efa383a3ff6861805b8590 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Tue, 20 Oct 2026 09:47:27 -0700 Subject: [PATCH 3/3] fix: hide filter chips while inline panel is open On desktop, the active filter chips stayed visible under the open inline filter panel, so each active filter appeared twice: as an editable control in the panel and as a summary chip beneath it. Show the chips only while the inline panel is closed. The mobile drawer is unchanged: it covers the page, so the chips keep their place rather than shifting the content behind it when it opens. --- .../shared/VideoList/VideoListContainer.tsx | 5 +- .../__tests__/VideoListContainer.test.tsx | 50 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/client/src/components/shared/VideoList/VideoListContainer.tsx b/client/src/components/shared/VideoList/VideoListContainer.tsx index a8a090db3..780a185e6 100644 --- a/client/src/components/shared/VideoList/VideoListContainer.tsx +++ b/client/src/components/shared/VideoList/VideoListContainer.tsx @@ -85,6 +85,9 @@ function VideoListContainer<IdType extends string | number>({ const hasFilters = activeCount > 0; const hasSearch = Boolean(state.search); const hasContent = itemCount > 0; + // The open inline panel already shows every active filter. The mobile drawer + // covers the page, so its chips stay put rather than shifting the content behind it. + const showSummaryChips = hasFilters && (isMobile || !state.filterPanelOpen); return ( <div data-testid="video-list-container"> @@ -116,7 +119,7 @@ function VideoListContainer<IdType extends string | number>({ customFilters={customFilters} /> - {activeCount > 0 && ( + {showSummaryChips && ( <div style={{ padding: isMobile ? '0 10px 6px 10px' : '0 16px 8px 16px' }} > diff --git a/client/src/components/shared/VideoList/__tests__/VideoListContainer.test.tsx b/client/src/components/shared/VideoList/__tests__/VideoListContainer.test.tsx index 57d046971..c7662e47c 100644 --- a/client/src/components/shared/VideoList/__tests__/VideoListContainer.test.tsx +++ b/client/src/components/shared/VideoList/__tests__/VideoListContainer.test.tsx @@ -1,20 +1,26 @@ import React from 'react'; import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import VideoListContainer from '../VideoListContainer'; import { useVideoListState } from '../hooks/useVideoListState'; +import { FilterConfig } from '../types'; import { renderWithProviders } from '../../../../test-utils'; interface HarnessProps { isLoading?: boolean; itemCount?: number; paginationMode?: 'pages' | 'infinite'; + filters?: FilterConfig[]; + isMobile?: boolean; } function Harness({ isLoading = false, itemCount = 0, paginationMode = 'pages', + filters, + isMobile = false, }: HarnessProps) { const state = useVideoListState({ initialViewMode: 'grid' }); return ( @@ -24,14 +30,19 @@ function Harness({ isLoading={isLoading} isError={false} paginationMode={paginationMode} + filters={filters} renderContent={() => ( <div data-testid="content">{itemCount} items</div> )} - isMobile={false} + isMobile={isMobile} /> ); } +function activeFilters(): FilterConfig[] { + return [{ id: 'missing', value: 'only', onChange: jest.fn() }]; +} + describe('VideoListContainer', () => { test('renders top and bottom loading bars during a paginated refetch with existing content', () => { renderWithProviders( @@ -65,4 +76,41 @@ describe('VideoListContainer', () => { expect(screen.queryByTestId('video-list-loading-bar-top')).not.toBeInTheDocument(); expect(screen.queryByTestId('video-list-loading-bar-bottom')).not.toBeInTheDocument(); }); + + describe('active filter chips', () => { + test('shows the chips while the filter panel is closed', () => { + renderWithProviders(<Harness filters={activeFilters()} />); + expect(screen.getByTestId('video-list-filter-chips')).toBeInTheDocument(); + }); + + test('hides the chips while the inline filter panel is open', async () => { + const user = userEvent.setup(); + renderWithProviders(<Harness filters={activeFilters()} />); + + await user.click(screen.getByTestId('video-list-filters-button')); + + expect(screen.getByTestId('video-list-filter-panel-inline')).toBeInTheDocument(); + expect(screen.queryByTestId('video-list-filter-chips')).not.toBeInTheDocument(); + }); + + test('shows the chips again after the inline filter panel closes', async () => { + const user = userEvent.setup(); + renderWithProviders(<Harness filters={activeFilters()} />); + + await user.click(screen.getByTestId('video-list-filters-button')); + await user.click(screen.getByTestId('video-list-filters-button')); + + expect(screen.getByTestId('video-list-filter-chips')).toBeInTheDocument(); + }); + + test('keeps the chips while the mobile filter drawer is open', async () => { + const user = userEvent.setup(); + renderWithProviders(<Harness filters={activeFilters()} isMobile />); + + await user.click(screen.getByTestId('video-list-filters-button')); + + expect(screen.getByTestId('video-list-filter-drawer')).toBeInTheDocument(); + expect(screen.getByTestId('video-list-filter-chips')).toBeInTheDocument(); + }); + }); });