From 3e3ba5a4e1c55c2f3cf760b3cba3a93b07999b5a Mon Sep 17 00:00:00 2001 From: dialmaster Date: Sun, 18 Oct 2026 12:44:57 -0700 Subject: [PATCH 1/4] feat: separate deleted videos from missing ones Every video without a file looked the same. A video deleted in the app, one removed by automatic cleanup, and one whose file vanished from disk all showed the same red Missing state, and they built up on the Videos page with no way to clear them out. - Record why a row lost its file (removed_reason, removed_at, removed_detail) and show it as Deleted (neutral) or Missing (red), with the reason in plain words in the chip tooltip and the video modal. Rows removed before this change show as Deleted. - Hide deleted videos on the Videos page by default and remember the choice in the browser. Channel and playlist pages keep listing both, so either can still be re-downloaded. - Add "Mark as deleted" for selected missing videos, plus "Mark all missing as deleted" and "Empty trash..." in Settings > Maintenance. Emptying the trash forgets the chosen videos and can also remove their download archive entries. - Check that the downloads folder and each __subfolder exist and are not empty before marking anything missing. An unmounted drive or share could mark the whole library missing; now the file check holds those rows back and warns, the rescan records a failed or partial run, and actions over all missing videos are refused. - Leave videos the archive lists without a row out of channel "Download all" and playlist download selection, which otherwise queued them for yt-dlp to skip on every run. - Refuse a purge that removes archive entries while downloads are running, and hold new downloads until such a purge finishes. Refs: #670 --- CLAUDE.md | 10 +- .../components/ChannelPage/ChannelVideos.tsx | 18 +- .../src/components/ChannelPage/VideoCard.tsx | 33 +- .../components/ChannelPage/VideoListItem.tsx | 31 +- .../components/ChannelPage/VideoTableView.tsx | 26 +- .../ChannelPage/__tests__/VideoCard.test.tsx | 10 +- .../__tests__/VideoListItem.test.tsx | 10 +- .../__tests__/VideoTableView.test.tsx | 29 +- .../ChannelPage/hooks/useChannelVideos.ts | 7 +- .../DownloadManager/DownloadHistory.tsx | 36 +- .../DownloadManager/MissingVideoChip.tsx | 28 - .../DownloadManager/VideoThumbnail.tsx | 37 +- .../__tests__/DownloadHistory.test.tsx | 23 +- .../__tests__/MissingVideoChip.test.tsx | 42 -- .../__tests__/VideoThumbnail.test.tsx | 20 +- .../FindVideos/__tests__/FindVideos.test.tsx | 20 + client/src/components/FindVideos/index.tsx | 7 +- client/src/components/FindVideos/types.ts | 16 +- client/src/components/PlaylistPage.tsx | 12 +- .../components/PlaylistVideoCard.tsx | 4 + .../components/PlaylistVideoTable.tsx | 4 + .../__tests__/playlistVideoHelpers.test.tsx | 19 +- .../components/playlistVideoHelpers.tsx | 19 +- .../Settings/MaintenanceSection.tsx | 5 + .../__tests__/MaintenanceSection.test.tsx | 23 + .../Settings/components/EmptyTrashDialog.tsx | 189 +++++++ .../Settings/components/RemovedVideosCard.tsx | 130 +++++ .../__tests__/EmptyTrashDialog.test.tsx | 106 ++++ .../__tests__/RemovedVideosCard.test.tsx | 120 +++++ .../hooks/__tests__/useRemovedVideos.test.ts | 109 ++++ .../Settings/hooks/useRemovedVideos.ts | 112 ++++ .../components/MarkDeletedDialog.tsx | 68 +++ .../VideosPage/components/VideoCard.tsx | 33 +- .../components/VideosListMobile.tsx | 26 +- .../VideosPage/components/VideosTable.tsx | 25 +- .../__tests__/MarkDeletedDialog.test.tsx | 65 +++ .../components/__tests__/VideoCard.test.tsx | 9 +- .../hooks/__tests__/useMarkDeleted.test.ts | 60 +++ .../hooks/__tests__/useVideosData.test.ts | 31 +- .../VideosPage/hooks/useMarkDeleted.ts | 42 ++ .../VideosPage/hooks/useVideosData.ts | 15 +- client/src/components/VideosPage/index.tsx | 98 +++- .../components/__tests__/VideosPage.test.tsx | 173 ++++++- .../components/shared/AvailabilityChip.tsx | 83 ++- .../shared/VideoList/VideoListFilterChips.tsx | 21 +- .../shared/VideoList/VideoListFilterPanel.tsx | 2 + .../__tests__/VideoListFilterChips.test.tsx | 42 ++ .../__tests__/usePersistedChipFilter.test.ts | 44 ++ .../VideoList/filters/ToggleChipFilter.tsx | 25 +- .../__tests__/ToggleChipFilter.test.tsx | 50 ++ .../src/components/shared/VideoList/index.ts | 1 + .../shared/VideoList/statusFilters.ts | 4 +- .../src/components/shared/VideoList/types.ts | 13 + .../VideoList/usePersistedChipFilter.ts | 36 ++ .../VideoModal/__tests__/VideoModal.test.tsx | 17 + .../VideoModal/components/VideoActions.tsx | 3 +- .../VideoModal/components/VideoPlayer.tsx | 14 +- .../VideoModal/components/VideoTechnical.tsx | 19 +- .../components/__tests__/VideoPlayer.test.tsx | 13 + .../__tests__/VideoTechnical.test.tsx | 26 + .../VideoModal/hooks/useVideoModalActions.ts | 5 + .../components/shared/VideoModal/index.tsx | 3 +- .../src/components/shared/VideoModal/types.ts | 3 + .../__tests__/AvailabilityChip.test.tsx | 33 +- client/src/hooks/useLocalVideoStatus.ts | 2 +- client/src/types/ChannelVideo.ts | 6 + client/src/types/VideoData.ts | 18 + client/src/types/playlist.ts | 6 + .../src/utils/__tests__/videoRemoval.test.ts | 93 ++++ .../src/utils/__tests__/videoStatus.test.tsx | 39 +- client/src/utils/formatters.ts | 8 + client/src/utils/videoRemoval.ts | 77 +++ client/src/utils/videoStatus.tsx | 24 +- docs/DATABASE.md | 2 +- docs/TROUBLESHOOTING.md | 15 + docs/USAGE_GUIDE.md | 6 +- ...20261018055008-add-video-removed-reason.js | 42 ++ server/__tests__/server.core.test.js | 2 + server/__tests__/server.routes.test.js | 14 +- server/models/video.js | 17 + .../modules/__tests__/archiveModule.test.js | 59 ++- .../modules/__tests__/archiveOnlyIds.test.js | 38 ++ .../channelDownloadAllModule.test.js | 22 + .../modules/__tests__/channelModule.test.js | 2 +- .../modules/__tests__/fileCheckModule.test.js | 125 ++++- server/modules/__tests__/jobModule.test.js | 56 +- .../__tests__/libraryRootCheck.test.js | 115 +++++ .../__tests__/playlistDownloadModule.test.js | 28 + .../modules/__tests__/rescanRowUpdate.test.js | 43 +- .../__tests__/rescanRunSummary.test.js | 14 + .../__tests__/videoDeletionModule.test.js | 61 ++- .../__tests__/videoLocalStatus.test.js | 22 +- .../__tests__/videoPersistence.test.js | 21 +- .../__tests__/videoRemovalState.test.js | 100 ++++ .../__tests__/videoSearchModule.test.js | 2 +- .../__tests__/videoTrashModule.test.js | 478 ++++++++++++++++++ server/modules/__tests__/videosModule.test.js | 169 ++++++- server/modules/archiveModule.js | 35 ++ server/modules/archiveOnlyIds.js | 24 + .../__tests__/channelVideoQuery.test.js | 105 +++- server/modules/channel/channelVideoQuery.js | 51 +- .../modules/channel/channelVideosService.js | 10 +- server/modules/channelDownloadAllModule.js | 8 +- server/modules/channelModule.js | 4 +- .../download/__tests__/archiveAccess.test.js | 48 ++ .../__tests__/downloadExecutor.test.js | 33 ++ .../__tests__/videoMetadataProcessor.test.js | 6 + server/modules/download/archiveAccess.js | 37 ++ server/modules/download/downloadExecutor.js | 7 + .../download/videoMetadataProcessor.js | 3 +- server/modules/fileCheckModule.js | 41 +- server/modules/jobModule.js | 60 ++- server/modules/libraryRootCheck.js | 75 +++ server/modules/playlistDownloadModule.js | 9 +- server/modules/rescanRowUpdate.js | 12 +- server/modules/rescanRunSummary.js | 20 +- server/modules/videoDeletionModule.js | 46 +- server/modules/videoLocalStatus.js | 27 +- server/modules/videoPersistence.js | 6 +- server/modules/videoRemovalState.js | 95 ++++ server/modules/videoTrashModule.js | 247 +++++++++ server/modules/videosModule.js | 103 +++- server/routes/__tests__/playlists.test.js | 7 + server/routes/__tests__/videoTrash.test.js | 168 ++++++ server/routes/__tests__/videos.routes.test.js | 49 ++ server/routes/channels.js | 15 +- server/routes/index.js | 5 + server/routes/playlists.js | 6 +- server/routes/videoTrash.js | 253 +++++++++ server/routes/videos.js | 17 +- 130 files changed, 5260 insertions(+), 455 deletions(-) delete mode 100644 client/src/components/DownloadManager/MissingVideoChip.tsx delete mode 100644 client/src/components/DownloadManager/__tests__/MissingVideoChip.test.tsx create mode 100644 client/src/components/Settings/components/EmptyTrashDialog.tsx create mode 100644 client/src/components/Settings/components/RemovedVideosCard.tsx create mode 100644 client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx create mode 100644 client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx create mode 100644 client/src/components/Settings/hooks/__tests__/useRemovedVideos.test.ts create mode 100644 client/src/components/Settings/hooks/useRemovedVideos.ts create mode 100644 client/src/components/VideosPage/components/MarkDeletedDialog.tsx create mode 100644 client/src/components/VideosPage/components/__tests__/MarkDeletedDialog.test.tsx create mode 100644 client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts create mode 100644 client/src/components/VideosPage/hooks/useMarkDeleted.ts create mode 100644 client/src/components/shared/VideoList/__tests__/usePersistedChipFilter.test.ts create mode 100644 client/src/components/shared/VideoList/usePersistedChipFilter.ts create mode 100644 client/src/utils/__tests__/videoRemoval.test.ts create mode 100644 client/src/utils/videoRemoval.ts create mode 100644 migrations/20261018055008-add-video-removed-reason.js create mode 100644 server/modules/__tests__/archiveOnlyIds.test.js create mode 100644 server/modules/__tests__/libraryRootCheck.test.js create mode 100644 server/modules/__tests__/videoRemovalState.test.js create mode 100644 server/modules/__tests__/videoTrashModule.test.js create mode 100644 server/modules/archiveOnlyIds.js create mode 100644 server/modules/download/__tests__/archiveAccess.test.js create mode 100644 server/modules/download/archiveAccess.js create mode 100644 server/modules/libraryRootCheck.js create mode 100644 server/modules/videoRemovalState.js create mode 100644 server/modules/videoTrashModule.js create mode 100644 server/routes/__tests__/videoTrash.test.js create mode 100644 server/routes/videoTrash.js diff --git a/CLAUDE.md b/CLAUDE.md index c2299263c..62bf16a78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,11 +37,13 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `logger.js` + `logging/`: two transport targets pinned to `trace`, so the root level is the only filter and `logger.applyLevelSetting` can change it live. Only the server process (`process.argv[1]` is `server.js`; the yt-dlp post-processor, scripts, and tests never write files, since post-processor output already reaches the server log through yt-dlp's stdout) also writes the console format, without colors or embedded ANSI codes, to `config/logs/youtarr..log` through `logging/logFileTransport.js` (pino-roll with `removeOtherLogFiles`, so files from earlier runs are pruned too; `LOG_FILE_MAX_SIZE` default 10MB, `LOG_FILE_MAX_COUNT` older files kept, default 5). A file error prints one stderr notice and stops file output only; an unwritable folder is detected at startup and reported through `logger.getLoggingStatus()` (served as `logging` on `/getconfig`). Never log a field named `level`: it overwrites pino's level and the record is dropped. `modules/logLevelSync.js` applies the `logLevel` setting ('' = `LOG_LEVEL`) at startup, on config change, and in the post-processor. `modules/logFilesModule.js` + `routes/logs.js` serve `GET /api/logs/download` (all files oldest first, numeric order), read line by line through `logging/logScrubber.js`, which replaces the configured keys/tokens and Apprise notification URLs by value plus `X-Plex-Token`/`X-Emby-Token`/`api_key` values and URL userinfo (proxy credentials) with `[REDACTED]`; pino's redaction only covers top-level keys, and tokens reach the log inside logged axios errors and the logged yt-dlp args. The files on disk are not scrubbed. - `models/`: Sequelize models (channel, video, job, jobvideo, jobvideodownload, channelvideo, session, apikey, playlist, playlistvideo, playlistsyncstate, subfolder, videowatchstatus). Associations: Channel hasMany Videos, Job hasMany JobVideos, Playlist hasMany PlaylistVideos and hasMany PlaylistSyncStates. - `routes/`: API handlers (auth, channels, videos, videoDetail, videoSearch, channelSearch, config, jobs, plex, setup, subscriptions, apikeys, ytdlpOptions, health, maintenance, playlists, mediaServers, subfolders, schedules, logs). All use the dependency injection factory pattern; wiring lives in `server/routes/index.js`. -- `modules/`: class-based singletons holding business logic. Top-level modules include `channelModule`, `downloadModule`, `plexModule`, `jobModule`, `configModule`, `videosModule`, `videoMetadataModule`, `videoSearchModule`, `webSocketServer`, `databaseHealthModule`, `notificationModule`, `channelSettingsModule`, `videoDeletionModule` (scheduled auto-removal, nightly by default, runs four strategies in order - age, watched, free-space, total size (`autoRemovalUsageLimit`, measured by storageUsage after the earlier strategies; free-space and total size share the oldest-first `_removeOldestUntilFreed` loop, which never retries a failed deletion within a run) - all excluding protected videos, videos of enabled channels with auto_removal_protected set (channel-level guards go dormant while a channel is unsubscribed), and, when `autoRemovalKeepRecentCount` is set, the N most recently downloaded unprotected videos (cleanup aborts if the guard query fails, and protected videos never consume keep-recent slots); per-channel auto_removal_keep_recent_count guards work the same way (unioned into the exclusion set, fail-closed on guard-query failure) and are mutually exclusive with auto_removal_protected - enabling protection clears the channel's count; the watched strategy honors `watchStatusWatchedRule`, treats never-synced videos as unwatched, and is skipped with a warning when watch status sync is disabled), `autoRemovalQueries` (read-only candidate queries for the watched strategy and the keep-most-recent guards, global and per-channel; candidate queries LEFT JOIN enabled channels to exclude auto_removal_protected channels; watched eligibility SQL comes from `watchStatusQueries.buildWatchedEligibilitySql`, where a qualifying played row with a NULL `last_watched_at` blocks days-since-watched removal), `nfoGenerator`, `cronJobs`, `apiKeyModule`, `ytdlpModule` (yt-dlp version management: `ytdlpUpdateChannel` config selects the stable or nightly release channel, every update runs `--update-to @latest` so the channel survives container recreation, latest-version checks hit the matching GitHub repo per channel, downloads no longer block updates, and startup re-applies the configured channel via `shouldReapplyChannel` when the installed binary's version shape drifts), `messageEmitter`, `jobAuxData` (serialize/parse for Jobs.aux_data, the JSON snapshot of job.data minus videos - failed downloads, diagnoses, skip counts, terminated channels - written at job save and merged back on startup load so Download History detail survives restarts; persisted failedVideos capped at 1000), `playlistModule` (YouTube playlist lifecycle + auto-create source channels; every fetch uses yt-dlp's default webpage path, capped at 5000 entries, with an InnerTube fallback on failure or a short result. A separate metadata-only request verifies the total; missing/conflicting totals and partial snapshots cannot initialize/reset following or prune tracked rows. Following setup above 5000 entries returns 422, unverifiable snapshots return 503, and concurrent refreshes return 409. First enable follows future discoveries only; an older auto-enabled playlist without a cutoff initializes the same way on its first successful sweep, without seeding a batch. Existing cutoffs survive upgrades. New baselines capture the highest tracked row id after refresh; legacy timestamp cutoffs still use preserved `added_at`. `refreshForFollowing` owns refresh/reload for subscribe, first enable, explicit reset, and scheduled downloads. Only an explicit reset clears saved batch requests, and it preserves the paused/running state. `first_seen_at` records immutable discovery; `downloaded_at` records actual download time during reconciliation. Removal remains a soft delete (`enabled=false`); re-subscribe restores saved settings. An idle successful auto-download sweep records one empty Complete 'Playlist Downloads' job, visible only with "Show jobs with no videos" enabled), `m3uGenerator` (writes `__playlists__/*.m3u` fallback files; entries prefer the file matching the playlist's Download Type - mp3 for MP3 Only playlists, video otherwise - and fall back to the other so no downloaded item is dropped; entries follow the playlist's saved `sort_order` ('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel `.m3u` files at the top of each channel directory (per-channel `m3u_enabled`/`m3u_sort_order` settings, entries filtered to files that exist on disk, no `.ignore` marker so Jellyfin/Emby auto-import them; regenerated on download completion, in-app deletion, settings changes, channel enable/disable, and rescan completion (the scheduled rescan, nightly by default, doubles as the periodic refresh); zero-entry channels get the file deleted instead of an empty playlist)), `playlistDownloadGrouper` (resolves per-video download settings for playlist downloads (override -> channel -> playlist -> global) and groups videos by resolved settings), `subfolderModule` (subfolder registry: `getAll` returns the union of the `subfolders` table + config default + Plex-mapping subfolders; `register` idempotently upserts a name; `delete` guards removal against non-empty disk paths and active references), `channelDownloadAllModule` ("download all videos for a channel tab": selects every known never-downloaded channel video (excluding ignored/YouTube-removed/members-only/live, and anything ever downloaded even if since deleted - download-all is not a re-download mechanism), previews count + total duration for the confirmation dialog, and queues one `doSpecificDownloads` job labelled `Channel Download All: `; these jobs are exempt from the 6-hour absolute runtime cap since large channels legitimately take days, with the 30-minute activity timeout remaining as the hang guard), `manualDownloadGrouper` (buckets manually pasted URLs by resolved per-channel resolution/audio format before job creation; per-URL channel attribution prefers the channelId captured during URL validation (server cache merged over the client echo), then videos.channel_id, then channelvideos listers, gated to tracked+enabled channels; multi-bucket pastes share one downloadRunTracker run labeled 'Manual downloads'; file structure is NOT grouped - manual jobs always download nested to temp and the post-processor resolves flat-vs-subfolder per video from its real channel at finalize, so bulk imports are structurally correct regardless of pre-download attribution), `youtubeUrlParser` (pure YouTube URL -> video-id parsing shared by videoValidationModule and manualDownloadGrouper), `playlistVideoFilters` (builds publication/discovery/download ordering before pagination and resolves the playlist videos listing's downloadState/watchedState filters into a youtube_id constraint for the paginated page query; models and watchStatusQueries are passed at call time so the playlists route keeps its dependency injection), `channelSearchModule` (YouTube channel search: YouTube Data API search.list type=channel + channels.list enrichment when a key is configured, yt-dlp channel-filtered results-page fallback otherwise; stamps subscribed from enabled Channel rows; videoCount is API-path only), `resolutionTier` (measures a video file's actual pixel dimensions via ffprobe - ground truth over info.json, which can describe a different format than what's on disk - storing the raw `"WIDTHxHEIGHT"` string on `Videos.video_resolution` at download time and via the rescan backfill (`"0x0"` = probe failed, stamped once so failed rows aren't re-probed nightly); also feeds videoMetadataModule's available-resolutions list: format_note for landscape formats, but the selection class (smallest ladder rung >= pixel height, via `selectionTierForHeight`) for vertical ones, because YouTube's format_note labels verticals by short edge (1080x1920 = "1080p") while the app labels them by selection class - without the conversion a top-of-ladder vertical download's tier would be absent from the modal's list. Interpreting dimensions into the displayed tier is deliberately display logic, in `client/src/utils/videoResolution.ts`: landscape maps to the YouTube ladder (wider-than-16:9 by long edge * 9/16, else height, floor-snapped), vertical maps to its SELECTION class (smallest rung >= pixel height, matching the height-capped yt-dlp selector, so 608x1080 reads 1080p and 1080x1920 reads 2160p); the listing chips (`DownloadFormatIndicator`) and the video modal (`608x1080 (1080p)`) share that util, so they always agree, and label-rule changes need no migration or re-probe). +- `modules/`: class-based singletons holding business logic. Top-level modules include `channelModule`, `downloadModule`, `plexModule`, `jobModule`, `configModule`, `videosModule`, `videoMetadataModule`, `videoSearchModule`, `webSocketServer`, `databaseHealthModule`, `notificationModule`, `channelSettingsModule`, `videoDeletionModule` (scheduled auto-removal, nightly by default, runs four strategies in order - age, watched, free-space, total size (`autoRemovalUsageLimit`, measured by storageUsage after the earlier strategies; free-space and total size share the oldest-first `_removeOldestUntilFreed` loop, which never retries a failed deletion within a run) - all excluding protected videos, videos of enabled channels with auto_removal_protected set (channel-level guards go dormant while a channel is unsubscribed), and, when `autoRemovalKeepRecentCount` is set, the N most recently downloaded unprotected videos (cleanup aborts if the guard query fails, and protected videos never consume keep-recent slots); per-channel auto_removal_keep_recent_count guards work the same way (unioned into the exclusion set, fail-closed on guard-query failure) and are mutually exclusive with auto_removal_protected - enabling protection clears the channel's count; the watched strategy honors `watchStatusWatchedRule`, treats never-synced videos as unwatched, and is skipped with a warning when watch status sync is disabled), `autoRemovalQueries` (read-only candidate queries for the watched strategy and the keep-most-recent guards, global and per-channel; candidate queries LEFT JOIN enabled channels to exclude auto_removal_protected channels; watched eligibility SQL comes from `watchStatusQueries.buildWatchedEligibilitySql`, where a qualifying played row with a NULL `last_watched_at` blocks days-since-watched removal), `nfoGenerator`, `cronJobs`, `apiKeyModule`, `ytdlpModule` (yt-dlp version management: `ytdlpUpdateChannel` config selects the stable or nightly release channel, every update runs `--update-to <channel>@latest` so the channel survives container recreation, latest-version checks hit the matching GitHub repo per channel, downloads no longer block updates, and startup re-applies the configured channel via `shouldReapplyChannel` when the installed binary's version shape drifts), `messageEmitter`, `jobAuxData` (serialize/parse for Jobs.aux_data, the JSON snapshot of job.data minus videos - failed downloads, diagnoses, skip counts, terminated channels - written at job save and merged back on startup load so Download History detail survives restarts; persisted failedVideos capped at 1000), `playlistModule` (YouTube playlist lifecycle + auto-create source channels; every fetch uses yt-dlp's default webpage path, capped at 5000 entries, with an InnerTube fallback on failure or a short result. A separate metadata-only request verifies the total; missing/conflicting totals and partial snapshots cannot initialize/reset following or prune tracked rows. Following setup above 5000 entries returns 422, unverifiable snapshots return 503, and concurrent refreshes return 409. First enable follows future discoveries only; an older auto-enabled playlist without a cutoff initializes the same way on its first successful sweep, without seeding a batch. Existing cutoffs survive upgrades. New baselines capture the highest tracked row id after refresh; legacy timestamp cutoffs still use preserved `added_at`. `refreshForFollowing` owns refresh/reload for subscribe, first enable, explicit reset, and scheduled downloads. Only an explicit reset clears saved batch requests, and it preserves the paused/running state. `first_seen_at` records immutable discovery; `downloaded_at` records actual download time during reconciliation. Removal remains a soft delete (`enabled=false`); re-subscribe restores saved settings. An idle successful auto-download sweep records one empty Complete 'Playlist Downloads' job, visible only with "Show jobs with no videos" enabled), `m3uGenerator` (writes `__playlists__/*.m3u` fallback files; entries prefer the file matching the playlist's Download Type - mp3 for MP3 Only playlists, video otherwise - and fall back to the other so no downloaded item is dropped; entries follow the playlist's saved `sort_order` ('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel `<channel folder>.m3u` files at the top of each channel directory (per-channel `m3u_enabled`/`m3u_sort_order` settings, entries filtered to files that exist on disk, no `.ignore` marker so Jellyfin/Emby auto-import them; regenerated on download completion, in-app deletion, settings changes, channel enable/disable, and rescan completion (the scheduled rescan, nightly by default, doubles as the periodic refresh); zero-entry channels get the file deleted instead of an empty playlist)), `playlistDownloadGrouper` (resolves per-video download settings for playlist downloads (override -> channel -> playlist -> global) and groups videos by resolved settings), `subfolderModule` (subfolder registry: `getAll` returns the union of the `subfolders` table + config default + Plex-mapping subfolders; `register` idempotently upserts a name; `delete` guards removal against non-empty disk paths and active references), `channelDownloadAllModule` ("download all videos for a channel tab": selects every known never-downloaded channel video (excluding ignored/YouTube-removed/members-only/live, and anything ever downloaded even if since deleted, or listed in the download archive without a row (`archiveOnlyIds`, e.g. emptied from the trash with the archive kept) - download-all is not a re-download mechanism), previews count + total duration for the confirmation dialog, and queues one `doSpecificDownloads` job labelled `Channel Download All: <title>`; these jobs are exempt from the 6-hour absolute runtime cap since large channels legitimately take days, with the 30-minute activity timeout remaining as the hang guard), `manualDownloadGrouper` (buckets manually pasted URLs by resolved per-channel resolution/audio format before job creation; per-URL channel attribution prefers the channelId captured during URL validation (server cache merged over the client echo), then videos.channel_id, then channelvideos listers, gated to tracked+enabled channels; multi-bucket pastes share one downloadRunTracker run labeled 'Manual downloads'; file structure is NOT grouped - manual jobs always download nested to temp and the post-processor resolves flat-vs-subfolder per video from its real channel at finalize, so bulk imports are structurally correct regardless of pre-download attribution), `youtubeUrlParser` (pure YouTube URL -> video-id parsing shared by videoValidationModule and manualDownloadGrouper), `playlistVideoFilters` (builds publication/discovery/download ordering before pagination and resolves the playlist videos listing's downloadState/watchedState filters into a youtube_id constraint for the paginated page query; models and watchStatusQueries are passed at call time so the playlists route keeps its dependency injection), `channelSearchModule` (YouTube channel search: YouTube Data API search.list type=channel + channels.list enrichment when a key is configured, yt-dlp channel-filtered results-page fallback otherwise; stamps subscribed from enabled Channel rows; videoCount is API-path only), `resolutionTier` (measures a video file's actual pixel dimensions via ffprobe - ground truth over info.json, which can describe a different format than what's on disk - storing the raw `"WIDTHxHEIGHT"` string on `Videos.video_resolution` at download time and via the rescan backfill (`"0x0"` = probe failed, stamped once so failed rows aren't re-probed nightly); also feeds videoMetadataModule's available-resolutions list: format_note for landscape formats, but the selection class (smallest ladder rung >= pixel height, via `selectionTierForHeight`) for vertical ones, because YouTube's format_note labels verticals by short edge (1080x1920 = "1080p") while the app labels them by selection class - without the conversion a top-of-ladder vertical download's tier would be absent from the modal's list. Interpreting dimensions into the displayed tier is deliberately display logic, in `client/src/utils/videoResolution.ts`: landscape maps to the YouTube ladder (wider-than-16:9 by long edge * 9/16, else height, floor-snapped), vertical maps to its SELECTION class (smallest rung >= pixel height, matching the height-capped yt-dlp selector, so 608x1080 reads 1080p and 1080x1920 reads 2160p); the listing chips (`DownloadFormatIndicator`) and the video modal (`608x1080 (1080p)`) share that util, so they always agree, and label-rule changes need no migration or re-probe). - `modules/titleFilterRegex.js`: evaluates channel title filter regexes with Python's `re` (via `utils/title-filter-regex.py`, one process per batch, JSON on stdin) so the channel filter preview and validation match yt-dlp's `--match-filter` exactly. Channel downloads apply the pattern through `ytdlpCommandBuilder.buildMatchFilters`, which escapes only `'` and `&` because yt-dlp's `match_str` unescapes nothing else. Playlist title filters are separate: `playlistModule.buildTitleFilterRegExp` evaluates them as case-insensitive JavaScript regexes, and the playlist settings route rejects patterns it cannot compile. - `modules/storageUsage.js`: total bytes of downloaded videos (`SUM(file_size + audio_file_size)` over non-removed rows, never a disk walk) and the shared `STORED_BYTES_SQL` fragment the oldest-first auto-removal candidate queries use, so audio-only (MP3) videos count their real size. - `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 `<positive int>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: <title>` (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. +- `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: <title>` (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`, with a one-sentence `message`). The file check runs it lazily, once per pass and only when a row would be marked missing, holds those rows back, 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). The rescan checks first: a failing root with present rows ends the run as `error` ("Nothing was marked missing; is the drive mounted?"), an empty `__subfolder` lets it finish but hold back missing marks (`notMarkedMissing`, outcome `partial`, named in the message), and with no present rows (a fresh install) it runs normally. `/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 plus the library check) 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 fails, a selection is not), 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 fails; 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/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. - Playlist batch/following endpoints: `GET /api/playlists/:playlistId/download-preview` previews publication/position choices; `POST /api/playlists/:playlistId/download-batch` queues explicit ids; `POST /api/playlists/:playlistId/following` sets up/resumes following or resets the starting point while preserving pause state. All require auth and an enabled playlist. Swagger annotations in `routes/playlists.js` describe validation and errors. @@ -59,7 +61,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. `/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). The Videos page has the matching per-selection "Mark as deleted" action (`VideosPage/components/MarkDeletedDialog.tsx`, `VideosPage/hooks/useMarkDeleted.ts`), 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. `/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. @@ -73,7 +75,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - Collation is `utf8mb4_unicode_ci` on every table from migration `20260907000000-normalize-utf8mb4-unicode-collation` onward (older installs had `utf8mb4_general_ci` legacy tables beside explicitly `unicode_ci` newer ones, which breaks any SQL comparing string columns across them). Migrations sorted before it must not compare string columns across tables; `migrations/lib/unicodeCollation.js` is the lowercase-table-name collation helper, `jobsUuidCollation.js` is legacy. The helper drops the foreign keys on the jobs UUID chain before converting and recreates them (stored names and rules, read from information_schema, never assumed) after restoring `utf8mb4_bin`, because MariaDB 10.4.31+/10.5.22+/10.6.15+/10.11.5+/11.x/12.x refuse to alter a foreign-key column even with `FOREIGN_KEY_CHECKS=0`; a key missing after an interrupted run is recreated from the canonical definition as `<table>_job_id_fk`. MariaDB 12.1+ keeps `<Table>_ibfk_N` names across renames and auto-names new constraints `1`, `2`, so Elfhosted-style databases carry mixed-case constraint names on lowercase tables. Test migrations that alter foreign-key columns against `mariadb:12.3` as well as the bundled 10.3. - `server/modules/scheduleConfig.js`: registry of the eight configurable schedules (config key, label, default), `getScheduleError` (syntax via node-cron plus the 15-minute minimum interval, computed structurally with node-cron's own step semantics: a step keeps values divisible by it; the across-midnight case asks the matcher whether two firing days are consecutive, so a Sundays-only `5,55 0,23` schedule is not rejected), `violatesMinimumInterval` with `normalizeToMinimumInterval` (what `configModule` applies at startup: runs closer than 15 minutes apart are dropped, never added, and the hour and calendar fields are kept, so `0,5 2 * * 0` becomes `0 2 * * 0` and pre-floor hand edits keep running instead of blocking every Settings save), and `getNextRun` (steps node-cron's internal time matcher over the minutes/hours the expression can fire on, so predictions match what actually fires, and adds the repeated period when clocks fall back, derived from the offset change so thirty-minute zones such as Lord Howe work, because `setHours` only yields an ambiguous time's first occurrence). `scheduledTaskManager.js` owns the timers: `updateTask({ id, expression, enabled, run, isRunning, getRunBlocker, manualCooldownMs, manualRunRequiresEnabled })` replaces a timer only when the expression changes, keeps the previous timer on an invalid edit, skips a scheduled occurrence while the previous run is still active, and records every run through the recorder injected by `setRunRecorder` (wired in server.js once the DB is ready); `manualRunRequiresEnabled: false` (channel downloads) means the feature switch only stops the timer, not a manual run. A task whose work outlives it may resolve with `finalRecord` (a promise of the run record): the lock is released as usual and only the history row waits for it (`finishWhenSettled`; a rejection closes the row as an error, a row still open at shutdown becomes interrupted), so a slow or stuck `finalRecord` can never hold a task running. `run` is always called as `run({ trigger, ...args })`. `runNow(id, { trigger, args, enforceEnabled, enforceCooldown })` is the single entry point for manual runs -- Scheduling's Run now via `routes/runNowResponse.js` and `POST /api/schedules/:key/run`, the Maintenance rescan, Watch Status Sync Now, the YT-DLP update button, and Download New -- returning `{ started, completion }` or a blocker, checked in order: running (the manager's own flag or the task's `isRunning` probe) -> the task's `getRunBlocker` -> turned off -> the manual-run cooldown (`MANUAL_RUN_COOLDOWN_MS`, kept in memory, applied to channel video counts only; channel downloads are refused only while a sweep is running). `getTaskSnapshot(id)` reads the run blocker and the task's status together, after the run history has been read, so `running` and `runNow` on `GET /api/schedules` never disagree; `scheduledTaskStatus` broadcasts (`{ key }`) tell open Scheduling pages to refetch, and `announceRun(id, promise)` gives the same broadcast to startup passes that run outside the manager (library repair, the yt-dlp channel re-apply, the channel-count refresh). Tasks resolve to `{ status: success|error|skipped, outcome, message, details }`; the feature tasks map their own results through adapters (`mediaServers/watchStatusRunSummary`, `channelAutoDownload`, `backfillFromCompleteList`, `rescanRunSummary`, `ytdlpUpdateRunSummary`) so an overlapped, deferred, or partially failed run is never recorded as a success; a run whose work partly failed (some deletions or empty-folder removals, some archive record writes, the playlist half of the download sweep or any individual playlist in it, one media server of a sync) records as `error` with outcome `partial` and counts in its message. `scheduledTaskRuns.js` is that recorder: a bounded history (20 rows per task, never pruning a running row or the newest row of each outcome, so "last installed" survives any number of later checks) in `scheduled_task_runs`, best-effort so a DB problem never reaches the task, with `markInterruptedRuns()` at startup flagging rows a crashed process left running. `rescanRunSummary.js` and `ytdlpUpdateRunSummary.js` map the rescan and yt-dlp update results to run records and back; startup passes (the filesystem rescan, library repair, and the channel-count refresh) record themselves, while every scheduled and manual run, including the rescan's, is recorded by `scheduledTaskManager` through `runNow`. `jobModule.runStartupBackfill` records the archive repair's startup pass the same way (one finished row, because it starts before `markInterruptedRuns()`), and their last-run displays (Maintenance page, YT-DLP page via `/api/ytdlp/latest-version`) read the history, falling back to the legacy `rescanLastRun` / `ytdlpLast*` config keys, which are no longer written, until a run has been recorded. -- `server/modules/videoLocalStatus.js`: shared local download/file metadata lookup for search results and the local-status endpoint. Also sets `inArchive` on each result: the ID is in `config/complete.list` (one read per call through `archiveModule.filterArchivedVideoIds`, which returns an empty set when the file cannot be read) but has no Videos row, so yt-dlp would skip it. Channel-ignored videos are excluded, because ignoring writes the archive entry on purpose. The channel listing sets the same flag on the page it returns (`channelVideosService.applyArchiveFlag`, called from `buildChannelVideosResponse` so the date probe that precedes each listing never reads the archive). The channel page, video modal, and Find Videos count such videos as previously downloaded when opening the download dialog, which turns on "Allow re-downloading"; the channel page and Find Videos pass the flag into the modal, whose own local-status lookup then keeps it current. +- `server/modules/videoLocalStatus.js`: shared local download/file metadata lookup for search results and the local-status endpoint. Also sets `inArchive` on each result: the ID is in `config/complete.list` (one read per call through `archiveModule.filterArchivedVideoIds`, which returns an empty set when the file cannot be read) but has no Videos row, so yt-dlp would skip it. Channel-ignored videos are excluded, because ignoring writes the archive entry on purpose. That lookup is `modules/archiveOnlyIds.js`, shared with channel Download all and playlist download selection; it requires only the ChannelVideo model, not the models index, because `downloadModule.test.js` replaces model files with plain objects and the index's association setup throws on them when `playlistDownloadModule` loads it. The channel listing sets the same flag on the page it returns (`channelVideosService.applyArchiveFlag`, called from `buildChannelVideosResponse` so the date probe that precedes each listing never reads the archive). The channel page, video modal, and Find Videos count such videos as previously downloaded when opening the download dialog, which turns on "Allow re-downloading"; the channel page and Find Videos pass the flag into the modal, whose own local-status lookup then keeps it current. - `server/modules/download/videoActivity.js`: transient per-video queued/downloading registry, duplicate admission claims, and revisioned snapshots. - `client/src/providers/VideoActivityProvider.tsx`: shared authenticated activity snapshots; `useLocalVideoStatus` refreshes persisted metadata independently of activity revisions. diff --git a/client/src/components/ChannelPage/ChannelVideos.tsx b/client/src/components/ChannelPage/ChannelVideos.tsx index 457e48f94..ad24c430d 100644 --- a/client/src/components/ChannelPage/ChannelVideos.tsx +++ b/client/src/components/ChannelPage/ChannelVideos.tsx @@ -22,7 +22,7 @@ import useMediaQuery from '../../hooks/useMediaQuery'; import { DownloadSettings } from '../DownloadManager/ManualDownload/types'; import { useVideoDeletion } from '../shared/useVideoDeletion'; import { useVideoProtection } from '../shared/useVideoProtection'; -import { getVideoStatus } from '../../utils/videoStatus'; +import { getVideoStatus, isRemovedStatus } from '../../utils/videoStatus'; import VideoCard from './VideoCard'; import VideoListItem from './VideoListItem'; import VideoTableView from './VideoTableView'; @@ -135,6 +135,7 @@ function ChannelVideos({ const [maxRating, setMaxRating] = useState(''); const [protectedFilter, setProtectedFilter] = useState<ChipFilterMode>('off'); const [missingFilter, setMissingFilter] = useState<ChipFilterMode>('off'); + const [deletedFilter, setDeletedFilter] = useState<ChipFilterMode>('off'); const [ignoredFilter, setIgnoredFilter] = useState<ChipFilterMode>('off'); const [watchedFilter, setWatchedFilter] = useState<ChipFilterMode>('off'); @@ -291,6 +292,7 @@ function ChannelVideos({ filters.dateTo ? filters.dateTo.toISOString() : '', protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, useInfiniteScroll, @@ -309,6 +311,7 @@ function ChannelVideos({ filters.dateTo, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, useInfiniteScroll, @@ -342,6 +345,7 @@ function ChannelVideos({ resetKey, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, onFirstLoad: onVideosLoaded, @@ -397,6 +401,7 @@ function ChannelVideos({ maxRating, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, downloadedFilter, @@ -514,6 +519,7 @@ function ChannelVideos({ maxRating, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, downloadedFilter, @@ -534,7 +540,7 @@ function ChannelVideos({ const downloadable = paginatedVideos .filter((video) => { const status = getVideoStatus(video); - return status === 'never_downloaded' || status === 'missing' || status === 'ignored'; + return status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored'; }) .map((video) => video.youtube_id); setCheckedBoxes((prev) => { @@ -557,7 +563,7 @@ function ChannelVideos({ const ids = paginatedVideos .filter((video) => { const status = getVideoStatus(video); - return status === 'never_downloaded' || status === 'missing' || status === 'ignored'; + return status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored'; }) .map((video) => video.youtube_id); setCheckedBoxes((prev) => { @@ -872,6 +878,7 @@ function ChannelVideos({ { id: 'maxRating', value: maxRating, onChange: setMaxRating }, { id: 'protected', value: protectedFilter, onChange: setProtectedFilter }, { id: 'missing', value: missingFilter, onChange: setMissingFilter }, + { id: 'deleted', value: deletedFilter, onChange: setDeletedFilter }, { id: 'ignored', value: ignoredFilter, onChange: setIgnoredFilter }, { id: 'downloaded', value: downloadedFilter, onChange: setDownloadedFilter }, { id: 'watched', value: watchedFilter, onChange: setWatchedFilter }, @@ -891,6 +898,7 @@ function ChannelVideos({ maxRating, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, downloadedFilter, @@ -1070,7 +1078,7 @@ function ChannelVideos({ // The summary already gives the tab's size; the list count only adds // something while a search or filter narrows the list. const listIsFiltered = Boolean(listState.search) || hasDurationOrDateFilter || maxRating !== '' - || [downloadedFilter, protectedFilter, missingFilter, ignoredFilter, watchedFilter].some((mode) => mode !== 'off'); + || [downloadedFilter, protectedFilter, missingFilter, deletedFilter, ignoredFilter, watchedFilter].some((mode) => mode !== 'off'); let countChipLabel: string | null = null; if (totalCount > 0 && listIsFiltered) { countChipLabel = `${totalCount} ${totalCount === 1 ? 'match' : 'matches'}`; @@ -1252,7 +1260,7 @@ function ChannelVideos({ disabled={ paginatedVideos.filter((video) => { const status = getVideoStatus(video); - return status === 'never_downloaded' || status === 'missing' || status === 'ignored'; + return status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored'; }).length === 0 } className="intent-warning" diff --git a/client/src/components/ChannelPage/VideoCard.tsx b/client/src/components/ChannelPage/VideoCard.tsx index 5135eae5b..95cee01e4 100644 --- a/client/src/components/ChannelPage/VideoCard.tsx +++ b/client/src/components/ChannelPage/VideoCard.tsx @@ -12,7 +12,8 @@ import { CalendarToday as CalendarTodayIcon, Block as BlockIcon, CheckCircleOutl import { formatDuration } from '../../utils'; import { ChannelVideo } from '../../types/ChannelVideo'; import { decodeHtml, formatAddedDateTime } from '../../utils/formatters'; -import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle } from '../../utils/videoStatus'; +import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus'; +import { describeRow } from '../../utils/videoRemoval'; import StillLiveDot from './StillLiveDot'; import RatingBadge from '../shared/RatingBadge'; import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; @@ -58,7 +59,7 @@ function VideoCard({ const status = getVideoStatus(video); // Check if video is still live (not "was_live" and not null/undefined) const isStillLive = video.live_status && video.live_status !== 'was_live'; - const isDownloadSelectable = (status === 'never_downloaded' || status === 'missing' || status === 'ignored') && !isStillLive; + const isDownloadSelectable = (status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored') && !isStillLive; const isDeleteSelectable = video.added && !video.removed && !isStillLive; const isDownloadAllowed = selectionMode !== 'delete'; const isDeleteAllowed = selectionMode !== 'download'; @@ -390,19 +391,21 @@ function VideoCard({ style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE, flexShrink: 0 }} /> <WatchedChip watchedBy={video.watchedBy || []} /> - <Chip - icon={getStatusIcon(status)} - label={statusLabel} - size="small" - color={getStatusColor(status)} - variant={getStatusChipVariant(status)} - style={{ - flex: '0 0 auto', - minWidth: 'fit-content', - ...SHARED_THEMED_CHIP_SMALL_STYLE, - ...getStatusChipStyle(status), - }} - /> + <Tooltip title={isRemovedStatus(status) ? describeRow(video) : ''} enterTouchDelay={0}> + <Chip + icon={getStatusIcon(status)} + label={statusLabel} + size="small" + color={getStatusColor(status)} + variant={getStatusChipVariant(status)} + style={{ + flex: '0 0 auto', + minWidth: 'fit-content', + ...SHARED_THEMED_CHIP_SMALL_STYLE, + ...getStatusChipStyle(status), + }} + /> + </Tooltip> </div> </div> </div> diff --git a/client/src/components/ChannelPage/VideoListItem.tsx b/client/src/components/ChannelPage/VideoListItem.tsx index 87bfa99ec..0365a9f21 100644 --- a/client/src/components/ChannelPage/VideoListItem.tsx +++ b/client/src/components/ChannelPage/VideoListItem.tsx @@ -14,7 +14,8 @@ import { ChannelVideo } from '../../types/ChannelVideo'; 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 } from '../../utils/videoStatus'; +import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus'; +import { describeRow } from '../../utils/videoRemoval'; import StillLiveDot from './StillLiveDot'; import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; import ProtectionShieldButton from '../shared/ProtectionShieldButton'; @@ -49,7 +50,7 @@ function VideoListItem({ const status = getVideoStatus(video); // Check if video is still live (not "was_live" and not null/undefined) const isStillLive = video.live_status && video.live_status !== 'was_live'; - const isDownloadSelectable = (status === 'never_downloaded' || status === 'missing' || status === 'ignored') && !isStillLive; + const isDownloadSelectable = (status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored') && !isStillLive; const isDeleteSelectable = video.added && !video.removed && !isStillLive; const isDownloadAllowed = selectionMode !== 'delete'; const isDeleteAllowed = selectionMode !== 'download'; @@ -370,18 +371,20 @@ function VideoListItem({ style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE }} /> <WatchedChip watchedBy={video.watchedBy || []} /> - <Chip - icon={getStatusIcon(status)} - label={statusLabel} - size="small" - color={getStatusColor(status)} - variant={getStatusChipVariant(status)} - style={{ - flex: '0 0 auto', - ...SHARED_THEMED_CHIP_SMALL_STYLE, - ...getStatusChipStyle(status), - }} - /> + <Tooltip title={isRemovedStatus(status) ? describeRow(video) : ''} enterTouchDelay={0}> + <Chip + icon={getStatusIcon(status)} + label={statusLabel} + size="small" + color={getStatusColor(status)} + variant={getStatusChipVariant(status)} + style={{ + flex: '0 0 auto', + ...SHARED_THEMED_CHIP_SMALL_STYLE, + ...getStatusChipStyle(status), + }} + /> + </Tooltip> </div> </CardContent> </Card> diff --git a/client/src/components/ChannelPage/VideoTableView.tsx b/client/src/components/ChannelPage/VideoTableView.tsx index 3e732b43f..899c516e5 100644 --- a/client/src/components/ChannelPage/VideoTableView.tsx +++ b/client/src/components/ChannelPage/VideoTableView.tsx @@ -3,6 +3,7 @@ import { Checkbox, Typography, Chip, + Tooltip, } from '../ui'; import { ArrowUpward as ArrowUpwardIcon, ArrowDownward as ArrowDownwardIcon, Block as BlockIcon, CheckCircleOutline as CheckCircleOutlineIcon, Delete as DeleteIcon } from '../../lib/icons'; import ProtectionShieldButton from '../shared/ProtectionShieldButton'; @@ -10,7 +11,8 @@ import ThumbnailClickOverlay from '../shared/ThumbnailClickOverlay'; import { formatDuration } from '../../utils'; import { ChannelVideo } from '../../types/ChannelVideo'; import { decodeHtml, formatAddedDateParts } from '../../utils/formatters'; -import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle } from '../../utils/videoStatus'; +import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus'; +import { describeRow } from '../../utils/videoRemoval'; import StillLiveDot from './StillLiveDot'; import RatingBadge from '../shared/RatingBadge'; import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; @@ -64,7 +66,7 @@ function VideoTableView({ if (isDeleteMode) { return video.added && !video.removed && !isStillLive; } - return (status === 'never_downloaded' || status === 'missing' || status === 'ignored') && !video.youtube_removed && !isStillLive; + return (status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored') && !video.youtube_removed && !isStillLive; }); return ( @@ -128,7 +130,7 @@ function VideoTableView({ const status = getVideoStatus(video); const statusLabel = status === 'downloaded' ? 'Available' : getStatusLabel(status); const isStillLive = video.live_status && video.live_status !== 'was_live'; - const isDownloadSelectable = (status === 'never_downloaded' || status === 'missing' || status === 'ignored') && !video.youtube_removed && !isStillLive; + const isDownloadSelectable = (status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored') && !video.youtube_removed && !isStillLive; const isDeleteSelectable = video.added && !video.removed && !isStillLive; const isDownloadAllowed = selectionMode !== 'delete'; const isDeleteAllowed = selectionMode !== 'download'; @@ -352,14 +354,16 @@ function VideoTableView({ /> )} <WatchedChip watchedBy={video.watchedBy || []} /> - <Chip - icon={getStatusIcon(status)} - label={statusLabel} - size="small" - color={getStatusColor(status)} - variant={getStatusChipVariant(status)} - style={{ ...SHARED_THEMED_CHIP_SMALL_STYLE, ...getStatusChipStyle(status) }} - /> + <Tooltip title={isRemovedStatus(status) ? describeRow(video) : ''} enterTouchDelay={0}> + <Chip + icon={getStatusIcon(status)} + label={statusLabel} + size="small" + color={getStatusColor(status)} + variant={getStatusChipVariant(status)} + style={{ ...SHARED_THEMED_CHIP_SMALL_STYLE, ...getStatusChipStyle(status) }} + /> + </Tooltip> </div> </td> </tr> diff --git a/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx b/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx index 5c9c4ae66..f573abb91 100644 --- a/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx @@ -125,7 +125,7 @@ describe('VideoCard Component', () => { }); test('renders "Missing" status for removed video', () => { - const removedVideo = { ...mockVideo, added: true, removed: true }; + const removedVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoCard {...defaultProps} video={removedVideo} />); expect(screen.getByText('Missing')).toBeInTheDocument(); }); @@ -242,7 +242,7 @@ describe('VideoCard Component', () => { }); test('renders checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoCard {...defaultProps} video={missingVideo} />); expect(screen.getByRole('checkbox')).toBeInTheDocument(); }); @@ -442,7 +442,7 @@ describe('VideoCard Component', () => { }); test('does not render delete checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoCard {...defaultProps} video={missingVideo} selectionMode="delete" />); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); @@ -475,7 +475,7 @@ describe('VideoCard Component', () => { }); test('renders ignore button for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoCard {...defaultProps} video={missingVideo} />); expect(screen.getByTestId('BlockIcon')).toBeInTheDocument(); }); @@ -849,7 +849,7 @@ describe('VideoCard Component', () => { }); test('renders CloudOffIcon for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoCard {...defaultProps} video={missingVideo} />); expect(screen.getByTestId('CloudOffIcon')).toBeInTheDocument(); }); diff --git a/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx b/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx index cfb263f33..ec56c4137 100644 --- a/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx @@ -106,7 +106,7 @@ describe('VideoListItem Component', () => { }); test('renders "Missing" status for removed video', () => { - const removedVideo = { ...mockVideo, added: true, removed: true }; + const removedVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoListItem {...defaultProps} video={removedVideo} />); expect(screen.getByText('Missing')).toBeInTheDocument(); }); @@ -238,7 +238,7 @@ describe('VideoListItem Component', () => { }); test('renders checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoListItem {...defaultProps} video={missingVideo} />); expect(screen.getByRole('checkbox')).toBeInTheDocument(); }); @@ -476,7 +476,7 @@ describe('VideoListItem Component', () => { }); test('does not render delete checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoListItem {...defaultProps} video={missingVideo} selectionMode="delete" />); expect(screen.queryByLabelText(/mark for deletion/i)).not.toBeInTheDocument(); }); @@ -511,7 +511,7 @@ describe('VideoListItem Component', () => { }); test('renders ignore button for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoListItem {...defaultProps} video={missingVideo} />); expect(screen.getByTestId('BlockIcon')).toBeInTheDocument(); expect(screen.getByLabelText('Click to ignore')).toBeInTheDocument(); @@ -758,7 +758,7 @@ describe('VideoListItem Component', () => { }); test('renders CloudOffIcon for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoListItem {...defaultProps} video={missingVideo} />); expect(screen.getByTestId('CloudOffIcon')).toBeInTheDocument(); }); diff --git a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx index 5b3e34f7d..87b9d7c60 100644 --- a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx @@ -239,7 +239,7 @@ describe('VideoTableView Component', () => { }); test('renders "Missing" status for removed video', () => { - const removedVideo = { ...mockVideo, added: true, removed: true }; + const removedVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoTableView {...defaultProps} videos={[removedVideo]} />); expect(screen.getByText('Missing')).toBeInTheDocument(); expect(screen.getByText('Missing')).toBeVisible(); @@ -427,13 +427,30 @@ describe('VideoTableView Component', () => { }); test('renders checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoTableView {...defaultProps} videos={[missingVideo]} />); const checkboxes = screen.getAllByRole('checkbox'); // Header + 1 video checkbox expect(checkboxes).toHaveLength(2); }); + test('renders checkbox for deleted videos, including ones removed before the reason was recorded', () => { + const deletedVideo = { ...mockVideo, added: true, removed: true, removed_reason: null }; + renderWithProviders(<VideoTableView {...defaultProps} videos={[deletedVideo]} />); + const checkboxes = screen.getAllByRole('checkbox'); + // Header + 1 video checkbox + expect(checkboxes).toHaveLength(2); + }); + + test('counts deleted videos as selectable for the header checkbox', () => { + const deletedVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'user_deleted' as const }; + renderWithProviders( + <VideoTableView {...defaultProps} videos={[deletedVideo]} checkedBoxes={[deletedVideo.youtube_id]} /> + ); + const headerCheckbox = screen.getAllByRole('checkbox')[0]; + expect(headerCheckbox).toBeChecked(); + }); + test('does not render checkbox for downloaded videos', () => { const downloadedVideo = { ...mockVideo, added: true, removed: false }; renderWithProviders(<VideoTableView {...defaultProps} videos={[downloadedVideo]} />); @@ -529,7 +546,7 @@ describe('VideoTableView Component', () => { }); test('does not render delete checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders( <VideoTableView {...defaultProps} videos={[missingVideo]} selectionMode="delete" /> ); @@ -569,7 +586,7 @@ describe('VideoTableView Component', () => { }); test('renders ignore button for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoTableView {...defaultProps} videos={[missingVideo]} />); const buttons = screen.getAllByRole('button', { name: /ignore/i }); expect(buttons.length).toBeGreaterThan(0); @@ -710,7 +727,7 @@ describe('VideoTableView Component', () => { }); test('renders ignore button for missing video, not delete button', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoTableView {...defaultProps} videos={[missingVideo]} />); // Should have ignore button @@ -936,7 +953,7 @@ describe('VideoTableView Component', () => { }); test('renders CloudOffIcon for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoTableView {...defaultProps} videos={[missingVideo]} />); expect(screen.getByTestId('CloudOffIcon')).toBeInTheDocument(); }); diff --git a/client/src/components/ChannelPage/hooks/useChannelVideos.ts b/client/src/components/ChannelPage/hooks/useChannelVideos.ts index 6f62f24da..3882e5d69 100644 --- a/client/src/components/ChannelPage/hooks/useChannelVideos.ts +++ b/client/src/components/ChannelPage/hooks/useChannelVideos.ts @@ -21,6 +21,7 @@ interface UseChannelVideosParams { dateTo?: Date | null; protectedFilter?: ChipFilterMode; missingFilter?: ChipFilterMode; + deletedFilter?: ChipFilterMode; ignoredFilter?: ChipFilterMode; watchedFilter?: ChipFilterMode; onFirstLoad?: (channelId: string) => void; @@ -56,6 +57,7 @@ export function useChannelVideos({ dateTo, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, onFirstLoad, @@ -128,6 +130,9 @@ export function useChannelVideos({ if (missingFilter && missingFilter !== 'off') { queryParams.append('missingFilter', missingFilter); } + if (deletedFilter && deletedFilter !== 'off') { + queryParams.append('deletedFilter', deletedFilter); + } if (ignoredFilter && ignoredFilter !== 'off') { queryParams.append('ignoredFilter', ignoredFilter); } @@ -201,7 +206,7 @@ export function useChannelVideos({ setLoading(false); } } - }, [channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, maxRating, token, append, resetKey, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter]); + }, [channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, maxRating, token, append, resetKey, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, deletedFilter, ignoredFilter, watchedFilter]); useEffect(() => { fetchVideos(); diff --git a/client/src/components/DownloadManager/DownloadHistory.tsx b/client/src/components/DownloadManager/DownloadHistory.tsx index 6071bdf10..28b4300bc 100644 --- a/client/src/components/DownloadManager/DownloadHistory.tsx +++ b/client/src/components/DownloadManager/DownloadHistory.tsx @@ -29,7 +29,8 @@ import PageControls from '../shared/PageControls'; import VideoModal from '../shared/VideoModal'; import { VideoModalData } from '../shared/VideoModal/types'; import VideoThumbnail from './VideoThumbnail'; -import MissingVideoChip from './MissingVideoChip'; +import AvailabilityChip from '../shared/AvailabilityChip'; +import { describeRow, getRemovalState, removalFromRow, removedState } from '../../utils/videoRemoval'; import FailedVideoChip from './FailedVideoChip'; import FailedDownloadsDetail from './FailedDownloadsDetail'; import { jobTypeLabel } from '../../utils/jobTypeLabel'; @@ -68,11 +69,12 @@ function getDiagnosisTitles(job: Job): string[] { function jobVideoToModalData(video: VideoData): VideoModalData { const isDownloaded = Boolean(video.filePath || video.audioFilePath) && !video.removed; const status: VideoModalData['status'] = video.removed - ? 'missing' + ? removedState(video.removed_reason) : isDownloaded ? 'downloaded' : 'never_downloaded'; return { + removal: removalFromRow(video), youtubeId: video.youtubeId, title: video.youTubeVideoName, channelName: video.youTubeChannelName, @@ -268,7 +270,8 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ const titleText = singleVideo?.youTubeVideoName || (hasMultiple ? `Multiple (${videos.length})` : cleanJobTypeLabel(job.jobType)); const channelText = !hasMultiple ? singleVideo?.youTubeChannelName : undefined; const showThumbnail = !hasMultiple && !!singleVideo; - const missingCount = videos.filter((v: VideoData) => v.removed).length; + const missingCount = videos.filter((v: VideoData) => getRemovalState(v.removed, v.removed_reason) === 'missing').length; + const deletedCount = videos.filter((v: VideoData) => getRemovalState(v.removed, v.removed_reason) === 'deleted').length; const failedForJob = getDisplayableFailedVideos(job); const hasExpandable = hasMultiple || failedForJob.length > 0; @@ -298,11 +301,19 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ )} </Typography> {hasMultiple && missingCount > 0 && ( - <MissingVideoChip + <AvailabilityChip + state="missing" label={`${missingCount} missing`} tooltip={`${missingCount} of ${videos.length} video files not found on disk`} /> )} + {hasMultiple && deletedCount > 0 && ( + <AvailabilityChip + state="deleted" + label={`${deletedCount} deleted`} + tooltip={`${deletedCount} of ${videos.length} videos were deleted by Youtarr or marked as deleted`} + /> + )} {failedForJob.length > 0 && ( <FailedVideoChip count={failedForJob.length} @@ -390,7 +401,7 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ {video.youTubeChannelName} </Typography> <VideoActivityChip youtubeId={video.youtubeId} /> - {video.removed && <MissingVideoChip />} + {video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={describeRow(video)} />} </Box> </Box> ))} @@ -496,7 +507,8 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ const failedForJob = getDisplayableFailedVideos(job); if (videos.length > 1 || failedForJob.length > 0) { - const missingCount = videos.filter((v: VideoData) => v.removed).length; + const missingCount = videos.filter((v: VideoData) => getRemovalState(v.removed, v.removed_reason) === 'missing').length; + const deletedCount = videos.filter((v: VideoData) => getRemovalState(v.removed, v.removed_reason) === 'deleted').length; const summaryLabel = videos.length > 1 ? `Multiple (${videos.length})` : videos[0]?.youTubeVideoName || cleanJobTypeLabel(job.jobType); @@ -508,11 +520,19 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ <Box className="flex items-center gap-2 flex-wrap"> <span>{summaryLabel}</span> {missingCount > 0 && ( - <MissingVideoChip + <AvailabilityChip + state="missing" label={`${missingCount} missing`} tooltip={`${missingCount} of ${videos.length} video files not found on disk`} /> )} + {deletedCount > 0 && ( + <AvailabilityChip + state="deleted" + label={`${deletedCount} deleted`} + tooltip={`${deletedCount} of ${videos.length} videos were deleted by Youtarr or marked as deleted`} + /> + )} {failedForJob.length > 0 && ( <FailedVideoChip count={failedForJob.length} @@ -551,7 +571,7 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ {video.youTubeVideoName} </Link> <VideoActivityChip youtubeId={video.youtubeId} /> - {video.removed && <MissingVideoChip />} + {video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={describeRow(video)} />} </Box> <Typography variant="caption" color="secondary" className="block">{video.youTubeChannelName}</Typography> </TableCell> diff --git a/client/src/components/DownloadManager/MissingVideoChip.tsx b/client/src/components/DownloadManager/MissingVideoChip.tsx deleted file mode 100644 index 8287bc6fe..000000000 --- a/client/src/components/DownloadManager/MissingVideoChip.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { Chip, Tooltip } from '../ui'; -import { AlertCircle } from 'lucide-react'; -import { SHARED_THEMED_CHIP_SMALL_STYLE } from '../shared/chipStyles'; - -const DEFAULT_TOOLTIP = 'Video file not found on disk. It may have been deleted or moved.'; - -interface MissingVideoChipProps { - label?: string; - tooltip?: string; -} - -function MissingVideoChip({ label = 'Missing', tooltip = DEFAULT_TOOLTIP }: MissingVideoChipProps) { - return ( - <Tooltip title={tooltip} enterTouchDelay={0}> - <Chip - size="small" - icon={<AlertCircle size={12} />} - label={label} - color="error" - variant="filled" - style={SHARED_THEMED_CHIP_SMALL_STYLE} - /> - </Tooltip> - ); -} - -export default MissingVideoChip; diff --git a/client/src/components/DownloadManager/VideoThumbnail.tsx b/client/src/components/DownloadManager/VideoThumbnail.tsx index afb10c026..d4eebbfd0 100644 --- a/client/src/components/DownloadManager/VideoThumbnail.tsx +++ b/client/src/components/DownloadManager/VideoThumbnail.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { Box, Typography } from '../ui'; -import { AlertCircle as ErrorOutlineIcon } from 'lucide-react'; +import { AlertCircle as ErrorOutlineIcon, Trash2 as DeletedIcon } from 'lucide-react'; import { VideoData } from '../../types/VideoData'; +import { DELETED_OVERLAY_BACKGROUND, getRemovalState } from '../../utils/videoRemoval'; const MISSING_OVERLAY_BG = 'color-mix(in srgb, var(--destructive) 30%, transparent)'; @@ -24,7 +25,11 @@ function VideoThumbnail({ onError, iconSize, }: VideoThumbnailProps) { - const isMissing = Boolean(video.removed); + // Missing (file not found) keeps the red warning tint; deleted (removed by + // Youtarr) gets a neutral one, since nothing is wrong. + const removalState = getRemovalState(video.removed, video.removed_reason); + const isRemoved = removalState !== 'present'; + const isMissing = removalState === 'missing'; return ( <Box @@ -44,7 +49,7 @@ function VideoThumbnail({ variant="caption" className="text-center px-1" style={{ - filter: isMissing ? 'grayscale(100%) brightness(0.6)' : 'none', + filter: isRemoved ? 'grayscale(100%) brightness(0.6)' : 'none', }} > No thumbnail @@ -59,21 +64,29 @@ function VideoThumbnail({ width: '100%', height: '100%', objectFit: video.media_type === 'short' ? 'contain' : 'cover', - filter: isMissing ? 'grayscale(100%) brightness(0.6)' : 'none', + filter: isRemoved ? 'grayscale(100%) brightness(0.6)' : 'none', }} /> )} - {isMissing && ( + {isRemoved && ( <Box - data-testid="video-thumbnail-missing-overlay" + data-testid={isMissing ? 'video-thumbnail-missing-overlay' : 'video-thumbnail-deleted-overlay'} className="absolute inset-0 z-[1] flex items-center justify-center" - style={{ backgroundColor: MISSING_OVERLAY_BG }} + style={{ backgroundColor: isMissing ? MISSING_OVERLAY_BG : DELETED_OVERLAY_BACKGROUND }} > - <ErrorOutlineIcon - size={iconSize} - className="text-destructive" - style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }} - /> + {isMissing ? ( + <ErrorOutlineIcon + size={iconSize} + className="text-destructive" + style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }} + /> + ) : ( + <DeletedIcon + size={iconSize} + className="text-muted-foreground" + style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }} + /> + )} </Box> )} </Box> diff --git a/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx b/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx index f5577a767..3466d3cca 100644 --- a/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx +++ b/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx @@ -727,7 +727,7 @@ describe('DownloadHistory', () => { test('desktop summary row shows "N missing" chip when at least one video is removed', () => { const job = makeMultiVideoJob('job-multi', [ makeVideo({ id: 1, youtubeId: 'v1', youTubeVideoName: 'Video A' }), - makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Video B', removed: true }), + makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Video B', removed: true, removed_reason: 'not_found' }), makeVideo({ id: 3, youtubeId: 'v3', youTubeVideoName: 'Video C' }), ]); @@ -737,6 +737,21 @@ describe('DownloadHistory', () => { expect(screen.getByText('1 missing')).toBeInTheDocument(); }); + test('desktop summary row counts deleted videos separately from missing ones', () => { + const job = makeMultiVideoJob('job-multi-deleted', [ + makeVideo({ id: 1, youtubeId: 'v1', youTubeVideoName: 'Video A' }), + makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Video B', removed: true, removed_reason: 'user_deleted' }), + makeVideo({ id: 3, youtubeId: 'v3', youTubeVideoName: 'Video C', removed: true, removed_reason: 'not_found' }), + ]); + + render(<DownloadHistory {...defaultProps} jobs={[job]} expanded={{ 'job-multi-deleted': true }} />); + + expect(screen.getByText('1 deleted')).toBeInTheDocument(); + expect(screen.getByText('1 missing')).toBeInTheDocument(); + expect(screen.getByText('Deleted')).toBeInTheDocument(); + expect(screen.getByText('Missing')).toBeInTheDocument(); + }); + test('desktop summary row hides the aggregate chip when no videos are removed', () => { const job = makeMultiVideoJob('job-multi-none', [ makeVideo({ id: 1, youtubeId: 'v1' }), @@ -752,7 +767,7 @@ describe('DownloadHistory', () => { test('desktop expanded row shows "Missing" chip next to removed videos only', () => { const job = makeMultiVideoJob('job-multi-expanded', [ makeVideo({ id: 1, youtubeId: 'v1', youTubeVideoName: 'Kept Video' }), - makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Gone Video', removed: true }), + makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Gone Video', removed: true, removed_reason: 'not_found' }), ]); render( @@ -771,7 +786,7 @@ describe('DownloadHistory', () => { test('mobile summary card shows "N missing" chip when at least one video is removed', () => { const job = makeMultiVideoJob('job-mobile-multi', [ makeVideo({ id: 1, youtubeId: 'v1' }), - makeVideo({ id: 2, youtubeId: 'v2', removed: true }), + makeVideo({ id: 2, youtubeId: 'v2', removed: true, removed_reason: 'not_found' }), ]); render(<DownloadHistory {...defaultProps} jobs={[job]} isMobile />); @@ -795,7 +810,7 @@ describe('DownloadHistory', () => { test('mobile expanded card shows per-video "Missing" chip only for removed videos', () => { const job = makeMultiVideoJob('job-mobile-expanded', [ makeVideo({ id: 1, youtubeId: 'v1', youTubeVideoName: 'Present Video' }), - makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Removed Video', removed: true }), + makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Removed Video', removed: true, removed_reason: 'not_found' }), ]); render( diff --git a/client/src/components/DownloadManager/__tests__/MissingVideoChip.test.tsx b/client/src/components/DownloadManager/__tests__/MissingVideoChip.test.tsx deleted file mode 100644 index 86fbaef35..000000000 --- a/client/src/components/DownloadManager/__tests__/MissingVideoChip.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom'; -import MissingVideoChip from '../MissingVideoChip'; - -describe('MissingVideoChip', () => { - test('renders with the default "Missing" label', () => { - render(<MissingVideoChip />); - expect(screen.getByText('Missing')).toBeInTheDocument(); - }); - - test('renders a custom label when provided', () => { - render(<MissingVideoChip label="2 missing" />); - expect(screen.getByText('2 missing')).toBeInTheDocument(); - expect(screen.queryByText('Missing')).not.toBeInTheDocument(); - }); - - test('shows the default tooltip message on hover', async () => { - const user = userEvent.setup(); - render(<MissingVideoChip />); - - await user.hover(screen.getByText('Missing')); - - const tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toHaveTextContent( - 'Video file not found on disk. It may have been deleted or moved.' - ); - }); - - test('shows a custom tooltip message on hover when the tooltip prop is provided', async () => { - const user = userEvent.setup(); - render( - <MissingVideoChip label="2 missing" tooltip="2 of 5 video files not found on disk" /> - ); - - await user.hover(screen.getByText('2 missing')); - - const tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toHaveTextContent('2 of 5 video files not found on disk'); - }); -}); diff --git a/client/src/components/DownloadManager/__tests__/VideoThumbnail.test.tsx b/client/src/components/DownloadManager/__tests__/VideoThumbnail.test.tsx index 12690c7ea..61c32370d 100644 --- a/client/src/components/DownloadManager/__tests__/VideoThumbnail.test.tsx +++ b/client/src/components/DownloadManager/__tests__/VideoThumbnail.test.tsx @@ -74,14 +74,28 @@ describe('VideoThumbnail', () => { expect(screen.queryByTestId('video-thumbnail-missing-overlay')).not.toBeInTheDocument(); }); - test('renders the missing overlay when video.removed is true', () => { - renderThumbnail({ video: { ...baseVideo, removed: true } }); + test('renders the missing overlay when the file was not found', () => { + renderThumbnail({ video: { ...baseVideo, removed: true, removed_reason: 'not_found' } }); expect(screen.getByTestId('video-thumbnail-missing-overlay')).toBeInTheDocument(); + expect(screen.queryByTestId('video-thumbnail-deleted-overlay')).not.toBeInTheDocument(); + }); + + test('renders the neutral deleted overlay when Youtarr removed the file', () => { + renderThumbnail({ video: { ...baseVideo, removed: true, removed_reason: 'user_deleted' } }); + + expect(screen.getByTestId('video-thumbnail-deleted-overlay')).toBeInTheDocument(); + expect(screen.queryByTestId('video-thumbnail-missing-overlay')).not.toBeInTheDocument(); + }); + + test('treats a video removed before the reason was recorded as deleted', () => { + renderThumbnail({ video: { ...baseVideo, removed: true } }); + + expect(screen.getByTestId('video-thumbnail-deleted-overlay')).toBeInTheDocument(); }); test('shows missing overlay on top of the "No thumbnail" fallback for a removed video', () => { - renderThumbnail({ hasError: true, video: { ...baseVideo, removed: true } }); + renderThumbnail({ hasError: true, video: { ...baseVideo, removed: true, removed_reason: 'not_found' } }); expect(screen.getByText('No thumbnail')).toBeInTheDocument(); expect(screen.getByTestId('video-thumbnail-missing-overlay')).toBeInTheDocument(); diff --git a/client/src/components/FindVideos/__tests__/FindVideos.test.tsx b/client/src/components/FindVideos/__tests__/FindVideos.test.tsx index 61f6f286b..8eda786f9 100644 --- a/client/src/components/FindVideos/__tests__/FindVideos.test.tsx +++ b/client/src/components/FindVideos/__tests__/FindVideos.test.tsx @@ -194,6 +194,26 @@ describe('FindVideos page', () => { expect(screen.queryByTestId('select-downloaded1')).not.toBeInTheDocument(); }); + test('a video Youtarr deleted is selectable and counts as previously downloaded', async () => { + axios.post.mockResolvedValueOnce({ + data: { results: [ + { youtubeId: 'deleted0001', title: 'Gone video', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'deleted', removedReason: 'user_deleted' }, + ] }, + }); + + renderWithRouter(<FindVideos token="t" />); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); + fireEvent.click(screen.getByRole('button', { name: /^search$/i })); + await screen.findByText('Gone video'); + + fireEvent.click(screen.getByTestId('select-deleted0001')); + fireEvent.click(screen.getByTestId('video-list-selection-pill')); + fireEvent.click(await screen.findByTestId('selection-menu-download')); + + expect(await screen.findByTestId('mock-download-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('dialog-missing-count')).toHaveTextContent('1'); + }); + test('selecting eligible videos and confirming dialog calls triggerDownloads with URLs', async () => { axios.post.mockResolvedValueOnce({ data: { results: [ diff --git a/client/src/components/FindVideos/index.tsx b/client/src/components/FindVideos/index.tsx index 356062993..eac2dafb2 100644 --- a/client/src/components/FindVideos/index.tsx +++ b/client/src/components/FindVideos/index.tsx @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useVideoActivity } from '../../providers/VideoActivityProvider'; import { useLocalVideoStatus } from '../../hooks/useLocalVideoStatus'; import { useNavigate } from 'react-router-dom'; +import { isRemovedStatus } from '../../utils/videoStatus'; +import { removedState } from '../../utils/videoRemoval'; import { Alert, Box, Typography } from '../ui'; import { Download as DownloadIcon } from '../../lib/icons'; import VideoModal from '../shared/VideoModal'; @@ -56,9 +58,10 @@ function toModalData(r: SearchResult): VideoModalData { addedAt: r.addedAt ?? null, mediaType: 'video', status: r.status === 'queued' || r.status === 'downloading' - ? (r.isDownloaded ? 'downloaded' : r.databaseId ? 'missing' : 'never_downloaded') + ? (r.isDownloaded ? 'downloaded' : r.databaseId ? removedState(r.removedReason) : 'never_downloaded') : r.status, inArchive: r.inArchive, + removal: { reason: r.removedReason ?? null, at: r.removedAt ?? null, detail: r.removedDetail ?? null }, isDownloaded: r.isDownloaded ?? r.status === 'downloaded', filePath: r.filePath ?? null, fileSize: r.fileSize ?? null, @@ -199,7 +202,7 @@ export default function FindVideos({ token }: FindVideosProps) { const missingVideoCount = useMemo( () => results.filter( - (r) => selection.isSelected(r.youtubeId) && (r.status === 'missing' || r.inArchive) + (r) => selection.isSelected(r.youtubeId) && (isRemovedStatus(r.status) || r.inArchive) ).length, [results, selection.isSelected] ); diff --git a/client/src/components/FindVideos/types.ts b/client/src/components/FindVideos/types.ts index 1ad60bdfa..95d63bb8f 100644 --- a/client/src/components/FindVideos/types.ts +++ b/client/src/components/FindVideos/types.ts @@ -1,4 +1,6 @@ -export type SearchResultStatus = 'downloaded' | 'missing' | 'never_downloaded' | 'queued' | 'downloading'; +import type { RemovalReason } from '../../utils/videoRemoval'; + +export type SearchResultStatus = 'downloaded' | 'deleted' | 'missing' | 'never_downloaded' | 'queued' | 'downloading'; export interface SearchResult { youtubeId: string; @@ -13,6 +15,10 @@ export interface SearchResult { // Listed in the download archive without a database record, so a download // skips it unless re-downloading is allowed. inArchive?: boolean; + // Why the file is gone, for a 'deleted' or 'missing' status (see utils/videoRemoval). + removedReason?: RemovalReason | null; + removedAt?: string | null; + removedDetail?: string | null; isDownloaded?: boolean; databaseId?: number | null; filePath?: string | null; @@ -56,9 +62,9 @@ export interface ResultSelection { toggle: (id: string) => void; } -// Eligible to multi-select for download. Includes 'missing' so users can bulk -// re-download videos whose files are gone; the DownloadSettingsDialog already -// surfaces the allow-redownload toggle for that case. +// Eligible to multi-select for download. Includes 'missing' and 'deleted' so +// users can bulk re-download videos whose files are gone; the +// DownloadSettingsDialog already surfaces the allow-redownload toggle for that case. export function isSelectableForDownload(status: SearchResultStatus): boolean { - return status === 'never_downloaded' || status === 'missing'; + return status === 'never_downloaded' || status === 'missing' || status === 'deleted'; } diff --git a/client/src/components/PlaylistPage.tsx b/client/src/components/PlaylistPage.tsx index 984a9b032..3998049c0 100644 --- a/client/src/components/PlaylistPage.tsx +++ b/client/src/components/PlaylistPage.tsx @@ -21,6 +21,7 @@ import { useDownloadListingsRefresh } from '../hooks/useDownloadListingsRefresh' import { usePlaylistMutations } from '../hooks/usePlaylistMutations'; import { useMediaServerStatus } from '../hooks/useMediaServerStatus'; import { MediaServerType, PlaylistSubscribeSettings, PlaylistVideo } from '../types/playlist'; +import { removalFromRow, removedState } from '../utils/videoRemoval'; import PlaylistHeader from './PlaylistPage/components/PlaylistHeader'; import NoMediaServerWarning from './PlaylistPage/components/NoMediaServerWarning'; import PlaylistVideoList from './PlaylistPage/components/PlaylistVideoList'; @@ -51,12 +52,15 @@ interface SnackbarState { } function toModalData(v: PlaylistVideo): VideoModalData { - const status = v.youtube_removed || v.previously_downloaded + const status: VideoModalData['status'] = v.youtube_removed ? 'missing' - : v.downloaded - ? 'downloaded' - : 'never_downloaded'; + : v.previously_downloaded + ? removedState(v.removed_reason) + : v.downloaded + ? 'downloaded' + : 'never_downloaded'; return { + removal: removalFromRow(v), youtubeId: v.youtube_id, title: v.title || v.youtube_id, channelName: v.channel_name || '', diff --git a/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx b/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx index c76a8548b..e0a9ded15 100644 --- a/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx +++ b/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx @@ -88,6 +88,10 @@ const PlaylistVideoCard: React.FC<PlaylistVideoCardProps> = ({ <Chip label={status.label} color={status.color} size="small" style={compactStatusChipStyle} /> )} </> + ) : status.tooltip ? ( + <Tooltip title={status.tooltip} enterTouchDelay={0}> + <Chip label={status.label} color={status.color} size="small" style={compactStatusChipStyle} /> + </Tooltip> ) : ( <Chip label={status.label} color={status.color} size="small" style={compactStatusChipStyle} /> )} diff --git a/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx b/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx index 1df2914d6..8f120f126 100644 --- a/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx +++ b/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx @@ -139,6 +139,10 @@ const PlaylistVideoTable: React.FC<PlaylistVideoTableProps> = ({ <Chip label={status.label} color={status.color} size="small" /> )} </> + ) : status.tooltip ? ( + <Tooltip title={status.tooltip} enterTouchDelay={0}> + <Chip label={status.label} color={status.color} size="small" /> + </Tooltip> ) : ( <Chip label={status.label} color={status.color} size="small" /> )} diff --git a/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx b/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx index cc247159e..00988c787 100644 --- a/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx +++ b/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx @@ -60,10 +60,25 @@ describe('statusLabel', () => { color: 'error', }); }); - test('returns Missing for a previously-downloaded video whose file is gone', () => { - expect(statusLabel(makeVideo({ previously_downloaded: true }))).toEqual({ + 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({ 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 from its channel or playlist page.', + }); + }); + 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.', }); }); test('returns Tracked for a plain tracked video', () => { diff --git a/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx b/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx index 6c34c032a..9ff3b55c1 100644 --- a/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx +++ b/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx @@ -2,9 +2,17 @@ import React from 'react'; import { CalendarToday as CalendarTodayIcon } from '../../../lib/icons'; import { PlaylistSortOrder } from '../../../hooks/usePlaylistDetail'; import { PlaylistVideo } from '../../../types/playlist'; +import { describeRemoval, removalFromRow, removedState } from '../../../utils/videoRemoval'; export type PlaylistVideoStatusColor = 'success' | 'default' | 'warning' | 'error' | 'info'; +export interface PlaylistVideoStatus { + label: string; + color: PlaylistVideoStatusColor; + // Plain-language explanation for a removed video, shown on hover or tap. + tooltip?: string; +} + // Ignored videos stay downloadable: explicitly selecting one is an intentional // override of the ignore. export function isDownloadable(v: PlaylistVideo): boolean { @@ -28,14 +36,21 @@ export function toDownloadFileProps(v: PlaylistVideo): { }; } -export function statusLabel(v: PlaylistVideo): { label: string; color: PlaylistVideoStatusColor } { +export function statusLabel(v: PlaylistVideo): PlaylistVideoStatus { if (v.activity) return { label: v.activity === 'queued' ? 'Queued…' : 'Downloading…', color: 'info' }; // "Excluded" is the user-facing name for the per-playlist `ignored` flag: // left out of this playlist's downloads, server sync, and M3U files. if (v.ignored) return { label: 'Excluded', color: 'warning' }; if (v.youtube_removed) return { label: 'Removed on YT', color: 'error' }; if (v.downloaded) return { label: 'Downloaded', color: 'success' }; - if (v.previously_downloaded) return { label: 'Missing', color: 'error' }; + 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); + return state === 'missing' + ? { label: 'Missing', color: 'error', tooltip } + : { label: 'Deleted', color: 'default', tooltip }; + } return { label: 'Tracked', color: 'default' }; } diff --git a/client/src/components/Settings/MaintenanceSection.tsx b/client/src/components/Settings/MaintenanceSection.tsx index 4a327a3c0..1dba6899e 100644 --- a/client/src/components/Settings/MaintenanceSection.tsx +++ b/client/src/components/Settings/MaintenanceSection.tsx @@ -5,6 +5,7 @@ import { Alert, Button, CircularProgress, Typography } from '../ui'; import { ConfigurationCard } from '../Configuration/common/ConfigurationCard'; import { useRescanStatus } from '../../hooks/useRescanStatus'; import { formatDateTime } from '../../utils/formatters'; +import RemovedVideosCard from './components/RemovedVideosCard'; interface MaintenanceSectionProps { token: string | null; @@ -43,6 +44,7 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) { } return ( + <div className="flex flex-col gap-6"> <ConfigurationCard title="Rescan files on disk"> <div className="flex flex-col gap-4"> <Typography variant="body2" color="text.secondary"> @@ -76,6 +78,9 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) { )} </div> </ConfigurationCard> + + <RemovedVideosCard token={token} /> + </div> ); } diff --git a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx index 06c54cb59..fde441e53 100644 --- a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx +++ b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx @@ -10,9 +10,16 @@ jest.mock('../../../hooks/useRescanStatus', () => ({ useRescanStatus: jest.fn() })); +jest.mock('../hooks/useRemovedVideos', () => ({ + useRemovedVideos: jest.fn() +})); + const { useRescanStatus } = require('../../../hooks/useRescanStatus') as { useRescanStatus: jest.Mock<UseRescanStatusReturn, [string | null]>; }; +const { useRemovedVideos } = require('../hooks/useRemovedVideos') as { + useRemovedVideos: jest.Mock; +}; function setup(overrides: Partial<UseRescanStatusReturn> = {}) { const triggerRescan = jest.fn().mockResolvedValue(undefined); @@ -24,6 +31,16 @@ function setup(overrides: Partial<UseRescanStatusReturn> = {}) { triggerRescan, ...overrides }); + useRemovedVideos.mockReturnValue({ + summary: { deleted: 2, missing: 1, legacy: 0, libraryCheck: { ok: true, reason: null, path: null, message: null } }, + loading: false, + error: null, + refetch: jest.fn(), + markAllMissingDeleted: jest.fn(), + emptyTrash: jest.fn(), + actionPending: false, + actionError: null, + }); renderWithProviders(<MaintenanceSection token="tok" config={DEFAULT_CONFIG} />); return { triggerRescan }; } @@ -37,6 +54,12 @@ describe('MaintenanceSection', () => { expect(screen.getByText(/no rescan has run yet/i)).toBeInTheDocument(); }); + test('hosts the deleted and missing videos card below the rescan card', () => { + setup(); + expect(screen.getByText('Deleted and missing videos')).toBeInTheDocument(); + expect(screen.getByText('Deleted: 2. Missing: 1.')).toBeInTheDocument(); + }); + test('renders idle with last-run summary', () => { const lastRun: RescanLastRun = { startedAt: '2026-05-04T15:00:00.000Z', diff --git a/client/src/components/Settings/components/EmptyTrashDialog.tsx b/client/src/components/Settings/components/EmptyTrashDialog.tsx new file mode 100644 index 000000000..565024ae4 --- /dev/null +++ b/client/src/components/Settings/components/EmptyTrashDialog.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useId, useState } from 'react'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '../../ui'; +import { Checkbox, FormControlLabel, RadioGroup, RadioGroupItem } from '../../ui/form'; +import { Trash2 as TrashIcon } from '../../../lib/icons'; +import type { EmptyTrashOptions, TrashState } from '../hooks/useRemovedVideos'; + +type WhichChoice = TrashState | 'both'; +type ArchiveChoice = 'keep' | 'remove'; + +interface EmptyTrashDialogProps { + open: boolean; + onClose: () => void; + onConfirm: (options: EmptyTrashOptions) => void; + counts: { deleted: number; missing: number }; + // Why the missing choices are unavailable (the downloads folder looks unmounted), or null. + missingDisabledReason: string | null; + pending: boolean; +} + +const STATES_FOR: Record<WhichChoice, TrashState[]> = { + deleted: ['deleted'], + missing: ['missing'], + both: ['deleted', 'missing'], +}; + +const videos = (n: number) => (n === 1 ? 'video' : 'videos'); + +interface ChoiceProps { + id: string; + value: string; + label: string; + help: string; + disabled?: boolean; +} + +// One radio with its label and a line of help beneath; the help stays out of +// the label so the radio's accessible name is just the choice. +function Choice({ id, value, label, help, disabled = false }: ChoiceProps) { + return ( + <div className="flex items-start gap-2"> + <RadioGroupItem id={id} value={value} disabled={disabled} className="mt-1 shrink-0" /> + <div className="min-w-0"> + <label htmlFor={id} className={`text-sm font-medium ${disabled ? 'text-muted-foreground' : 'cursor-pointer'}`}> + {label} + </label> + <Typography component="p" variant="caption" color="text.secondary"> + {help} + </Typography> + </div> + </div> + ); +} + +// Confirms a purge: which videos, what happens to the download archive, what +// it does, and that it cannot be undone. +const EmptyTrashDialog: React.FC<EmptyTrashDialogProps> = ({ + open, + onClose, + onConfirm, + counts, + missingDisabledReason, + pending, +}) => { + const id = useId(); + const [which, setWhich] = useState<WhichChoice>('deleted'); + const [archive, setArchive] = useState<ArchiveChoice>('keep'); + const [acknowledged, setAcknowledged] = useState(false); + + // Every opening starts from the safe defaults. + useEffect(() => { + if (open) { + setWhich('deleted'); + setArchive('keep'); + setAcknowledged(false); + } + }, [open]); + + const missingDisabled = missingDisabledReason !== null || counts.missing === 0; + const total = counts.deleted + counts.missing; + const count = which === 'deleted' ? counts.deleted : which === 'missing' ? counts.missing : total; + + return ( + <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> + <DialogTitle> + <TrashIcon size={20} className="shrink-0" /> + Empty trash + </DialogTitle> + + <DialogContent> + <div className="space-y-5"> + <fieldset className="space-y-3"> + <legend className="text-sm font-semibold mb-2">Which videos</legend> + <RadioGroup value={which} onValueChange={(value) => setWhich(value as WhichChoice)} className="space-y-3"> + <Choice + id={`${id}-deleted`} + value="deleted" + label={`Deleted (${counts.deleted})`} + help="Removed by Youtarr or marked as deleted." + /> + <Choice + id={`${id}-missing`} + value="missing" + label={`Missing (${counts.missing})`} + help={missingDisabledReason ?? 'Expected on disk but not found.'} + disabled={missingDisabled} + /> + <Choice + id={`${id}-both`} + value="both" + label={`Both (${total})`} + help="Every video whose file is gone." + disabled={missingDisabled} + /> + </RadioGroup> + </fieldset> + + <fieldset className="space-y-3"> + <legend className="text-sm font-semibold mb-2">Download archive</legend> + <RadioGroup value={archive} onValueChange={(value) => setArchive(value as ArchiveChoice)} className="space-y-3"> + <Choice + id={`${id}-keep`} + value="keep" + label="Keep them in the download archive (recommended)" + help="Automatic channel and playlist downloads keep skipping them, as they do now." + /> + <Choice + id={`${id}-remove`} + value="remove" + label="Remove them from the download archive" + help="Automatic downloads treat them as new and may download them again at the next run." + /> + </RadioGroup> + </fieldset> + + <div> + <Typography variant="body2" className="font-semibold mb-1"> + What happens + </Typography> + <ul className="list-disc pl-5 space-y-1"> + <Typography component="li" variant="body2" color="text.secondary"> + They disappear from the Videos page and from Download History. + </Typography> + <Typography component="li" variant="body2" color="text.secondary"> + Their channel and playlist pages show them as not downloaded. + </Typography> + <Typography component="li" variant="body2" color="text.secondary"> + Watch history Youtarr synced for them from your media servers is removed. + </Typography> + <Typography component="li" variant="body2" color="text.secondary"> + Nothing on disk is touched. If a missing video's file turns up later, Youtarr will not know about it; download it again instead. + </Typography> + </ul> + </div> + + <Alert severity="error">This cannot be undone.</Alert> + + <FormControlLabel + control={<Checkbox checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} />} + label="I understand this cannot be undone" + /> + </div> + </DialogContent> + + <DialogActions> + <Button onClick={onClose} variant="contained" color="primary" autoFocus> + Cancel + </Button> + <Button + onClick={() => onConfirm({ states: STATES_FOR[which], removeFromArchive: archive === 'remove' })} + variant="outlined" + color="error" + disabled={!acknowledged || count === 0 || pending} + > + Empty trash ({count} {videos(count)}) + </Button> + </DialogActions> + </Dialog> + ); +}; + +export default EmptyTrashDialog; diff --git a/client/src/components/Settings/components/RemovedVideosCard.tsx b/client/src/components/Settings/components/RemovedVideosCard.tsx new file mode 100644 index 000000000..c95de1ed7 --- /dev/null +++ b/client/src/components/Settings/components/RemovedVideosCard.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '../../ui'; +import { ConfigurationCard } from '../../Configuration/common/ConfigurationCard'; +import { useRemovedVideos, type EmptyTrashOptions } from '../hooks/useRemovedVideos'; +import EmptyTrashDialog from './EmptyTrashDialog'; + +interface RemovedVideosCardProps { + token: string | null; +} + +const videos = (n: number) => (n === 1 ? 'video' : 'videos'); + +// Settings > Maintenance: what Deleted and Missing mean, how many there are, +// and the two ways to clear them out. +function RemovedVideosCard({ token }: RemovedVideosCardProps) { + const { summary, loading, error, markAllMissingDeleted, emptyTrash, actionPending, actionError } = useRemovedVideos(token); + const [markAllOpen, setMarkAllOpen] = useState(false); + const [emptyTrashOpen, setEmptyTrashOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState<string | null>(null); + + const deleted = summary?.deleted ?? 0; + const missing = summary?.missing ?? 0; + const legacy = summary?.legacy ?? 0; + const libraryWarning = summary && !summary.libraryCheck.ok ? summary.libraryCheck.message : null; + + const handleMarkAll = async () => { + setSuccessMessage(null); + const result = await markAllMissingDeleted(); + setMarkAllOpen(false); + if (result) setSuccessMessage(`Marked ${result.updated} ${videos(result.updated)} as deleted.`); + }; + + const handleEmptyTrash = async (options: EmptyTrashOptions) => { + setSuccessMessage(null); + const result = await emptyTrash(options); + setEmptyTrashOpen(false); + if (!result) return; + const archive = result.archiveEntriesRemoved > 0 + ? `, ${result.archiveEntriesRemoved} download archive ${result.archiveEntriesRemoved === 1 ? 'entry' : 'entries'} removed` + : ''; + setSuccessMessage(`Emptied trash: ${result.purged} ${videos(result.purged)} removed${archive}.`); + }; + + return ( + <ConfigurationCard title="Deleted and missing videos"> + <div className="flex flex-col gap-4"> + <Typography variant="body2" color="text.secondary"> + Videos whose files are no longer on disk stay in Youtarr's library so they can be re-downloaded. + Deleted means Youtarr removed the file: a delete in the app, automatic cleanup, or you marked a missing + video as deleted. Missing means Youtarr expected a file and could not find it. The Videos page hides + deleted videos by default and shows missing ones so you can check on them. + </Typography> + + {loading && !summary && ( + <Typography variant="body2" color="text.secondary">Counting deleted and missing videos...</Typography> + )} + {summary && ( + <Typography variant="body2"> + {`Deleted: ${deleted}${legacy > 0 ? ` (${legacy} removed before Youtarr recorded why)` : ''}. Missing: ${missing}.`} + </Typography> + )} + + {libraryWarning && ( + <Alert severity="warning"> + {libraryWarning} Marking or emptying missing videos is disabled until it is back. + </Alert> + )} + {error && <Alert severity="error">{error}</Alert>} + {actionError && <Alert severity="error">{actionError}</Alert>} + {successMessage && <Alert severity="success">{successMessage}</Alert>} + + <div className="flex flex-wrap gap-2"> + <Button + variant="outlined" + disabled={!summary || missing === 0 || libraryWarning !== null || actionPending} + onClick={() => setMarkAllOpen(true)} + > + Mark all missing as deleted + </Button> + <Button + variant="outlined" + color="error" + disabled={!summary || deleted + missing === 0 || actionPending} + onClick={() => setEmptyTrashOpen(true)} + > + Empty trash... + </Button> + </div> + </div> + + <Dialog open={markAllOpen} onClose={() => setMarkAllOpen(false)} maxWidth="sm" fullWidth> + <DialogTitle>Mark all missing videos as deleted</DialogTitle> + <DialogContent> + <Typography variant="body2"> + {missing} missing {videos(missing)} will be marked as deleted and hidden from the Videos page by default. + Their records are kept, so they can still be re-downloaded, and if a file turns up again the rescan + marks it downloaded. Only do this if the files are really gone, not sitting on an unmounted drive. + </Typography> + </DialogContent> + <DialogActions> + <Button onClick={() => setMarkAllOpen(false)} variant="contained" color="primary" autoFocus> + Cancel + </Button> + <Button onClick={handleMarkAll} variant="outlined" color="warning" disabled={actionPending}> + Mark {missing} as deleted + </Button> + </DialogActions> + </Dialog> + + <EmptyTrashDialog + open={emptyTrashOpen} + onClose={() => setEmptyTrashOpen(false)} + onConfirm={handleEmptyTrash} + counts={{ deleted, missing }} + missingDisabledReason={libraryWarning} + pending={actionPending} + /> + </ConfigurationCard> + ); +} + +export default RemovedVideosCard; diff --git a/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx b/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx new file mode 100644 index 000000000..88a740d4a --- /dev/null +++ b/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import EmptyTrashDialog from '../EmptyTrashDialog'; +import { renderWithProviders } from '../../../../test-utils'; + +const renderDialog = (overrides: Partial<React.ComponentProps<typeof EmptyTrashDialog>> = {}) => { + const onClose = jest.fn(); + const onConfirm = jest.fn(); + renderWithProviders( + <EmptyTrashDialog + open + onClose={onClose} + onConfirm={onConfirm} + counts={{ deleted: 12, missing: 4 }} + missingDisabledReason={null} + pending={false} + {...overrides} + /> + ); + return { onClose, onConfirm, user: userEvent.setup() }; +}; + +const confirmButton = () => screen.getByRole('button', { name: /^Empty trash \(/ }); + +describe('EmptyTrashDialog', () => { + test('starts with Deleted and Keep selected, the confirm button disabled until the risk is acknowledged', async () => { + const { user } = renderDialog(); + + expect(screen.getByRole('radio', { name: 'Deleted (12)' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('radio', { name: 'Keep them in the download archive (recommended)' })).toHaveAttribute('aria-checked', 'true'); + expect(confirmButton()).toHaveTextContent('Empty trash (12 videos)'); + expect(confirmButton()).toBeDisabled(); + + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + expect(confirmButton()).toBeEnabled(); + }); + + test('spells out what happens and that it cannot be undone', () => { + renderDialog(); + expect(screen.getByText('They disappear from the Videos page and from Download History.')).toBeInTheDocument(); + expect(screen.getByText('Their channel and playlist pages show them as not downloaded.')).toBeInTheDocument(); + expect(screen.getByText('Watch history Youtarr synced for them from your media servers is removed.')).toBeInTheDocument(); + expect(screen.getByText("Nothing on disk is touched. If a missing video's file turns up later, Youtarr will not know about it; download it again instead.")).toBeInTheDocument(); + expect(screen.getByText('This cannot be undone.')).toBeInTheDocument(); + expect(screen.getByText('Automatic channel and playlist downloads keep skipping them, as they do now.')).toBeInTheDocument(); + expect(screen.getByText('Automatic downloads treat them as new and may download them again at the next run.')).toBeInTheDocument(); + }); + + test('confirms with the chosen states and archive choice', async () => { + const { user, onConfirm } = renderDialog(); + + await user.click(screen.getByRole('radio', { name: 'Both (16)' })); + expect(confirmButton()).toHaveTextContent('Empty trash (16 videos)'); + await user.click(screen.getByRole('radio', { name: 'Remove them from the download archive' })); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(confirmButton()); + + expect(onConfirm).toHaveBeenCalledWith({ states: ['deleted', 'missing'], removeFromArchive: true }); + }); + + test('confirms missing only, keeping the archive', async () => { + const { user, onConfirm } = renderDialog(); + + await user.click(screen.getByRole('radio', { name: 'Missing (4)' })); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(confirmButton()); + + expect(confirmButton()).toHaveTextContent('Empty trash (4 videos)'); + expect(onConfirm).toHaveBeenCalledWith({ states: ['missing'], removeFromArchive: false }); + }); + + test('disables the missing choices and shows why while the downloads folder looks unmounted', () => { + renderDialog({ missingDisabledReason: 'The downloads folder /d is empty.' }); + + expect(screen.getByRole('radio', { name: 'Missing (4)' })).toBeDisabled(); + expect(screen.getByRole('radio', { name: 'Both (16)' })).toBeDisabled(); + expect(screen.getByText('The downloads folder /d is empty.')).toBeInTheDocument(); + }); + + test('disables the missing choices when there are no missing videos', () => { + renderDialog({ counts: { deleted: 1, missing: 0 } }); + + expect(screen.getByRole('radio', { name: 'Missing (0)' })).toBeDisabled(); + expect(screen.getByRole('radio', { name: 'Both (1)' })).toBeDisabled(); + expect(confirmButton()).toHaveTextContent('Empty trash (1 video)'); + }); + + test('keeps the confirm button disabled while pending', async () => { + const { user } = renderDialog({ pending: true }); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + expect(confirmButton()).toBeDisabled(); + }); + + test('cancels', async () => { + const { user, onClose } = renderDialog(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('renders nothing when closed', () => { + renderDialog({ open: false }); + expect(screen.queryByText('Empty trash')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx b/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx new file mode 100644 index 000000000..07640238e --- /dev/null +++ b/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import RemovedVideosCard from '../RemovedVideosCard'; +import { renderWithProviders } from '../../../../test-utils'; +import type { RemovedVideosSummary, UseRemovedVideosReturn } from '../../hooks/useRemovedVideos'; + +jest.mock('../../hooks/useRemovedVideos', () => ({ + useRemovedVideos: jest.fn(), +})); + +const { useRemovedVideos } = require('../../hooks/useRemovedVideos') as { + useRemovedVideos: jest.Mock<UseRemovedVideosReturn, [string | null]>; +}; + +const summary = (overrides: Partial<RemovedVideosSummary> = {}): RemovedVideosSummary => ({ + deleted: 12, + missing: 4, + legacy: 0, + libraryCheck: { ok: true, reason: null, path: null, message: null }, + ...overrides, +}); + +function setup(summaryValue: RemovedVideosSummary | null = summary(), overrides: Partial<UseRemovedVideosReturn> = {}) { + const hook: UseRemovedVideosReturn = { + summary: summaryValue, + loading: false, + error: null, + refetch: jest.fn().mockResolvedValue(undefined), + markAllMissingDeleted: jest.fn().mockResolvedValue({ updated: 4 }), + emptyTrash: jest.fn().mockResolvedValue({ purged: 16, archiveEntriesRemoved: 16, jobsAffected: 2 }), + actionPending: false, + actionError: null, + ...overrides, + }; + useRemovedVideos.mockReturnValue(hook); + renderWithProviders(<RemovedVideosCard token="tok" />); + return { hook, user: userEvent.setup() }; +} + +describe('RemovedVideosCard', () => { + beforeEach(() => jest.clearAllMocks()); + + test('explains the two states and shows the counts', () => { + setup(); + expect(screen.getByText('Deleted and missing videos')).toBeInTheDocument(); + expect(screen.getByText(/Deleted means Youtarr removed the file/)).toBeInTheDocument(); + expect(screen.getByText('Deleted: 12. Missing: 4.')).toBeInTheDocument(); + }); + + test('says how many deleted videos predate the reason being recorded', () => { + setup(summary({ legacy: 3 })); + expect(screen.getByText('Deleted: 12 (3 removed before Youtarr recorded why). Missing: 4.')).toBeInTheDocument(); + }); + + test('disables the missing action with the warning while the downloads folder looks unmounted', () => { + setup(summary({ libraryCheck: { ok: false, reason: 'empty', path: '/d', message: 'The downloads folder /d is empty.' } })); + expect(screen.getByText('The downloads folder /d is empty. Marking or emptying missing videos is disabled until it is back.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mark all missing as deleted' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeEnabled(); + }); + + test('disables both actions when the trash is empty', () => { + setup(summary({ deleted: 0, missing: 0 })); + expect(screen.getByRole('button', { name: 'Mark all missing as deleted' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeDisabled(); + }); + + test('marks all missing videos as deleted after a confirmation that explains it', async () => { + const { hook, user } = setup(); + + await user.click(screen.getByRole('button', { name: 'Mark all missing as deleted' })); + expect(screen.getByText('Mark all missing videos as deleted')).toBeInTheDocument(); + expect(screen.getByText(/4 missing videos will be marked as deleted and hidden from the Videos page by default\./)).toBeInTheDocument(); + expect(screen.getByText(/Only do this if the files are really gone, not sitting on an unmounted drive\./)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Mark 4 as deleted' })); + + expect(hook.markAllMissingDeleted).toHaveBeenCalledTimes(1); + expect(await screen.findByText('Marked 4 videos as deleted.')).toBeInTheDocument(); + }); + + test('empties the trash through the dialog and reports what went', async () => { + const { hook, user } = setup(); + + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + expect(screen.getByText('Empty trash')).toBeInTheDocument(); + await user.click(screen.getByRole('radio', { name: 'Both (16)' })); + await user.click(screen.getByRole('radio', { name: 'Remove them from the download archive' })); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(screen.getByRole('button', { name: 'Empty trash (16 videos)' })); + + expect(hook.emptyTrash).toHaveBeenCalledWith({ states: ['deleted', 'missing'], removeFromArchive: true }); + expect(await screen.findByText('Emptied trash: 16 videos removed, 16 download archive entries removed.')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('Empty trash')).not.toBeInTheDocument()); + }); + + test('reports an emptied trash without archive changes', async () => { + const { user } = setup(summary(), { + emptyTrash: jest.fn().mockResolvedValue({ purged: 1, archiveEntriesRemoved: 0, jobsAffected: 0 }), + }); + + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(screen.getByRole('button', { name: 'Empty trash (12 videos)' })); + + expect(await screen.findByText('Emptied trash: 1 video removed.')).toBeInTheDocument(); + }); + + test('shows an action error', () => { + setup(summary(), { actionError: 'A purge is already running' }); + expect(screen.getByText('A purge is already running')).toBeInTheDocument(); + }); + + test('shows a load error', () => { + setup(null, { error: 'Failed to load the deleted and missing video counts' }); + expect(screen.getByText('Failed to load the deleted and missing video counts')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Settings/hooks/__tests__/useRemovedVideos.test.ts b/client/src/components/Settings/hooks/__tests__/useRemovedVideos.test.ts new file mode 100644 index 000000000..851c6d77e --- /dev/null +++ b/client/src/components/Settings/hooks/__tests__/useRemovedVideos.test.ts @@ -0,0 +1,109 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; + +jest.mock('axios', () => ({ + get: jest.fn(), + post: jest.fn(), +})); + +const axios = require('axios'); + +const { useRemovedVideos } = require('../useRemovedVideos'); + +const SUMMARY = { + deleted: 12, + missing: 4, + legacy: 3, + libraryCheck: { ok: true, reason: null, path: null, message: null }, +}; + +describe('useRemovedVideos', () => { + beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockResolvedValue({ data: SUMMARY }); + }); + + test('loads the summary on mount with the token', async () => { + const { result } = renderHook(() => useRemovedVideos('tok')); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(axios.get).toHaveBeenCalledWith('/api/videos/removed-summary', { headers: { 'x-access-token': 'tok' } }); + expect(result.current.summary).toEqual(SUMMARY); + expect(result.current.error).toBeNull(); + }); + + test('does not load without a token', () => { + renderHook(() => useRemovedVideos(null)); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test('exposes a load error', async () => { + axios.get.mockRejectedValueOnce(new Error('offline')); + const { result } = renderHook(() => useRemovedVideos('tok')); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe('Failed to load the deleted and missing video counts'); + expect(result.current.summary).toBeNull(); + }); + + test('markAllMissingDeleted posts all: true, returns the count, and reloads the summary', async () => { + axios.post.mockResolvedValueOnce({ data: { updated: 4 } }); + const { result } = renderHook(() => useRemovedVideos('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let outcome: { updated: number } | null = null; + await act(async () => { + outcome = await result.current.markAllMissingDeleted(); + }); + + expect(outcome).toEqual({ updated: 4 }); + expect(axios.post).toHaveBeenCalledWith('/api/videos/mark-deleted', { all: true }, { headers: { 'x-access-token': 'tok' } }); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + expect(result.current.actionPending).toBe(false); + }); + + test('emptyTrash posts the options and returns the result', async () => { + axios.post.mockResolvedValueOnce({ data: { purged: 16, archiveEntriesRemoved: 16, jobsAffected: 3 } }); + const { result } = renderHook(() => useRemovedVideos('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let outcome: unknown = null; + await act(async () => { + outcome = await result.current.emptyTrash({ states: ['deleted', 'missing'], removeFromArchive: true }); + }); + + expect(outcome).toEqual({ purged: 16, archiveEntriesRemoved: 16, jobsAffected: 3 }); + expect(axios.post).toHaveBeenCalledWith( + '/api/videos/purge-removed', + { states: ['deleted', 'missing'], removeFromArchive: true }, + { headers: { 'x-access-token': 'tok' } } + ); + }); + + test('surfaces the server message when an action is refused', async () => { + axios.post.mockRejectedValueOnce({ response: { status: 409, data: { error: 'The downloads folder /d is empty.', reason: 'library-unavailable' } } }); + const { result } = renderHook(() => useRemovedVideos('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let outcome: unknown = 'unset'; + await act(async () => { + outcome = await result.current.emptyTrash({ states: ['missing'], removeFromArchive: false }); + }); + + expect(outcome).toBeNull(); + expect(result.current.actionError).toBe('The downloads folder /d is empty.'); + expect(result.current.actionPending).toBe(false); + }); + + test('reloads the summary after a failed purge, which may have forgotten some videos', async () => { + axios.post.mockRejectedValueOnce({ response: { status: 500, data: { error: 'Failed to empty the trash' } } }); + const { result } = renderHook(() => useRemovedVideos('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + axios.get.mockResolvedValueOnce({ data: { ...SUMMARY, deleted: 2 } }); + + await act(async () => { + await result.current.emptyTrash({ states: ['deleted'], removeFromArchive: false }); + }); + + expect(result.current.summary?.deleted).toBe(2); + }); +}); diff --git a/client/src/components/Settings/hooks/useRemovedVideos.ts b/client/src/components/Settings/hooks/useRemovedVideos.ts new file mode 100644 index 000000000..72f7f792b --- /dev/null +++ b/client/src/components/Settings/hooks/useRemovedVideos.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from 'react'; +import axios from 'axios'; + +export interface RemovedVideosLibraryCheck { + ok: boolean; + reason: string | null; + path: string | null; + message: string | null; +} + +export interface RemovedVideosSummary { + deleted: number; + missing: number; + // Deleted videos removed before Youtarr recorded why (a subset of deleted). + legacy: number; + libraryCheck: RemovedVideosLibraryCheck; +} + +export type TrashState = 'deleted' | 'missing'; + +export interface EmptyTrashOptions { + states: TrashState[]; + removeFromArchive: boolean; +} + +export interface EmptyTrashResult { + purged: number; + archiveEntriesRemoved: number; + jobsAffected: number; +} + +export interface UseRemovedVideosReturn { + summary: RemovedVideosSummary | null; + loading: boolean; + error: string | null; + refetch: () => Promise<void>; + // Both resolve null on failure, with the server's message in actionError. + markAllMissingDeleted: () => Promise<{ updated: number } | null>; + emptyTrash: (options: EmptyTrashOptions) => Promise<EmptyTrashResult | null>; + actionPending: boolean; + actionError: string | null; +} + +const LOAD_ERROR = 'Failed to load the deleted and missing video counts'; + +function serverMessage(err: unknown, fallback: string): string { + const message = (err as { response?: { data?: { error?: unknown } } })?.response?.data?.error; + return typeof message === 'string' ? message : fallback; +} + +export function useRemovedVideos(token: string | null): UseRemovedVideosReturn { + const [summary, setSummary] = useState<RemovedVideosSummary | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [actionPending, setActionPending] = useState(false); + const [actionError, setActionError] = useState<string | null>(null); + + const headers = useCallback(() => ({ headers: { 'x-access-token': token ?? '' } }), [token]); + + const refetch = useCallback(async () => { + if (!token) return; + setLoading(true); + try { + const response = await axios.get<RemovedVideosSummary>('/api/videos/removed-summary', headers()); + setSummary(response.data); + setError(null); + } catch { + setError(LOAD_ERROR); + } finally { + setLoading(false); + } + }, [token, headers]); + + useEffect(() => { + void refetch(); + }, [refetch]); + + const runAction = useCallback(async <T,>(request: () => Promise<{ data: T }>, fallback: string): Promise<T | null> => { + setActionPending(true); + setActionError(null); + try { + const response = await request(); + return response.data; + } catch (err: unknown) { + setActionError(serverMessage(err, fallback)); + return null; + } finally { + // Also after a failure: a purge that failed partway still forgot the + // videos it reached, and a refusal can mean the library check changed. + await refetch(); + setActionPending(false); + } + }, [refetch]); + + const markAllMissingDeleted = useCallback( + () => runAction( + () => axios.post<{ updated: number }>('/api/videos/mark-deleted', { all: true }, headers()), + 'Failed to mark videos as deleted' + ), + [runAction, headers] + ); + + const emptyTrash = useCallback( + (options: EmptyTrashOptions) => runAction( + () => axios.post<EmptyTrashResult>('/api/videos/purge-removed', options, headers()), + 'Failed to empty the trash' + ), + [runAction, headers] + ); + + return { summary, loading, error, refetch, markAllMissingDeleted, emptyTrash, actionPending, actionError }; +} diff --git a/client/src/components/VideosPage/components/MarkDeletedDialog.tsx b/client/src/components/VideosPage/components/MarkDeletedDialog.tsx new file mode 100644 index 000000000..b27b7bd8a --- /dev/null +++ b/client/src/components/VideosPage/components/MarkDeletedDialog.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, +} from '../../ui'; +import { CloudOff as CloudOffIcon } from '../../../lib/icons'; + +interface MarkDeletedDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + // Missing videos in the selection: the ones that will be marked. + count: number; + // Selected videos that are not missing, left unchanged. + skippedCount: number; + pending: boolean; +} + +const videos = (n: number) => (n === 1 ? 'video' : 'videos'); + +// Confirms turning missing videos into deleted ones. The record is kept; only +// how the Videos page treats it changes. +const MarkDeletedDialog: React.FC<MarkDeletedDialogProps> = ({ + open, + onClose, + onConfirm, + count, + skippedCount, + pending, +}) => ( + <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> + <DialogTitle> + <CloudOffIcon size={20} className="shrink-0" /> + Mark as deleted + </DialogTitle> + + <DialogContent> + <div className="space-y-3"> + <Typography component="p" variant="body2"> + {count} missing {videos(count)} will be marked as deleted and hidden by default. + </Typography> + {skippedCount > 0 && ( + <Typography component="p" variant="body2" color="text.secondary"> + {skippedCount} selected {videos(skippedCount)} {skippedCount === 1 ? 'is' : 'are'} not missing and will not change. + </Typography> + )} + <Typography component="p" variant="body2" color="text.secondary"> + The record is kept, so the video can still be re-downloaded; if its file turns up again, the rescan marks it downloaded. + </Typography> + </div> + </DialogContent> + + <DialogActions> + <Button onClick={onClose} variant="contained" color="primary" autoFocus> + Cancel + </Button> + <Button onClick={onConfirm} variant="outlined" color="warning" disabled={pending || count === 0}> + Mark {count} as deleted + </Button> + </DialogActions> + </Dialog> +); + +export default MarkDeletedDialog; diff --git a/client/src/components/VideosPage/components/VideoCard.tsx b/client/src/components/VideosPage/components/VideoCard.tsx index 2b286cf22..1754c70e2 100644 --- a/client/src/components/VideosPage/components/VideoCard.tsx +++ b/client/src/components/VideosPage/components/VideoCard.tsx @@ -20,6 +20,7 @@ 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 ChannelNameDisplay from './ChannelNameDisplay'; import WatchedChip from '../../shared/WatchedChip'; @@ -151,20 +152,32 @@ function VideoCard({ left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(244, 67, 54, 0.3)', + backgroundColor: getRemovalState(video.removed, video.removed_reason) === 'missing' + ? 'rgba(244, 67, 54, 0.3)' + : DELETED_OVERLAY_BACKGROUND, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1, }} > - <ErrorOutlineIcon - className="text-destructive" - style={{ - fontSize: '3rem', - filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))', - }} - /> + {getRemovalState(video.removed, video.removed_reason) === 'missing' ? ( + <ErrorOutlineIcon + className="text-destructive" + style={{ + fontSize: '3rem', + filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))', + }} + /> + ) : ( + <DeleteIcon + className="text-muted-foreground" + style={{ + fontSize: '3rem', + filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))', + }} + /> + )} </Box> )} @@ -305,9 +318,9 @@ function VideoCard({ size="small" /> {video.removed ? ( - <AvailabilityChip isAvailable={false} /> + <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={describeRow(video)} /> ) : video.fileSize ? ( - <AvailabilityChip isAvailable={true} /> + <AvailabilityChip state="present" /> ) : null} <WatchedChip watchedBy={video.watchedBy || []} /> </Box> diff --git a/client/src/components/VideosPage/components/VideosListMobile.tsx b/client/src/components/VideosPage/components/VideosListMobile.tsx index a90c1dc0e..33307efa1 100644 --- a/client/src/components/VideosPage/components/VideosListMobile.tsx +++ b/client/src/components/VideosPage/components/VideosListMobile.tsx @@ -1,7 +1,8 @@ import React from 'react'; import VideoActivityChip from '../../shared/VideoActivityChip'; import { Box, Typography, Chip, Checkbox, Stack } from '../../ui'; -import { AlertCircle as ErrorOutlineIcon } from 'lucide-react'; +import { AlertCircle as ErrorOutlineIcon, Trash2 as DeletedIcon } from 'lucide-react'; +import { DELETED_OVERLAY_BACKGROUND, describeRow, getRemovalState } from '../../../utils/videoRemoval'; import { formatDuration, formatYTDate } from '../../../utils'; import { formatAddedDateTime, formatFileSize } from '../../../utils/formatters'; import { getMediaTypeInfo } from '../../../utils/videoStatus'; @@ -169,7 +170,9 @@ function VideosListMobile({ style={{ position: 'absolute', inset: 0, - backgroundColor: 'rgba(244, 67, 54, 0.3)', + backgroundColor: getRemovalState(video.removed, video.removed_reason) === 'missing' + ? 'rgba(244, 67, 54, 0.3)' + : DELETED_OVERLAY_BACKGROUND, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -177,10 +180,17 @@ function VideosListMobile({ pointerEvents: 'none', }} > - <ErrorOutlineIcon - className="text-destructive" - style={{ fontSize: '1.5rem' }} - /> + {getRemovalState(video.removed, video.removed_reason) === 'missing' ? ( + <ErrorOutlineIcon + className="text-destructive" + style={{ fontSize: '1.5rem' }} + /> + ) : ( + <DeletedIcon + className="text-muted-foreground" + style={{ fontSize: '1.5rem' }} + /> + )} </Box> )} {video.duration ? ( @@ -300,9 +310,9 @@ function VideosListMobile({ style={compactRatingChipStyle} /> {video.removed ? ( - <AvailabilityChip isAvailable={false} compact /> + <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={describeRow(video)} compact /> ) : video.fileSize ? ( - <AvailabilityChip isAvailable={true} compact /> + <AvailabilityChip state="present" compact /> ) : null} <WatchedChip watchedBy={video.watchedBy || []} compact /> </Stack> diff --git a/client/src/components/VideosPage/components/VideosTable.tsx b/client/src/components/VideosPage/components/VideosTable.tsx index 082a8880f..9f2d0eed9 100644 --- a/client/src/components/VideosPage/components/VideosTable.tsx +++ b/client/src/components/VideosPage/components/VideosTable.tsx @@ -19,6 +19,7 @@ import { } from '../../ui'; import { AlertCircle as ErrorOutlineIcon, + Trash2 as DeletedIcon, Trash2 as DeleteIcon, } from 'lucide-react'; import { formatDuration, formatYTDate } from '../../../utils'; @@ -32,6 +33,7 @@ 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 ChannelNameDisplay from './ChannelNameDisplay'; import WatchedChip from '../../shared/WatchedChip'; @@ -219,7 +221,9 @@ function VideosTable({ style={{ position: 'absolute', inset: 0, - backgroundColor: 'rgba(244, 67, 54, 0.3)', + backgroundColor: getRemovalState(video.removed, video.removed_reason) === 'missing' + ? 'rgba(244, 67, 54, 0.3)' + : DELETED_OVERLAY_BACKGROUND, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -227,10 +231,17 @@ function VideosTable({ pointerEvents: 'none', }} > - <ErrorOutlineIcon - className="text-destructive" - style={{ fontSize: '1.5rem' }} - /> + {getRemovalState(video.removed, video.removed_reason) === 'missing' ? ( + <ErrorOutlineIcon + className="text-destructive" + style={{ fontSize: '1.5rem' }} + /> + ) : ( + <DeletedIcon + className="text-muted-foreground" + style={{ fontSize: '1.5rem' }} + /> + )} </Box> )} </Box> @@ -312,9 +323,9 @@ function VideosTable({ /> )} {video.removed ? ( - <AvailabilityChip isAvailable={false} /> + <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={describeRow(video)} /> ) : video.fileSize ? ( - <AvailabilityChip isAvailable={true} /> + <AvailabilityChip state="present" /> ) : null} <WatchedChip watchedBy={video.watchedBy || []} /> </Stack> diff --git a/client/src/components/VideosPage/components/__tests__/MarkDeletedDialog.test.tsx b/client/src/components/VideosPage/components/__tests__/MarkDeletedDialog.test.tsx new file mode 100644 index 000000000..c9c0fa45a --- /dev/null +++ b/client/src/components/VideosPage/components/__tests__/MarkDeletedDialog.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import MarkDeletedDialog from '../MarkDeletedDialog'; +import { renderWithProviders } from '../../../../test-utils'; + +const renderDialog = (overrides: Partial<React.ComponentProps<typeof MarkDeletedDialog>> = {}) => { + const onClose = jest.fn(); + const onConfirm = jest.fn(); + renderWithProviders( + <MarkDeletedDialog + open + onClose={onClose} + onConfirm={onConfirm} + count={2} + skippedCount={0} + pending={false} + {...overrides} + /> + ); + return { onClose, onConfirm }; +}; + +describe('MarkDeletedDialog', () => { + test('explains what marking does, in plural', () => { + renderDialog({ count: 2, skippedCount: 1 }); + expect(screen.getByText('Mark as deleted')).toBeInTheDocument(); + expect(screen.getByText('2 missing videos will be marked as deleted and hidden by default.')).toBeInTheDocument(); + expect(screen.getByText('1 selected video is not missing and will not change.')).toBeInTheDocument(); + expect(screen.getByText('The record is kept, so the video can still be re-downloaded; if its file turns up again, the rescan marks it downloaded.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mark 2 as deleted' })).toBeEnabled(); + }); + + test('explains what marking does, in singular, without a skipped note when nothing is skipped', () => { + renderDialog({ count: 1, skippedCount: 0 }); + expect(screen.getByText('1 missing video will be marked as deleted and hidden by default.')).toBeInTheDocument(); + expect(screen.queryByText(/will not change/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mark 1 as deleted' })).toBeInTheDocument(); + }); + + test('pluralizes the skipped note', () => { + renderDialog({ count: 1, skippedCount: 3 }); + expect(screen.getByText('3 selected videos are not missing and will not change.')).toBeInTheDocument(); + }); + + test('confirms and cancels', async () => { + const user = userEvent.setup(); + const { onClose, onConfirm } = renderDialog(); + await user.click(screen.getByRole('button', { name: 'Mark 2 as deleted' })); + expect(onConfirm).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('disables the confirm button while pending', () => { + renderDialog({ pending: true }); + expect(screen.getByRole('button', { name: 'Mark 2 as deleted' })).toBeDisabled(); + }); + + test('renders nothing when closed', () => { + renderDialog({ open: false }); + expect(screen.queryByText('Mark as deleted')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/VideosPage/components/__tests__/VideoCard.test.tsx b/client/src/components/VideosPage/components/__tests__/VideoCard.test.tsx index 4c15187bc..b30dde908 100644 --- a/client/src/components/VideosPage/components/__tests__/VideoCard.test.tsx +++ b/client/src/components/VideosPage/components/__tests__/VideoCard.test.tsx @@ -138,10 +138,17 @@ describe('VideoCard', () => { ); expect(screen.getByText('Available')).toBeInTheDocument(); unmount(); - renderCard({ video: { ...baseVideo, removed: true } }); + renderCard({ video: { ...baseVideo, removed: true, removed_reason: 'not_found' } }); expect(screen.getByText('Missing')).toBeInTheDocument(); }); + test('shows a neutral Deleted chip for a video Youtarr removed', () => { + renderCard({ video: { ...baseVideo, removed: true, removed_reason: 'auto_removal', removed_detail: 'older than 30 days' } }); + expect(screen.getByText('Deleted')).toBeInTheDocument(); + expect(screen.queryByText('Missing')).not.toBeInTheDocument(); + expect(screen.queryByText('Available')).not.toBeInTheDocument(); + }); + test('delete button calls onDeleteSingle with the video id', () => { const { onDeleteSingle } = renderCard(); fireEvent.click(screen.getByRole('button', { name: 'Delete video from disk' })); diff --git a/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts b/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts new file mode 100644 index 000000000..ce64ee676 --- /dev/null +++ b/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts @@ -0,0 +1,60 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; + +jest.mock('axios', () => ({ + post: jest.fn(), + isAxiosError: jest.fn((err: unknown) => Boolean((err as { isAxiosError?: boolean })?.isAxiosError)), +})); + +const axios = require('axios'); + +const { useMarkDeleted } = require('../useMarkDeleted'); + +describe('useMarkDeleted', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('posts the selected ids with the token and returns how many were marked', async () => { + axios.post.mockResolvedValueOnce({ data: { updated: 2 } }); + const { result } = renderHook(() => useMarkDeleted('tok')); + + let outcome: { updated: number } | null = null; + await act(async () => { + outcome = await result.current.markDeleted([4, 9]); + }); + + expect(outcome).toEqual({ updated: 2 }); + expect(axios.post).toHaveBeenCalledWith( + '/api/videos/mark-deleted', + { videoIds: [4, 9] }, + { headers: { 'x-access-token': 'tok' } } + ); + expect(result.current.error).toBeNull(); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + test('exposes the server error message and returns null when the request fails', async () => { + axios.post.mockRejectedValueOnce({ isAxiosError: true, response: { data: { error: 'The downloads folder /d is empty.' } } }); + const { result } = renderHook(() => useMarkDeleted('tok')); + + let outcome: { updated: number } | null = { updated: -1 }; + await act(async () => { + outcome = await result.current.markDeleted([4]); + }); + + expect(outcome).toBeNull(); + expect(result.current.error).toBe('The downloads folder /d is empty.'); + expect(result.current.loading).toBe(false); + }); + + test('falls back to a generic message when the failure carries none', async () => { + axios.post.mockRejectedValueOnce(new Error('network')); + const { result } = renderHook(() => useMarkDeleted('tok')); + + await act(async () => { + await result.current.markDeleted([4]); + }); + + expect(result.current.error).toBe('Failed to mark videos as deleted'); + }); +}); diff --git a/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts b/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts index 4698b2000..3f001c293 100644 --- a/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts +++ b/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts @@ -21,11 +21,14 @@ const baseParams = { maxRatingFilter: '', protectedFilter: 'off' as const, missingFilter: 'off' as const, + deletedFilter: 'off' as const, watchedFilter: 'off' as const, useInfiniteScroll: false, }; -const buildResponse = (videos: { id: number; youtubeId: string }[]) => ({ +const buildResponse = (videos: { id: number; youtubeId: string }[]): { + data: Record<string, unknown> & { hiddenDeletedCount?: number | null; libraryWarning?: Record<string, unknown> | null }; +} => ({ data: { videos: videos.map((v) => ({ ...v, @@ -72,12 +75,14 @@ describe('useVideosData', () => { maxRatingFilter: 'TV-MA', protectedFilter: 'only', missingFilter: 'exclude', + deletedFilter: 'exclude', watchedFilter: 'exclude', }) ); await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); const url = axios.get.mock.calls[0][0] as string; + expect(url).toContain('deletedFilter=exclude'); expect(url).toContain('page=2'); expect(url).toContain('limit=12'); expect(url).toContain('sortBy=published'); @@ -98,10 +103,34 @@ describe('useVideosData', () => { await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); const url = axios.get.mock.calls[0][0] as string; expect(url).not.toContain('missingFilter'); + expect(url).not.toContain('deletedFilter'); expect(url).not.toContain('protectedFilter'); expect(url).not.toContain('watchedFilter'); }); + test('exposes the hidden deleted count and the library warning from the response', async () => { + const response = buildResponse([{ id: 1, youtubeId: 'a' }]); + response.data.hiddenDeletedCount = 4; + response.data.libraryWarning = { reason: 'empty', path: '/d', message: 'The downloads folder /d is empty.', heldBack: 2 }; + axios.get.mockResolvedValueOnce(response); + + const { result } = renderHook(() => useVideosData(baseParams)); + + await waitFor(() => expect(result.current.hiddenDeletedCount).toBe(4)); + expect(result.current.libraryWarning).toEqual({ reason: 'empty', path: '/d', message: 'The downloads folder /d is empty.', heldBack: 2 }); + }); + + test('reports no hidden deleted count and no warning when the response has none', async () => { + axios.get.mockResolvedValueOnce(buildResponse([{ id: 1, youtubeId: 'a' }])); + + const { result } = renderHook(() => useVideosData(baseParams)); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hiddenDeletedCount).toBeNull(); + expect(result.current.libraryWarning).toBeNull(); + }); + test('replaces videos when not using infinite scroll', async () => { axios.get.mockResolvedValueOnce(buildResponse([{ id: 1, youtubeId: 'a' }])); diff --git a/client/src/components/VideosPage/hooks/useMarkDeleted.ts b/client/src/components/VideosPage/hooks/useMarkDeleted.ts new file mode 100644 index 000000000..1b7c96f26 --- /dev/null +++ b/client/src/components/VideosPage/hooks/useMarkDeleted.ts @@ -0,0 +1,42 @@ +import { useCallback, useState } from 'react'; +import axios from 'axios'; + +export interface MarkDeletedResult { + updated: number; +} + +export interface UseMarkDeletedReturn { + // Marks the missing videos among `videoIds` as deleted; resolves null on failure (see `error`). + markDeleted: (videoIds: number[]) => Promise<MarkDeletedResult | null>; + loading: boolean; + error: string | null; +} + +const FALLBACK_ERROR = 'Failed to mark videos as deleted'; + +export function useMarkDeleted(token: string | null): UseMarkDeletedReturn { + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + const markDeleted = useCallback(async (videoIds: number[]): Promise<MarkDeletedResult | null> => { + setLoading(true); + setError(null); + try { + const response = await axios.post<MarkDeletedResult>( + '/api/videos/mark-deleted', + { videoIds }, + { headers: { 'x-access-token': token ?? '' } } + ); + return response.data; + } catch (err: unknown) { + // The server answers 409/500 with { error }; anything else gets the fallback. + const serverMessage = (err as { response?: { data?: { error?: unknown } } })?.response?.data?.error; + setError(typeof serverMessage === 'string' ? serverMessage : FALLBACK_ERROR); + return null; + } finally { + setLoading(false); + } + }, [token]); + + return { markDeleted, loading, error }; +} diff --git a/client/src/components/VideosPage/hooks/useVideosData.ts b/client/src/components/VideosPage/hooks/useVideosData.ts index f27301bd8..e229347d9 100644 --- a/client/src/components/VideosPage/hooks/useVideosData.ts +++ b/client/src/components/VideosPage/hooks/useVideosData.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import axios from 'axios'; -import { EnabledChannel, PaginatedVideosResponse, VideoData } from '../../../types/VideoData'; +import { EnabledChannel, LibraryWarning, PaginatedVideosResponse, VideoData } from '../../../types/VideoData'; import { ChipFilterMode } from '../../shared/VideoList/types'; export interface UseVideosDataParams { @@ -16,6 +16,7 @@ export interface UseVideosDataParams { maxRatingFilter: string; protectedFilter: ChipFilterMode; missingFilter: ChipFilterMode; + deletedFilter: ChipFilterMode; watchedFilter: ChipFilterMode; useInfiniteScroll: boolean; } @@ -27,6 +28,9 @@ export interface UseVideosDataResult { totalPages: number; uniqueChannels: string[]; enabledChannels: EnabledChannel[]; + // How many deleted videos the current filters hide; null unless deleted are excluded. + hiddenDeletedCount: number | null; + libraryWarning: LibraryWarning | null; loading: boolean; loadError: string | null; refetch: () => Promise<void>; @@ -55,6 +59,7 @@ export function useVideosData({ maxRatingFilter, protectedFilter, missingFilter, + deletedFilter, watchedFilter, useInfiniteScroll, }: UseVideosDataParams): UseVideosDataResult { @@ -63,6 +68,8 @@ export function useVideosData({ const [totalPages, setTotalPages] = useState(1); const [uniqueChannels, setUniqueChannels] = useState<string[]>([]); const [enabledChannels, setEnabledChannels] = useState<EnabledChannel[]>([]); + const [hiddenDeletedCount, setHiddenDeletedCount] = useState<number | null>(null); + const [libraryWarning, setLibraryWarning] = useState<LibraryWarning | null>(null); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState<string | null>(null); const latestRequestId = useRef(0); @@ -86,6 +93,7 @@ export function useVideosData({ if (maxRatingFilter) params.append('maxRating', maxRatingFilter); if (protectedFilter !== 'off') params.append('protectedFilter', protectedFilter); if (missingFilter !== 'off') params.append('missingFilter', missingFilter); + if (deletedFilter !== 'off') params.append('deletedFilter', deletedFilter); if (watchedFilter !== 'off') params.append('watchedFilter', watchedFilter); try { @@ -113,6 +121,8 @@ export function useVideosData({ setTotalPages(response.data.totalPages); setUniqueChannels(response.data.channels || []); setEnabledChannels(response.data.enabledChannels || []); + setHiddenDeletedCount(response.data.hiddenDeletedCount ?? null); + setLibraryWarning(response.data.libraryWarning ?? null); } catch (error) { if (requestId !== latestRequestId.current) { return; @@ -139,6 +149,7 @@ export function useVideosData({ maxRatingFilter, protectedFilter, missingFilter, + deletedFilter, watchedFilter, useInfiniteScroll, ]); @@ -154,6 +165,8 @@ export function useVideosData({ totalPages, uniqueChannels, enabledChannels, + hiddenDeletedCount, + libraryWarning, loading, loadError, refetch: fetchVideos, diff --git a/client/src/components/VideosPage/index.tsx b/client/src/components/VideosPage/index.tsx index 9557fae25..fc1cfd194 100644 --- a/client/src/components/VideosPage/index.tsx +++ b/client/src/components/VideosPage/index.tsx @@ -3,8 +3,8 @@ import { useVideoActivity } from '../../providers/VideoActivityProvider'; import axios from 'axios'; import { useNavigate } from 'react-router-dom'; import { useSwipeable } from 'react-swipeable'; -import { Alert, Box, Grid, Snackbar, Typography } from '../ui'; -import { Trash2 as DeleteIcon, Star as RatingIcon, Download as DownloadIcon } from '../../lib/icons'; +import { Alert, Box, Button, Grid, Snackbar, Typography } from '../ui'; +import { Trash2 as DeleteIcon, Star as RatingIcon, Download as DownloadIcon, CloudOff as CloudOffIcon } from '../../lib/icons'; import { useMediaQuery } from '../../hooks/useMediaQuery'; import { useConfig } from '../../hooks/useConfig'; import { useDownloadListingsRefresh } from '../../hooks/useDownloadListingsRefresh'; @@ -17,6 +17,9 @@ import ChangeRatingDialog from '../shared/ChangeRatingDialog'; import { useVideoProtection } from '../shared/useVideoProtection'; 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 { useMarkDeleted } from './hooks/useMarkDeleted'; import DownloadSettingsDialog from '../DownloadManager/ManualDownload/DownloadSettingsDialog'; import { DownloadSettings } from '../DownloadManager/ManualDownload/types'; import VideoCard from './components/VideoCard'; @@ -28,6 +31,7 @@ import { VideoListContainer, VideoListPaginationBar, useListPageSize, + usePersistedChipFilter, useVideoListState, useVideoSelection, type ChipFilterMode, @@ -43,6 +47,8 @@ interface VideosPageProps { } const VIEW_MODE_STORAGE_KEY = 'youtarr:videosPageViewMode'; +// Deleted videos are hidden by default; showing them is remembered in this browser. +const DELETED_FILTER_STORAGE_KEY = 'youtarr.videosPage.deletedFilter'; const YOUTUBE_CHANNEL_ID_PATTERN = /^UC[a-zA-Z0-9_-]{22}$/; @@ -50,6 +56,7 @@ interface VideoSelectionMeta { youtubeId: string; channelId: string | null; removed: boolean; + removalState: RemovalState; youtubeRemoved: boolean; } @@ -63,7 +70,8 @@ function videoDataToModalData(video: VideoData): VideoModalData { publishedAt: video.originalDate || null, addedAt: video.timeCreated || null, mediaType: video.media_type || 'video', - status: video.removed ? 'missing' : 'downloaded', + status: video.removed ? removedState(video.removed_reason) : 'downloaded', + removal: removalFromRow(video), isDownloaded: !video.removed, filePath: video.filePath || null, fileSize: video.fileSize ? Number(video.fileSize) : null, @@ -95,10 +103,12 @@ function VideosPage({ token }: VideosPageProps) { const [maxRatingFilter, setMaxRatingFilter] = useState(''); const [protectedFilter, setProtectedFilter] = useState<ChipFilterMode>('off'); const [missingFilter, setMissingFilter] = useState<ChipFilterMode>('off'); + const [deletedFilter, setDeletedFilter] = usePersistedChipFilter(DELETED_FILTER_STORAGE_KEY, 'exclude'); const [watchedFilter, setWatchedFilter] = useState<ChipFilterMode>('off'); const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({}); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [markDeletedDialogOpen, setMarkDeletedDialogOpen] = useState(false); const [ratingDialogOpen, setRatingDialogOpen] = useState(false); const [successMessage, setSuccessMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null); @@ -117,6 +127,7 @@ function VideosPage({ token }: VideosPageProps) { const loadMoreRef = useRef<HTMLDivElement | null>(null); const { deleteVideos, loading: deleteLoading } = useVideoDeletion(); + const { markDeleted, loading: markDeletedLoading, error: markDeletedError } = useMarkDeleted(token); const configState = useConfig(token); const useInfiniteScroll = configState?.config?.channelVideosHotLoad ?? false; const { @@ -141,6 +152,8 @@ function VideosPage({ token }: VideosPageProps) { totalPages, uniqueChannels, enabledChannels, + hiddenDeletedCount, + libraryWarning, loading, loadError, refetch, @@ -157,6 +170,7 @@ function VideosPage({ token }: VideosPageProps) { maxRatingFilter, protectedFilter, missingFilter, + deletedFilter, watchedFilter, useInfiniteScroll, }); @@ -169,6 +183,7 @@ function VideosPage({ token }: VideosPageProps) { youtubeId: video.youtubeId, channelId: video.channel_id || null, removed: Boolean(video.removed), + removalState: getRemovalState(video.removed, video.removed_reason), youtubeRemoved: Boolean(video.youtube_removed), }); } @@ -247,6 +262,34 @@ function VideosPage({ token }: VideosPageProps) { } }; + // Mark as deleted applies to the missing videos in the selection only. + const missingSelectedIds = (selectedIds: number[]) => + selectedIds.filter((id) => videoMetaRef.current.get(id)?.removalState === 'missing'); + + const handleMarkDeletedConfirm = async (selectedIds: number[]) => { + const ids = missingSelectedIds(selectedIds); + if (ids.length === 0) { + setMarkDeletedDialogOpen(false); + return; + } + const result = await markDeleted(ids); + setMarkDeletedDialogOpen(false); + if (!result) { + setErrorMessage(markDeletedError || 'Failed to mark videos as deleted'); + return; + } + setSuccessMessage(`Marked ${result.updated} video${result.updated !== 1 ? 's' : ''} as deleted`); + selection.clear(); + // Refetching a later infinite-scroll page would retain stale earlier rows. + // Changing page triggers the hook's fetch; page one needs an explicit fetch. + setVideos([]); + if (page === 1) { + void refetch(); + } else { + setPage(1); + } + }; + const handleApplyRating = async (rating: string | null, selectedIds: number[]) => { if (!token) return; try { @@ -303,8 +346,18 @@ function VideosPage({ token }: VideosPageProps) { }), onClick: () => setDeleteDialogOpen(true), }, + { + id: 'mark-deleted', + label: 'Mark as deleted', + icon: <CloudOffIcon size={14} />, + intent: 'warning', + disabled: (ids) => + markDeletedLoading || + !ids.some((id) => videoMetaRef.current.get(id)?.removalState === 'missing'), + onClick: () => setMarkDeletedDialogOpen(true), + }, ], - [deleteLoading, snapshot] + [deleteLoading, markDeletedLoading, snapshot] ); const selection = useVideoSelection<number>({ actions: selectionActions }); @@ -322,6 +375,7 @@ function VideosPage({ token }: VideosPageProps) { maxRatingFilter, protectedFilter, missingFilter, + deletedFilter, watchedFilter, selection.clear, ]); @@ -375,6 +429,13 @@ function VideosPage({ token }: VideosPageProps) { }; const deleteCounts = getDeleteCounts(); + const markDeletedCounts = markDeletedDialogOpen + ? { + eligible: missingSelectedIds(selection.selectedIds).length, + skipped: selection.selectedIds.length - missingSelectedIds(selection.selectedIds).length, + } + : { eligible: 0, skipped: 0 }; + const handleDownloadConfirm = async (settings: DownloadSettings | null) => { setDownloadDialogOpen(false); const eligible = selection.selectedIds @@ -458,6 +519,7 @@ function VideosPage({ token }: VideosPageProps) { { id: 'maxRating', value: maxRatingFilter, onChange: withPageReset(setMaxRatingFilter) }, { id: 'protected', value: protectedFilter, onChange: withPageReset(setProtectedFilter) }, { id: 'missing', value: missingFilter, onChange: withPageReset(setMissingFilter) }, + { id: 'deleted', value: deletedFilter, onChange: withPageReset(setDeletedFilter), defaultValue: 'exclude' }, { id: 'watched', value: watchedFilter, onChange: withPageReset(setWatchedFilter) }, { id: 'channel', @@ -467,7 +529,7 @@ function VideosPage({ token }: VideosPageProps) { }, ]; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dateFrom, dateTo, maxRatingFilter, protectedFilter, missingFilter, watchedFilter, channelFilter, uniqueChannels]); + }, [dateFrom, dateTo, maxRatingFilter, protectedFilter, missingFilter, deletedFilter, watchedFilter, channelFilter, uniqueChannels]); const sortConfig: SortConfig = useMemo( () => ({ @@ -485,11 +547,28 @@ function VideosPage({ token }: VideosPageProps) { [orderBy, sortOrder] ); + const showDeleted = () => { + setDeletedFilter('off'); + setPage(1); + }; + 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> + </Typography> + )} + {libraryWarning && ( + <Alert severity="warning" style={{ marginTop: 8 }}> + {libraryWarning.message} Videos are not being marked missing until it is back. If your downloads + live on a drive or network share, check that it is mounted. + </Alert> + )} </div> ); @@ -640,6 +719,15 @@ function VideosPage({ token }: VideosPageProps) { skippedCount={deleteCounts.skipped} /> + <MarkDeletedDialog + open={markDeletedDialogOpen} + onClose={() => setMarkDeletedDialogOpen(false)} + onConfirm={() => handleMarkDeletedConfirm(selection.selectedIds)} + count={markDeletedCounts.eligible} + skippedCount={markDeletedCounts.skipped} + pending={markDeletedLoading} + /> + <ChangeRatingDialog open={ratingDialogOpen} onClose={() => setRatingDialogOpen(false)} diff --git a/client/src/components/__tests__/VideosPage.test.tsx b/client/src/components/__tests__/VideosPage.test.tsx index fd068bd2f..9c12358ae 100644 --- a/client/src/components/__tests__/VideosPage.test.tsx +++ b/client/src/components/__tests__/VideosPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { MemoryRouter, useLocation } from 'react-router-dom'; @@ -6,7 +6,9 @@ import VideosPage from '../VideosPage'; import { VideoData } from '../../types/VideoData'; jest.mock('axios', () => ({ - get: jest.fn() + get: jest.fn(), + post: jest.fn(), + isAxiosError: jest.fn(() => false), })); const axios = require('axios'); @@ -221,7 +223,8 @@ jest.mock('../shared/VideoList', () => { 'button', { type: 'button', - disabled: (selection.actions || []).some((a: any) => + // Like the real pill: only an all-disabled menu is unreachable. + disabled: (selection.actions || []).length > 0 && (selection.actions || []).every((a: any) => a.disabled ? a.disabled(selection.selectedIds) : false ), onClick: () => setMenuOpen((v: boolean) => !v), @@ -331,6 +334,7 @@ jest.mock('../shared/VideoList', () => { sortControls, selectionBar, content, + props.infiniteScrollSentinel, props.pagination ); }, @@ -454,6 +458,56 @@ describe('VideosPage Component', () => { }); }); + describe('deleted videos', () => { + test('hides them by default and offers to show them, saying how many are hidden', async () => { + axios.get.mockResolvedValueOnce({ data: { ...mockPaginatedResponse(mockVideos), hiddenDeletedCount: 3 } }); + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); + + render(<VideosPage token={mockToken} />); + + expect(await screen.findByText('3 deleted videos hidden.')).toBeInTheDocument(); + expect(axios.get.mock.calls[0][0]).toContain('deletedFilter=exclude'); + + await setupUser().click(screen.getByRole('button', { name: 'Show' })); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + expect(axios.get.mock.calls[1][0]).not.toContain('deletedFilter'); + expect(window.localStorage.getItem('youtarr.videosPage.deletedFilter')).toBe('off'); + }); + + test('shows no hidden note when nothing is hidden', async () => { + axios.get.mockResolvedValueOnce({ data: { ...mockPaginatedResponse(mockVideos), hiddenDeletedCount: 0 } }); + + render(<VideosPage token={mockToken} />); + + await waitFor(() => expect(axios.get).toHaveBeenCalled()); + expect(screen.queryByText(/deleted videos? hidden/)).not.toBeInTheDocument(); + }); + + test('remembers that deleted videos were shown', async () => { + window.localStorage.setItem('youtarr.videosPage.deletedFilter', 'off'); + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); + + render(<VideosPage token={mockToken} />); + + await waitFor(() => expect(axios.get).toHaveBeenCalled()); + expect(axios.get.mock.calls[0][0]).not.toContain('deletedFilter'); + }); + }); + + test('warns when the downloads folder looks unmounted and videos were not marked missing', async () => { + axios.get.mockResolvedValueOnce({ + data: { + ...mockPaginatedResponse(mockVideos), + libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder /data/youtube is empty.', heldBack: 2 }, + }, + }); + + render(<VideosPage token={mockToken} />); + + expect(await screen.findByText(/The downloads folder \/data\/youtube is empty\. Videos are not being marked missing until it is back\./)).toBeInTheDocument(); + }); + test('fetches and displays videos when token is provided', async () => { axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -912,7 +966,8 @@ describe('VideosPage Component', () => { test('displays missing file status for removed videos', async () => { const removedVideo = { ...mockVideos[0], - removed: true + removed: true, + removed_reason: 'not_found' as const }; axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([removedVideo]) }); @@ -1247,6 +1302,107 @@ describe('VideosPage Component', () => { }); }); + test('marks the missing videos in a selection as deleted, skipping the rest, then refetches', async () => { + const user = setupUser(); + const missingVideo = { ...mockVideos[0], removed: true, removed_reason: 'not_found' as const }; + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([missingVideo, mockVideos[1]]) }); + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([mockVideos[1]]) }); + axios.post.mockResolvedValueOnce({ data: { updated: 1 } }); + + render(<VideosPage token={mockToken} />); + + await waitFor(() => { + expect(screen.getByText('How to Code')).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[1]); + await user.click(checkboxes[2]); + await waitFor(() => { + expect(screen.getByText(/2 videos selected/)).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Actions (2)')); + await user.click(screen.getByText('Mark as deleted Selected')); + + expect(await screen.findByText('1 missing video will be marked as deleted and hidden by default.')).toBeInTheDocument(); + expect(screen.getByText('1 selected video is not missing and will not change.')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Mark 1 as deleted' })); + + await waitFor(() => { + expect(axios.post).toHaveBeenCalledWith( + '/api/videos/mark-deleted', + { videoIds: [1] }, + { headers: { 'x-access-token': mockToken } } + ); + }); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + expect(await screen.findByText('Marked 1 video as deleted')).toBeInTheDocument(); + }); + + test('offers Mark as deleted only when the selection holds a missing video', async () => { + const user = setupUser(); + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); + + render(<VideosPage token={mockToken} />); + + await waitFor(() => { + expect(screen.getByText('How to Code')).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[1]); + await user.click(screen.getByText('Actions (1)')); + + expect(screen.getByText('Mark as deleted Selected')).toHaveAttribute('data-disabled', 'true'); + }); + + test('reloads page one after marking an earlier infinite-scroll video as deleted', async () => { + const { useConfig } = require('../../hooks/useConfig'); + const originalObserver = window.IntersectionObserver; + let intersect = () => {}; + window.IntersectionObserver = jest.fn().mockImplementation((callback: IntersectionObserverCallback) => { + const observer = { + observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn(), + takeRecords: () => [], root: null, rootMargin: '', thresholds: [0], + }; + intersect = () => callback([{ isIntersecting: true } as IntersectionObserverEntry], observer); + return observer; + }); + useConfig.mockReturnValue({ config: { channelVideosHotLoad: true } }); + useTableView(); + const user = setupUser(); + const missingVideo = { ...mockVideos[0], removed: true, removed_reason: 'not_found' as const }; + axios.get + .mockResolvedValueOnce({ data: { ...mockPaginatedResponse([missingVideo]), total: 2, totalPages: 2 } }) + .mockResolvedValueOnce({ data: { ...mockPaginatedResponse([mockVideos[1]]), page: 2, total: 2, totalPages: 2 } }) + .mockResolvedValueOnce({ data: mockPaginatedResponse([mockVideos[1]]) }); + axios.post.mockResolvedValueOnce({ data: { updated: 1 } }); + + const { unmount } = render(<VideosPage token={mockToken} />); + try { + await screen.findByText('How to Code'); + act(() => intersect()); + await screen.findByText('Game Review'); + expect(axios.get.mock.calls[1][0]).toContain('page=2'); + + await user.click(screen.getAllByRole('checkbox')[1]); + await user.click(screen.getByText('Actions (1)')); + await user.click(screen.getByText('Mark as deleted Selected')); + await user.click(await screen.findByRole('button', { name: 'Mark 1 as deleted' })); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(3)); + expect(axios.get.mock.calls[2][0]).toContain('page=1'); + await screen.findByText('Game Review'); + expect(screen.queryByText('How to Code')).not.toBeInTheDocument(); + } finally { + unmount(); + window.IntersectionObserver = originalObserver; + useConfig.mockReturnValue({ config: { channelVideosHotLoad: false } }); + } + }); + test('opens delete dialog when delete button is clicked', async () => { const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -1616,9 +1772,10 @@ describe('VideosPage Component', () => { const checkboxes = screen.getAllByRole('checkbox'); await user.click(checkboxes[1]); - // Actions button should be disabled - const actionsButton = screen.getByText('Actions (1)'); - expect(actionsButton).toBeDisabled(); + // The delete action should be disabled; the other actions stay available + await user.click(screen.getByText('Actions (1)')); + expect(screen.getByText('Delete Selected')).toHaveAttribute('data-disabled', 'true'); + expect(screen.getByText('Rating Selected')).toHaveAttribute('data-disabled', 'false'); }); }); @@ -1918,4 +2075,4 @@ describe('VideosPage Component', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/client/src/components/shared/AvailabilityChip.tsx b/client/src/components/shared/AvailabilityChip.tsx index cf34ae35a..78d3ae370 100644 --- a/client/src/components/shared/AvailabilityChip.tsx +++ b/client/src/components/shared/AvailabilityChip.tsx @@ -3,41 +3,82 @@ import { Chip, Tooltip } from '../ui'; import { AlertCircle as ErrorOutlineIcon, CheckCircle as CheckCircleIcon, + Trash2 as DeletedIcon, } from 'lucide-react'; import { SHARED_THEMED_CHIP_SMALL_STYLE, SHARED_COMPACT_CHIP_OVERRIDES } from './chipStyles'; +import type { RemovalState } from '../../utils/videoRemoval'; export interface AvailabilityChipProps { - isAvailable: boolean; + // 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; + // Replaces the state's label, e.g. a "2 missing" summary count. + label?: string; compact?: boolean; } -const baseAvailableStyle: React.CSSProperties = { - ...SHARED_THEMED_CHIP_SMALL_STYLE, - backgroundColor: 'var(--success)', - color: 'var(--success-foreground)', -}; +interface Variant { + label: string; + tooltip: string; + color: 'success' | 'error' | 'default'; + variant: 'filled' | 'outlined'; + icon: React.ReactNode; + style: React.CSSProperties; +} -const baseMissingStyle: React.CSSProperties = { - ...SHARED_THEMED_CHIP_SMALL_STYLE, - backgroundColor: 'var(--destructive)', - color: 'var(--destructive-foreground)', +const VARIANTS: Record<RemovalState, Variant> = { + present: { + label: 'Available', + tooltip: 'Video file exists on disk', + color: 'success', + variant: 'filled', + icon: <CheckCircleIcon size={12} />, + style: { + ...SHARED_THEMED_CHIP_SMALL_STYLE, + backgroundColor: 'var(--success)', + color: 'var(--success-foreground)', + }, + }, + missing: { + label: 'Missing', + tooltip: 'Video file not found on disk', + color: 'error', + variant: 'filled', + icon: <ErrorOutlineIcon size={12} />, + style: { + ...SHARED_THEMED_CHIP_SMALL_STYLE, + backgroundColor: 'var(--destructive)', + color: 'var(--destructive-foreground)', + }, + }, + deleted: { + label: 'Deleted', + tooltip: 'Video file was deleted', + color: 'default', + variant: 'outlined', + icon: <DeletedIcon size={12} data-testid="DeletedChipIcon" />, + style: { + ...SHARED_THEMED_CHIP_SMALL_STYLE, + backgroundColor: 'transparent', + color: 'var(--muted-foreground)', + }, + }, }; -function AvailabilityChip({ isAvailable, compact = false }: AvailabilityChipProps) { - const baseStyle = isAvailable ? baseAvailableStyle : baseMissingStyle; - const style = compact ? { ...baseStyle, ...SHARED_COMPACT_CHIP_OVERRIDES } : baseStyle; - const tooltip = isAvailable ? 'Video file exists on disk' : 'Video file not found on disk'; - const label = isAvailable ? 'Available' : 'Missing'; - const icon = isAvailable ? <CheckCircleIcon size={12} /> : <ErrorOutlineIcon size={12} />; +function AvailabilityChip({ state, tooltip, label, compact = false }: AvailabilityChipProps) { + const variant = VARIANTS[state]; + const style = compact ? { ...variant.style, ...SHARED_COMPACT_CHIP_OVERRIDES } : variant.style; return ( - <Tooltip title={tooltip} enterTouchDelay={0}> + <Tooltip title={tooltip ?? variant.tooltip} enterTouchDelay={0}> <Chip size="small" - icon={icon} - label={label} - color={isAvailable ? 'success' : 'error'} - variant="filled" + icon={variant.icon} + label={label ?? variant.label} + color={variant.color} + variant={variant.variant} style={style} /> </Tooltip> diff --git a/client/src/components/shared/VideoList/VideoListFilterChips.tsx b/client/src/components/shared/VideoList/VideoListFilterChips.tsx index 99e6e9fdb..bee2cf22c 100644 --- a/client/src/components/shared/VideoList/VideoListFilterChips.tsx +++ b/client/src/components/shared/VideoList/VideoListFilterChips.tsx @@ -152,18 +152,25 @@ function VideoListFilterChips({ filters }: VideoListFilterChipsProps) { } if (isStatusFilter(filter)) { - if (filter.value === 'off') continue; + // A chip at its resting value is not a filter the user applied. + const resting = filter.defaultValue ?? 'off'; + if (filter.value === resting) continue; const { Icon, noun } = STATUS_CHIP_DESCRIPTORS[filter.id]; + const label = filter.value === 'only' + ? `Only: ${noun}` + : filter.value === 'exclude' + ? `Hide: ${noun}` + : `Show: ${noun}`; chips.push( <Chip key={filter.id} icon={<Icon size={14} />} - label={filter.value === 'only' ? `Only: ${noun}` : `Hide: ${noun}`} + label={label} size="small" - onDelete={() => filter.onChange('off')} - onClick={() => filter.onChange('off')} + onDelete={() => filter.onChange(resting)} + onClick={() => filter.onChange(resting)} deleteIcon={<CloseIcon data-testid="CancelIcon" size={14} />} - color={filter.value === 'only' ? 'primary' : 'warning'} + color={filter.value === 'exclude' ? 'warning' : 'primary'} variant="outlined" /> ); @@ -208,7 +215,7 @@ export function countActiveFilters(filters: FilterConfig[]): number { else if (filter.id === 'dateRange' && !filter.hidden && (filter.dateFrom !== null || filter.dateTo !== null)) count++; else if (filter.id === 'dateRangeString' && !filter.hidden && (filter.dateFrom || filter.dateTo)) count++; else if (filter.id === 'maxRating' && filter.value) count++; - else if (isStatusFilter(filter) && filter.value !== 'off') count++; + else if (isStatusFilter(filter) && filter.value !== (filter.defaultValue ?? 'off')) count++; else if (filter.id === 'channel' && filter.value) count++; } return count; @@ -232,7 +239,7 @@ export function clearAllFilters(filters: FilterConfig[]): void { } else if (filter.id === 'maxRating') { filter.onChange(''); } else if (isStatusFilter(filter)) { - filter.onChange('off'); + filter.onChange(filter.defaultValue ?? 'off'); } else if (filter.id === 'channel') { filter.onChange(''); } diff --git a/client/src/components/shared/VideoList/VideoListFilterPanel.tsx b/client/src/components/shared/VideoList/VideoListFilterPanel.tsx index b9ee65166..ebbb7fae4 100644 --- a/client/src/components/shared/VideoList/VideoListFilterPanel.tsx +++ b/client/src/components/shared/VideoList/VideoListFilterPanel.tsx @@ -82,6 +82,8 @@ function renderFilter(filter: FilterConfig, compact: boolean): React.ReactNode { inactiveLabel={noun} onlyLabel={`Only: ${noun}`} excludeLabel={`Hide: ${noun}`} + offLabel={`Show: ${noun}`} + restingValue={filter.defaultValue} /> ); } diff --git a/client/src/components/shared/VideoList/__tests__/VideoListFilterChips.test.tsx b/client/src/components/shared/VideoList/__tests__/VideoListFilterChips.test.tsx index 840f2cc0c..5d3a0db7c 100644 --- a/client/src/components/shared/VideoList/__tests__/VideoListFilterChips.test.tsx +++ b/client/src/components/shared/VideoList/__tests__/VideoListFilterChips.test.tsx @@ -169,6 +169,48 @@ describe('VideoListFilterChips', () => { expect(onMissingChange).toHaveBeenCalledWith('off'); }); + describe('a chip whose resting value is not off (the Videos page hides deleted videos by default)', () => { + test('renders no chip and counts nothing while at its resting value', () => { + const filters: FilterConfig[] = [ + { id: 'deleted', value: 'exclude', onChange: jest.fn(), defaultValue: 'exclude' }, + ]; + renderWithProviders(<VideoListFilterChips filters={filters} />); + expect(screen.queryByTestId('video-list-filter-chips')).not.toBeInTheDocument(); + expect(countActiveFilters(filters)).toBe(0); + }); + + test('renders a "Show" chip when turned off, counts it, and clicking it returns to the resting value', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const filters: FilterConfig[] = [ + { id: 'deleted', value: 'off', onChange, defaultValue: 'exclude' }, + ]; + renderWithProviders(<VideoListFilterChips filters={filters} />); + expect(screen.getByText('Show: Deleted')).toBeInTheDocument(); + expect(countActiveFilters(filters)).toBe(1); + await user.click(screen.getByTestId('CancelIcon')); + expect(onChange).toHaveBeenCalledWith('exclude'); + }); + + test('clearAllFilters returns it to its resting value, not off', () => { + const onChange = jest.fn(); + const filters: FilterConfig[] = [ + { id: 'deleted', value: 'only', onChange, defaultValue: 'exclude' }, + ]; + clearAllFilters(filters); + expect(onChange).toHaveBeenCalledWith('exclude'); + }); + + test('a chip without a resting value still rests at off', () => { + const filters: FilterConfig[] = [ + { id: 'protected', value: 'only', onChange: jest.fn() }, + ]; + renderWithProviders(<VideoListFilterChips filters={filters} />); + expect(screen.getByText(/Only: Protected/i)).toBeInTheDocument(); + expect(countActiveFilters(filters)).toBe(1); + }); + }); + test('countActiveFilters counts missing and ignored when on', () => { const filters: FilterConfig[] = [ { id: 'protected', value: 'only', onChange: jest.fn() }, diff --git a/client/src/components/shared/VideoList/__tests__/usePersistedChipFilter.test.ts b/client/src/components/shared/VideoList/__tests__/usePersistedChipFilter.test.ts new file mode 100644 index 000000000..b01367977 --- /dev/null +++ b/client/src/components/shared/VideoList/__tests__/usePersistedChipFilter.test.ts @@ -0,0 +1,44 @@ +import { renderHook, act } from '@testing-library/react'; +import { usePersistedChipFilter } from '../usePersistedChipFilter'; + +const STORAGE_KEY = 'youtarr.test.deletedFilter'; + +describe('usePersistedChipFilter', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('returns the default when nothing is stored', () => { + const { result } = renderHook(() => usePersistedChipFilter(STORAGE_KEY, 'exclude')); + expect(result.current[0]).toBe('exclude'); + }); + + test('returns a stored mode', () => { + localStorage.setItem(STORAGE_KEY, 'only'); + const { result } = renderHook(() => usePersistedChipFilter(STORAGE_KEY, 'exclude')); + expect(result.current[0]).toBe('only'); + }); + + test.each(['', 'bogus', 'ONLY', '1'])('falls back to the default for the stored value "%s"', (bad) => { + localStorage.setItem(STORAGE_KEY, bad); + const { result } = renderHook(() => usePersistedChipFilter(STORAGE_KEY, 'exclude')); + expect(result.current[0]).toBe('exclude'); + }); + + test('remembers a change in storage and in state', () => { + const { result } = renderHook(() => usePersistedChipFilter(STORAGE_KEY, 'exclude')); + act(() => result.current[1]('off')); + expect(result.current[0]).toBe('off'); + expect(localStorage.getItem(STORAGE_KEY)).toBe('off'); + }); + + test('keeps working in memory when storage throws', () => { + const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + const { result } = renderHook(() => usePersistedChipFilter(STORAGE_KEY, 'exclude')); + act(() => result.current[1]('only')); + expect(result.current[0]).toBe('only'); + setItem.mockRestore(); + }); +}); diff --git a/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx b/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx index 59ce7bdc2..4d53b2d5f 100644 --- a/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx +++ b/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx @@ -9,6 +9,11 @@ export interface ToggleChipFilterProps { inactiveLabel: string; onlyLabel: string; excludeLabel: string; + // The value the chip returns to when cleared (default off). A chip whose + // resting value is exclude, like "deleted videos hidden", is active when off. + restingValue?: ChipFilterMode; + // Label for the off state when it is not the resting value (e.g. "Show: Deleted"). + offLabel?: string; size?: 'small' | 'medium'; } @@ -25,11 +30,23 @@ function ToggleChipFilter({ inactiveLabel, onlyLabel, excludeLabel, + restingValue = 'off', + offLabel, size = 'small', }: ToggleChipFilterProps) { - const label = value === 'only' ? onlyLabel : value === 'exclude' ? excludeLabel : inactiveLabel; - const color = value === 'only' ? 'primary' : value === 'exclude' ? 'warning' : 'default'; - const variant = value === 'off' ? 'outlined' : 'filled'; + const offIsActive = restingValue !== 'off'; + const label = value === 'only' + ? onlyLabel + : value === 'exclude' + ? excludeLabel + : (offIsActive && offLabel) || inactiveLabel; + const color = value === 'only' + ? 'primary' + : value === 'exclude' + ? 'warning' + : offIsActive ? 'primary' : 'default'; + const isResting = value === restingValue; + const variant = isResting ? 'outlined' : 'filled'; const ariaPressed: boolean | 'mixed' = value === 'off' ? false : value === 'only' ? true : 'mixed'; @@ -41,7 +58,7 @@ function ToggleChipFilter({ color={color} size={size} onClick={() => onChange(NEXT_MODE[value])} - onDelete={value === 'off' ? undefined : () => onChange('off')} + onDelete={isResting ? undefined : () => onChange(restingValue)} style={{ cursor: 'pointer' }} aria-pressed={ariaPressed} /> diff --git a/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx b/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx index 47ce47666..33aa2e6f7 100644 --- a/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx +++ b/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx @@ -99,6 +99,56 @@ describe('ToggleChipFilter', () => { expect(screen.queryByLabelText('Remove')).not.toBeInTheDocument(); }); + describe('with a resting value other than off', () => { + const restingProps = { + ...defaultProps, + inactiveLabel: 'Deleted', + onlyLabel: 'Only: Deleted', + excludeLabel: 'Hide: Deleted', + offLabel: 'Show: Deleted', + restingValue: 'exclude' as const, + }; + + test('shows no delete affordance while resting at exclude', () => { + renderWithProviders( + <ToggleChipFilter {...restingProps} value="exclude" onChange={jest.fn()} /> + ); + expect(screen.getByText('Hide: Deleted')).toBeInTheDocument(); + expect(screen.queryByLabelText('Remove')).not.toBeInTheDocument(); + }); + + test('labels the off state as showing, with a delete affordance back to resting', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders( + <ToggleChipFilter {...restingProps} value="off" onChange={onChange} /> + ); + expect(screen.getByText('Show: Deleted')).toBeInTheDocument(); + await user.click(screen.getByLabelText('Remove')); + expect(onChange).toHaveBeenCalledWith('exclude'); + }); + + test('delete from only returns to the resting value', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders( + <ToggleChipFilter {...restingProps} value="only" onChange={onChange} /> + ); + await user.click(screen.getByLabelText('Remove')); + expect(onChange).toHaveBeenCalledWith('exclude'); + }); + + test('still cycles off -> only -> exclude -> off on click', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders( + <ToggleChipFilter {...restingProps} value="exclude" onChange={onChange} /> + ); + await user.click(screen.getByText('Hide: Deleted')); + expect(onChange).toHaveBeenCalledWith('off'); + }); + }); + test('renders the provided icon', () => { renderWithProviders( <ToggleChipFilter {...defaultProps} value="off" onChange={jest.fn()} /> diff --git a/client/src/components/shared/VideoList/index.ts b/client/src/components/shared/VideoList/index.ts index bdbfff7b6..77480d546 100644 --- a/client/src/components/shared/VideoList/index.ts +++ b/client/src/components/shared/VideoList/index.ts @@ -26,3 +26,4 @@ export { default as VideoListPaginationBar } from './VideoListPaginationBar'; export { ALLOWED_PAGE_SIZES, DEFAULT_PAGE_SIZE, INFINITE_SCROLL_FETCH_SIZE, isPageSize } from './pageSizes'; export type { PageSize } from './pageSizes'; export { useListPageSize } from './useListPageSize'; +export { usePersistedChipFilter } from './usePersistedChipFilter'; diff --git a/client/src/components/shared/VideoList/statusFilters.ts b/client/src/components/shared/VideoList/statusFilters.ts index cbda47bed..4e805312f 100644 --- a/client/src/components/shared/VideoList/statusFilters.ts +++ b/client/src/components/shared/VideoList/statusFilters.ts @@ -4,10 +4,11 @@ import { Block as BlockIcon, Download as DownloadIcon, Eye as EyeIcon, + Trash2 as Trash2Icon, } from '../../../lib/icons'; import type { FilterConfig } from './types'; -export type StatusChipId = 'protected' | 'missing' | 'ignored' | 'downloaded' | 'watched'; +export type StatusChipId = 'protected' | 'missing' | 'deleted' | 'ignored' | 'downloaded' | 'watched'; export type StatusFilterConfig = Extract<FilterConfig, { id: StatusChipId }>; // Shape kept deliberately thin: the Icon component (not an element) so each @@ -22,6 +23,7 @@ export interface StatusChipDescriptor { export const STATUS_CHIP_DESCRIPTORS: Record<StatusChipId, StatusChipDescriptor> = { protected: { Icon: ShieldIcon, noun: 'Protected' }, missing: { Icon: CloudOffIcon, noun: 'Missing' }, + deleted: { Icon: Trash2Icon, noun: 'Deleted' }, ignored: { Icon: BlockIcon, noun: 'Ignored' }, downloaded: { Icon: DownloadIcon, noun: 'Downloaded' }, watched: { Icon: EyeIcon, noun: 'Watched' }, diff --git a/client/src/components/shared/VideoList/types.ts b/client/src/components/shared/VideoList/types.ts index faf6e0229..7b83f53c9 100644 --- a/client/src/components/shared/VideoList/types.ts +++ b/client/src/components/shared/VideoList/types.ts @@ -29,30 +29,43 @@ export type FilterConfig = value: string; onChange: (value: string) => void; } + // Status chips rest at `defaultValue` (off unless given): a chip at its + // resting value is not "active", and clearing filters returns it there. | { id: 'protected'; value: ChipFilterMode; onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; } | { id: 'missing'; value: ChipFilterMode; onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; + } + | { + id: 'deleted'; + value: ChipFilterMode; + onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; } | { id: 'ignored'; value: ChipFilterMode; onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; } | { id: 'downloaded'; value: ChipFilterMode; onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; } | { id: 'watched'; value: ChipFilterMode; onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; } | { id: 'duration'; diff --git a/client/src/components/shared/VideoList/usePersistedChipFilter.ts b/client/src/components/shared/VideoList/usePersistedChipFilter.ts new file mode 100644 index 000000000..880035705 --- /dev/null +++ b/client/src/components/shared/VideoList/usePersistedChipFilter.ts @@ -0,0 +1,36 @@ +import { useState, useCallback } from 'react'; +import type { ChipFilterMode } from './types'; + +const MODES: ChipFilterMode[] = ['off', 'only', 'exclude']; + +function readStoredMode(storageKey: string, defaultValue: ChipFilterMode): ChipFilterMode { + try { + const raw = localStorage.getItem(storageKey); + return raw !== null && (MODES as string[]).includes(raw) ? (raw as ChipFilterMode) : defaultValue; + } catch { + return defaultValue; + } +} + +// A tri-state chip filter remembered in this browser, for a filter whose +// resting value is a choice the user made (e.g. "show deleted videos"). +export function usePersistedChipFilter( + storageKey: string, + defaultValue: ChipFilterMode +): [ChipFilterMode, (next: ChipFilterMode) => void] { + const [mode, setModeState] = useState<ChipFilterMode>(() => readStoredMode(storageKey, defaultValue)); + + const setMode = useCallback( + (next: ChipFilterMode) => { + try { + localStorage.setItem(storageKey, next); + } catch { + // localStorage may be unavailable (private mode, quota); keep in-memory value + } + setModeState(next); + }, + [storageKey] + ); + + return [mode, setMode]; +} diff --git a/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx b/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx index 771b88979..58091058b 100644 --- a/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx +++ b/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx @@ -470,6 +470,23 @@ describe('VideoModal', () => { expect(screen.queryByRole('button', { name: /delete video/i })).not.toBeInTheDocument(); }); + test('hides delete and protect for a deleted video and offers a download instead', () => { + renderModal({ + video: { ...baseVideo, status: 'deleted' }, + }); + expect(screen.queryByRole('button', { name: /delete video/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /protect/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /download video/i })).toBeInTheDocument(); + }); + + test('passes missingVideoCount 1 to the download dialog for a deleted video', () => { + renderModal({ + video: { ...baseVideo, status: 'deleted' }, + }); + fireEvent.click(screen.getByRole('button', { name: /download video/i })); + expect(screen.getByTestId('download-dialog-missing-count')).toHaveTextContent('1'); + }); + test('renders ignore button for non-downloaded videos', () => { renderModal({ video: neverDownloadedVideo }); expect(screen.getByRole('button', { name: /ignore/i })).toBeInTheDocument(); diff --git a/client/src/components/shared/VideoModal/components/VideoActions.tsx b/client/src/components/shared/VideoModal/components/VideoActions.tsx index 01dcaa055..99a82e4c6 100644 --- a/client/src/components/shared/VideoModal/components/VideoActions.tsx +++ b/client/src/components/shared/VideoModal/components/VideoActions.tsx @@ -18,6 +18,7 @@ import { getStatusColor, getStatusIcon, getStatusLabel, + isRemovedStatus, } from '../../../../utils/videoStatus'; import { SHARED_STATUS_CHIP_STYLE, SHARED_THEMED_CHIP_STYLE } from '../../../shared/chipStyles'; import { YOUTUBE_URL_BASE } from '../constants'; @@ -45,7 +46,7 @@ function VideoActions({ isMobile, allowIgnore = true, }: VideoActionsProps) { - const isDownloadedAndPresent = video.isDownloaded && video.status !== 'missing'; + const isDownloadedAndPresent = video.isDownloaded && !isRemovedStatus(video.status); const showProtect = isDownloadedAndPresent; const showDownload = !isDownloadedAndPresent && video.status !== 'queued' && video.status !== 'downloading'; const showIgnore = (!isDownloadedAndPresent || video.isIgnored) && allowIgnore; diff --git a/client/src/components/shared/VideoModal/components/VideoPlayer.tsx b/client/src/components/shared/VideoModal/components/VideoPlayer.tsx index 7817a22c8..2c5e7d9db 100644 --- a/client/src/components/shared/VideoModal/components/VideoPlayer.tsx +++ b/client/src/components/shared/VideoModal/components/VideoPlayer.tsx @@ -9,7 +9,9 @@ import { Close as CloseIcon, WarningAmber as WarningAmberIcon, Lock as LockIcon, + Trash2 as DeletedIcon, } from '../../../../lib/icons'; +import { isRemovedStatus } from '../../../../utils/videoStatus'; import { VideoModalData } from '../types'; import { YOUTUBE_URL_BASE } from '../constants'; @@ -36,7 +38,7 @@ function VideoPlayer({ video, token, onDownloadClick, isMobile }: VideoPlayerPro setStreamAspectRatio(null); }, [video.youtubeId]); - const canStream = video.isDownloaded && video.status !== 'missing'; + const canStream = video.isDownloaded && !isRemovedStatus(video.status); const playbackType: 'video' | 'audio' = !video.filePath && video.audioFilePath ? 'audio' : 'video'; const streamUrl = token @@ -209,11 +211,15 @@ function VideoPlayer({ video, token, onDownloadClick, isMobile }: VideoPlayerPro Ignored </Typography> </> - ) : video.status === 'missing' ? ( + ) : isRemovedStatus(video.status) ? ( <> - <WarningAmberIcon size={48} color="var(--warning)" /> + {video.status === 'deleted' ? ( + <DeletedIcon size={48} color="var(--muted-foreground)" /> + ) : ( + <WarningAmberIcon size={48} color="var(--warning)" /> + )} <Typography variant="body1" sx={{ color: 'common.white', fontWeight: 500 }}> - File Missing + {video.status === 'deleted' ? 'File Deleted' : 'File Missing'} </Typography> <IconButton onClick={onDownloadClick} diff --git a/client/src/components/shared/VideoModal/components/VideoTechnical.tsx b/client/src/components/shared/VideoModal/components/VideoTechnical.tsx index 759aa1c26..3c3df949f 100644 --- a/client/src/components/shared/VideoModal/components/VideoTechnical.tsx +++ b/client/src/components/shared/VideoModal/components/VideoTechnical.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { Box, Typography, Skeleton, Tooltip, Chip, Accordion, AccordionSummary, AccordionDetails } from '../../../ui'; -import { formatDate } from '../../../../utils/formatters'; +import { formatDate, stripInternalPath } from '../../../../utils/formatters'; import { tierFromDimensions } from '../../../../utils/videoResolution'; +import { isRemovedStatus } from '../../../../utils/videoStatus'; +import { describeRemoval } from '../../../../utils/videoRemoval'; import { VideoModalData, VideoExtendedMetadata } from '../types'; interface VideoTechnicalProps { @@ -14,8 +16,6 @@ const BYTES_PER_KB = 1024; const BYTES_PER_MB = 1024 * 1024; const BYTES_PER_GB = 1024 * 1024 * 1024; -const INTERNAL_PATH_PREFIX = '/usr/src/app/data/'; - function formatFileSize(bytes: number): string { if (bytes >= BYTES_PER_GB) { return `${(bytes / BYTES_PER_GB).toFixed(2)} GB`; @@ -29,12 +29,6 @@ function formatFileSize(bytes: number): string { return `${bytes} B`; } -function stripInternalPath(filePath: string): string { - if (filePath.startsWith(INTERNAL_PATH_PREFIX)) { - return filePath.slice(INTERNAL_PATH_PREFIX.length); - } - return filePath; -} function getOrientationLabel(ratio: number): string { if (ratio > 1) return 'Landscape'; @@ -154,7 +148,11 @@ function VideoTechnical({ video, metadata, loading }: VideoTechnicalProps) { const hasAudioFile = video.audioFilePath !== null; const relatedFiles = !loading ? (metadata?.relatedFiles ?? null) : null; const addedDate = formatDate(video.addedAt); - const showFileDetails = video.isDownloaded || video.status === 'missing'; + const showFileDetails = video.isDownloaded || isRemovedStatus(video.status); + // Why there is no file, in plain words, so nobody has to find the tooltip. + const removalStatus = isRemovedStatus(video.status) + ? describeRemoval(video.status, video.removal ?? undefined, video.filePath) + : null; return ( <Box style={{ display: 'flex', flexDirection: 'column', gap: 'var(--video-modal-section-gap, 12px)' }}> @@ -209,6 +207,7 @@ function VideoTechnical({ video, metadata, loading }: VideoTechnicalProps) { <AccordionSummary>File Details</AccordionSummary> <AccordionDetails> <Box> + {removalStatus && <DetailRow label="Status" value={removalStatus} />} {hasVideoFile && ( <FileRow label="Video" diff --git a/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx b/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx index 30224de78..8584dae93 100644 --- a/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx +++ b/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx @@ -20,6 +20,7 @@ jest.mock('../../../../../lib/icons', () => { Close: createIcon('CloseIcon'), WarningAmber: createIcon('WarningAmberIcon'), Lock: createIcon('LockIcon'), + Trash2: createIcon('Trash2Icon'), }; }); @@ -121,6 +122,18 @@ describe('VideoPlayer', () => { expect(screen.getByRole('button', { name: /re-download video/i })).toBeInTheDocument(); }); + test('renders "deleted file" state with a re-download button and no player', () => { + renderPlayer({ + status: 'deleted', + isDownloaded: true, + }); + + expect(screen.getByText('File Deleted')).toBeInTheDocument(); + expect(screen.queryByText('File Missing')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /re-download video/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Play video' })).not.toBeInTheDocument(); + }); + test('renders "members only" state with lock icon and no download button', () => { renderPlayer({ status: 'members_only', diff --git a/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx b/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx index 8ed2c48aa..0f9e92e68 100644 --- a/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx +++ b/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx @@ -187,6 +187,32 @@ describe('VideoTechnical', () => { expect(screen.getByText('File Details')).toBeInTheDocument(); }); + test('explains why a deleted video has no file in a Status row', () => { + renderTech({ + isDownloaded: false, + status: 'deleted', + removal: { reason: 'auto_removal', at: null, detail: 'older than 30 days' }, + }); + expect(screen.getByText('File Details')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Deleted by automatic cleanup (older than 30 days). It can be downloaded again from its channel or playlist page.')).toBeInTheDocument(); + }); + + test('tells a missing video from a deleted one in the Status row', () => { + renderTech({ + isDownloaded: false, + status: 'missing', + filePath: '/usr/src/app/data/Channel/video.mp4', + removal: { reason: 'not_found', at: null, detail: null }, + }); + expect(screen.getByText(/Youtarr expected a file at .*video\.mp4 and could not find it\./)).toBeInTheDocument(); + }); + + test('has no Status row for a present file', () => { + renderTech({ isDownloaded: true, status: 'downloaded', filePath: '/videos/a.mp4', fileSize: 10 }); + expect(screen.queryByText('Status')).not.toBeInTheDocument(); + }); + test('renders the video file row with stripped internal path and formatted size', () => { renderTech({ filePath: '/usr/src/app/data/Channel/video.mp4', diff --git a/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts b/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts index 1bc3ad925..e7de79c08 100644 --- a/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts +++ b/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts @@ -83,6 +83,11 @@ export function useVideoModalActions({ audioFilePath: current.audioFilePath ?? null, audioFileSize: current.audioFileSize ?? null, addedAt: current.addedAt ?? null, + removal: { + reason: current.removedReason ?? null, + at: current.removedAt ?? null, + detail: current.removedDetail ?? null, + }, })); }, [statuses, video.youtubeId]); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); diff --git a/client/src/components/shared/VideoModal/index.tsx b/client/src/components/shared/VideoModal/index.tsx index 82f40e373..1b54a6719 100644 --- a/client/src/components/shared/VideoModal/index.tsx +++ b/client/src/components/shared/VideoModal/index.tsx @@ -27,6 +27,7 @@ import AddChannelDialog from '../AddChannelDialog'; import DownloadSettingsDialog from '../../DownloadManager/ManualDownload/DownloadSettingsDialog'; import { useConfig } from '../../../hooks/useConfig'; import { uploadDateToIso } from '../../../utils/formatters'; +import { isRemovedStatus } from '../../../utils/videoStatus'; const SNACKBAR_AUTO_HIDE_MS = 4000; @@ -303,7 +304,7 @@ function VideoModal({ onClose={() => setDownloadDialogOpen(false)} onConfirm={handleDownloadConfirm} videoCount={1} - missingVideoCount={displayVideo.status === 'missing' || displayVideo.inArchive ? 1 : 0} + missingVideoCount={isRemovedStatus(displayVideo.status) || displayVideo.inArchive ? 1 : 0} mode="manual" token={token} defaultResolution={defaultResolution} diff --git a/client/src/components/shared/VideoModal/types.ts b/client/src/components/shared/VideoModal/types.ts index 45c663adb..57b2d5c23 100644 --- a/client/src/components/shared/VideoModal/types.ts +++ b/client/src/components/shared/VideoModal/types.ts @@ -1,4 +1,5 @@ import { VideoStatus } from '../../../utils/videoStatus'; +import type { RemovalInfo } from '../../../utils/videoRemoval'; export interface VideoModalData { youtubeId: string; @@ -13,6 +14,8 @@ export interface VideoModalData { // Listed in the download archive without a database record. Pages that know // it pass it in; the modal's local status lookup then keeps it current. inArchive?: boolean; + // Why the file is gone, for a 'deleted' or 'missing' status (see utils/videoRemoval). + removal?: RemovalInfo | null; isDownloaded: boolean; filePath: string | null; fileSize: number | null; diff --git a/client/src/components/shared/__tests__/AvailabilityChip.test.tsx b/client/src/components/shared/__tests__/AvailabilityChip.test.tsx index af3234231..8198f6b7d 100644 --- a/client/src/components/shared/__tests__/AvailabilityChip.test.tsx +++ b/client/src/components/shared/__tests__/AvailabilityChip.test.tsx @@ -1,24 +1,47 @@ import React from 'react'; import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import AvailabilityChip from '../AvailabilityChip'; import { renderWithProviders } from '../../../test-utils'; describe('AvailabilityChip', () => { - test('renders Available label when isAvailable is true', () => { - renderWithProviders(<AvailabilityChip isAvailable={true} />); + test('renders Available for a present file', () => { + renderWithProviders(<AvailabilityChip state="present" />); expect(screen.getByText('Available')).toBeInTheDocument(); expect(screen.queryByText('Missing')).not.toBeInTheDocument(); }); - test('renders Missing label when isAvailable is false', () => { - renderWithProviders(<AvailabilityChip isAvailable={false} />); + test('renders Missing for a file Youtarr could not find', () => { + renderWithProviders(<AvailabilityChip state="missing" />); expect(screen.getByText('Missing')).toBeInTheDocument(); expect(screen.queryByText('Available')).not.toBeInTheDocument(); }); + test('renders Deleted for a file Youtarr removed', () => { + renderWithProviders(<AvailabilityChip state="deleted" />); + expect(screen.getByText('Deleted')).toBeInTheDocument(); + expect(screen.getByTestId('DeletedChipIcon')).toBeInTheDocument(); + }); + + test('explains the removal in its tooltip', async () => { + const user = userEvent.setup(); + renderWithProviders( + <AvailabilityChip state="deleted" tooltip="Deleted by automatic cleanup on 10/3/2026 (older than 30 days)." /> + ); + + await user.hover(screen.getByText('Deleted')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Deleted by automatic cleanup on 10/3/2026 (older than 30 days).'); + }); + + test('takes a label override for summary counts', () => { + renderWithProviders(<AvailabilityChip state="missing" label="2 missing" />); + expect(screen.getByText('2 missing')).toBeInTheDocument(); + }); + test('renders with compact styling when compact prop is true', () => { - renderWithProviders(<AvailabilityChip isAvailable={true} compact />); + renderWithProviders(<AvailabilityChip state="present" compact />); expect(screen.getByText('Available')).toBeInTheDocument(); }); }); diff --git a/client/src/hooks/useLocalVideoStatus.ts b/client/src/hooks/useLocalVideoStatus.ts index 6ade7ed85..040dbdd7a 100644 --- a/client/src/hooks/useLocalVideoStatus.ts +++ b/client/src/hooks/useLocalVideoStatus.ts @@ -4,7 +4,7 @@ import WebSocketContext from '../contexts/WebSocketContext'; import { SearchResult } from '../components/FindVideos/types'; import { useVideoActivity } from '../providers/VideoActivityProvider'; -export type LocalVideoStatus = Pick<SearchResult, 'youtubeId' | 'status' | 'inArchive' | 'databaseId' | 'filePath' | 'fileSize' | 'audioFilePath' | 'audioFileSize' | 'addedAt' | 'isProtected' | 'normalizedRating' | 'ratingSource'>; +export type LocalVideoStatus = Pick<SearchResult, 'youtubeId' | 'status' | 'inArchive' | 'databaseId' | 'filePath' | 'fileSize' | 'audioFilePath' | 'audioFileSize' | 'addedAt' | 'isProtected' | 'normalizedRating' | 'ratingSource' | 'removedReason' | 'removedAt' | 'removedDetail'>; // Refresh only local DB metadata; never re-run a YouTube search for activity. export function useLocalVideoStatus(ids: string[], token: string | null) { diff --git a/client/src/types/ChannelVideo.ts b/client/src/types/ChannelVideo.ts index ac3358d2c..a43e172a9 100644 --- a/client/src/types/ChannelVideo.ts +++ b/client/src/types/ChannelVideo.ts @@ -6,6 +6,8 @@ "added": false },*/ +import type { RemovalReason } from '../utils/videoRemoval'; + export interface ChannelVideo { activity?: 'queued' | 'downloading'; id?: number; @@ -19,6 +21,10 @@ export interface ChannelVideo { thumbnail: string; added: boolean; removed?: boolean; + // Why the file is gone (see utils/videoRemoval): 'not_found' = missing, anything else = deleted + removed_reason?: RemovalReason | null; + removed_at?: string | null; + removed_detail?: string | null; // Listed in the download archive without a database record, so a download // skips it unless re-downloading is allowed. Never true for ignored videos. inArchive?: boolean; diff --git a/client/src/types/VideoData.ts b/client/src/types/VideoData.ts index 3bac3f618..190fba591 100644 --- a/client/src/types/VideoData.ts +++ b/client/src/types/VideoData.ts @@ -5,6 +5,8 @@ duration: number; }*/ +import type { RemovalReason } from '../utils/videoRemoval'; + export interface VideoData { id: number; youtubeId: string; @@ -19,6 +21,10 @@ export interface VideoData { audioFilePath?: string | null; audioFileSize?: string | null; removed?: boolean; + // Why the file is gone (see utils/videoRemoval): 'not_found' = missing, anything else = deleted + removed_reason?: RemovalReason | null; + removed_at?: string | null; + removed_detail?: string | null; youtube_removed?: boolean; channel_id?: string | null; media_type?: string; @@ -35,6 +41,15 @@ export interface EnabledChannel { uploader: string; } +// Set when videos were not marked missing because the downloads folder looked +// unmounted (missing, empty, unreadable, or an empty __subfolder). +export interface LibraryWarning { + reason: string; + path: string; + message: string; + heldBack: number; +} + export interface PaginatedVideosResponse { videos: VideoData[]; total: number; @@ -42,4 +57,7 @@ export interface PaginatedVideosResponse { totalPages: number; channels: string[]; enabledChannels: EnabledChannel[]; + // How many deleted videos the deleted filter hid; null unless it excludes them. + hiddenDeletedCount?: number | null; + libraryWarning?: LibraryWarning | null; } diff --git a/client/src/types/playlist.ts b/client/src/types/playlist.ts index e28b06bf2..6e7595542 100644 --- a/client/src/types/playlist.ts +++ b/client/src/types/playlist.ts @@ -1,3 +1,5 @@ +import type { RemovalReason } from '../utils/videoRemoval'; + // Saved output order for the .m3u file and media server playlist sync. export type PlaylistSortOrderSetting = 'default' | 'reversed'; @@ -56,6 +58,10 @@ export interface PlaylistVideo { // Previously downloaded but the file is gone (deleted/missing). The backend // skips these during downloads unless allowRedownload is set. previously_downloaded: boolean; + // Why it is gone (see utils/videoRemoval): 'not_found' = missing, anything else = deleted + removed_reason?: RemovalReason | null; + removed_at?: string | null; + removed_detail?: string | null; youtube_removed: boolean; // Populated only for videos that have been downloaded; used to hydrate VideoModal video_id: number | null; diff --git a/client/src/utils/__tests__/videoRemoval.test.ts b/client/src/utils/__tests__/videoRemoval.test.ts new file mode 100644 index 000000000..a9b0ff3ff --- /dev/null +++ b/client/src/utils/__tests__/videoRemoval.test.ts @@ -0,0 +1,93 @@ +import { describeRemoval, describeRow, getRemovalState, removalFromRow } from '../videoRemoval'; +import { formatDate } from '../formatters'; + +const AT = '2026-10-03T12:00:00.000Z'; +const ON = ` on ${formatDate(AT)}`; + +describe('getRemovalState', () => { + test('is present while the row is not removed, whatever the reason says', () => { + expect(getRemovalState(false, null)).toBe('present'); + expect(getRemovalState(undefined, undefined)).toBe('present'); + expect(getRemovalState(false, 'not_found')).toBe('present'); + }); + + test('is missing for a removed row whose file was not found', () => { + expect(getRemovalState(true, 'not_found')).toBe('missing'); + }); + + test('is deleted for a removed row with any other reason', () => { + expect(getRemovalState(true, 'user_deleted')).toBe('deleted'); + expect(getRemovalState(true, 'auto_removal')).toBe('deleted'); + expect(getRemovalState(true, 'marked_deleted')).toBe('deleted'); + }); + + test('treats a removed row without a recorded reason as deleted', () => { + expect(getRemovalState(true, null)).toBe('deleted'); + expect(getRemovalState(true, undefined)).toBe('deleted'); + }); +}); + +describe('describeRemoval', () => { + test('describes a present file', () => { + expect(describeRemoval('present', { reason: null, at: null, detail: null })).toBe('Video file exists on disk'); + }); + + test('explains a row removed before the reason was recorded', () => { + expect(describeRemoval('deleted', { reason: null, at: null, detail: null })).toBe('Removed before Youtarr recorded why.'); + }); + + test('explains a delete made in Youtarr', () => { + expect(describeRemoval('deleted', { reason: 'user_deleted', at: AT, detail: null })).toBe( + `Deleted in Youtarr${ON}. It can be downloaded again from its channel or playlist page.` + ); + }); + + test('explains automatic cleanup with the rule that removed it', () => { + expect(describeRemoval('deleted', { reason: 'auto_removal', at: AT, detail: 'older than 30 days' })).toBe( + `Deleted by automatic cleanup${ON} (older than 30 days). It can be downloaded again from its channel or playlist page.` + ); + expect(describeRemoval('deleted', { reason: 'auto_removal', at: null, detail: null })).toBe( + 'Deleted by automatic cleanup. It can be downloaded again from its channel or playlist page.' + ); + }); + + test('explains a missing video the user marked as deleted', () => { + expect(describeRemoval('deleted', { reason: 'marked_deleted', at: AT, detail: null })).toBe( + `Marked as deleted${ON}. Its file was missing from disk when it was marked.` + ); + }); + + test('explains a missing file, with the path it looked at and when', () => { + expect(describeRemoval('missing', { reason: 'not_found', at: AT, detail: null }, '/data/Channel/video.mp4')).toBe( + `Youtarr expected a file at /data/Channel/video.mp4 and could not find it (checked ${formatDate(AT)}). If the drive is mounted and the file is really gone, mark it as deleted.` + ); + expect(describeRemoval('missing', { reason: 'not_found', at: null, detail: null })).toBe( + 'Youtarr expected a file and could not find it. If the drive is mounted and the file is really gone, mark it as deleted.' + ); + }); +}); + +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({ + reason: 'auto_removal', at: AT, detail: 'older than 30 days', + }); + expect(removalFromRow({})).toEqual({ reason: null, at: null, detail: null }); + }); + + test('shows the path as the user knows it, without the container prefix', () => { + expect(describeRow({ removed: true, removed_reason: 'not_found', filePath: '/usr/src/app/data/Channel/v.mp4' })).toContain( + 'expected a file at Channel/v.mp4 and' + ); + expect(describeRemoval('missing', { reason: 'not_found', at: null, detail: null }, '/mnt/nas/Channel/v.mp4')).toContain( + 'expected a file at /mnt/nas/Channel/v.mp4 and' + ); + }); + + test('describeRow derives the state and text from a listing row', () => { + expect(describeRow({ removed: true, removed_reason: 'not_found', removed_at: null, removed_detail: null, filePath: '/d/v.mp4' })).toBe( + '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.' + ); + expect(describeRow({ removed: false })).toBe('Video file exists on disk'); + }); +}); diff --git a/client/src/utils/__tests__/videoStatus.test.tsx b/client/src/utils/__tests__/videoStatus.test.tsx index c2dfa76b0..6886a7703 100644 --- a/client/src/utils/__tests__/videoStatus.test.tsx +++ b/client/src/utils/__tests__/videoStatus.test.tsx @@ -4,13 +4,17 @@ import { getStatusColor, getStatusIcon, getStatusLabel, + getStatusChipVariant, + getStatusChipStyle, getMediaTypeInfo, + isRemovedStatus, VideoStatus, } from '../videoStatus'; import { ChannelVideo } from '../../types/ChannelVideo'; import { CheckCircle as CheckCircleIcon, CloudOff as CloudOffIcon, + Trash2 as Trash2Icon, Lock as LockIcon, Block as BlockIcon, NewReleases as NewReleasesIcon, @@ -79,11 +83,22 @@ describe('videoStatus Utility', () => { expect(getVideoStatus(neverDownloadedVideo)).toBe('never_downloaded'); }); - test('returns "missing" when video was added but has been removed', () => { + test('returns "deleted" when Youtarr removed the video\'s file', () => { + expect(getVideoStatus({ ...baseMockVideo, added: true, removed: true, removed_reason: 'user_deleted' })).toBe('deleted'); + expect(getVideoStatus({ ...baseMockVideo, added: true, removed: true, removed_reason: 'auto_removal' })).toBe('deleted'); + expect(getVideoStatus({ ...baseMockVideo, added: true, removed: true, removed_reason: 'marked_deleted' })).toBe('deleted'); + }); + + test('returns "deleted" for a video removed before Youtarr recorded why', () => { + expect(getVideoStatus({ ...baseMockVideo, added: true, removed: true })).toBe('deleted'); + }); + + test('returns "missing" when video was added but its file was not found', () => { const missingVideo = { ...baseMockVideo, added: true, removed: true, + removed_reason: 'not_found' as const, }; expect(getVideoStatus(missingVideo)).toBe('missing'); }); @@ -215,7 +230,7 @@ describe('videoStatus Utility', () => { }); test('all icons have correct size prop', () => { - const statuses: VideoStatus[] = ['downloaded', 'missing', 'members_only', 'ignored', 'never_downloaded']; + const statuses: VideoStatus[] = ['downloaded', 'missing', 'deleted', 'members_only', 'ignored', 'never_downloaded']; statuses.forEach(status => { const icon = getStatusIcon(status); expect(icon.props.size).toBe(16); @@ -323,7 +338,7 @@ describe('videoStatus Utility', () => { }); test('missing video returns correct color, icon, and label', () => { - const missingVideo = { ...baseMockVideo, added: true, removed: true }; + const missingVideo = { ...baseMockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; const status = getVideoStatus(missingVideo); expect(status).toBe('missing'); @@ -333,6 +348,24 @@ describe('videoStatus Utility', () => { expect(getStatusIcon(status).props.size).toBe(16); }); + test('deleted video is neutral, not an error: default color, trash icon, outlined chip', () => { + const status = getVideoStatus({ ...baseMockVideo, added: true, removed: true, removed_reason: 'auto_removal' }); + + expect(status).toBe('deleted'); + expect(getStatusColor(status)).toBe('default'); + expect(getStatusLabel(status)).toBe('Deleted'); + expect(getStatusIcon(status).type).toBe(Trash2Icon); + expect(getStatusChipVariant(status)).toBe('outlined'); + expect(getStatusChipStyle(status)).toEqual({ backgroundColor: 'transparent', color: 'var(--muted-foreground)' }); + }); + + test('isRemovedStatus is true for missing and deleted only', () => { + expect(isRemovedStatus('missing')).toBe(true); + expect(isRemovedStatus('deleted')).toBe(true); + expect(isRemovedStatus('downloaded')).toBe(false); + expect(isRemovedStatus('never_downloaded')).toBe(false); + }); + test('members only video returns correct color, icon, and label', () => { const membersOnlyVideo = { ...baseMockVideo, availability: 'subscriber_only' }; const status = getVideoStatus(membersOnlyVideo); diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index dba504e74..4a04bf8ab 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -16,6 +16,14 @@ export const decodeHtml = (html: string): string => { return txt.value; }; +// Where the container keeps the downloads folder. The host path differs per +// install, so paths shown to users drop this prefix. +export const INTERNAL_PATH_PREFIX = '/usr/src/app/data/'; + +export function stripInternalPath(filePath: string): string { + return filePath.startsWith(INTERNAL_PATH_PREFIX) ? filePath.slice(INTERNAL_PATH_PREFIX.length) : filePath; +} + export function parseDate(dateStr: string | null | undefined): Date | null { if (!dateStr) { return null; diff --git a/client/src/utils/videoRemoval.ts b/client/src/utils/videoRemoval.ts new file mode 100644 index 000000000..92e5ccd9b --- /dev/null +++ b/client/src/utils/videoRemoval.ts @@ -0,0 +1,77 @@ +import { formatDate, stripInternalPath } from './formatters'; + +// Why a video has no file, layered on the `removed` flag. Mirrors +// server/modules/videoRemovalState.js: a removed row whose reason is +// 'not_found' is MISSING (Youtarr expected a file and could not find it); +// any other removed row, including one with no recorded reason (removed +// before Youtarr kept track), is DELETED. +export type RemovalReason = 'user_deleted' | 'auto_removal' | 'not_found' | 'marked_deleted'; +export type RemovalState = 'present' | 'deleted' | 'missing'; +export type RemovedState = Exclude<RemovalState, 'present'>; + +export interface RemovalInfo { + reason: RemovalReason | null; + at: string | null; + detail: string | null; +} + +// The removal columns as listing rows carry them (snake_case, as stored). +export interface RemovalRow { + removed?: boolean | null; + removed_reason?: string | null; + removed_at?: string | Date | null; + removed_detail?: string | null; + filePath?: string | null; +} + +// Neutral tint for a deleted video's thumbnail; missing keeps the red tint. +export const DELETED_OVERLAY_BACKGROUND = 'rgba(0, 0, 0, 0.35)'; + +const NO_REMOVAL: RemovalInfo = { reason: null, at: null, detail: null }; + +export function removedState(reason: string | null | undefined): RemovedState { + return reason === 'not_found' ? 'missing' : 'deleted'; +} + +export function getRemovalState(removed: boolean | null | undefined, reason: string | null | undefined): RemovalState { + if (!removed) return 'present'; + return removedState(reason); +} + +export function removalFromRow(row: RemovalRow): RemovalInfo { + const at = row.removed_at instanceof Date ? row.removed_at.toISOString() : row.removed_at; + return { + reason: (row.removed_reason as RemovalReason | null | undefined) ?? null, + at: at ?? null, + detail: row.removed_detail ?? null, + }; +} + +const CAN_REDOWNLOAD = 'It can be downloaded again from its channel or playlist page.'; + +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.`; + } + + switch (info.reason) { + case 'user_deleted': + return `Deleted in Youtarr${on}. ${CAN_REDOWNLOAD}`; + case 'auto_removal': + return `Deleted by automatic cleanup${on}${info.detail ? ` (${info.detail})` : ''}. ${CAN_REDOWNLOAD}`; + case 'marked_deleted': + return `Marked as deleted${on}. Its file was missing from disk when it was marked.`; + default: + return 'Removed before Youtarr recorded why.'; + } +} + +export function describeRow(row: RemovalRow): string { + return describeRemoval(getRemovalState(row.removed, row.removed_reason), removalFromRow(row), row.filePath); +} diff --git a/client/src/utils/videoStatus.tsx b/client/src/utils/videoStatus.tsx index f88f1df16..955ed4a69 100644 --- a/client/src/utils/videoStatus.tsx +++ b/client/src/utils/videoStatus.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { CheckCircle as CheckCircleIcon, CloudOff as CloudOffIcon, Lock as LockIcon, NewReleases as NewReleasesIcon, Schedule as ScheduleIcon, VideoLibrary as VideoLibraryIcon, Block as BlockIcon } from '../lib/icons'; +import { CheckCircle as CheckCircleIcon, CloudOff as CloudOffIcon, Lock as LockIcon, NewReleases as NewReleasesIcon, Schedule as ScheduleIcon, VideoLibrary as VideoLibraryIcon, Block as BlockIcon, Trash2 as Trash2Icon } from '../lib/icons'; import { ChannelVideo } from '../types/ChannelVideo'; +import { getRemovalState } from './videoRemoval'; -export type VideoStatus = 'never_downloaded' | 'downloaded' | 'missing' | 'members_only' | 'ignored' | 'queued' | 'downloading'; +export type VideoStatus = 'never_downloaded' | 'downloaded' | 'missing' | 'deleted' | 'members_only' | 'ignored' | 'queued' | 'downloading'; export const getVideoStatus = (video: ChannelVideo): VideoStatus => { if (video.activity) return video.activity; @@ -15,12 +16,15 @@ export const getVideoStatus = (video: ChannelVideo): VideoStatus => { if (!video.added) { return 'never_downloaded'; } - if (video.removed) { - return 'missing'; - } - return 'downloaded'; + const removalState = getRemovalState(video.removed, video.removed_reason); + return removalState === 'present' ? 'downloaded' : removalState; }; +// Previously downloaded, but the file is gone: deleted by Youtarr (or marked +// as deleted), or missing from disk. +export const isRemovedStatus = (status: VideoStatus): status is 'missing' | 'deleted' => + status === 'missing' || status === 'deleted'; + export const getStatusColor = (status: VideoStatus) => { switch (status) { case 'queued': @@ -32,8 +36,8 @@ export const getStatusColor = (status: VideoStatus) => { return 'error'; case 'never_downloaded': return 'warning'; + case 'deleted': case 'members_only': - return 'default'; case 'ignored': return 'default'; default: @@ -51,6 +55,8 @@ export const getStatusIcon = (status: VideoStatus) => { return <CheckCircleIcon size={16} data-testid="CheckCircleIcon" />; case 'missing': return <CloudOffIcon size={16} data-testid="CloudOffIcon" />; + case 'deleted': + return <Trash2Icon size={16} data-testid="Trash2Icon" />; case 'members_only': return <LockIcon size={16} data-testid="LockIcon" />; case 'ignored': @@ -70,6 +76,8 @@ export const getStatusLabel = (status: VideoStatus) => { return 'Downloaded'; case 'missing': return 'Missing'; + case 'deleted': + return 'Deleted'; case 'members_only': return 'Members Only'; case 'ignored': @@ -109,6 +117,8 @@ export const getStatusChipStyle = (status: VideoStatus): React.CSSProperties => backgroundColor: 'transparent', color: 'var(--warning)', }; + // Deleted is a neutral state, not a warning: Youtarr removed the file on purpose. + case 'deleted': case 'ignored': return { backgroundColor: 'transparent', diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 5c99e5dab..a5a467dbe 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -23,7 +23,7 @@ Youtarr uses MariaDB/MySQL for storing: | Table | Model | Description | | :----------------- | :------------- | :-------------------------------- | | `channels` | `Channel` | YouTube channel information. `m3u_enabled` (boolean, default false): generate a `.m3u` playlist file in the channel folder. `m3u_sort_order` (string, default `oldest_first`): `.m3u` entry order, `oldest_first` or `newest_first`. `auto_removal_protected` (boolean, default false): exclude every video of this channel from auto-removal; only applies while the channel is subscribed (`enabled`), and enabling it clears `auto_removal_keep_recent_count`. `auto_removal_keep_recent_count` (int, nullable): auto-removal always keeps this many of the channel's most recently downloaded videos; mutually exclusive with `auto_removal_protected`, dormant while the channel is unsubscribed. `additional_tags` (text, nullable, default null): adds additional, custom tags to videos downloaded from the channel. `tab_video_counts` (text, nullable): JSON of YouTube's public video totals per tab, keyed by media type (`{"video":{"total":449,"fetchedAt":"..."}}`), used for the download percentage (refreshed daily with a YouTube API key, every three days or more in capped batches through yt-dlp without one). `tab_counts_attempted_at` (datetime, nullable): when a tab count lookup last started for the channel, successful or not; yt-dlp bulk refreshes take the oldest attempts first and skip channels attempted within three days, and opening a channel page waits an hour after an attempt. Non-unique index on `channel_id` (some installs carry duplicate rows). | -| `videos` | `Video` | Downloaded video metadata. `video_resolution` (VARCHAR(20), nullable): actual pixel dimensions of the downloaded file, e.g. `"1920x1080"`, measured by ffprobe at download time and backfilled by the filesystem rescan; the displayed tier label (e.g. "1080p") is derived client-side (`client/src/utils/videoResolution.ts`), so labeling rules can change without re-probing; NULL = not yet checked or audio-only, `"0x0"` = probe failed (never shown in the UI). Index on `channel_id`. | +| `videos` | `Video` | Downloaded video metadata. `removed` (boolean): no file on disk. `removed_reason` (VARCHAR(32), nullable) says why: `user_deleted` (deleted in the app), `auto_removal` (automatic cleanup; `removed_detail` VARCHAR(255) holds the rule, e.g. "older than 30 days"), `not_found` (a file was expected on disk and not found: the video is *missing*), or `marked_deleted` (the user marked a missing video as deleted). Every other removed row, including NULL (removed before the reason was recorded), is *deleted*. `removed_at` (DATETIME, nullable): when it was removed. Deleted videos are hidden on the Videos page by default; a file that reappears clears all three columns. Index on `(removed, removed_reason)`. `video_resolution` (VARCHAR(20), nullable): actual pixel dimensions of the downloaded file, e.g. `"1920x1080"`, measured by ffprobe at download time and backfilled by the filesystem rescan; the displayed tier label (e.g. "1080p") is derived client-side (`client/src/utils/videoResolution.ts`), so labeling rules can change without re-probing; NULL = not yet checked or audio-only, `"0x0"` = probe failed (never shown in the UI). Index on `channel_id`. | | `channelvideos` | `ChannelVideo` | Channel <-> video associations. `published_at_source` tracks `published_at` provenance: `exact` (.info.json), `approximate` (yt-dlp flat-playlist date), `estimated` (ordering-only placeholder assigned when YouTube returns a listing with no dates; never displayed), NULL (legacy, treated as approximate). Non-unique indexes on `(channel_id, youtube_id)` and `youtube_id`. | | `jobs` | `Job` | Download job queue. `aux_data` (MEDIUMTEXT, nullable): JSON snapshot of job data other than videos (failed downloads, diagnoses, skip counts, terminated channels), written on job save and merged back on startup load; NULL for jobs recorded before the column existed. | | `jobvideos` | `JobVideo` | Job <-> video associations | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index bac1bc1c5..4cec56396 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -155,6 +155,21 @@ See [Authentication - Cannot Find the Setup Token](AUTHENTICATION.md#cannot-find The rescan recognizes `.mp4`, `.webm`, `.mkv`, `.m4v`, `.avi`, and `.mp3`. Files that no longer have the `[<youtube-id>]` segment in their name (for example, if you renamed `Channel - Video [abc123XYZ01].mp4` to `My Movie.mp4`) cannot be matched and will continue to show as missing. +### Deleted vs. Missing Videos, and Emptying the Trash + +Youtarr keeps a record of every video it has downloaded, even after the file is gone, so the video can be re-downloaded and so automatic downloads do not fetch it again. A video without a file is shown in one of two ways: + +- **Deleted** (neutral grey chip): Youtarr removed the file on purpose, through a delete in the app or automatic cleanup, or you marked a missing video as deleted. Videos removed before Youtarr started recording the reason also show as Deleted ("Removed before Youtarr recorded why"). The Videos page hides deleted videos by default; the **Deleted** filter chip (set to "Hide" unless you change it, which is remembered in your browser) and the "N deleted videos hidden. Show" note bring them back. Channel and playlist pages keep listing them. +- **Missing** (red chip): Youtarr expected a file on disk and could not find it. Missing videos stay visible so you can look into them. Hover or tap the chip, or open the video, to see the path Youtarr looked at and when. + +**The downloads folder looks unmounted**: if your downloads live on a drive or network share and it is not mounted, every file looks gone. Youtarr checks that the downloads folder (and each `__subfolder` in it) exists and is not empty before marking anything missing. While that check fails, the Videos page shows a warning instead of marking videos missing, the scheduled rescan records a partial run that names the folder, and the "all missing videos" actions below are disabled. Mount the share and the warning goes away on its own; nothing was changed in the meantime. + +**Mark as deleted**: when a missing video's file really is gone, select it on the Videos page and choose **Mark as deleted**, or use **Settings -> Maintenance & Rescan -> Mark all missing as deleted**. The record is kept (the video can still be re-downloaded, and a file that turns up again is marked downloaded by the rescan); only how the Videos page treats it changes. + +**Empty trash** (`Settings -> Maintenance & Rescan -> Empty trash...`) forgets deleted and/or missing videos entirely. You choose which videos (Deleted, Missing, or both) and what happens to the download archive (`complete.list`): keeping the entries (recommended) means automatic channel and playlist downloads keep skipping those videos; removing them means automatic downloads treat the videos as new and may download them again at the next run. Emptying the trash removes the videos from the Videos page and Download History, makes their channel and playlist pages show them as not downloaded, and removes the watch history Youtarr synced for them from your media servers. Nothing on disk is touched, and if a missing video's file turns up later Youtarr will not know about it. This cannot be undone. + +If you choose to remove download archive entries, wait for active downloads to finish before emptying the trash. Downloads queued during that purge wait for it to finish. If a purge fails partway, earlier batches remain purged; a batch whose archive cleanup failed stays in the trash so you can fix the error and retry. + ### I Converted Videos to a Different Format and Youtarr Lost Them **Problem**: You used ffmpeg or another tool to convert downloaded `.mp4` videos to `.mkv` (or another container), and Youtarr now lists those videos as missing. diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index 60b1562a6..288500f21 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -374,9 +374,11 @@ Videos can become "missing" if they're manually deleted from disk. This feature > **Note**: If the file still exists somewhere (you moved it, renamed its folder, or converted it to a different format), use [Rescan Files on Disk](#rescan-files-on-disk) instead. Rescan reconciles Youtarr's database with what's already on disk without re-downloading. +Youtarr tells two kinds of gone-from-disk videos apart. **Deleted** (grey) means Youtarr removed the file itself, through a delete in the app or automatic cleanup, or you marked it as deleted; the Videos page hides these by default behind the **Deleted** filter chip, and remembers it when you turn them back on. **Missing** (red) means Youtarr expected a file and could not find it; these stay visible so you can check on them. Hover over or tap either chip, or open the video, for the reason in plain words. When a missing video really is gone, select it and choose **Mark as deleted**, or mark them all from **Settings -> Maintenance & Rescan**, where **Empty trash...** can also forget deleted and missing videos entirely (see [Troubleshooting](TROUBLESHOOTING.md#deleted-vs-missing-videos-and-emptying-the-trash) for what that removes and how it treats the download archive). Both kinds can be re-downloaded with the steps below. + 1. **Identify missing videos** - Go to "Downloaded Videos" or a specific channel's video page - - Look for videos marked with a cloud-off icon (indicates missing from disk) + - Look for videos marked with a cloud-off icon (missing from disk) or a trash icon (deleted); on the Videos page, turn the **Deleted** filter chip off to see deleted videos - The video metadata is still in Youtarr's database, but the file is gone 2. **Select videos to re-download** @@ -409,6 +411,8 @@ You can also start a rescan with **Run now** on the Rescan files on disk card in A scan also runs daily on a schedule and once at server startup, so changes you make outside Youtarr will eventually be picked up even if you don't trigger a manual rescan. +If the downloads folder (or one of its `__subfolders`) is missing or empty when the scan runs, as it is while a drive or network share is unmounted, the scan does not mark anything missing: it records a run that names the folder and asks whether the drive is mounted, and the Videos page shows the same warning. Mount the share and run it again. + **Supported file extensions**: `.mp4`, `.webm`, `.mkv`, `.m4v`, `.avi` for video, plus `.mp3` for audio-only downloads. Youtarr only writes `.mp4` (or `.mp3` for audio-only), but the rescan recognizes any of these so transcoding outside Youtarr won't orphan your library. Files must keep the `[<youtube-id>]` segment in their filename (the 11-character ID in brackets that yt-dlp writes by default) for Youtarr to match them back to the database. **When to use this vs. Re-download Missing Videos**: diff --git a/migrations/20261018055008-add-video-removed-reason.js b/migrations/20261018055008-add-video-removed-reason.js new file mode 100644 index 000000000..99cb6ad10 --- /dev/null +++ b/migrations/20261018055008-add-video-removed-reason.js @@ -0,0 +1,42 @@ +'use strict'; + +const { + addColumnIfMissing, + removeColumnIfExists, + addIndexIfMissing, + removeIndexIfExists +} = require('./helpers'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + // Why a removed row has no file: 'user_deleted' (deleted in the app), + // 'auto_removal' (automatic cleanup, with the rule in removed_detail), + // 'not_found' (expected on disk, not there), or 'marked_deleted' (the + // user marked a missing video as deleted). NULL on a removed row means + // it was removed before the reason was recorded; it is shown as deleted. + await addColumnIfMissing(queryInterface, 'videos', 'removed_reason', { + type: Sequelize.STRING(32), + allowNull: true, + defaultValue: null + }); + await addColumnIfMissing(queryInterface, 'videos', 'removed_at', { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null + }); + await addColumnIfMissing(queryInterface, 'videos', 'removed_detail', { + type: Sequelize.STRING(255), + allowNull: true, + defaultValue: null + }); + await addIndexIfMissing(queryInterface, 'videos', ['removed', 'removed_reason']); + }, + + async down (queryInterface) { + await removeIndexIfExists(queryInterface, 'videos', ['removed', 'removed_reason']); + await removeColumnIfExists(queryInterface, 'videos', 'removed_detail'); + await removeColumnIfExists(queryInterface, 'videos', 'removed_at'); + await removeColumnIfExists(queryInterface, 'videos', 'removed_reason'); + } +}; diff --git a/server/__tests__/server.core.test.js b/server/__tests__/server.core.test.js index 07ff3be72..0f2307d3e 100644 --- a/server/__tests__/server.core.test.js +++ b/server/__tests__/server.core.test.js @@ -631,6 +631,7 @@ describe('server initialization', () => { channelFilter: 'channel123', protectedFilter: 'off', missingFilter: 'off', + deletedFilter: 'off', watchedFilter: 'off', maxRating: null, }); @@ -729,6 +730,7 @@ describe('server initialization', () => { channelFilter: '', protectedFilter: 'off', missingFilter: 'off', + deletedFilter: 'off', watchedFilter: 'off', maxRating: null, }); diff --git a/server/__tests__/server.routes.test.js b/server/__tests__/server.routes.test.js index 5b29c411f..6c1fac626 100644 --- a/server/__tests__/server.routes.test.js +++ b/server/__tests__/server.routes.test.js @@ -1096,7 +1096,8 @@ describe('server routes - channels', () => { 'off', // default missingFilter 'off', // default ignoredFilter 'off', // default watchedFilter - null // default maxRating + null, // default maxRating + 'off' // default deletedFilter ); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ @@ -1166,7 +1167,8 @@ describe('server routes - channels', () => { 'off', // default missingFilter 'off', // default ignoredFilter 'only', // watchedFilter - null // default maxRating + null, // default maxRating + 'off' // default deletedFilter ); expect(res.statusCode).toBe(200); }); @@ -1210,7 +1212,8 @@ describe('server routes - channels', () => { 'off', // default missingFilter 'off', // default ignoredFilter 'off', // default watchedFilter - null // default maxRating + null, // default maxRating + 'off' // default deletedFilter ); expect(res.statusCode).toBe(200); }); @@ -1230,7 +1233,8 @@ describe('server routes - channels', () => { await getVideosHandler(req, res); const args = channelModuleMock.getChannelVideos.mock.calls[0]; - expect(args[args.length - 1]).toBe('TV-14'); + // maxRating is the 17th argument; deletedFilter follows it. + expect(args[16]).toBe('TV-14'); }); test('rejects an unknown maxRating with 400', async () => { @@ -1607,6 +1611,7 @@ describe('server routes - videos', () => { channelFilter: '', protectedFilter: 'off', missingFilter: 'off', + deletedFilter: 'off', watchedFilter: 'off', maxRating: null, }); @@ -1659,6 +1664,7 @@ describe('server routes - videos', () => { channelFilter: 'channel123', protectedFilter: 'off', missingFilter: 'off', + deletedFilter: 'off', watchedFilter: 'exclude', maxRating: null, }); diff --git a/server/models/video.js b/server/models/video.js index 1eb7dcfcf..e68143bfc 100644 --- a/server/models/video.js +++ b/server/models/video.js @@ -63,6 +63,23 @@ Video.init( allowNull: false, defaultValue: false, }, + // See modules/videoRemovalState.js: why a removed row has no file. + // NULL on a removed row = removed before the reason was recorded. + removed_reason: { + type: DataTypes.STRING(32), + allowNull: true, + defaultValue: null, + }, + removed_at: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + removed_detail: { + type: DataTypes.STRING(255), + allowNull: true, + defaultValue: null, + }, media_type: { type: DataTypes.STRING, allowNull: false, diff --git a/server/modules/__tests__/archiveModule.test.js b/server/modules/__tests__/archiveModule.test.js index 9490e373d..2a701b5f3 100644 --- a/server/modules/__tests__/archiveModule.test.js +++ b/server/modules/__tests__/archiveModule.test.js @@ -779,4 +779,61 @@ youtube video2 ); }); }); -}); \ No newline at end of file + + describe('removeVideosFromArchive', () => { + test('drops every listed id in one rewrite and reports how many lines went', async () => { + fs.readFileSync.mockReturnValue('youtube video1\nyoutube video2\nyoutube video3\nyoutube video4'); + + const removed = await ArchiveModule.removeVideosFromArchive(['video2', 'video4', 'notthere']); + + expect(removed).toBe(2); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + expect(fs.writeFileSync).toHaveBeenCalledWith( + `${mockArchivePath}.purge.tmp`, + 'youtube video1\nyoutube video3\n', + 'utf-8' + ); + expect(fs.renameSync).toHaveBeenCalledWith(`${mockArchivePath}.purge.tmp`, mockArchivePath); + }); + + test('leaves the file alone when given no ids', async () => { + fs.readFileSync.mockReturnValue('youtube video1'); + + expect(await ArchiveModule.removeVideosFromArchive([])).toBe(0); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + test('leaves the file alone when none of the ids are listed', async () => { + fs.readFileSync.mockReturnValue('youtube video1'); + + expect(await ArchiveModule.removeVideosFromArchive(['video9'])).toBe(0); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + test('writes an empty file when every line goes', async () => { + fs.readFileSync.mockReturnValue('youtube video1\nyoutube video2'); + + expect(await ArchiveModule.removeVideosFromArchive(['video1', 'video2'])).toBe(2); + expect(fs.writeFileSync).toHaveBeenCalledWith(`${mockArchivePath}.purge.tmp`, '', 'utf-8'); + }); + + test('keeps the original archive when writing its replacement fails', async () => { + fs.readFileSync.mockReturnValue('youtube video1\nyoutube video2'); + fs.writeFileSync.mockImplementation(() => { throw new Error('ENOSPC'); }); + + await expect(ArchiveModule.removeVideosFromArchive(['video1'])).rejects.toThrow('ENOSPC'); + + expect(fs.renameSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalledWith(`${mockArchivePath}.purge.tmp`, 'youtube video2\n', 'utf-8'); + expect(fs.unlinkSync).toHaveBeenCalledWith(`${mockArchivePath}.purge.tmp`); + }); + + test('propagates replacement failures so the database transaction can roll back', async () => { + fs.readFileSync.mockReturnValue('youtube video1\nyoutube video2'); + fs.renameSync.mockImplementation(() => { throw new Error('EACCES'); }); + + await expect(ArchiveModule.removeVideosFromArchive(['video1'])).rejects.toThrow('EACCES'); + expect(fs.unlinkSync).toHaveBeenCalledWith(`${mockArchivePath}.purge.tmp`); + }); + }); +}); diff --git a/server/modules/__tests__/archiveOnlyIds.test.js b/server/modules/__tests__/archiveOnlyIds.test.js new file mode 100644 index 000000000..f03a4a696 --- /dev/null +++ b/server/modules/__tests__/archiveOnlyIds.test.js @@ -0,0 +1,38 @@ +/* eslint-env jest */ +jest.mock('../../models/channelvideo', () => ({ findAll: jest.fn() })); +jest.mock('../archiveModule', () => ({ filterArchivedVideoIds: jest.fn() })); +const ChannelVideo = require('../../models/channelvideo'); +const archiveModule = require('../archiveModule'); +const { findArchiveOnlyIds } = require('../archiveOnlyIds'); + +beforeEach(() => { + jest.clearAllMocks(); + ChannelVideo.findAll.mockResolvedValue([]); +}); + +describe('findArchiveOnlyIds', () => { + test('returns the ids the download archive lists', async () => { + archiveModule.filterArchivedVideoIds.mockReturnValue(new Set(['aaaaaaaaaaa'])); + + const result = await findArchiveOnlyIds(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + + expect([...result]).toEqual(['aaaaaaaaaaa']); + }); + + test('leaves out ignored videos, whose archive entry an explicit download removes', async () => { + archiveModule.filterArchivedVideoIds.mockReturnValue(new Set(['aaaaaaaaaaa', 'bbbbbbbbbbb'])); + ChannelVideo.findAll.mockResolvedValue([{ youtube_id: 'bbbbbbbbbbb' }]); + + const result = await findArchiveOnlyIds(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + + expect([...result]).toEqual(['aaaaaaaaaaa']); + }); + + test('does not look up ignored videos when nothing is archived', async () => { + archiveModule.filterArchivedVideoIds.mockReturnValue(new Set()); + + await findArchiveOnlyIds(['aaaaaaaaaaa']); + + expect(ChannelVideo.findAll).not.toHaveBeenCalled(); + }); +}); diff --git a/server/modules/__tests__/channelDownloadAllModule.test.js b/server/modules/__tests__/channelDownloadAllModule.test.js index 19ef4a140..1187c659a 100644 --- a/server/modules/__tests__/channelDownloadAllModule.test.js +++ b/server/modules/__tests__/channelDownloadAllModule.test.js @@ -4,6 +4,7 @@ jest.mock('../../models', () => ({ Channel: { findOne: jest.fn() }, })); jest.mock('../downloadModule', () => ({ doSpecificDownloads: jest.fn() })); +jest.mock('../archiveOnlyIds', () => ({ findArchiveOnlyIds: jest.fn() })); jest.mock('../../logger', () => ({ info: jest.fn(), debug: jest.fn(), @@ -14,6 +15,7 @@ jest.mock('../../logger', () => ({ const ChannelVideo = require('../../models/channelvideo'); const { Video, Channel } = require('../../models'); const downloadModule = require('../downloadModule'); +const { findArchiveOnlyIds } = require('../archiveOnlyIds'); const channelDownloadAllModule = require('../channelDownloadAllModule'); const CHANNEL_ID = 'UC123'; @@ -33,6 +35,7 @@ beforeEach(() => { jest.clearAllMocks(); ChannelVideo.findAll.mockResolvedValue([]); Video.findAll.mockResolvedValue([]); + findArchiveOnlyIds.mockResolvedValue(new Set()); Channel.findOne.mockResolvedValue(channelRow); downloadModule.doSpecificDownloads.mockImplementation(async ({ body }) => ({ queued: body.urls.length })); }); @@ -94,6 +97,25 @@ describe('getDownloadableVideos', () => { ); }); + it('excludes videos the download archive lists without a row, such as emptied from the trash', async () => { + ChannelVideo.findAll.mockResolvedValue([cv('a'), cv('b'), cv('c')]); + Video.findAll.mockResolvedValue([{ youtubeId: 'a', removed: true }]); + findArchiveOnlyIds.mockResolvedValue(new Set(['b'])); + + const result = await channelDownloadAllModule.getDownloadableVideos(CHANNEL_ID, 'videos'); + + expect(result.map((v) => v.youtube_id)).toEqual(['c']); + }); + + it('looks up the archive only for videos without a row', async () => { + ChannelVideo.findAll.mockResolvedValue([cv('a'), cv('b')]); + Video.findAll.mockResolvedValue([{ youtubeId: 'a', removed: true }]); + + await channelDownloadAllModule.getDownloadableVideos(CHANNEL_ID, 'videos'); + + expect(findArchiveOnlyIds).toHaveBeenCalledWith(['b']); + }); + it('does not query the videos table when no candidates exist', async () => { ChannelVideo.findAll.mockResolvedValue([]); diff --git a/server/modules/__tests__/channelModule.test.js b/server/modules/__tests__/channelModule.test.js index 801b663b1..6c68c5670 100644 --- a/server/modules/__tests__/channelModule.test.js +++ b/server/modules/__tests__/channelModule.test.js @@ -83,7 +83,7 @@ describe('channelModule facade', () => { ['getChannelsPaginated', '../channel/channelCatalog', 'getChannelsPaginated', [{ page: 2, pageSize: 10 }], 'async'], ['writeChannels', '../channel/channelCatalog', 'writeChannels', [['https://www.youtube.com/@a']], 'async'], ['updateChannelsByDelta', '../channel/channelCatalog', 'updateChannelsByDelta', [{ enableUrls: ['https://www.youtube.com/@a'], disableUrls: [] }], 'async'], - ['getChannelVideos', '../channel/channelVideosService', 'getChannelVideos', ['UC1', 2, 25, 'only', 'q', 'title', 'asc', 'shorts', 60, 600, '2026-01-01', '2026-02-01', 'only', 'exclude', 'off', 'only', 'PG'], 'async'], + ['getChannelVideos', '../channel/channelVideosService', 'getChannelVideos', ['UC1', 2, 25, 'only', 'q', 'title', 'asc', 'shorts', 60, 600, '2026-01-01', '2026-02-01', 'only', 'exclude', 'off', 'only', 'PG', 'exclude'], 'async'], ['fetchAllChannelVideos', '../channel/channelVideosService', 'fetchAllChannelVideos', ['UC1', 2, 25, 'only', 'shorts'], 'async'], ['getChannelAvailableTabs', '../channel/tabManager', 'getChannelAvailableTabs', ['UC1'], 'async'], ['updateAutoDownloadForTab', '../channel/tabManager', 'updateAutoDownloadForTab', ['UC1', 'shorts', true], 'async'], diff --git a/server/modules/__tests__/fileCheckModule.test.js b/server/modules/__tests__/fileCheckModule.test.js index 75991d7f9..e47848bc5 100644 --- a/server/modules/__tests__/fileCheckModule.test.js +++ b/server/modules/__tests__/fileCheckModule.test.js @@ -6,10 +6,15 @@ jest.mock('fs', () => ({ } })); +// What the check writes when a file is gone, and when it is (back) on disk. +const MISSING_COLUMNS = { removed: true, removed_reason: 'not_found', removed_at: expect.any(Date), removed_detail: null }; +const PRESENT_COLUMNS = { removed: false, removed_reason: null, removed_at: null, removed_detail: null }; + describe('FileCheckModule', () => { let fileCheckModule; let mockFs; let mockVideo; + let mockLibraryRootCheck; beforeEach(() => { jest.resetModules(); @@ -25,9 +30,53 @@ describe('FileCheckModule', () => { Video: mockVideo })); + // The downloads folder looks mounted unless a test says otherwise. + mockLibraryRootCheck = { checkLibraryRoot: jest.fn().mockResolvedValue({ ok: true }) }; + jest.doMock('../libraryRootCheck', () => mockLibraryRootCheck); + jest.doMock('../configModule', () => ({ directoryPath: '/videos' })); + fileCheckModule = require('../fileCheckModule'); }); + describe('while the downloads folder looks unmounted', () => { + const failedCheck = { ok: false, reason: 'empty', path: '/videos', message: 'The downloads folder /videos is empty.' }; + const presentRow = (id, youtubeId) => ({ + id, youtubeId, filePath: `/videos/channel/${youtubeId}.mp4`, fileSize: '1000', removed: false + }); + + test('holds back the missing mark and reports how many rows it held', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(failedCheck); + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + + const result = await fileCheckModule.checkVideoFiles([presentRow(1, 'abc123'), presentRow(2, 'def456')]); + + expect(result.updates).toEqual([]); + expect(result.videos.map((v) => v.removed)).toEqual([false, false]); + expect(result.libraryWarning).toEqual({ reason: 'empty', path: '/videos', message: failedCheck.message, heldBack: 2 }); + expect(mockLibraryRootCheck.checkLibraryRoot).toHaveBeenCalledTimes(1); + expect(mockLibraryRootCheck.checkLibraryRoot).toHaveBeenCalledWith('/videos'); + }); + + test('still records a present file\'s new size', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(failedCheck); + mockFs.stat.mockResolvedValue({ size: 2000 }); + + const result = await fileCheckModule.checkVideoFiles([presentRow(1, 'abc123')]); + + expect(result.updates).toEqual([{ id: 1, fileSize: 2000 }]); + expect(result.libraryWarning).toBeNull(); + }); + + test('does not run the check when no row would be marked missing', async () => { + mockFs.stat.mockResolvedValue({ size: 1000 }); + + const result = await fileCheckModule.checkVideoFiles([presentRow(1, 'abc123')]); + + expect(mockLibraryRootCheck.checkLibraryRoot).not.toHaveBeenCalled(); + expect(result.libraryWarning).toBeNull(); + }); + }); + describe('checkVideoFiles', () => { test('should return unchanged videos when none have filePaths', async () => { const videos = [ @@ -61,12 +110,13 @@ describe('FileCheckModule', () => { id: 1, youtubeId: 'abc123', filePath: '/videos/channel/video.mp4', - removed: true + ...MISSING_COLUMNS } ]); expect(result.updates).toEqual([ - { id: 1, removed: true } + { id: 1, ...MISSING_COLUMNS } ]); + expect(result.libraryWarning).toBeNull(); expect(mockFs.stat).toHaveBeenCalledWith('/videos/channel/video.mp4'); }); @@ -89,6 +139,27 @@ describe('FileCheckModule', () => { expect(mockFs.stat).toHaveBeenCalledWith('/videos/channel/video.mp4'); }); + test('leaves a deleted video deleted while its file is still gone', async () => { + const videos = [ + { + id: 1, + youtubeId: 'abc123', + filePath: '/videos/channel/video.mp4', + removed: true, + removed_reason: 'user_deleted', + removed_at: new Date('2026-10-03T00:00:00Z'), + removed_detail: null + } + ]; + + mockFs.stat.mockRejectedValueOnce({ code: 'ENOENT' }); + + const result = await fileCheckModule.checkVideoFiles(videos); + + expect(result.videos).toEqual(videos); + expect(result.updates).toEqual([]); + }); + test('should mark video as not removed when file exists', async () => { const videos = [ { @@ -110,15 +181,37 @@ describe('FileCheckModule', () => { youtubeId: 'abc123', filePath: '/videos/channel/video.mp4', fileSize: '1000', - removed: false + ...PRESENT_COLUMNS } ]); expect(result.updates).toEqual([ - { id: 1, removed: false } + { id: 1, ...PRESENT_COLUMNS } ]); expect(mockFs.stat).toHaveBeenCalledWith('/videos/channel/video.mp4'); }); + test('clears the recorded reason when a deleted video\'s file is back', async () => { + const videos = [ + { + id: 1, + youtubeId: 'abc123', + filePath: '/videos/channel/video.mp4', + fileSize: '1000', + removed: true, + removed_reason: 'user_deleted', + removed_at: new Date('2026-10-03T00:00:00Z'), + removed_detail: null + } + ]; + + mockFs.stat.mockResolvedValueOnce({ size: 1000 }); + + const result = await fileCheckModule.checkVideoFiles(videos); + + expect(result.updates).toEqual([{ id: 1, ...PRESENT_COLUMNS }]); + expect(result.videos[0]).toEqual(expect.objectContaining(PRESENT_COLUMNS)); + }); + test('should update fileSize when it has changed', async () => { const videos = [ { @@ -218,14 +311,14 @@ describe('FileCheckModule', () => { youtubeId: 'abc123', filePath: '/videos/channel/video1.mp4', fileSize: '1000', - removed: true + ...MISSING_COLUMNS }, { id: 2, youtubeId: 'def456', filePath: '/videos/channel/video2.mp4', fileSize: '2000', - removed: false + ...PRESENT_COLUMNS }, { id: 3, @@ -241,8 +334,8 @@ describe('FileCheckModule', () => { } ]); expect(result.updates).toEqual([ - { id: 1, removed: true }, - { id: 2, removed: false }, + { id: 1, ...MISSING_COLUMNS }, + { id: 2, ...PRESENT_COLUMNS }, { id: 4, fileSize: 4000 } ]); }); @@ -460,7 +553,7 @@ describe('FileCheckModule', () => { // Marked removed, but filePath/fileSize must NOT appear in the update expect(result.updates).toHaveLength(1); - expect(result.updates[0]).toEqual({ id: 1, removed: true }); + expect(result.updates[0]).toEqual({ id: 1, ...MISSING_COLUMNS }); expect(result.updates[0].filePath).toBeUndefined(); expect(result.updates[0].fileSize).toBeUndefined(); }); @@ -502,7 +595,7 @@ describe('FileCheckModule', () => { const result = await fileCheckModule.checkVideoFiles(videos); expect(result.updates).toHaveLength(1); - expect(result.updates[0]).toEqual({ id: 1, removed: true }); + expect(result.updates[0]).toEqual({ id: 1, ...MISSING_COLUMNS }); expect(result.updates[0].audioFilePath).toBeUndefined(); }); }); @@ -542,6 +635,18 @@ describe('FileCheckModule', () => { ); }); + test('writes the removal reason columns along with the flag', async () => { + const at = new Date('2026-10-03T00:00:00Z'); + await fileCheckModule.applyVideoUpdates([ + { id: 1, removed: true, removed_reason: 'not_found', removed_at: at, removed_detail: null } + ]); + + expect(mockVideo.update).toHaveBeenCalledWith( + { removed: true, removed_reason: 'not_found', removed_at: at, removed_detail: null }, + { where: { id: 1 } }, + ); + }); + test('should update both fileSize and removed status', async () => { const updates = [ { id: 1, fileSize: 2000, removed: false } diff --git a/server/modules/__tests__/jobModule.test.js b/server/modules/__tests__/jobModule.test.js index 8b906c66c..09049c57f 100644 --- a/server/modules/__tests__/jobModule.test.js +++ b/server/modules/__tests__/jobModule.test.js @@ -616,7 +616,11 @@ describe('JobModule', () => { expect(Video.create).toHaveBeenCalledWith(expect.objectContaining({ youtubeId: 'video-1', filePath: actualPath, - fileSize: '98765' + fileSize: '98765', + removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null })); }); @@ -2477,6 +2481,14 @@ describe('JobModule', () => { details: { videosUpserts: 2, channelVideosUpserts: 2, failed: 0, unreadableOnDisk: 0, recreatedUnverified: 0 } })); expect(Video.create).toHaveBeenCalledTimes(2); + // Recreated without a file on disk: missing, not deleted. + expect(Video.create).toHaveBeenCalledWith(expect.objectContaining({ + youtubeId: 'video-1', + removed: true, + removed_reason: 'not_found', + removed_at: expect.any(Date), + removed_detail: null + })); expect(ChannelVideo.findOrCreate).toHaveBeenCalledTimes(2); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ @@ -2639,7 +2651,10 @@ describe('JobModule', () => { youtubeId: 'video-1', filePath, fileSize: '123456789', - removed: false + removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null }) ); @@ -2693,7 +2708,7 @@ describe('JobModule', () => { expect(Video.create).not.toHaveBeenCalled(); expect(Video.update).toHaveBeenCalledWith( - { filePath: mp4Path, fileSize: '987654321', removed: false }, + { filePath: mp4Path, fileSize: '987654321', removed: false, removed_reason: null, removed_at: null, removed_detail: null }, { where: expect.objectContaining({ id: 7, removed: false, filePath: mp4Path, fileSize: null }) } ); }); @@ -2894,7 +2909,7 @@ describe('JobModule', () => { await JobModule.backfillFromCompleteList(); expect(Video.update).toHaveBeenCalledWith( - { filePath: `${base}.webm`, fileSize: '777', removed: false }, + { filePath: `${base}.webm`, fileSize: '777', removed: false, removed_reason: null, removed_at: null, removed_detail: null }, expect.anything() ); }); @@ -3526,4 +3541,37 @@ describe('JobModule', () => { expect(JobModule.jobs['job-4'].data).toEqual({ videos: [] }); }); }); + + describe('reloadJobVideos', () => { + beforeEach(() => { + fs.existsSync.mockReturnValue(false); + fs.readFileSync.mockReturnValue(JSON.stringify({ plexApiKey: 'test-key' })); + JobModule = require('../jobModule'); + }); + + test('replaces each named job\'s videos with the rows still linked to it', async () => { + JobModule.jobs = { + 'job-1': { timeCreated: Date.now(), status: 'Complete', data: { videos: [{ id: 1 }, { id: 2 }] } }, + 'job-2': { timeCreated: Date.now(), status: 'Complete', data: { videos: [{ id: 3 }] } }, + }; + JobVideo.findAll.mockImplementation(async ({ where }) => (where.job_id === 'job-1' ? [{ video_id: 2 }] : [])); + Video.findOne.mockResolvedValue({ dataValues: { id: 2, youtubeId: 'b' } }); + + await JobModule.reloadJobVideos(['job-1', 'job-2']); + + expect(JobModule.jobs['job-1'].data.videos).toEqual([{ id: 2, youtubeId: 'b' }]); + expect(JobModule.jobs['job-2'].data.videos).toEqual([]); + }); + + test('skips jobs it does not hold and never throws', async () => { + JobModule.jobs = { 'job-1': { timeCreated: Date.now(), status: 'Complete', data: { videos: [{ id: 1 }] } } }; + JobVideo.findAll.mockRejectedValue(new Error('db down')); + + await expect(JobModule.reloadJobVideos(['job-1', 'unknown'])).resolves.toBeUndefined(); + + expect(JobModule.jobs['job-1'].data.videos).toEqual([{ id: 1 }]); + expect(JobVideo.findAll).toHaveBeenCalledWith({ where: { job_id: 'job-1' } }); + expect(JobVideo.findAll).not.toHaveBeenCalledWith({ where: { job_id: 'unknown' } }); + }); + }); }); diff --git a/server/modules/__tests__/libraryRootCheck.test.js b/server/modules/__tests__/libraryRootCheck.test.js new file mode 100644 index 000000000..bce1da75c --- /dev/null +++ b/server/modules/__tests__/libraryRootCheck.test.js @@ -0,0 +1,115 @@ +/* eslint-env jest */ +const path = require('path'); +const { LIBRARY_CHECK_REASONS, checkLibraryRoot, describeLibraryCheck } = require('../libraryRootCheck'); + +const ROOT = '/data/youtube'; + +const dir = (name) => ({ name, isDirectory: () => true }); +const file = (name) => ({ name, isDirectory: () => false }); +const fsError = (code) => Object.assign(new Error(code), { code }); + +// A fake disk: readdir answers per path, with a thrown error code or a listing. +const diskWith = (listings) => async (dirPath) => { + const entry = listings[dirPath]; + if (entry === undefined) throw fsError('ENOENT'); + if (typeof entry === 'string') throw fsError(entry); + return entry; +}; + +describe('checkLibraryRoot', () => { + test('passes for a root that has files and non-empty subfolders', async () => { + const readdir = diskWith({ + [ROOT]: [dir('Channel A'), dir('__Music'), file('.keep')], + [path.join(ROOT, '__Music')]: [dir('Artist')], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ ok: true }); + }); + + test('fails as missing when the root does not exist', async () => { + const result = await checkLibraryRoot(ROOT, { readdir: diskWith({}) }); + expect(result).toEqual({ + ok: false, + reason: LIBRARY_CHECK_REASONS.MISSING, + path: ROOT, + message: 'The downloads folder /data/youtube does not exist.', + }); + }); + + test('fails as missing when the root path is not a directory', async () => { + const result = await checkLibraryRoot(ROOT, { readdir: diskWith({ [ROOT]: 'ENOTDIR' }) }); + expect(result).toEqual(expect.objectContaining({ ok: false, reason: 'missing', path: ROOT })); + }); + + test('fails as unreadable when the root cannot be listed', async () => { + const result = await checkLibraryRoot(ROOT, { readdir: diskWith({ [ROOT]: 'EACCES' }) }); + expect(result).toEqual({ + ok: false, + reason: 'unreadable', + path: ROOT, + message: 'The downloads folder /data/youtube could not be read.', + }); + }); + + test('fails as empty when the root has no entries at all', async () => { + const result = await checkLibraryRoot(ROOT, { readdir: diskWith({ [ROOT]: [] }) }); + expect(result).toEqual({ + ok: false, + reason: 'empty', + path: ROOT, + message: 'The downloads folder /data/youtube is empty.', + }); + }); + + test('fails when a __subfolder exists but is empty, naming that subfolder', async () => { + const music = path.join(ROOT, '__Music'); + const readdir = diskWith({ + [ROOT]: [dir('Channel A'), dir('__Music')], + [music]: [], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ + ok: false, + reason: 'subfolder-empty', + path: music, + message: 'The subfolder /data/youtube/__Music is empty.', + }); + }); + + test('only checks __ folders: an empty channel folder is fine', async () => { + const readdir = diskWith({ + [ROOT]: [dir('Channel A'), dir('__Music')], + [path.join(ROOT, 'Channel A')]: [], + [path.join(ROOT, '__Music')]: [file('song [abc].mp3')], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ ok: true }); + }); + + test('ignores a __subfolder that vanished between the two listings', async () => { + const readdir = diskWith({ [ROOT]: [dir('__Music'), file('a [abc].mp4')] }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ ok: true }); + }); + + test('fails as unreadable when a __subfolder cannot be listed', async () => { + const music = path.join(ROOT, '__Music'); + const readdir = diskWith({ [ROOT]: [dir('__Music')], [music]: 'EIO' }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual( + expect.objectContaining({ ok: false, reason: 'unreadable', path: music }) + ); + }); + + test('treats no configured folder as missing', async () => { + const readdir = jest.fn(); + await expect(checkLibraryRoot('', { readdir })).resolves.toEqual( + expect.objectContaining({ ok: false, reason: 'missing' }) + ); + expect(readdir).not.toHaveBeenCalled(); + }); +}); + +describe('describeLibraryCheck', () => { + test('describes each reason in one sentence', () => { + expect(describeLibraryCheck({ reason: 'missing', path: '/d' })).toBe('The downloads folder /d does not exist.'); + expect(describeLibraryCheck({ reason: 'empty', path: '/d' })).toBe('The downloads folder /d is empty.'); + expect(describeLibraryCheck({ reason: 'unreadable', path: '/d' })).toBe('The downloads folder /d could not be read.'); + expect(describeLibraryCheck({ reason: 'subfolder-empty', path: '/d/__Music' })).toBe('The subfolder /d/__Music is empty.'); + }); +}); diff --git a/server/modules/__tests__/playlistDownloadModule.test.js b/server/modules/__tests__/playlistDownloadModule.test.js index 716c1bdcd..305112024 100644 --- a/server/modules/__tests__/playlistDownloadModule.test.js +++ b/server/modules/__tests__/playlistDownloadModule.test.js @@ -1,7 +1,9 @@ jest.mock('../../logger', () => ({ error: jest.fn() })); jest.mock('../download/videoActivity', () => ({ isActive: jest.fn(() => false) })); +jest.mock('../archiveOnlyIds', () => ({ findArchiveOnlyIds: jest.fn() })); const playlistDownloads = require('../playlistDownloadModule'); const videoActivity = require('../download/videoActivity'); +const { findArchiveOnlyIds } = require('../archiveOnlyIds'); const baseline = new Date('2026-09-01T00:00:00Z'); const playlist = { playlist_id: 'PL1', auto_download: true, auto_download_baseline_at: baseline, auto_download_baseline_id: 10 }; @@ -25,6 +27,32 @@ describe('playlistDownloadModule', () => { beforeEach(() => { jest.clearAllMocks(); videoActivity.isActive.mockReturnValue(false); + findArchiveOnlyIds.mockResolvedValue(new Set()); + }); + + test('leaves out videos the download archive lists without a row, such as emptied from the trash', async () => { + findArchiveOnlyIds.mockResolvedValue(new Set(['old'])); + const { candidates } = await playlistDownloads.getTrackedVideos('PL1', dependencies()); + expect(candidates.map((row) => row.youtube_id)).toEqual(['new']); + }); + + test('looks up the archive only for videos without a row', async () => { + const deps = dependencies(); + deps.Video.findAll.mockResolvedValue([{ youtubeId: 'old' }]); + await playlistDownloads.getTrackedVideos('PL1', deps); + expect(findArchiveOnlyIds).toHaveBeenCalledWith(['new']); + }); + + test('keeps archived videos when re-downloading is allowed', async () => { + findArchiveOnlyIds.mockResolvedValue(new Set(['old'])); + const { candidates } = await playlistDownloads.getTrackedVideos('PL1', dependencies(), { allowRedownload: true }); + expect(candidates.map((row) => row.youtube_id)).toEqual(['old', 'new']); + }); + + test('leaves archived videos out of the not-downloaded count', async () => { + findArchiveOnlyIds.mockResolvedValue(new Set(['new'])); + expect(await playlistDownloads.getCounts(playlist, dependencies())) + .toMatchObject({ not_downloaded_count: 1 }); }); test('keeps channel attribution for all download paths', async () => { diff --git a/server/modules/__tests__/rescanRowUpdate.test.js b/server/modules/__tests__/rescanRowUpdate.test.js index 4a2420621..9e2aa75d5 100644 --- a/server/modules/__tests__/rescanRowUpdate.test.js +++ b/server/modules/__tests__/rescanRowUpdate.test.js @@ -5,10 +5,17 @@ const MP4 = '/videos/Channel/Video [abc].mp4'; const MP3 = '/videos/Channel/Video [abc].mp3'; const MOVED_MP4 = '/videos/Other/Video [abc].mp4'; +// What the rescan writes when a row loses its files, and when they are (back) on disk. +const MISSING = { removed: true, removed_reason: 'not_found', removed_at: expect.any(Date), removed_detail: null }; +const PRESENT = { removed: false, removed_reason: null, removed_at: null, removed_detail: null }; + const row = (overrides = {}) => ({ id: 1, youtubeId: 'abc', removed: 0, + removed_reason: null, + removed_at: null, + removed_detail: null, filePath: null, fileSize: null, audioFilePath: null, @@ -69,7 +76,7 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '1000', audioFilePath: MP3, audioFileSize: '500', video_resolution: '1920x1080' }), undefined, null, diskWith({}) ); - expect(update).toEqual({ removed: true }); + expect(update).toEqual(MISSING); }); test('writes nothing for a row already missing whose files are still gone', async () => { @@ -88,7 +95,21 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '1000', removed: 1 }), { videoFilePath: MP4, videoFileSize: 1000 }, null, diskWith({ [MP4]: 1000 }) ); - expect(update).toEqual({ removed: false }); + expect(update).toEqual(PRESENT); + }); + + test('restores a deleted row whose file is back, clearing why it was removed', async () => { + const update = await resolveRescanUpdate( + row({ filePath: MP4, fileSize: '1000', removed: 1, removed_reason: 'marked_deleted', removed_at: new Date('2026-10-03T00:00:00Z') }), + { videoFilePath: MP4, videoFileSize: 1000 }, null, diskWith({ [MP4]: 1000 }) + ); + expect(update).toEqual(PRESENT); + }); + + test('leaves a deleted row deleted while its files are still gone', async () => { + expect(await resolveRescanUpdate( + row({ filePath: MP4, removed: 1, removed_reason: 'user_deleted' }), undefined, null, diskWith({}) + )).toBeNull(); }); test('records a moved file at the path the walk found, with its fresh size', async () => { @@ -96,7 +117,7 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '1000' }), { videoFilePath: MOVED_MP4, videoFileSize: 1000 }, null, diskWith({ [MOVED_MP4]: 1200 }) ); - expect(update).toEqual({ filePath: MOVED_MP4, fileSize: 1200, removed: false }); + expect(update).toEqual({ filePath: MOVED_MP4, fileSize: 1200, ...PRESENT }); }); test('falls back to the stored path when the walked path is gone', async () => { @@ -112,7 +133,7 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '1000', audioFilePath: MP3, audioFileSize: '500', video_resolution: '1920x1080' }), { audioFilePath: MP3, audioFileSize: 500 }, null, diskWith({ [MP3]: 500 }) ); - expect(update).toEqual({ filePath: null, fileSize: null, video_resolution: null, removed: false }); + expect(update).toEqual({ filePath: null, fileSize: null, video_resolution: null, ...PRESENT }); }); test('keeps a format that appeared after the walk', async () => { @@ -128,7 +149,7 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '900' }), { videoFilePath: MP4, videoFileSize: 1000 }, null, diskWith({ [MP4]: 1100 }) ); - expect(update).toEqual({ fileSize: 1100, removed: false }); + expect(update).toEqual({ fileSize: 1100, ...PRESENT }); }); test('stores a probed resolution only while the probed file is unchanged', async () => { @@ -139,8 +160,8 @@ describe('resolveRescanUpdate', () => { const replaced = await resolveRescanUpdate( row({ filePath: MP4, fileSize: '1000' }), fileInfo, '1920x1080', diskWith({ [MP4]: 2000 }) ); - expect(unchanged).toEqual({ video_resolution: '1920x1080', removed: false }); - expect(replaced).toEqual({ fileSize: 2000, removed: false }); + expect(unchanged).toEqual({ video_resolution: '1920x1080', ...PRESENT }); + expect(replaced).toEqual({ fileSize: 2000, ...PRESENT }); }); test('leaves the row alone when its only file cannot be checked', async () => { @@ -154,14 +175,14 @@ describe('resolveRescanUpdate', () => { const update = await resolveRescanUpdate( row({ filePath: MP4, fileSize: '1000' }), undefined, null, diskWith({}, { [MP4]: 'ENOTDIR' }) ); - expect(update).toEqual({ removed: true }); + expect(update).toEqual(MISSING); }); test('does not record a directory at the stored path as the file', async () => { const update = await resolveRescanUpdate( row({ filePath: MP4, fileSize: '1000' }), undefined, null, diskWith({}, {}, [MP4]) ); - expect(update).toEqual({ removed: true }); + expect(update).toEqual(MISSING); }); test('neither clears nor marks missing while one format cannot be checked', async () => { @@ -177,7 +198,7 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '900', audioFilePath: MP3, audioFileSize: '500' }), undefined, null, diskWith({ [MP4]: 1000 }, { [MP3]: 'EIO' }) ); - expect(update).toEqual({ fileSize: 1000, removed: false }); + expect(update).toEqual({ fileSize: 1000, ...PRESENT }); }); test('restores a missing row whose video is back even though its audio cannot be checked', async () => { @@ -185,7 +206,7 @@ describe('resolveRescanUpdate', () => { row({ removed: 1, filePath: MP4, fileSize: '1000', audioFilePath: MP3, audioFileSize: '500' }), { videoFilePath: MP4, videoFileSize: 1000 }, null, diskWith({ [MP4]: 1000 }, { [MP3]: 'EIO' }) ); - expect(update).toEqual({ removed: false }); + expect(update).toEqual(PRESENT); }); // videoRowGuard can't tell a no-op write from a conflict (Sequelize connects diff --git a/server/modules/__tests__/rescanRunSummary.test.js b/server/modules/__tests__/rescanRunSummary.test.js index e37b3c955..01e92df00 100644 --- a/server/modules/__tests__/rescanRunSummary.test.js +++ b/server/modules/__tests__/rescanRunSummary.test.js @@ -21,10 +21,24 @@ describe('rescanRunSummary', () => { videosKept: 0, videosSkipped: 0, videosFailed: 0, + videosNotMarkedMissing: 0, }, }); }); + test('records a scan that held back missing marks behind a library check as partial, naming the cause', () => { + const record = toRunRecord({ + status: 'completed', processed: 10, updated: 1, removed: 0, + notMarkedMissing: 2, libraryCheckMessage: 'The subfolder /d/__Music is empty.', + }); + expect(record).toEqual(expect.objectContaining({ + status: 'error', + outcome: 'partial', + message: 'Scanned 10 videos: 1 updated, 0 marked missing, 2 not marked missing (The subfolder /d/__Music is empty.).', + })); + expect(record.details.videosNotMarkedMissing).toBe(2); + }); + test('reports rows skipped because they changed during the scan without treating the run as skipped', () => { const record = toRunRecord({ status: 'completed', processed: 10, updated: 1, removed: 0, skippedChanged: 2 }); expect(record).toEqual(expect.objectContaining({ diff --git a/server/modules/__tests__/videoDeletionModule.test.js b/server/modules/__tests__/videoDeletionModule.test.js index d105207cd..4b5e7a056 100644 --- a/server/modules/__tests__/videoDeletionModule.test.js +++ b/server/modules/__tests__/videoDeletionModule.test.js @@ -18,6 +18,14 @@ jest.mock('../storageGuard', () => ({ // which fs-extra (required by directoryManager) cannot load against. const { isVideoDirectoryFor } = jest.requireActual('../filesystem/directoryManager'); +// What a delete in the app writes on the row: removed, by the user, now. +const USER_DELETED_COLUMNS = expect.objectContaining({ + removed: true, + removed_reason: 'user_deleted', + removed_at: expect.any(Date), + removed_detail: null +}); + describe('VideoDeletionModule', () => { let VideoDeletionModule; let mockVideo; @@ -101,7 +109,7 @@ describe('VideoDeletionModule', () => { expect(mockFilesystem.removeDirectoryResilient).toHaveBeenCalledWith( '/test/output/Channel Name/Channel Name - Video Title - abc123' ); - expect(mockVideoRecord.update).toHaveBeenCalledWith({ removed: true }); + expect(mockVideoRecord.update).toHaveBeenCalledWith(USER_DELETED_COLUMNS); expect(result).toEqual({ success: true, videoId: 1, @@ -113,6 +121,26 @@ describe('VideoDeletionModule', () => { ); }); + test('records the removal reason and detail it is given', async () => { + const mockVideoRecord = { + id: 1, + youtubeId: 'abc123', + filePath: '/test/output/Channel Name/Channel Name - Video Title - abc123/video.mp4', + removed: false, + update: jest.fn().mockResolvedValue() + }; + mockVideo.findByPk.mockResolvedValue(mockVideoRecord); + + await VideoDeletionModule.deleteVideoById(1, { reason: 'auto_removal', detail: 'older than 30 days' }); + + expect(mockVideoRecord.update).toHaveBeenCalledWith(expect.objectContaining({ + removed: true, + removed_reason: 'auto_removal', + removed_at: expect.any(Date), + removed_detail: 'older than 30 days' + })); + }); + test('should return error when video not found', async () => { mockVideo.findByPk.mockResolvedValue(null); @@ -159,7 +187,7 @@ describe('VideoDeletionModule', () => { const result = await VideoDeletionModule.deleteVideoById(1); - expect(mockVideoRecord.update).toHaveBeenCalledWith({ removed: true }); + expect(mockVideoRecord.update).toHaveBeenCalledWith(USER_DELETED_COLUMNS); expect(mockFilesystem.removeDirectoryResilient).not.toHaveBeenCalled(); expect(result).toEqual({ success: true, @@ -273,7 +301,7 @@ describe('VideoDeletionModule', () => { const result = await VideoDeletionModule.deleteVideoById(1); expect(result.success).toBe(true); - expect(record.update).toHaveBeenCalledWith({ removed: true }); + expect(record.update).toHaveBeenCalledWith(USER_DELETED_COLUMNS); }); }); @@ -336,7 +364,7 @@ describe('VideoDeletionModule', () => { expect(mockFilesystem.removeDirectoryResilient).toHaveBeenCalledWith( '/test/output/Channel/Channel - Video - abc123' ); - expect(mockVideoRecord.update).toHaveBeenCalledWith({ removed: true }); + expect(mockVideoRecord.update).toHaveBeenCalledWith(USER_DELETED_COLUMNS); expect(result).toEqual({ success: true, videoId: 1, @@ -1502,6 +1530,11 @@ describe('VideoDeletionModule', () => { expect(result.freedBytes).toBeGreaterThan(0); expect(result.plan.ageStrategy.deletedCount).toBe(1); expect(mockFilesystem.removeDirectoryResilient).toHaveBeenCalled(); + expect(mockVideoRecord.update).toHaveBeenCalledWith(expect.objectContaining({ + removed: true, + removed_reason: 'auto_removal', + removed_detail: 'older than 30 days' + })); }); test('should perform space-based cleanup in dry-run mode', async () => { @@ -2117,6 +2150,26 @@ describe('VideoDeletionModule', () => { expect(result.deletedByUsage).toBe(2); }); + test('records the usage limit as the reason on each deleted video', async () => { + const records = {}; + mockVideo.findByPk.mockImplementation((id) => { + records[id] = deletableRecord(id); + return Promise.resolve(records[id]); + }); + mockConfigModule.getConfig.mockReturnValue({ autoRemovalEnabled: true, autoRemovalUsageLimit: '10GB' }); + mockStorageUsage.getDownloadedBytes.mockResolvedValue(11 * GB); + mockSequelize.query.mockResolvedValueOnce([ + { id: 1, youtubeId: 'yt1', fileSize: String(2 * GB) } + ]); + + await VideoDeletionModule.performAutomaticCleanup(); + + expect(records[1].update).toHaveBeenCalledWith(expect.objectContaining({ + removed_reason: 'auto_removal', + removed_detail: 'over the 10GB total size limit' + })); + }); + test('does nothing while usage is within the limit', async () => { mockConfigModule.getConfig.mockReturnValue({ autoRemovalEnabled: true, autoRemovalUsageLimit: '10GB' }); mockStorageUsage.getDownloadedBytes.mockResolvedValue(10 * GB); diff --git a/server/modules/__tests__/videoLocalStatus.test.js b/server/modules/__tests__/videoLocalStatus.test.js index ef67cb9fe..7697203af 100644 --- a/server/modules/__tests__/videoLocalStatus.test.js +++ b/server/modules/__tests__/videoLocalStatus.test.js @@ -26,17 +26,33 @@ it('merges downloaded and removed records while leaving unknown videos alone', a { id: 1, youtubeId: 'aaaaaaaaaaa', removed: false, filePath: '/video.mp4', fileSize: 123, audioFilePath: '/audio.mp3', audioFileSize: 45, last_downloaded_at: '2026-09-01T00:00:00Z', protected: true, normalized_rating: 'PG', rating_source: 'manual' }, - { id: 2, youtubeId: 'bbbbbbbbbbb', removed: true, last_downloaded_at: null, protected: false }, + { id: 2, youtubeId: 'bbbbbbbbbbb', removed: true, removed_reason: 'not_found', removed_at: '2026-10-03T00:00:00Z', + removed_detail: null, last_downloaded_at: null, protected: false }, ]); const results = pending('aaaaaaaaaaa', 'bbbbbbbbbbb', 'ccccccccccc'); await applyLocalVideoStatus(results); expect(results[0]).toMatchObject({ status: 'downloaded', databaseId: 1, filePath: '/video.mp4', fileSize: 123, audioFilePath: '/audio.mp3', audioFileSize: 45, addedAt: '2026-09-01T00:00:00.000Z', - isProtected: true, normalizedRating: 'PG', ratingSource: 'manual' }); - expect(results[1]).toMatchObject({ status: 'missing', databaseId: 2, addedAt: null, isProtected: false }); + isProtected: true, normalizedRating: 'PG', ratingSource: 'manual', removedReason: null, removedAt: null, removedDetail: null }); + expect(results[1]).toMatchObject({ status: 'missing', databaseId: 2, addedAt: null, isProtected: false, + removedReason: 'not_found', removedAt: '2026-10-03T00:00:00.000Z', removedDetail: null }); expect(results[2]).toEqual({ youtubeId: 'ccccccccccc', status: 'never_downloaded', inArchive: false }); }); +it('reports a video Youtarr removed as deleted, with why', async () => { + Video.findAll.mockResolvedValue([ + { id: 3, youtubeId: 'ddddddddddd', removed: true, removed_reason: 'auto_removal', + removed_at: new Date('2026-10-03T00:00:00Z'), removed_detail: 'older than 30 days' }, + { id: 4, youtubeId: 'eeeeeeeeeee', removed: true, removed_reason: null, removed_at: null, removed_detail: null }, + ]); + const results = pending('ddddddddddd', 'eeeeeeeeeee'); + await applyLocalVideoStatus(results); + expect(results[0]).toMatchObject({ status: 'deleted', removedReason: 'auto_removal', + removedAt: '2026-10-03T00:00:00.000Z', removedDetail: 'older than 30 days' }); + // Removed before the reason was recorded: deleted, with nothing to explain it. + expect(results[1]).toMatchObject({ status: 'deleted', removedReason: null, removedAt: null, removedDetail: null }); +}); + describe('inArchive', () => { // The mock answers the way the real lookup does: only for the ids it is asked about. const archiveContains = (...archivedIds) => { diff --git a/server/modules/__tests__/videoPersistence.test.js b/server/modules/__tests__/videoPersistence.test.js index 0a2422def..46e63e17b 100644 --- a/server/modules/__tests__/videoPersistence.test.js +++ b/server/modules/__tests__/videoPersistence.test.js @@ -52,10 +52,29 @@ describe('videoPersistence', () => { fileSize: '1000', }, true); - expect(data.removed).toBe(false); + expect(data).toEqual(expect.objectContaining({ + removed: false, removed_reason: null, removed_at: null, removed_detail: null, + })); expect(data.last_downloaded_at).toBeInstanceOf(Date); }); + test('drops every removal column on update when nothing is verified', () => { + const data = videoPersistence.prepareVideoDataForSave({ + youtubeId: 'abc123', + filePath: null, + fileSize: null, + removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null, + }, false); + + expect(data).not.toHaveProperty('removed'); + expect(data).not.toHaveProperty('removed_reason'); + expect(data).not.toHaveProperty('removed_at'); + expect(data).not.toHaveProperty('removed_detail'); + }); + test('does not set last_downloaded_at when no file is verified', () => { const data = videoPersistence.prepareVideoDataForSave({ youtubeId: 'abc123', diff --git a/server/modules/__tests__/videoRemovalState.test.js b/server/modules/__tests__/videoRemovalState.test.js new file mode 100644 index 000000000..f49b7b3c5 --- /dev/null +++ b/server/modules/__tests__/videoRemovalState.test.js @@ -0,0 +1,100 @@ +/* eslint-env jest */ +const { Op } = require('sequelize'); +const { + REMOVAL_REASONS, + REMOVAL_STATES, + REMOVAL_COLUMNS, + removalState, + removedColumns, + presentColumns, + stateWhere, + notStateWhere, +} = require('../videoRemovalState'); + +describe('videoRemovalState', () => { + describe('removalState', () => { + test('is present for a row that is not removed', () => { + expect(removalState({ removed: 0 })).toBe(REMOVAL_STATES.PRESENT); + expect(removalState({ removed: false, removed_reason: 'not_found' })).toBe(REMOVAL_STATES.PRESENT); + }); + + test('is missing for a removed row whose file was not found', () => { + expect(removalState({ removed: 1, removed_reason: 'not_found' })).toBe(REMOVAL_STATES.MISSING); + }); + + test('is deleted for a removed row with any other reason', () => { + expect(removalState({ removed: true, removed_reason: 'user_deleted' })).toBe(REMOVAL_STATES.DELETED); + expect(removalState({ removed: true, removed_reason: 'auto_removal' })).toBe(REMOVAL_STATES.DELETED); + expect(removalState({ removed: true, removed_reason: 'marked_deleted' })).toBe(REMOVAL_STATES.DELETED); + }); + + test('treats a removed row without a recorded reason as deleted', () => { + expect(removalState({ removed: 1, removed_reason: null })).toBe(REMOVAL_STATES.DELETED); + expect(removalState({ removed: 1 })).toBe(REMOVAL_STATES.DELETED); + }); + }); + + describe('removedColumns', () => { + test('marks the row removed with the reason and a timestamp', () => { + const columns = removedColumns(REMOVAL_REASONS.NOT_FOUND); + expect(columns).toEqual({ + removed: true, + removed_reason: 'not_found', + removed_at: expect.any(Date), + removed_detail: null, + }); + }); + + test('records the detail and timestamp it is given', () => { + const at = new Date('2026-10-03T12:00:00Z'); + expect(removedColumns(REMOVAL_REASONS.AUTO_REMOVAL, { detail: 'older than 30 days', at })).toEqual({ + removed: true, + removed_reason: 'auto_removal', + removed_at: at, + removed_detail: 'older than 30 days', + }); + }); + + test('rejects an unknown reason', () => { + expect(() => removedColumns('bogus')).toThrow('Unknown removal reason: bogus'); + }); + }); + + test('presentColumns clears the removed flag and every reason column', () => { + expect(presentColumns()).toEqual({ + removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null, + }); + expect(REMOVAL_COLUMNS).toEqual(['removed', 'removed_reason', 'removed_at', 'removed_detail']); + }); + + describe('stateWhere / notStateWhere', () => { + test('missing is removed with the not_found reason', () => { + expect(stateWhere('missing')).toEqual({ removed: true, removed_reason: 'not_found' }); + expect(notStateWhere('missing')).toEqual({ + [Op.or]: [ + { removed: false }, + { removed_reason: null }, + { removed_reason: { [Op.ne]: 'not_found' } }, + ], + }); + }); + + test('deleted is removed with any other reason, including none', () => { + expect(stateWhere('deleted')).toEqual({ + removed: true, + [Op.or]: [{ removed_reason: null }, { removed_reason: { [Op.ne]: 'not_found' } }], + }); + expect(notStateWhere('deleted')).toEqual({ + [Op.or]: [{ removed: false }, { removed_reason: 'not_found' }], + }); + }); + + test('rejects a state that is not deleted or missing', () => { + expect(() => stateWhere('present')).toThrow('Unknown removal state: present'); + expect(() => notStateWhere('bogus')).toThrow('Unknown removal state: bogus'); + }); + }); +}); diff --git a/server/modules/__tests__/videoSearchModule.test.js b/server/modules/__tests__/videoSearchModule.test.js index 1c8650f2e..441e47f1e 100644 --- a/server/modules/__tests__/videoSearchModule.test.js +++ b/server/modules/__tests__/videoSearchModule.test.js @@ -106,7 +106,7 @@ describe('videoSearchModule', () => { test('marks results as downloaded, missing, or never_downloaded based on DB state', async () => { Video.findAll.mockResolvedValueOnce([ { youtubeId: 'aaaaaaaaaaa', removed: false }, - { youtubeId: 'bbbbbbbbbbb', removed: true }, + { youtubeId: 'bbbbbbbbbbb', removed: true, removed_reason: 'not_found' }, ]); const ndjson = diff --git a/server/modules/__tests__/videoTrashModule.test.js b/server/modules/__tests__/videoTrashModule.test.js new file mode 100644 index 000000000..f47fe60cf --- /dev/null +++ b/server/modules/__tests__/videoTrashModule.test.js @@ -0,0 +1,478 @@ +/* eslint-env jest */ +const { Op } = require('sequelize'); + +// `mock` prefix: jest hoists the factory above this declaration and only lets it see mock* variables. +const mockModels = { + Video: { count: jest.fn(), update: jest.fn(), findAll: jest.fn(), destroy: jest.fn() }, + JobVideo: { findAll: jest.fn(), destroy: jest.fn() }, + VideoWatchStatus: { destroy: jest.fn() }, + PlaylistVideo: { update: jest.fn() }, +}; + +jest.mock('../../models', () => mockModels); +jest.mock('../../db.js', () => ({ + sequelize: { transaction: jest.fn(async (fn) => fn('tx')) }, +})); +jest.mock('../libraryRootCheck', () => ({ checkLibraryRoot: jest.fn() })); +jest.mock('../configModule', () => ({ + directoryPath: '/data/youtube', + getJobsPath: () => '/app/jobs', + getImagePath: () => '/app/images', +})); +jest.mock('../messageEmitter', () => ({ emitMessage: jest.fn() })); +jest.mock('../archiveModule', () => ({ removeVideosFromArchive: jest.fn() })); +jest.mock('fs', () => ({ promises: { unlink: jest.fn() } })); +jest.mock('../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + +const { Video, JobVideo, VideoWatchStatus, PlaylistVideo } = require('../../models'); +const { sequelize } = require('../../db.js'); +const { checkLibraryRoot } = require('../libraryRootCheck'); +const messageEmitter = require('../messageEmitter'); +const archiveModule = require('../archiveModule'); +const archiveAccess = require('../download/archiveAccess'); +const fs = require('fs'); +const { stateWhere } = require('../videoRemovalState'); +const videoTrashModule = require('../videoTrashModule'); +const { LibraryUnavailableError, PurgeRunningError } = videoTrashModule; + +const LIBRARY_OK = { ok: true }; +const LIBRARY_EMPTY = { ok: false, reason: 'empty', path: '/data/youtube', message: 'The downloads folder /data/youtube is empty.' }; + +beforeEach(() => { + jest.clearAllMocks(); + checkLibraryRoot.mockResolvedValue(LIBRARY_OK); + Video.update.mockResolvedValue([0]); +}); + +describe('getRemovedSummary', () => { + test('counts deleted, missing, and legacy rows and reports the library check', async () => { + Video.count + .mockResolvedValueOnce(12) // deleted + .mockResolvedValueOnce(4) // missing + .mockResolvedValueOnce(3); // legacy + + const summary = await videoTrashModule.getRemovedSummary(); + + expect(summary).toEqual({ + deleted: 12, + missing: 4, + legacy: 3, + libraryCheck: { ok: true, reason: null, path: null, message: null }, + }); + expect(Video.count).toHaveBeenNthCalledWith(1, { where: stateWhere('deleted') }); + expect(Video.count).toHaveBeenNthCalledWith(2, { where: stateWhere('missing') }); + expect(Video.count).toHaveBeenNthCalledWith(3, { where: { removed: true, removed_reason: null } }); + }); + + test('passes a failed library check through so the page can disable the missing actions', async () => { + Video.count.mockResolvedValue(0); + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + + const summary = await videoTrashModule.getRemovedSummary(); + + expect(summary.libraryCheck).toEqual({ ok: false, reason: 'empty', path: '/data/youtube', message: LIBRARY_EMPTY.message }); + expect(checkLibraryRoot).toHaveBeenCalledWith('/data/youtube'); + }); +}); + +describe('markMissingAsDeleted', () => { + test('marks only the selected rows that are missing, leaving present and deleted ones alone', async () => { + Video.update.mockResolvedValue([2]); + + const result = await videoTrashModule.markMissingAsDeleted({ videoIds: [1, 2, 3] }); + + expect(result).toEqual({ updated: 2 }); + expect(Video.update).toHaveBeenCalledWith( + expect.objectContaining({ removed: true, removed_reason: 'marked_deleted', removed_at: expect.any(Date), removed_detail: null }), + { where: { removed: true, removed_reason: 'not_found', id: [1, 2, 3] } }, + ); + }); + + test('marks every missing row when asked for all', async () => { + Video.update.mockResolvedValue([40]); + + const result = await videoTrashModule.markMissingAsDeleted({ all: true }); + + expect(result).toEqual({ updated: 40 }); + expect(Video.update).toHaveBeenCalledWith( + expect.objectContaining({ removed_reason: 'marked_deleted' }), + { where: { removed: true, removed_reason: 'not_found' } }, + ); + }); + + test('refuses to mark all while the downloads folder looks unmounted', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + + await expect(videoTrashModule.markMissingAsDeleted({ all: true })).rejects.toBeInstanceOf(LibraryUnavailableError); + await expect(videoTrashModule.markMissingAsDeleted({ all: true })).rejects.toMatchObject({ + code: 'LIBRARY_UNAVAILABLE', + message: LIBRARY_EMPTY.message, + libraryCheck: LIBRARY_EMPTY, + }); + expect(Video.update).not.toHaveBeenCalled(); + }); + + test('still marks a chosen selection while the downloads folder looks unmounted', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + Video.update.mockResolvedValue([1]); + + await expect(videoTrashModule.markMissingAsDeleted({ videoIds: [7] })).resolves.toEqual({ updated: 1 }); + }); + + test('tells listing pages to refresh when rows changed, and stays quiet when none did', async () => { + Video.update.mockResolvedValueOnce([3]).mockResolvedValueOnce([0]); + + await videoTrashModule.markMissingAsDeleted({ videoIds: [1, 2, 3] }); + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'videosUpdated', { source: 'mark-deleted', updated: 3 }); + + messageEmitter.emitMessage.mockClear(); + await videoTrashModule.markMissingAsDeleted({ videoIds: [9] }); + expect(messageEmitter.emitMessage).not.toHaveBeenCalled(); + }); + + test('exposes the Sequelize operators it builds its where with', () => { + // The "deleted" count must include rows without a recorded reason. + expect(stateWhere('deleted')[Op.or]).toEqual(expect.arrayContaining([{ removed_reason: null }])); + }); +}); + +describe('purgeRemoved', () => { + const { Op } = require('sequelize'); + const rows = [ + { id: 1, youtubeId: 'aaaaaaaaaaa' }, + { id: 2, youtubeId: 'bbbbbbbbbbb' }, + ]; + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + + beforeEach(() => { + // mockReset: a queued chunk left over by a test that never reached findAll must not leak. + // First call: the chunk's candidates; second: the same rows re-read under lock; then empty. + Video.findAll.mockReset(); + Video.findAll.mockResolvedValueOnce(rows).mockResolvedValueOnce(rows).mockResolvedValue([]); + sequelize.transaction.mockReset().mockImplementation(async (fn) => fn('tx')); + JobVideo.findAll.mockResolvedValue([{ job_id: 'job-1' }, { job_id: 'job-2' }, { job_id: 'job-1' }]); + VideoWatchStatus.destroy.mockResolvedValue(2); + JobVideo.destroy.mockResolvedValue(3); + PlaylistVideo.update.mockResolvedValue([1]); + Video.destroy.mockResolvedValue(2); + fs.promises.unlink.mockResolvedValue(undefined); + archiveModule.removeVideosFromArchive.mockResolvedValue(2); + }); + + test('selects deleted rows only, including ones removed before the reason was recorded', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + const where = Video.findAll.mock.calls[0][0].where; + expect(where[Op.or]).toEqual([stateWhere('deleted')]); + expect(Video.findAll.mock.calls[0][0]).toEqual(expect.objectContaining({ attributes: ['id', 'youtubeId'], limit: 500 })); + }); + + test('selects both states when asked', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted', 'missing'], removeFromArchive: false }); + + expect(Video.findAll.mock.calls[0][0].where[Op.or]).toEqual([stateWhere('deleted'), stateWhere('missing')]); + }); + + test('forgets every trace of the rows, in an order the foreign keys allow', async () => { + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + expect(sequelize.transaction).toHaveBeenCalledTimes(1); + expect(VideoWatchStatus.destroy).toHaveBeenCalledWith({ where: { video_id: [1, 2] }, transaction: 'tx' }); + expect(JobVideo.destroy).toHaveBeenCalledWith({ where: { video_id: [1, 2] }, transaction: 'tx' }); + expect(PlaylistVideo.update).toHaveBeenCalledWith( + { downloaded_at: null }, + { where: { youtube_id: ['aaaaaaaaaaa', 'bbbbbbbbbbb'] }, transaction: 'tx' } + ); + // The destroy keeps the state predicate, so a row that became present in between survives. + expect(Video.destroy).toHaveBeenCalledWith({ where: { id: [1, 2], [Op.or]: [stateWhere('deleted')] }, transaction: 'tx' }); + const order = [VideoWatchStatus.destroy, JobVideo.destroy, Video.destroy].map((fn) => fn.mock.invocationCallOrder[0]); + expect(order).toEqual([...order].sort((a, b) => a - b)); + expect(result).toEqual({ purged: 2, archiveEntriesRemoved: 0, jobIds: ['job-1', 'job-2'] }); + }); + + test('re-reads the chunk under lock inside the transaction before touching anything', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + expect(Video.findAll.mock.calls[1][0]).toEqual(expect.objectContaining({ + where: { id: [1, 2], [Op.or]: [stateWhere('deleted')] }, + transaction: 'tx', + lock: true, + })); + expect(Video.findAll.mock.invocationCallOrder[1]).toBeLessThan(VideoWatchStatus.destroy.mock.invocationCallOrder[0]); + }); + + test('keeps a video that came back (re-downloaded or restored) between the select and the transaction', async () => { + Video.findAll.mockReset(); + Video.findAll + .mockResolvedValueOnce(rows) // candidates + .mockResolvedValueOnce([rows[0]]) // under lock, bbbbbbbbbbb is present again + .mockResolvedValue([]); + Video.destroy.mockResolvedValue(1); + + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true }); + + expect(VideoWatchStatus.destroy).toHaveBeenCalledWith({ where: { video_id: [1] }, transaction: 'tx' }); + expect(JobVideo.destroy).toHaveBeenCalledWith({ where: { video_id: [1] }, transaction: 'tx' }); + expect(Video.destroy).toHaveBeenCalledWith({ where: { id: [1], [Op.or]: [stateWhere('deleted')] }, transaction: 'tx' }); + const unlinked = fs.promises.unlink.mock.calls.map(([p]) => p); + expect(unlinked).toHaveLength(3); + expect(unlinked.every((p) => p.includes('aaaaaaaaaaa'))).toBe(true); + expect(archiveModule.removeVideosFromArchive).toHaveBeenCalledWith(['aaaaaaaaaaa']); + expect(result.purged).toBe(1); + }); + + test('walks the trash in chunks until the selection comes back empty', async () => { + const more = [{ id: 3, youtubeId: 'ccccccccccc' }, { id: 4, youtubeId: 'ddddddddddd' }]; + Video.findAll.mockReset(); + Video.findAll + .mockResolvedValueOnce(rows).mockResolvedValueOnce(rows) + .mockResolvedValueOnce(more).mockResolvedValueOnce(more) + .mockResolvedValue([]); + + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + expect(sequelize.transaction).toHaveBeenCalledTimes(2); + expect(Video.destroy).toHaveBeenNthCalledWith(2, { where: { id: [3, 4], [Op.or]: [stateWhere('deleted')] }, transaction: 'tx' }); + expect(result.purged).toBe(4); + }); + + test('stops when a chunk has nothing left to forget under lock', async () => { + Video.findAll.mockReset(); + Video.findAll.mockResolvedValueOnce(rows).mockResolvedValue([]); // under lock: every candidate is present again + + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true }); + + expect(Video.destroy).not.toHaveBeenCalled(); + expect(fs.promises.unlink).not.toHaveBeenCalled(); + expect(archiveModule.removeVideosFromArchive).not.toHaveBeenCalled(); + expect(result).toEqual({ purged: 0, archiveEntriesRemoved: 0, jobIds: [] }); + }); + + test('refreshes the download history before telling it to refetch', async () => { + const reloadJobVideos = jest.fn().mockResolvedValue(undefined); + + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }, { reloadJobVideos }); + + expect(reloadJobVideos).toHaveBeenCalledWith(['job-1', 'job-2']); + const jobsUpdatedCall = messageEmitter.emitMessage.mock.calls.findIndex(([, , , type]) => type === 'jobsUpdated'); + expect(jobsUpdatedCall).toBeGreaterThanOrEqual(0); + expect(reloadJobVideos.mock.invocationCallOrder[0]).toBeLessThan(messageEmitter.emitMessage.mock.invocationCallOrder[jobsUpdatedCall]); + }); + + test('still broadcasts when refreshing the download history fails', async () => { + const reloadJobVideos = jest.fn().mockRejectedValue(new Error('boom')); + + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }, { reloadJobVideos }); + + expect(result.purged).toBe(2); + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'jobsUpdated', { source: 'purge' }); + }); + + test('removes the cached info.json and thumbnails, so library repair cannot recreate the rows', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + const unlinked = fs.promises.unlink.mock.calls.map(([p]) => p); + expect(unlinked).toEqual(expect.arrayContaining([ + '/app/jobs/info/aaaaaaaaaaa.info.json', + '/app/images/videothumb-aaaaaaaaaaa.jpg', + '/app/images/videothumb-aaaaaaaaaaa-small.jpg', + '/app/jobs/info/bbbbbbbbbbb.info.json', + ])); + expect(unlinked).toHaveLength(6); + }); + + test('ignores files that are already gone', async () => { + fs.promises.unlink.mockRejectedValue(enoent); + + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })).resolves.toEqual( + expect.objectContaining({ purged: 2 }) + ); + }); + + test('leaves the download archive alone unless asked', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + expect(archiveModule.removeVideosFromArchive).not.toHaveBeenCalled(); + }); + + test('removes the archive entries for every purged video when asked', async () => { + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true }); + + expect(archiveModule.removeVideosFromArchive).toHaveBeenCalledTimes(1); + expect(archiveModule.removeVideosFromArchive).toHaveBeenCalledWith(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + expect(result.archiveEntriesRemoved).toBe(2); + }); + + test('tells the listing and history pages to refresh', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'videosUpdated', { source: 'purge', purged: 2 }); + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'jobsUpdated', { source: 'purge' }); + }); + + test('refuses to purge missing videos while the downloads folder looks unmounted, before touching anything', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + + await expect(videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: false })).rejects.toBeInstanceOf(LibraryUnavailableError); + expect(Video.destroy).not.toHaveBeenCalled(); + expect(sequelize.transaction).not.toHaveBeenCalled(); + }); + + test('still purges deleted videos while the downloads folder looks unmounted', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })).resolves.toEqual( + expect.objectContaining({ purged: 2 }) + ); + }); + + test('runs one purge at a time', async () => { + let release; + sequelize.transaction.mockImplementationOnce(() => new Promise((resolve) => { release = resolve; })); + + const first = videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + await new Promise((resolve) => setImmediate(resolve)); + expect(videoTrashModule.isPurgeRunning()).toBe(true); + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })).rejects.toBeInstanceOf(PurgeRunningError); + + release([]); // the stalled transaction resolves to an empty confirmed chunk + await first; + expect(videoTrashModule.isPurgeRunning()).toBe(false); + }); + + test('runs one purge at a time while the first is still checking the downloads folder', async () => { + let finishCheck; + checkLibraryRoot.mockImplementationOnce(() => new Promise((resolve) => { finishCheck = resolve; })); + + const first = videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: true }); + await expect(videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: true })).rejects.toBeInstanceOf(PurgeRunningError); + + finishCheck(LIBRARY_OK); + await first; + }); + + test('releases the lock when the downloads folder looks unmounted', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + + await expect(videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: false })).rejects.toBeInstanceOf(LibraryUnavailableError); + expect(videoTrashModule.isPurgeRunning()).toBe(false); + }); + + test('releases the lock when a purge fails', async () => { + sequelize.transaction.mockRejectedValueOnce(new Error('db down')); + + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })).rejects.toThrow('db down'); + expect(videoTrashModule.isPurgeRunning()).toBe(false); + }); + + describe('when a later chunk fails', () => { + const more = [{ id: 3, youtubeId: 'ccccccccccc' }]; + + beforeEach(() => { + Video.findAll.mockReset(); + Video.findAll.mockResolvedValueOnce(rows).mockResolvedValueOnce(rows).mockResolvedValueOnce(more).mockResolvedValue([]); + sequelize.transaction + .mockImplementationOnce(async (fn) => fn('tx')) + .mockRejectedValueOnce(new Error('lock wait timeout')); + }); + + test('still removes the committed chunks from the download archive, then reports the failure', async () => { + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true })).rejects.toThrow('lock wait timeout'); + + expect(archiveModule.removeVideosFromArchive).toHaveBeenCalledWith(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + }); + + test('still refreshes the download history for the committed chunks', async () => { + const reloadJobVideos = jest.fn().mockResolvedValue(undefined); + + await expect( + videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }, { reloadJobVideos }) + ).rejects.toThrow('lock wait timeout'); + + expect(reloadJobVideos).toHaveBeenCalledWith(['job-1', 'job-2']); + }); + + test('still tells the listing and history pages to refresh', async () => { + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })).rejects.toThrow('lock wait timeout'); + + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'videosUpdated', { source: 'purge', purged: 2 }); + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'jobsUpdated', { source: 'purge' }); + }); + }); + + test('rolls back an archive failure so the same rows can be purged on retry', async () => { + // Model transaction rollback explicitly: failed transactions restore rows. + let remaining = [...rows]; + let inTransaction = false; + Video.findAll.mockReset().mockImplementation(async () => [...remaining]); + Video.destroy.mockImplementation(async () => { remaining = []; return 2; }); + sequelize.transaction.mockImplementation(async (fn) => { + const before = [...remaining]; + inTransaction = true; + try { + return await fn('tx'); + } catch (err) { + remaining = before; + throw err; + } finally { + inTransaction = false; + } + }); + archiveModule.removeVideosFromArchive.mockImplementationOnce(async () => { + expect(inTransaction).toBe(true); + throw new Error('EACCES'); + }); + const reloadJobVideos = jest.fn().mockResolvedValue(undefined); + + await expect( + videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true }, { reloadJobVideos }) + ).rejects.toThrow('EACCES'); + + expect(fs.promises.unlink).not.toHaveBeenCalled(); + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true }, { reloadJobVideos }); + expect(result).toEqual({ purged: 2, archiveEntriesRemoved: 2, jobIds: ['job-1', 'job-2'] }); + expect(archiveModule.removeVideosFromArchive).toHaveBeenNthCalledWith(2, ['aaaaaaaaaaa', 'bbbbbbbbbbb']); + }); + + test('refuses archive removal during a download before deleting any rows', async () => { + await archiveAccess.withDownload(async () => { + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true })) + .rejects.toMatchObject({ code: 'ARCHIVE_BUSY' }); + expect(Video.destroy).not.toHaveBeenCalled(); + }); + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true })) + .resolves.toMatchObject({ purged: 2 }); + }); + + test('allows a purge that keeps the archive during a download', async () => { + await archiveAccess.withDownload(async () => { + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })) + .resolves.toMatchObject({ purged: 2 }); + }); + }); + + test('releases waiting downloads after a purge fails its library check', async () => { + let finishCheck; + checkLibraryRoot.mockImplementationOnce(() => new Promise((resolve) => { finishCheck = resolve; })); + const purge = videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: true }); + const rejected = expect(purge).rejects.toBeInstanceOf(LibraryUnavailableError); + const run = jest.fn().mockResolvedValue(undefined); + const download = archiveAccess.withDownload(run); + expect(run).not.toHaveBeenCalled(); + finishCheck(LIBRARY_EMPTY); + await rejected; + await download; + expect(run).toHaveBeenCalledTimes(1); + }); + + test('does nothing when the trash is empty', async () => { + Video.findAll.mockReset(); + Video.findAll.mockResolvedValue([]); + + const result = await videoTrashModule.purgeRemoved({ states: ['deleted', 'missing'], removeFromArchive: true }); + + expect(result).toEqual({ purged: 0, archiveEntriesRemoved: 0, jobIds: [] }); + expect(sequelize.transaction).not.toHaveBeenCalled(); + expect(archiveModule.removeVideosFromArchive).not.toHaveBeenCalled(); + expect(messageEmitter.emitMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/server/modules/__tests__/videosModule.test.js b/server/modules/__tests__/videosModule.test.js index 0b7181e59..8a3dc030e 100644 --- a/server/modules/__tests__/videosModule.test.js +++ b/server/modules/__tests__/videosModule.test.js @@ -1,10 +1,18 @@ /* eslint-env jest */ const { Sequelize } = require('sequelize'); +const { stateWhere, notStateWhere } = require('../videoRemovalState'); + +const { Op } = Sequelize; + +// What the rescan and the page-load check write when a file is gone or (back) on disk. +const MISSING_COLUMNS = { removed: true, removed_reason: 'not_found', removed_at: expect.any(Date), removed_detail: null }; +const PRESENT_COLUMNS = { removed: false, removed_reason: null, removed_at: null, removed_detail: null }; describe('VideosModule', () => { let VideosModule; let mockSequelize; let mockVideo; + let mockLibraryRootCheck; let mockWatchStatusQueries; let mockFs; let mockConfigModule; @@ -130,6 +138,13 @@ describe('VideosModule', () => { jest.doMock('../youtubeExistenceCheck', () => mockYoutubeExistenceCheck); + // The downloads folder looks mounted unless a test says otherwise. + mockLibraryRootCheck = { + LIBRARY_CHECK_REASONS: { MISSING: 'missing', EMPTY: 'empty', UNREADABLE: 'unreadable', SUBFOLDER_EMPTY: 'subfolder-empty' }, + checkLibraryRoot: jest.fn().mockResolvedValue({ ok: true }), + }; + jest.doMock('../libraryRootCheck', () => mockLibraryRootCheck); + jest.doMock('../nfoGenerator', () => mockNfoGenerator); jest.doMock('../messageEmitter', () => mockMessageEmitter); @@ -216,7 +231,9 @@ describe('VideosModule', () => { videos: mockVideos, total: 2, page: 1, - totalPages: 1 + totalPages: 1, + libraryWarning: null, + hiddenDeletedCount: null }); // Count query + videos query + getAllUniqueChannels query + no update queries needed expect(mockVideo.count).toHaveBeenCalledTimes(1); @@ -448,32 +465,65 @@ describe('VideosModule', () => { })); }); - test('should apply missingFilter=only to the WHERE clause', async () => { + // The removal-state filters land in the WHERE's AND list; read it back from the count call. + const countWhereAnd = (call = 0) => mockVideo.count.mock.calls[call][0].where[Op.and]; + + test('missingFilter=only keeps only rows whose file was not found, not deleted ones', async () => { mockVideo.count.mockResolvedValue(0); mockVideo.findAll.mockResolvedValue([]); mockSequelize.query.mockResolvedValueOnce([]); await VideosModule.getVideosPaginated({ missingFilter: 'only' }); - expect(mockVideo.count).toHaveBeenCalledWith(expect.objectContaining({ - where: { - removed: true, - }, - })); + expect(countWhereAnd()).toEqual([stateWhere('missing')]); }); - test('should apply missingFilter=exclude to the WHERE clause', async () => { + test('missingFilter=exclude keeps deleted rows, dropping only the missing ones', async () => { mockVideo.count.mockResolvedValue(0); mockVideo.findAll.mockResolvedValue([]); mockSequelize.query.mockResolvedValueOnce([]); await VideosModule.getVideosPaginated({ missingFilter: 'exclude' }); - expect(mockVideo.count).toHaveBeenCalledWith(expect.objectContaining({ - where: { - removed: false, - }, - })); + expect(countWhereAnd()).toEqual([notStateWhere('missing')]); + }); + + test('deletedFilter=only keeps rows removed by Youtarr, including ones without a recorded reason', async () => { + mockVideo.count.mockResolvedValue(0); + mockVideo.findAll.mockResolvedValue([]); + mockSequelize.query.mockResolvedValueOnce([]); + + await VideosModule.getVideosPaginated({ deletedFilter: 'only' }); + + expect(countWhereAnd()).toEqual([stateWhere('deleted')]); + expect(countWhereAnd()[0][Op.or]).toEqual(expect.arrayContaining([{ removed_reason: null }])); + }); + + test('deletedFilter=exclude hides deleted rows and reports how many it hid', async () => { + mockVideo.count.mockResolvedValueOnce(10).mockResolvedValueOnce(3); + mockVideo.findAll.mockResolvedValue([]); + mockSequelize.query.mockResolvedValueOnce([]); + + const result = await VideosModule.getVideosPaginated({ deletedFilter: 'exclude', protectedFilter: 'only' }); + + expect(countWhereAnd(0)).toEqual([notStateWhere('deleted')]); + // The hidden count keeps every other filter and swaps in "deleted only". + expect(mockVideo.count).toHaveBeenCalledTimes(2); + expect(mockVideo.count.mock.calls[1][0].where.protected).toBe(true); + expect(countWhereAnd(1)).toEqual([stateWhere('deleted')]); + expect(result.total).toBe(10); + expect(result.hiddenDeletedCount).toBe(3); + }); + + test('does not count hidden deleted rows unless the deleted filter excludes them', async () => { + mockVideo.count.mockResolvedValue(0); + mockVideo.findAll.mockResolvedValue([]); + mockSequelize.query.mockResolvedValueOnce([]); + + const result = await VideosModule.getVideosPaginated({ deletedFilter: 'only' }); + + expect(mockVideo.count).toHaveBeenCalledTimes(1); + expect(result.hiddenDeletedCount).toBeNull(); }); test('should omit missingFilter from the WHERE clause by default', async () => { @@ -667,7 +717,7 @@ describe('VideosModule', () => { expect(mockVideo.update).toHaveBeenCalledTimes(2); expect(mockVideo.update).toHaveBeenNthCalledWith( 1, - { removed: true }, + { ...MISSING_COLUMNS }, { where: { id: 1 } }, ); expect(mockVideo.update).toHaveBeenNthCalledWith( @@ -677,6 +727,37 @@ describe('VideosModule', () => { ); }); + test('holds back the missing mark and reports a library warning while the downloads folder looks unmounted', async () => { + const mockVideos = [ + { + id: 1, + youtubeId: 'abc123', + youTubeChannelName: 'Test Channel', + youTubeVideoName: 'Test Video', + filePath: '/test/output/dir/Test Channel/video [abc123].mp4', + fileSize: '1000', + removed: false, + youtube_removed: false, + youtube_removed_checked_at: null + } + ]; + const failedCheck = { ok: false, reason: 'empty', path: '/test/output/dir', message: 'The downloads folder /test/output/dir is empty.' }; + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(failedCheck); + mockVideo.count.mockResolvedValue(1); + mockVideo.findAll.mockResolvedValue(mockVideos); + mockVideo.aggregate.mockResolvedValue([]); + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + + const result = await VideosModule.getVideosPaginated(); + + expect(result.videos[0].removed).toBe(false); + expect(result.libraryWarning).toEqual({ reason: 'empty', path: '/test/output/dir', message: failedCheck.message, heldBack: 1 }); + expect(mockVideo.update).not.toHaveBeenCalledWith( + expect.objectContaining({ removed: true }), + expect.anything() + ); + }); + test('should mark video as YouTube removed when validation fails', async () => { const mockVideos = [ { @@ -1185,7 +1266,7 @@ describe('VideosModule', () => { ); expect(mockVideo.update).toHaveBeenCalledTimes(1); expect(mockVideo.update).toHaveBeenCalledWith( - { removed: false, video_resolution: '1280x720' }, + { ...PRESENT_COLUMNS, video_resolution: '1280x720' }, { where: expect.objectContaining({ id: 1, video_resolution: null }) }, ); }); @@ -1294,7 +1375,7 @@ describe('VideosModule', () => { expect(mockVideo.update).toHaveBeenCalledTimes(1); expect(mockVideo.update).toHaveBeenCalledWith( - { removed: false, video_resolution: '0x0' }, + { ...PRESENT_COLUMNS, video_resolution: '0x0' }, { where: expect.objectContaining({ id: 1 }) }, ); }); @@ -1324,7 +1405,7 @@ describe('VideosModule', () => { expect(mockExecFile).not.toHaveBeenCalled(); expect(mockVideo.update).toHaveBeenCalledTimes(1); expect(mockVideo.update).toHaveBeenCalledWith( - { filePath: null, fileSize: null, video_resolution: null, removed: false }, + { filePath: null, fileSize: null, video_resolution: null, ...PRESENT_COLUMNS }, { where: expect.objectContaining({ id: 1 }) }, ); }); @@ -1408,11 +1489,61 @@ describe('VideosModule', () => { expect(result.updated).toBe(0); // Stored paths are kept so a file returning to its location is found again. expect(mockVideo.update).toHaveBeenCalledWith( - { removed: true }, + { ...MISSING_COLUMNS }, { where: expect.objectContaining({ id: 1, filePath: '/test/missing.mp4' }) }, ); }); + describe('when the downloads folder looks unmounted', () => { + const missingRoot = { ok: false, reason: 'missing', path: '/test/output/dir', message: 'The downloads folder /test/output/dir does not exist.' }; + const emptySubfolder = { ok: false, reason: 'subfolder-empty', path: '/test/output/dir/__Music', message: 'The subfolder /test/output/dir/__Music is empty.' }; + + test('stops before the walk, changing nothing, while videos are present', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(missingRoot); + mockVideo.count.mockResolvedValueOnce(5); // present rows + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toBe('The downloads folder /test/output/dir does not exist. Nothing was marked missing; is the drive mounted?'); + expect(result.libraryCheckMessage).toBe(missingRoot.message); + expect(mockFs.readdir).not.toHaveBeenCalled(); + expect(mockVideo.update).not.toHaveBeenCalled(); + }); + + test('runs normally on a fresh install with no present videos', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue({ ...missingRoot, reason: 'empty' }); + mockVideo.count.mockResolvedValueOnce(0); // present rows + mockVideo.count.mockResolvedValueOnce(0); // total rows + mockFs.readdir.mockResolvedValueOnce([]); + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('completed'); + expect(result.notMarkedMissing).toBe(0); + expect(result.libraryCheckMessage).toBeNull(); + }); + + test('finishes the scan but holds back missing marks while a subfolder is empty, counting them', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(emptySubfolder); + mockVideo.count.mockResolvedValueOnce(1); // present rows + mockVideo.count.mockResolvedValueOnce(1); // total rows + mockFs.readdir.mockResolvedValueOnce([]); + mockVideo.findAll.mockResolvedValueOnce([ + dbRow({ youtubeId: 'missing123', filePath: '/test/output/dir/__Music/missing.mp4', fileSize: '1000' }) + ]); + mockFs.stat.mockRejectedValue(fsError('ENOENT')); + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('completed'); + expect(result.removed).toBe(0); + expect(result.notMarkedMissing).toBe(1); + expect(result.libraryCheckMessage).toBe(emptySubfolder.message); + expect(mockVideo.update).not.toHaveBeenCalled(); + }); + }); + test('should clear removed flag when a previously-missing file reappears (raw row removed=1)', async () => { // Sequelize raw mode returns BOOLEAN columns as 0/1, not true/false. // This test exercises that path to ensure restoration is detected. @@ -1544,7 +1675,7 @@ describe('VideosModule', () => { await VideosModule.backfillVideoMetadata(); - expect(mockVideo.update).toHaveBeenCalledWith({ removed: false }, { + expect(mockVideo.update).toHaveBeenCalledWith(PRESENT_COLUMNS, { where: { id: 1, removed: true, diff --git a/server/modules/archiveModule.js b/server/modules/archiveModule.js index ec8bdf01c..a0bff6a24 100644 --- a/server/modules/archiveModule.js +++ b/server/modules/archiveModule.js @@ -138,6 +138,40 @@ async function removeVideoFromArchive(videoId) { } } +// Remove a purge chunk in one rewrite. The caller holds archiveAccess until +// the purge finishes, so yt-dlp cannot append to the file during replacement. +// Returns how many lines went; the file is left alone when none would. +async function removeVideosFromArchive(youtubeIds) { + const wanted = new Set((youtubeIds || []).filter(Boolean)); + if (wanted.size === 0) return 0; + + const lines = readCompleteListLines(); + const kept = lines.filter((line) => { + const parts = line.split(/\s+/).filter(Boolean); + return !(parts.length >= 2 && parts[0] === 'youtube' && wanted.has(parts[1])); + }); + const removed = lines.length - kept.length; + if (removed === 0) return 0; + + const content = kept.join('\n') + (kept.length > 0 ? '\n' : ''); + const archivePath = getArchivePath(); + const tempPath = `${archivePath}.purge.tmp`; + try { + // A disk-full/write error must leave the original archive intact for retry. + fs.writeFileSync(tempPath, content, 'utf-8'); + fs.renameSync(tempPath, archivePath); + } catch (err) { + try { + fs.unlinkSync(tempPath); + } catch (cleanupError) { + if (cleanupError.code !== 'ENOENT') logger.warn({ err: cleanupError }, 'Could not clean up temporary purge archive'); + } + throw err; + } + logger.info({ removed }, 'Removed videos from archive'); + return removed; +} + module.exports = { getArchivePath, readCompleteListLines, @@ -146,4 +180,5 @@ module.exports = { filterArchivedVideoIds, addVideoToArchive, removeVideoFromArchive, + removeVideosFromArchive, }; diff --git a/server/modules/archiveOnlyIds.js b/server/modules/archiveOnlyIds.js new file mode 100644 index 000000000..a64951f1e --- /dev/null +++ b/server/modules/archiveOnlyIds.js @@ -0,0 +1,24 @@ +const ChannelVideo = require('../models/channelvideo'); +const archiveModule = require('./archiveModule'); + +// IDs yt-dlp would skip as already downloaded even though no Videos row +// exists (e.g. emptied from the trash with the archive entries kept). Ignored +// videos are left out: ignoring writes the archive entry on purpose, and an +// explicit download removes it again. +// Requires the one model it needs, not the models index. Nothing in production +// breaks either way; the constraint is downloadModule.test.js, which swaps model +// files for plain objects (jest.doMock) and loads playlistDownloadModule, which +// requires this. The index's association setup throws on those ("JobVideo.belongsTo +// called with something that's not a subclass of Sequelize.Model"). +async function findArchiveOnlyIds(youtubeIds) { + const archived = archiveModule.filterArchivedVideoIds(youtubeIds); + if (archived.size === 0) return archived; + const ignored = await ChannelVideo.findAll({ + where: { youtube_id: [...archived], ignored: true }, + attributes: ['youtube_id'], + }); + for (const row of ignored) archived.delete(row.youtube_id); + return archived; +} + +module.exports = { findArchiveOnlyIds }; diff --git a/server/modules/channel/__tests__/channelVideoQuery.test.js b/server/modules/channel/__tests__/channelVideoQuery.test.js index 7aacf17b2..99decea75 100644 --- a/server/modules/channel/__tests__/channelVideoQuery.test.js +++ b/server/modules/channel/__tests__/channelVideoQuery.test.js @@ -48,7 +48,7 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2', 'video3'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -95,7 +95,7 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -152,7 +152,7 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -181,7 +181,7 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2', 'video3'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] }); // Video1 - not downloaded @@ -209,7 +209,7 @@ describe('channelVideoQuery', () => { where: { youtubeId: [] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] }); expect(result).toEqual([]); }); @@ -373,7 +373,7 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -643,7 +643,7 @@ describe('channelVideoQuery', () => { ChannelVideo.findAll.mockResolvedValue(mockVideos); // video1: added and removed (missing); video2: added and present (downloaded); video3: never added. Video.findAll = jest.fn().mockResolvedValue([ - { id: 1, youtubeId: 'video1', removed: true, fileSize: null, filePath: '/path' }, + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', fileSize: null, filePath: '/path' }, { id: 2, youtubeId: 'video2', removed: false, fileSize: 1000, filePath: '/path' } ]); @@ -656,6 +656,91 @@ describe('channelVideoQuery', () => { expect(result[0].youtube_id).toBe('video1'); }); + test('missingFilter=only leaves out videos Youtarr deleted', async () => { + const Video = require('../../../models/video'); + ChannelVideo.findAll.mockResolvedValue([ + { youtube_id: 'video1', title: 'Missing', publishedAt: '2024-01-03', toJSON() { return this; } }, + { youtube_id: 'video2', title: 'Deleted', publishedAt: '2024-01-02', toJSON() { return this; } }, + { youtube_id: 'video3', title: 'Legacy removed', publishedAt: '2024-01-01', toJSON() { return this; } }, + ]); + Video.findAll = jest.fn().mockResolvedValue([ + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', filePath: '/path' }, + { id: 2, youtubeId: 'video2', removed: true, removed_reason: 'user_deleted', filePath: '/path' }, + { id: 3, youtubeId: 'video3', removed: true, removed_reason: null, filePath: '/path' }, + ]); + + const result = await channelVideoQuery.fetchNewestVideosFromDb( + 'UC123', 50, 0, false, '', 'date', 'desc', false, 'video', + null, null, null, null, 'off', 'only', 'off' + ); + + expect(result.map(v => v.youtube_id)).toEqual(['video1']); + }); + + test('deletedFilter=only keeps videos Youtarr deleted, including ones removed before the reason was recorded', async () => { + const Video = require('../../../models/video'); + ChannelVideo.findAll.mockResolvedValue([ + { youtube_id: 'video1', title: 'Missing', publishedAt: '2024-01-03', toJSON() { return this; } }, + { youtube_id: 'video2', title: 'Deleted', publishedAt: '2024-01-02', toJSON() { return this; } }, + { youtube_id: 'video3', title: 'Legacy removed', publishedAt: '2024-01-01', toJSON() { return this; } }, + { youtube_id: 'video4', title: 'Present', publishedAt: '2024-01-01', toJSON() { return this; } }, + ]); + Video.findAll = jest.fn().mockResolvedValue([ + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', filePath: '/path' }, + { id: 2, youtubeId: 'video2', removed: true, removed_reason: 'user_deleted', filePath: '/path' }, + { id: 3, youtubeId: 'video3', removed: true, removed_reason: null, filePath: '/path' }, + { id: 4, youtubeId: 'video4', removed: false, fileSize: 1000, filePath: '/path' }, + ]); + + const result = await channelVideoQuery.fetchNewestVideosFromDb( + 'UC123', 50, 0, false, '', 'date', 'desc', false, 'video', + null, null, null, null, 'off', 'off', 'off', 'off', null, 'only' + ); + + expect(result.map(v => v.youtube_id)).toEqual(['video2', 'video3']); + }); + + test('deletedFilter=exclude drops deleted videos but keeps missing and present ones', async () => { + const Video = require('../../../models/video'); + ChannelVideo.findAll.mockResolvedValue([ + { youtube_id: 'video1', title: 'Missing', publishedAt: '2024-01-03', toJSON() { return this; } }, + { youtube_id: 'video2', title: 'Deleted', publishedAt: '2024-01-02', toJSON() { return this; } }, + { youtube_id: 'video4', title: 'Present', publishedAt: '2024-01-01', toJSON() { return this; } }, + ]); + Video.findAll = jest.fn().mockResolvedValue([ + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', filePath: '/path' }, + { id: 2, youtubeId: 'video2', removed: true, removed_reason: 'user_deleted', filePath: '/path' }, + { id: 4, youtubeId: 'video4', removed: false, fileSize: 1000, filePath: '/path' }, + ]); + + const result = await channelVideoQuery.fetchNewestVideosFromDb( + 'UC123', 50, 0, false, '', 'date', 'desc', false, 'video', + null, null, null, null, 'off', 'off', 'off', 'off', null, 'exclude' + ); + + expect(result.map(v => v.youtube_id)).toEqual(['video1', 'video4']); + }); + + test('carries why a video was removed into the listing row', async () => { + const Video = require('../../../models/video'); + const removedAt = new Date('2026-10-03T00:00:00Z'); + ChannelVideo.findAll.mockResolvedValue([ + { youtube_id: 'video2', title: 'Deleted', publishedAt: '2024-01-02', toJSON() { return this; } }, + ]); + Video.findAll = jest.fn().mockResolvedValue([ + { id: 2, youtubeId: 'video2', removed: true, removed_reason: 'auto_removal', removed_at: removedAt, removed_detail: 'older than 30 days', filePath: '/path' }, + ]); + + const result = await channelVideoQuery.fetchNewestVideosFromDb('UC123', 50, 0, false, '', 'date', 'desc', false); + + expect(result[0]).toEqual(expect.objectContaining({ + removed: true, + removed_reason: 'auto_removal', + removed_at: removedAt, + removed_detail: 'older than 30 days', + })); + }); + test('missingFilter=exclude drops videos that are downloaded and removed', async () => { const Video = require('../../../models/video'); const mockVideos = [ @@ -665,7 +750,7 @@ describe('channelVideoQuery', () => { ]; ChannelVideo.findAll.mockResolvedValue(mockVideos); Video.findAll = jest.fn().mockResolvedValue([ - { id: 1, youtubeId: 'video1', removed: true, fileSize: null, filePath: '/path' }, + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', fileSize: null, filePath: '/path' }, { id: 2, youtubeId: 'video2', removed: false, fileSize: 1000, filePath: '/path' } ]); @@ -789,8 +874,8 @@ describe('channelVideoQuery', () => { ]; ChannelVideo.findAll.mockResolvedValue(mockVideos); Video.findAll = jest.fn().mockResolvedValue([ - { id: 1, youtubeId: 'video1', removed: true, fileSize: null, filePath: '/path' }, - { id: 2, youtubeId: 'video2', removed: true, fileSize: null, filePath: '/path' } + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', fileSize: null, filePath: '/path' }, + { id: 2, youtubeId: 'video2', removed: true, removed_reason: 'not_found', fileSize: null, filePath: '/path' } ]); const result = await channelVideoQuery.fetchNewestVideosFromDb( diff --git a/server/modules/channel/channelVideoQuery.js b/server/modules/channel/channelVideoQuery.js index e650d7fa2..5f4bc6741 100644 --- a/server/modules/channel/channelVideoQuery.js +++ b/server/modules/channel/channelVideoQuery.js @@ -3,6 +3,7 @@ const watchStatusQueries = require('../mediaServers/watchStatusQueries'); const fileCheckModule = require('../fileCheckModule'); const ratingMapper = require('../ratingMapper'); const { PUBLISHED_AT_SOURCE } = require('../constants/publishedAtSource'); +const { REMOVAL_STATES, removalState } = require('../videoRemovalState'); class ChannelVideoQuery { /** @@ -26,6 +27,9 @@ class ChannelVideoQuery { 'id', 'youtubeId', 'removed', + 'removed_reason', + 'removed_at', + 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', @@ -47,6 +51,9 @@ class ChannelVideoQuery { id: v.id, added: true, removed: v.removed, + removed_reason: v.removed_reason ?? null, + removed_at: v.removed_at ?? null, + removed_detail: v.removed_detail ?? null, fileSize: v.fileSize, filePath: v.filePath, audioFilePath: v.audioFilePath, @@ -72,6 +79,9 @@ class ChannelVideoQuery { const status = downloadStatusMap.get(v.youtubeId); if (status) { status.removed = v.removed; + status.removed_reason = v.removed_reason ?? null; + status.removed_at = v.removed_at ?? null; + status.removed_detail = v.removed_detail ?? null; status.fileSize = v.fileSize; status.audioFileSize = v.audioFileSize; } @@ -100,6 +110,9 @@ class ChannelVideoQuery { // Video exists in database plainVideoObject.added = true; plainVideoObject.removed = status.removed; + plainVideoObject.removed_reason = status.removed_reason; + plainVideoObject.removed_at = status.removed_at; + plainVideoObject.removed_detail = status.removed_detail; plainVideoObject.fileSize = status.fileSize; plainVideoObject.filePath = status.filePath; plainVideoObject.audioFilePath = status.audioFilePath; @@ -118,6 +131,9 @@ class ChannelVideoQuery { // Video never downloaded plainVideoObject.added = false; plainVideoObject.removed = false; + plainVideoObject.removed_reason = null; + plainVideoObject.removed_at = null; + plainVideoObject.removed_detail = null; plainVideoObject.fileSize = null; plainVideoObject.filePath = null; plainVideoObject.audioFilePath = null; @@ -202,9 +218,11 @@ class ChannelVideoQuery { } /** - * Apply tri-state status filters (protected / missing / ignored / watched). + * Apply tri-state status filters (protected / missing / ignored / watched / deleted). * Mode is 'off' (no filtering), 'only' (keep matches), or 'exclude' (drop matches). - * Missing is "previously downloaded, file no longer present". + * Missing is "previously downloaded, a file was expected and not found"; + * deleted is "previously downloaded, removed by Youtarr or marked as deleted" + * (see videoRemovalState). * Watched is "has a watched-by entry under the configured watched rule"; * never-downloaded videos are never watched. * @param {Array} videos - Array of enriched videos @@ -212,9 +230,10 @@ class ChannelVideoQuery { * @param {string} missingMode - 'off' | 'only' | 'exclude' * @param {string} ignoredMode - 'off' | 'only' | 'exclude' * @param {string} watchedMode - 'off' | 'only' | 'exclude' + * @param {string} deletedMode - 'off' | 'only' | 'exclude' * @returns {Array} - Filtered array of videos */ - _applyStatusFilters(videos, protectedMode, missingMode, ignoredMode, watchedMode) { + _applyStatusFilters(videos, protectedMode, missingMode, ignoredMode, watchedMode, deletedMode = 'off') { let filtered = videos; if (protectedMode === 'only') { @@ -223,10 +242,13 @@ class ChannelVideoQuery { filtered = filtered.filter(video => !video.protected); } - if (missingMode === 'only') { - filtered = filtered.filter(video => video.added === true && video.removed === true); - } else if (missingMode === 'exclude') { - filtered = filtered.filter(video => !(video.added === true && video.removed === true)); + const inState = (video, state) => video.added === true && removalState(video) === state; + for (const [mode, state] of [[missingMode, REMOVAL_STATES.MISSING], [deletedMode, REMOVAL_STATES.DELETED]]) { + if (mode === 'only') { + filtered = filtered.filter(video => inState(video, state)); + } else if (mode === 'exclude') { + filtered = filtered.filter(video => !inState(video, state)); + } } if (ignoredMode === 'only') { @@ -260,9 +282,10 @@ class ChannelVideoQuery { * @param {number|null} maxDuration - Maximum duration in seconds (default null) * @param {string|null} dateFrom - Filter videos from this date (ISO string, default null) * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) + * @param {string} deletedFilter - Tri-state filter on videos Youtarr deleted: 'off' | 'only' | 'exclude' (default 'off') * @returns {Promise<Array>} - Array of video objects with download status */ - async fetchNewestVideosFromDb(channelId, limit = 50, offset = 0, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', checkFiles = false, mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null) { + async fetchNewestVideosFromDb(channelId, limit = 50, offset = 0, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', checkFiles = false, mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null, deletedFilter = 'off') { // First get all videos to enrich with download status const allChannelVideos = await ChannelVideo.findAll({ where: { @@ -295,7 +318,7 @@ class ChannelVideoQuery { // Apply duration and date filters filteredVideos = this._applyDurationAndDateFilters(filteredVideos, minDuration, maxDuration, dateFrom, dateTo); - filteredVideos = this._applyStatusFilters(filteredVideos, protectedFilter, missingFilter, ignoredFilter, watchedFilter); + filteredVideos = this._applyStatusFilters(filteredVideos, protectedFilter, missingFilter, ignoredFilter, watchedFilter, deletedFilter); filteredVideos = this._applyRatingFilter(filteredVideos, ratingFilter); @@ -350,6 +373,9 @@ class ChannelVideoQuery { if (checkedVideos[i]) { paginatedVideos[i].added = checkedVideos[i].added; paginatedVideos[i].removed = checkedVideos[i].removed; + paginatedVideos[i].removed_reason = checkedVideos[i].removed_reason; + paginatedVideos[i].removed_at = checkedVideos[i].removed_at; + paginatedVideos[i].removed_detail = checkedVideos[i].removed_detail; paginatedVideos[i].fileSize = checkedVideos[i].fileSize; paginatedVideos[i].thumbnail = checkedVideos[i].thumbnail; } @@ -381,11 +407,12 @@ class ChannelVideoQuery { * @param {number|null} maxDuration - Maximum duration in seconds (default null) * @param {string|null} dateFrom - Filter videos from this date (ISO string, default null) * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) + * @param {string} deletedFilter - Tri-state filter on videos Youtarr deleted: 'off' | 'only' | 'exclude' (default 'off') * @returns {Promise<Object>} - Object with totalCount and oldestVideoDate */ - async getChannelVideoStats(channelId, downloadedFilter = 'off', searchQuery = '', mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null) { + async getChannelVideoStats(channelId, downloadedFilter = 'off', searchQuery = '', mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null, deletedFilter = 'off') { // If we have search or filter, we need to get all videos - if (downloadedFilter !== 'off' || searchQuery || minDuration !== null || maxDuration !== null || dateFrom || dateTo || protectedFilter !== 'off' || missingFilter !== 'off' || ignoredFilter !== 'off' || watchedFilter !== 'off' || ratingFilter) { + if (downloadedFilter !== 'off' || searchQuery || minDuration !== null || maxDuration !== null || dateFrom || dateTo || protectedFilter !== 'off' || missingFilter !== 'off' || ignoredFilter !== 'off' || watchedFilter !== 'off' || ratingFilter || deletedFilter !== 'off') { // Need to filter by download status and/or search const allChannelVideos = await ChannelVideo.findAll({ where: { @@ -417,7 +444,7 @@ class ChannelVideoQuery { // Apply duration and date filters filteredVideos = this._applyDurationAndDateFilters(filteredVideos, minDuration, maxDuration, dateFrom, dateTo); - filteredVideos = this._applyStatusFilters(filteredVideos, protectedFilter, missingFilter, ignoredFilter, watchedFilter); + filteredVideos = this._applyStatusFilters(filteredVideos, protectedFilter, missingFilter, ignoredFilter, watchedFilter, deletedFilter); filteredVideos = this._applyRatingFilter(filteredVideos, ratingFilter); diff --git a/server/modules/channel/channelVideosService.js b/server/modules/channel/channelVideosService.js index 16bd8c631..e7115d029 100644 --- a/server/modules/channel/channelVideosService.js +++ b/server/modules/channel/channelVideosService.js @@ -134,7 +134,7 @@ class ChannelVideosService { * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) * @returns {Promise<Object>} - Response object with videos and metadata */ - async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null) { + async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null, deletedFilter = 'off') { const channel = await Channel.findOne({ where: { channel_id: channelId }, }); @@ -203,7 +203,7 @@ class ChannelVideosService { // Now fetch the requested page of videos with file checking enabled const offset = (page - 1) * pageSize; - const paginatedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); + const paginatedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, deletedFilter); // Check if videos still exist on YouTube and mark as removed if they don't const updates = []; @@ -275,7 +275,7 @@ class ChannelVideosService { } // Get stats for the response - const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); + const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, deletedFilter); return { ...this.buildChannelVideosResponse(paginatedVideos, channel, 'cache', stats, autoDownloadsEnabled, mediaType), @@ -285,8 +285,8 @@ class ChannelVideosService { } catch (error) { logger.error({ err: error, channelId }, 'Error fetching channel videos'); const offset = (page - 1) * pageSize; - const cachedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); - const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); + const cachedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, deletedFilter); + const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, deletedFilter); const response = this.buildChannelVideosResponse(cachedVideos, channel, 'cache', stats, autoDownloadsEnabled, mediaType); // Only surface a user-visible error when we have nothing to show. // Silent recovery when cached results exist; the filter-aware empty diff --git a/server/modules/channelDownloadAllModule.js b/server/modules/channelDownloadAllModule.js index f1e05b54a..6073e0939 100644 --- a/server/modules/channelDownloadAllModule.js +++ b/server/modules/channelDownloadAllModule.js @@ -5,6 +5,7 @@ const { channelDownloadAllJobLabel } = require('./download/jobTypes'); const { MEDIA_TAB_TYPE_MAP } = require('./tabsUtils'); const logger = require('../logger'); const videoActivity = require('./download/videoActivity'); +const { findArchiveOnlyIds } = require('./archiveOnlyIds'); const WATCH_URL_PREFIX = 'https://www.youtube.com/watch?v='; @@ -46,9 +47,14 @@ class ChannelDownloadAllModule { // those stay in the yt-dlp archive, so yt-dlp would skip them and the // preview count would overstate. const downloaded = new Set(existing.map((video) => video.youtubeId)); + // So is anything the archive lists without a row (e.g. emptied from the + // trash with its archive entry kept): yt-dlp skips those too. + const archiveOnly = await findArchiveOnlyIds( + candidates.map((row) => row.youtube_id).filter((id) => !downloaded.has(id)) + ); return candidates - .filter((row) => !downloaded.has(row.youtube_id) && !videoActivity.isActive(row.youtube_id)) + .filter((row) => !downloaded.has(row.youtube_id) && !archiveOnly.has(row.youtube_id) && !videoActivity.isActive(row.youtube_id)) .map((row) => ({ youtube_id: row.youtube_id, duration: row.duration })); } diff --git a/server/modules/channelModule.js b/server/modules/channelModule.js index 56c9c32e3..606c10bda 100644 --- a/server/modules/channelModule.js +++ b/server/modules/channelModule.js @@ -201,8 +201,8 @@ class ChannelModule { * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) * @returns {Promise<Object>} - Response object with videos and metadata */ - async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null) { - return channelVideosService.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating); + async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null, deletedFilter = 'off') { + return channelVideosService.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating, deletedFilter); } /** diff --git a/server/modules/download/__tests__/archiveAccess.test.js b/server/modules/download/__tests__/archiveAccess.test.js new file mode 100644 index 000000000..e823dc3cc --- /dev/null +++ b/server/modules/download/__tests__/archiveAccess.test.js @@ -0,0 +1,48 @@ +/* eslint-env jest */ +const { ArchiveAccess } = require('../archiveAccess'); + +describe('archive access', () => { + let access; + + beforeEach(() => { + access = new ArchiveAccess(); + }); + + test('refuses a purge until the entire download and finalization finish', async () => { + let finish; + const download = access.withDownload(() => new Promise((resolve) => { finish = resolve; })); + expect(() => access.acquirePurge()).toThrow(/active downloads/); + finish(); + await download; + const release = access.acquirePurge(); + release(); + }); + + test('waits for the purge before starting a new download', async () => { + const release = access.acquirePurge(); + const run = jest.fn().mockResolvedValue('complete'); + const download = access.withDownload(run); + await Promise.resolve(); + expect(run).not.toHaveBeenCalled(); + release(); + await expect(download).resolves.toBe('complete'); + expect(run).toHaveBeenCalledTimes(1); + }); + + test('releases access even when a download throws', async () => { + await expect(access.withDownload(async () => { throw new Error('failed'); })).rejects.toThrow('failed'); + const release = access.acquirePurge(); + release(); + }); + + test('keeps the archive occupied until every overlapping download finishes', async () => { + let finish; + const first = access.withDownload(() => new Promise((resolve) => { finish = resolve; })); + await access.withDownload(async () => {}); + expect(() => access.acquirePurge()).toThrow(/active downloads/); + finish(); + await first; + const release = access.acquirePurge(); + release(); + }); +}); diff --git a/server/modules/download/__tests__/downloadExecutor.test.js b/server/modules/download/__tests__/downloadExecutor.test.js index b7448fa88..9feb28742 100644 --- a/server/modules/download/__tests__/downloadExecutor.test.js +++ b/server/modules/download/__tests__/downloadExecutor.test.js @@ -125,6 +125,7 @@ const plexModule = require('../../plexModule'); const jobModule = require('../../jobModule'); const MessageEmitter = require('../../messageEmitter'); const archiveModule = require('../../archiveModule'); +const archiveAccess = require('../archiveAccess'); const notificationModule = require('../../notificationModule'); const channelModule = require('../../channelModule'); const DownloadProgressMonitor = require('../DownloadProgressMonitor'); @@ -300,6 +301,38 @@ describe('DownloadExecutor', () => { }); }); + describe('archive access during downloads', () => { + it('defers startup until the purge releases the archive', async () => { + const release = archiveAccess.acquirePurge(); + const download = executor.doDownload([], 'job-123', 'Channel Downloads'); + await Promise.resolve(); + expect(archiveModule.readCompleteListLines).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + release(); + await new Promise((resolve) => setImmediate(resolve)); + expect(mockSpawn).toHaveBeenCalled(); + endProcess(0, null); + await download; + }); + + it('blocks archive removal through finalization and releases it afterward', async () => { + let finishMetadata; + VideoMetadataProcessor.processVideoMetadata.mockImplementationOnce( + () => new Promise((resolve) => { finishMetadata = resolve; }) + ); + const download = executor.doDownload([], 'job-123', 'Channel Downloads'); + await new Promise((resolve) => setImmediate(resolve)); + expect(() => archiveAccess.acquirePurge()).toThrow(/active downloads/); + endProcess(0, null); + await new Promise((resolve) => setImmediate(resolve)); + expect(() => archiveAccess.acquirePurge()).toThrow(/active downloads/); + finishMetadata([]); + await download; + const release = archiveAccess.acquirePurge(); + release(); + }); + }); + describe('stdio drain before finalization', () => { const mockArgs = ['--format', 'best', 'https://youtube.com/watch?v=test']; const mockJobId = 'job-123'; diff --git a/server/modules/download/__tests__/videoMetadataProcessor.test.js b/server/modules/download/__tests__/videoMetadataProcessor.test.js index f9c6b9a58..c4389a05d 100644 --- a/server/modules/download/__tests__/videoMetadataProcessor.test.js +++ b/server/modules/download/__tests__/videoMetadataProcessor.test.js @@ -149,6 +149,9 @@ describe('VideoMetadataProcessor', () => { audioFilePath: null, audioFileSize: null, removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null, video_resolution: null }); @@ -403,6 +406,9 @@ describe('VideoMetadataProcessor', () => { audioFilePath: null, audioFileSize: null, removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null, video_resolution: null }); }); diff --git a/server/modules/download/archiveAccess.js b/server/modules/download/archiveAccess.js new file mode 100644 index 000000000..c0ffaf673 --- /dev/null +++ b/server/modules/download/archiveAccess.js @@ -0,0 +1,37 @@ +// A purge must not rewrite complete.list while yt-dlp is using it or while +// finalization still relies on the archive's starting line count. +class ArchiveAccess { + constructor() { + this.downloads = 0; + this.purgeFinished = null; + } + + async withDownload(run) { + while (this.purgeFinished) await this.purgeFinished; + this.downloads += 1; + try { + return await run(); + } finally { + this.downloads -= 1; + } + } + + // Claim synchronously, before the purge's first await. New downloads wait + // for release; a purge requested during a download is refused immediately. + acquirePurge() { + if (this.downloads > 0 || this.purgeFinished) { + const error = new Error('Wait for active downloads to finish before emptying trash with archive removal.'); + error.code = 'ARCHIVE_BUSY'; + throw error; + } + let finish; + this.purgeFinished = new Promise((resolve) => { finish = resolve; }); + return () => { + this.purgeFinished = null; + finish(); + }; + } +} + +module.exports = new ArchiveAccess(); +module.exports.ArchiveAccess = ArchiveAccess; diff --git a/server/modules/download/downloadExecutor.js b/server/modules/download/downloadExecutor.js index 5e4f70ae0..746dc526d 100644 --- a/server/modules/download/downloadExecutor.js +++ b/server/modules/download/downloadExecutor.js @@ -17,6 +17,7 @@ const { buildYtdlpEnv } = require('./ytdlpEnvBuilder'); const DownloadTimeoutController = require('./DownloadTimeoutController'); const { YtdlpErrorTracker } = require('./YtdlpErrorTracker'); const downloadRunTracker = require('./downloadRunTracker'); +const archiveAccess = require('./archiveAccess'); // Tells the job's download run about a job that failed before yt-dlp ran, so // the run counts it instead of treating the job as quietly finished. @@ -158,6 +159,12 @@ class DownloadExecutor { } async doDownload(args, jobId, jobType, urlCount = 0, originalUrls = null, allowRedownload = false, skipJobTransition = false, postProcessDirectives = {}) { + return archiveAccess.withDownload(() => this.runDownload( + args, jobId, jobType, urlCount, originalUrls, allowRedownload, skipJobTransition, postProcessDirectives + )); + } + + async runDownload(args, jobId, jobType, urlCount, originalUrls, allowRedownload, skipJobTransition, postProcessDirectives) { const subfolderOverride = (postProcessDirectives || {}).subfolderOverride ?? null; const initialCount = downloadResultProcessor.getCountOfDownloadedVideos(); const config = configModule.getConfig(); diff --git a/server/modules/download/videoMetadataProcessor.js b/server/modules/download/videoMetadataProcessor.js index 9f962364b..b95d4020b 100644 --- a/server/modules/download/videoMetadataProcessor.js +++ b/server/modules/download/videoMetadataProcessor.js @@ -4,6 +4,7 @@ const path = require('path'); const configModule = require('../configModule'); const logger = require('../../logger'); const { probeVideoDimensions } = require('../resolutionTier'); +const { presentColumns } = require('../videoRemovalState'); class VideoMetadataProcessor { static normalizeChannelName(value) { @@ -184,7 +185,7 @@ class VideoMetadataProcessor { fileSize: null, audioFilePath: null, audioFileSize: null, - removed: false, + ...presentColumns(), video_resolution: null, }; diff --git a/server/modules/fileCheckModule.js b/server/modules/fileCheckModule.js index 51b31220b..4adbc6caf 100644 --- a/server/modules/fileCheckModule.js +++ b/server/modules/fileCheckModule.js @@ -3,6 +3,9 @@ const path = require('path'); const { Video } = require('../models'); const { VIDEO_EXTENSIONS, AUDIO_EXTENSIONS } = require('./filesystem/constants'); const createLimiter = require('./subscriptionImport/concurrencyLimiter'); +const { REMOVAL_REASONS, REMOVAL_COLUMNS, removedColumns, presentColumns } = require('./videoRemovalState'); +const { checkLibraryRoot } = require('./libraryRootCheck'); +const configModule = require('./configModule'); // Per-video checks run concurrently up to this bound: each stat costs a full // round trip on network-backed mounts (NAS, WSL drvfs), so checking a @@ -59,6 +62,12 @@ class FileCheckModule { return { exists: false, statusKnown: true }; } + /** + * @returns {Promise<{videos: Array, updates: Array, libraryWarning: (Object|null)}>} + * libraryWarning is set when rows would have been marked missing but the + * downloads folder looks unmounted (see libraryRootCheck): those rows are + * left as they are and counted in `heldBack`. + */ async checkVideoFiles(videos) { const updatedVideos = [...videos]; const limit = createLimiter(MAX_CONCURRENT_FILE_CHECKS); @@ -66,6 +75,16 @@ class FileCheckModule { // check finishes first. const updateSlots = new Array(updatedVideos.length).fill(null); + // An unmounted drive makes every file look gone, so the folder itself is + // checked before the first row is marked missing: once per pass, and only + // when a pass would mark something. + let libraryCheckPromise = null; + const getLibraryCheck = () => { + libraryCheckPromise ??= checkLibraryRoot(configModule.directoryPath); + return libraryCheckPromise; + }; + let heldBack = 0; + await Promise.all(updatedVideos.map((video, i) => limit(async () => { const update = { id: video.id }; let hasUpdates = false; @@ -120,11 +139,18 @@ class FileCheckModule { if (hasAnyPath && canDetermineRemovedStatus) { if (hasAnyFile && video.removed) { - update.removed = false; + Object.assign(update, presentColumns()); hasUpdates = true; } else if (!hasAnyFile && !video.removed) { - update.removed = true; - hasUpdates = true; + // A row that is already removed (deleted in Youtarr, or marked as + // deleted) is left as it is: only a present row becomes missing. + const libraryCheck = await getLibraryCheck(); + if (libraryCheck.ok) { + Object.assign(update, removedColumns(REMOVAL_REASONS.NOT_FOUND)); + hasUpdates = true; + } else { + heldBack++; + } } } @@ -136,12 +162,14 @@ class FileCheckModule { ...(update.fileSize !== undefined && { fileSize: update.fileSize.toString() }), ...(update.audioFilePath !== undefined && { audioFilePath: update.audioFilePath }), ...(update.audioFileSize !== undefined && { audioFileSize: update.audioFileSize.toString() }), - ...(update.removed !== undefined && { removed: update.removed }) + ...(update.removed !== undefined && Object.fromEntries(REMOVAL_COLUMNS.map((column) => [column, update[column]]))) }; } }))); - return { videos: updatedVideos, updates: updateSlots.filter(Boolean) }; + const libraryWarning = heldBack > 0 ? { ...(await getLibraryCheck()), heldBack } : null; + if (libraryWarning) delete libraryWarning.ok; + return { videos: updatedVideos, updates: updateSlots.filter(Boolean), libraryWarning }; } async applyVideoUpdates(updates) { @@ -152,6 +180,9 @@ class FileCheckModule { audioFilePath: update.audioFilePath, audioFileSize: update.audioFileSize, removed: update.removed, + removed_reason: update.removed_reason, + removed_at: update.removed_at, + removed_detail: update.removed_detail, }; if (Object.values(attributes).some((v) => v !== undefined)) { diff --git a/server/modules/jobModule.js b/server/modules/jobModule.js index 3f7dffa55..0972145c3 100644 --- a/server/modules/jobModule.js +++ b/server/modules/jobModule.js @@ -19,6 +19,7 @@ const downloadCleanup = require('./download/downloadCleanup'); const { serializeAuxData, parseAuxData } = require('./jobAuxData'); const storageGuard = require('./storageGuard'); const { unchangedSinceRead } = require('./videoRowGuard'); +const { REMOVAL_REASONS, removedColumns, presentColumns } = require('./videoRemovalState'); const videosModule = require('./videosModule'); const logger = require('../logger'); @@ -232,13 +233,13 @@ class JobModule { if (resolvedPath) { videoData.filePath = resolvedPath; videoData.fileSize = resolvedStats?.size !== undefined ? resolvedStats.size.toString() : null; - videoData.removed = false; + Object.assign(videoData, presentColumns()); } else { // File not found, but we still want to record the expected metadata location const assumedPath = actualFilePath || `${fallbackBasePath}.mp4`; videoData.filePath = assumedPath; videoData.fileSize = null; - videoData.removed = false; + Object.assign(videoData, presentColumns()); } // Upsert video into Videos table and ensure JobVideo relationship exists @@ -973,7 +974,7 @@ class JobModule { fileSize: files ? sizeOf(files.videoFileSize) : null, audioFilePath: files ? files.audioFilePath : null, audioFileSize: files ? sizeOf(files.audioFileSize) : null, - removed: !files, + ...(files ? presentColumns() : removedColumns(REMOVAL_REASONS.NOT_FOUND)), }); videosUpserts += 1; if (!files && unreadableOnDisk > 0) recreatedUnverified += 1; @@ -991,7 +992,7 @@ class JobModule { // column, which the zero-rows conflict check relies on. if (foundFile) { const [affected] = await Video.update( - { filePath: foundFile.path, fileSize: foundFile.size, removed: false }, + { filePath: foundFile.path, fileSize: foundFile.size, ...presentColumns() }, { where: unchangedSinceRead(videoInstance) } ); if (affected === 0) { @@ -1161,6 +1162,45 @@ class JobModule { return this.jobs; } + // The videos still linked to a job, as Download History shows them. + async _loadJobVideos(jobId) { + const jobVideos = await JobVideo.findAll({ + where: { job_id: jobId } + }); + + const videos = []; + for (const jobVideo of jobVideos) { + const video = await Video.findOne({ where: { id: jobVideo.video_id } }); + if (video) { + videos.push(video.dataValues); + } + } + return videos; + } + + /** + * Refresh the in-memory video lists of the given jobs from the database, + * after their rows changed outside the download path (emptying the trash + * deletes jobvideos rows). Jobs not held in memory are skipped; a failure + * is logged and the job keeps its previous list. + * @param {string[]} jobIds + */ + async reloadJobVideos(jobIds) { + for (const jobId of jobIds || []) { + const job = this.jobs[jobId]; + if (!job) continue; + try { + const videos = await this._loadJobVideos(jobId); + if (!job.data) { + job.data = {}; + } + job.data.videos = videos; + } catch (err) { + logger.error({ err, jobId }, 'Error reloading videos for job'); + } + } + } + // Lets listing pages (e.g. Download History) refetch when a job is enqueued or starts. emitJobsUpdated(jobId, status) { MessageEmitter.emitMessage('broadcast', null, 'download', 'jobsUpdated', { @@ -1264,17 +1304,7 @@ class JobModule { // This is especially important for multi-group downloads where each group // updates the job with only its own videos, potentially losing earlier videos try { - const jobVideos = await JobVideo.findAll({ - where: { job_id: jobId } - }); - - const videos = []; - for (const jobVideo of jobVideos) { - const video = await Video.findOne({ where: { id: jobVideo.video_id } }); - if (video) { - videos.push(video.dataValues); - } - } + const videos = await this._loadJobVideos(jobId); // Update in-memory structure with complete video list from DB if (!job.data) { diff --git a/server/modules/libraryRootCheck.js b/server/modules/libraryRootCheck.js new file mode 100644 index 000000000..6c407c65d --- /dev/null +++ b/server/modules/libraryRootCheck.js @@ -0,0 +1,75 @@ +// Is the downloads folder really there? An unmounted drive or network share +// leaves its mount point missing or empty, and every stored path under it +// would then look gone. Nothing may be marked missing while this check fails. +const fs = require('fs'); +const path = require('path'); +// pathBuilder, not the filesystem index: the index loads fileOperations, which +// binds child_process at require time, and this check needs neither. +const { isSubfolderDirectory } = require('./filesystem/pathBuilder'); + +const LIBRARY_CHECK_REASONS = { + MISSING: 'missing', + EMPTY: 'empty', + UNREADABLE: 'unreadable', + SUBFOLDER_EMPTY: 'subfolder-empty', +}; + +// ENOTDIR: the folder's path now leads to a regular file, so the folder is gone. +const ABSENT_CODES = ['ENOENT', 'ENOTDIR']; + +function describeLibraryCheck({ reason, path: checkedPath }) { + switch (reason) { + case LIBRARY_CHECK_REASONS.MISSING: + return `The downloads folder ${checkedPath} does not exist.`; + case LIBRARY_CHECK_REASONS.EMPTY: + return `The downloads folder ${checkedPath} is empty.`; + case LIBRARY_CHECK_REASONS.UNREADABLE: + return `The downloads folder ${checkedPath} could not be read.`; + case LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY: + return `The subfolder ${checkedPath} is empty.`; + default: + return `The downloads folder ${checkedPath} could not be checked.`; + } +} + +function failure(reason, checkedPath) { + return { ok: false, reason, path: checkedPath, message: describeLibraryCheck({ reason, path: checkedPath }) }; +} + +/** + * The root must exist and hold something; every `__subfolder` in it (a share + * of its own, on some setups) must hold something too. First failure wins. + * @param {string} baseDir - The downloads root + * @param {{ readdir?: Function }} [deps] - fs.promises.readdir, injectable for tests + * @returns {Promise<{ok: true}|{ok: false, reason: string, path: string, message: string}>} + */ +async function checkLibraryRoot(baseDir, { readdir = fs.promises.readdir } = {}) { + if (!baseDir) return failure(LIBRARY_CHECK_REASONS.MISSING, baseDir || ''); + + let entries; + try { + entries = await readdir(baseDir, { withFileTypes: true }); + } catch (err) { + const reason = ABSENT_CODES.includes(err.code) ? LIBRARY_CHECK_REASONS.MISSING : LIBRARY_CHECK_REASONS.UNREADABLE; + return failure(reason, baseDir); + } + if (entries.length === 0) return failure(LIBRARY_CHECK_REASONS.EMPTY, baseDir); + + for (const entry of entries) { + if (!entry.isDirectory() || !isSubfolderDirectory(entry.name)) continue; + const subfolder = path.join(baseDir, entry.name); + let children; + try { + children = await readdir(subfolder, { withFileTypes: true }); + } catch (err) { + // Gone since the root was listed: not a mount problem. + if (ABSENT_CODES.includes(err.code)) continue; + return failure(LIBRARY_CHECK_REASONS.UNREADABLE, subfolder); + } + if (children.length === 0) return failure(LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY, subfolder); + } + + return { ok: true }; +} + +module.exports = { LIBRARY_CHECK_REASONS, checkLibraryRoot, describeLibraryCheck }; diff --git a/server/modules/playlistDownloadModule.js b/server/modules/playlistDownloadModule.js index 937128f29..b0754bf5e 100644 --- a/server/modules/playlistDownloadModule.js +++ b/server/modules/playlistDownloadModule.js @@ -2,6 +2,7 @@ const { QueryTypes } = require('sequelize'); const playlistSelection = require('./download/playlistAutoSelection'); const logger = require('../logger'); const videoActivity = require('./download/videoActivity'); +const { findArchiveOnlyIds } = require('./archiveOnlyIds'); // Raw SQL: one grouped count for a whole page of playlists. "Downloaded" means // a usable file on disk, the same predicate as the playlist videos listing's @@ -34,8 +35,14 @@ class PlaylistDownloadModule { attributes: ['youtubeId', 'filePath', 'audioFilePath'], }) : []; const downloaded = new Set(existing.map((video) => video.youtubeId)); + // Without a re-download, yt-dlp also skips what the archive lists without a + // row (e.g. emptied from the trash with its archive entry kept); queued, such + // a video would never download and would be picked again on every sweep. + const archiveOnly = allowRedownload ? new Set() : await findArchiveOnlyIds( + candidates.map((row) => row.youtube_id).filter((id) => !downloaded.has(id)) + ); return { candidates: candidates.filter((row) => !downloaded.has(row.youtube_id) && - (!excludeActive || !videoActivity.isActive(row.youtube_id))), existing }; + !archiveOnly.has(row.youtube_id) && (!excludeActive || !videoActivity.isActive(row.youtube_id))), existing }; } /** diff --git a/server/modules/rescanRowUpdate.js b/server/modules/rescanRowUpdate.js index 76ecd6475..efe6c1fe3 100644 --- a/server/modules/rescanRowUpdate.js +++ b/server/modules/rescanRowUpdate.js @@ -6,6 +6,8 @@ // stat of each format's files at write time (resolveRescanUpdate), and the // caller writes it only if the row is still as read. +const { REMOVAL_REASONS, removedColumns, presentColumns } = require('./videoRemovalState'); + const PRESENT = 'present'; const MISSING = 'missing'; const UNKNOWN = 'unknown'; @@ -87,7 +89,9 @@ async function resolveRescanUpdate(row, fileInfo, probedResolution, stat) { // Nothing on disk and one format unchecked: can't tell, so write nothing. if (!statusKnown) return null; // Keep the stored paths: page-load checks look there for a returning file. - return row.removed ? null : { removed: true }; + // A row already removed (deleted in Youtarr, or marked as deleted) stays + // as recorded; only a present row becomes missing. + return row.removed ? null : removedColumns(REMOVAL_REASONS.NOT_FOUND); } const desired = {}; @@ -121,10 +125,10 @@ async function resolveRescanUpdate(row, fileInfo, probedResolution, stat) { } // A file is present, so the row isn't missing, even if the other format - // couldn't be checked (that format is left as stored above). + // couldn't be checked (that format is left as stored above). Clearing why + // it was removed comes with the flag. if (Object.keys(changes).length === 0 && !row.removed) return null; - changes.removed = false; - return changes; + return Object.assign(changes, presentColumns()); } module.exports = { isRescanCandidate, resolveRescanUpdate }; diff --git a/server/modules/rescanRunSummary.js b/server/modules/rescanRunSummary.js index d0ac28a78..d7f7aa5b2 100644 --- a/server/modules/rescanRunSummary.js +++ b/server/modules/rescanRunSummary.js @@ -11,12 +11,17 @@ function count(value) { return Number(value) || 0; } -// Rows skipped because another writer changed them, and rows whose write -// failed; empty when there were none. +// Rows skipped because another writer changed them, rows whose write failed, +// and rows left unmarked because the downloads folder looked unmounted; +// empty when there were none. function describeRowProblems(result) { const parts = []; if (count(result.skippedChanged) > 0) parts.push(`${count(result.skippedChanged)} skipped (changed during the scan)`); if (count(result.failed) > 0) parts.push(`${count(result.failed)} failed`); + if (count(result.notMarkedMissing) > 0) { + const cause = result.libraryCheckMessage ? ` (${result.libraryCheckMessage})` : ''; + parts.push(`${count(result.notMarkedMissing)} not marked missing${cause}`); + } return parts; } @@ -37,10 +42,11 @@ function toRunRecord(result = {}) { return { status: 'skipped', outcome: 'skipped', message: 'A rescan was already running.', details: null }; } let outcome = result.status || (result.timedOut ? 'timed-out' : 'completed'); - // A finished scan with failed row writes partly failed, like other scheduled - // tasks. A timed-out scan keeps its outcome so the Maintenance page still - // offers to continue it; its message carries the count. - if (outcome === 'completed' && count(result.failed) > 0) outcome = 'partial'; + // A finished scan with failed row writes, or with rows it could not mark + // missing because the downloads folder looked unmounted, partly failed, + // like other scheduled tasks. A timed-out scan keeps its outcome so the + // Maintenance page still offers to continue it; its message carries the count. + if (outcome === 'completed' && (count(result.failed) > 0 || count(result.notMarkedMissing) > 0)) outcome = 'partial'; return { status: outcome === 'error' || outcome === 'partial' ? 'error' : 'success', outcome, @@ -58,6 +64,8 @@ function toRunRecord(result = {}) { // Rows another writer changed after the rescan read them; left for a later run. videosSkipped: count(result.skippedChanged), videosFailed: count(result.failed), + // Rows left unmarked because the downloads folder looked unmounted. + videosNotMarkedMissing: count(result.notMarkedMissing), }, }; } diff --git a/server/modules/videoDeletionModule.js b/server/modules/videoDeletionModule.js index 64a090102..6dcbd31ba 100644 --- a/server/modules/videoDeletionModule.js +++ b/server/modules/videoDeletionModule.js @@ -8,6 +8,7 @@ const m3uGenerator = require('./m3uGenerator'); const storageUsage = require('./storageUsage'); const { STORED_BYTES_SQL } = storageUsage; const storageGuard = require('./storageGuard'); +const { REMOVAL_REASONS, removedColumns } = require('./videoRemovalState'); // Oldest-first removal (free-space and total-usage strategies) works in // batches and stops after a bounded number of them per run. @@ -15,6 +16,11 @@ const OLDEST_FIRST_BATCH_SIZE = 50; const OLDEST_FIRST_MAX_BATCHES = 10; const PLAN_SAMPLE_LIMIT = 10; +// What a delete records on the row unless the caller says otherwise: the +// user deleted it in the app. Automatic cleanup passes its own reason and +// the rule that selected the video. +const USER_DELETED_REMOVAL = { reason: REMOVAL_REASONS.USER_DELETED, detail: null }; + class VideoDeletionModule { constructor() {} @@ -95,9 +101,10 @@ class VideoDeletionModule { * Delete a single video by ID * Deletes the video directory from disk and marks the video as removed in the database * @param {number} videoId - The database ID of the video to delete + * @param {{reason: string, detail: (string|null)}} [removal] - Why the video is removed (see videoRemovalState) * @returns {Promise<{success: boolean, videoId: number, error?: string}>} */ - async deleteVideoById(videoId) { + async deleteVideoById(videoId, removal = USER_DELETED_REMOVAL) { try { // Fetch video from database const video = await Video.findByPk(videoId); @@ -125,7 +132,7 @@ class VideoDeletionModule { if (!primaryPath) { // No file path, just mark as removed in database - await video.update({ removed: true }); + await video.update(removedColumns(removal.reason, { detail: removal.detail })); return { success: true, videoId, @@ -200,7 +207,7 @@ class VideoDeletionModule { } // Mark video as removed in database - await video.update({ removed: true }); + await video.update(removedColumns(removal.reason, { detail: removal.detail })); // Best-effort cleanup of empty channel directory await this._tryCleanupChannelDirectory(primaryPath, flat); @@ -224,16 +231,17 @@ class VideoDeletionModule { /** * Delete multiple videos * @param {number[]} videoIds - Array of video IDs to delete + * @param {{reason: string, detail: (string|null)}} [removal] - Why the videos are removed * @returns {Promise<{success: boolean, deleted: number[], failed: Array<{videoId: number, error: string}>}>} */ - async deleteVideos(videoIds) { + async deleteVideos(videoIds, removal = USER_DELETED_REMOVAL) { const deleted = []; const failed = []; const affectedChannelIds = []; // Process deletions sequentially to avoid overwhelming the file system for (const videoId of videoIds) { - const result = await this.deleteVideoById(videoId); + const result = await this.deleteVideoById(videoId, removal); if (result.success) { deleted.push(videoId); @@ -259,9 +267,10 @@ class VideoDeletionModule { /** * Delete videos by YouTube IDs * @param {string[]} youtubeIds - Array of YouTube video IDs + * @param {{reason: string, detail: (string|null)}} [removal] - Why the videos are removed * @returns {Promise<{success: boolean, deleted: string[], failed: Array<{youtubeId: string, error: string}>}>} */ - async deleteVideosByYoutubeIds(youtubeIds) { + async deleteVideosByYoutubeIds(youtubeIds, removal = USER_DELETED_REMOVAL) { const deleted = []; const failed = []; const affectedChannelIds = []; @@ -282,7 +291,7 @@ class VideoDeletionModule { } // Delete using the database ID - const result = await this.deleteVideoById(video.id); + const result = await this.deleteVideoById(video.id, removal); if (result.success) { deleted.push(youtubeId); @@ -516,13 +525,14 @@ ${excludeClause} ORDER BY timeCreated ASC * @param {object} options.bucket - The strategy's entry in result.plan * @param {string} options.deletedKey - Result counter to increment, e.g. 'deletedBySpace' * @param {string} options.label - Strategy name for log messages + * @param {{reason: string, detail: (string|null)}} options.removal - Recorded on each deleted row * @param {boolean} options.dryRun * @param {boolean} options.includeSamples * @param {object} options.result - The cleanup result being built * @returns {Promise<{selectedIds: number[]}>} * @private */ - async _removeOldestUntilFreed({ bytesToFree, excludeIds, bucket, deletedKey, label, dryRun, includeSamples, result }) { + async _removeOldestUntilFreed({ bytesToFree, excludeIds, bucket, deletedKey, label, removal, dryRun, includeSamples, result }) { const triedIds = new Set(); const affectedChannelIds = []; let freedSoFar = 0; @@ -567,7 +577,7 @@ ${excludeClause} ORDER BY timeCreated ASC continue; } - const deleteResult = await this.deleteVideoById(video.id); + const deleteResult = await this.deleteVideoById(video.id, removal); if (deleteResult.success) { freedSoFar += videoSize; @@ -801,7 +811,10 @@ ${excludeClause} ORDER BY timeCreated ASC } else if (oldVideos.length > 0) { logger.info({ count: oldVideos.length }, '[Auto-Removal] Deleting videos older than threshold'); const videoIds = oldVideos.map(v => v.id); - const deleteResult = await this.deleteVideos(videoIds); + const deleteResult = await this.deleteVideos(videoIds, { + reason: REMOVAL_REASONS.AUTO_REMOVAL, + detail: `older than ${thresholdDays} days` + }); result.deletedByAge = deleteResult.deleted.length; result.plan.ageStrategy.deletedCount = deleteResult.deleted.length; @@ -883,7 +896,10 @@ ${excludeClause} ORDER BY timeCreated ASC } else if (watchedVideos.length > 0) { logger.info({ count: watchedVideos.length }, '[Auto-Removal] Deleting watched videos'); const videoIds = watchedVideos.map(v => v.id); - const deleteResult = await this.deleteVideos(videoIds); + const deleteResult = await this.deleteVideos(videoIds, { + reason: REMOVAL_REASONS.AUTO_REMOVAL, + detail: minDaysSinceWatched > 0 ? `watched at least ${minDaysSinceWatched} days ago` : 'watched' + }); result.deletedByWatched = deleteResult.deleted.length; result.plan.watchedStrategy.deletedCount = deleteResult.deleted.length; @@ -954,6 +970,10 @@ ${excludeClause} ORDER BY timeCreated ASC bucket: result.plan.spaceStrategy, deletedKey: 'deletedBySpace', label: 'space-based', + removal: { + reason: REMOVAL_REASONS.AUTO_REMOVAL, + detail: `to keep ${config.autoRemovalFreeSpaceThreshold} free` + }, dryRun, includeSamples, result @@ -1011,6 +1031,10 @@ ${excludeClause} ORDER BY timeCreated ASC bucket, deletedKey: 'deletedByUsage', label: 'usage-based', + removal: { + reason: REMOVAL_REASONS.AUTO_REMOVAL, + detail: `over the ${config.autoRemovalUsageLimit} total size limit` + }, dryRun, includeSamples, result diff --git a/server/modules/videoLocalStatus.js b/server/modules/videoLocalStatus.js index e882783eb..30f4fb240 100644 --- a/server/modules/videoLocalStatus.js +++ b/server/modules/videoLocalStatus.js @@ -1,20 +1,8 @@ const { Video } = require('../models'); -const ChannelVideo = require('../models/channelvideo'); -const archiveModule = require('./archiveModule'); +const { findArchiveOnlyIds } = require('./archiveOnlyIds'); +const { REMOVAL_STATES, removalState } = require('./videoRemovalState'); -// IDs yt-dlp would skip as already downloaded even though no Videos row -// exists. Ignored videos are left out: ignoring writes the archive entry on -// purpose, and an explicit download removes it again. -async function findArchiveOnlyIds(youtubeIds) { - const archived = archiveModule.filterArchivedVideoIds(youtubeIds); - if (archived.size === 0) return archived; - const ignored = await ChannelVideo.findAll({ - where: { youtube_id: [...archived], ignored: true }, - attributes: ['youtube_id'], - }); - for (const row of ignored) archived.delete(row.youtube_id); - return archived; -} +const toIso = (value) => (value ? new Date(value).toISOString() : null); async function applyLocalVideoStatus(results) { const youtubeIds = results.map(r => r.youtubeId).filter(Boolean); @@ -25,6 +13,9 @@ async function applyLocalVideoStatus(results) { 'id', 'youtubeId', 'removed', + 'removed_reason', + 'removed_at', + 'removed_detail', 'filePath', 'fileSize', 'audioFilePath', @@ -43,7 +34,11 @@ async function applyLocalVideoStatus(results) { r.inArchive = archiveOnlyIds.has(r.youtubeId); const record = recordByYoutubeId.get(r.youtubeId); if (!record) continue; - r.status = record.removed ? 'missing' : 'downloaded'; + const state = removalState(record); + r.status = state === REMOVAL_STATES.PRESENT ? 'downloaded' : state; + r.removedReason = record.removed_reason ?? null; + r.removedAt = toIso(record.removed_at); + r.removedDetail = record.removed_detail ?? null; r.databaseId = record.id; r.filePath = record.filePath; r.fileSize = record.fileSize; diff --git a/server/modules/videoPersistence.js b/server/modules/videoPersistence.js index 77e948301..1e192e04e 100644 --- a/server/modules/videoPersistence.js +++ b/server/modules/videoPersistence.js @@ -5,6 +5,7 @@ const ChannelVideo = require('../models/channelvideo'); const channelVideoReanchor = require('./channelVideoReanchor'); const { PUBLISHED_AT_SOURCE } = require('./constants/publishedAtSource'); const VideoMetadataProcessor = require('./download/videoMetadataProcessor'); +const { REMOVAL_COLUMNS, presentColumns } = require('./videoRemovalState'); const logger = require('../logger'); /** @@ -44,12 +45,13 @@ class VideoPersistence { } if (hasVerifiedFile) { - data.removed = false; + Object.assign(data, presentColumns()); data.last_downloaded_at = new Date(); logger.debug({ youtubeId: video.youtubeId, fileSize: video.fileSize, audioFileSize: video.audioFileSize, isNewVideo }, 'Setting last_downloaded_at - file verified'); } else { if (!isNewVideo) { - delete data.removed; + // Nothing verified: leave the row's removed state and reason alone. + for (const column of REMOVAL_COLUMNS) delete data[column]; } logger.debug({ youtubeId: video.youtubeId, filePath: video.filePath, fileSize: video.fileSize, isNewVideo }, 'NOT setting last_downloaded_at - hasVerifiedFile is false'); } diff --git a/server/modules/videoRemovalState.js b/server/modules/videoRemovalState.js new file mode 100644 index 000000000..9e4659d45 --- /dev/null +++ b/server/modules/videoRemovalState.js @@ -0,0 +1,95 @@ +// Why a videos row has no file, layered on the `removed` flag. +// +// `removed` stays the single "no file" flag every query reads. The reason +// columns say who removed it: a removed row whose reason is 'not_found' is +// MISSING (Youtarr expected a file and could not find it); a removed row with +// any other reason, including none (rows removed before the reason was +// recorded), is DELETED (Youtarr removed the file, or the user said it is gone). +const { Op } = require('sequelize'); + +const REMOVAL_REASONS = { + USER_DELETED: 'user_deleted', + AUTO_REMOVAL: 'auto_removal', + NOT_FOUND: 'not_found', + MARKED_DELETED: 'marked_deleted', +}; + +const REMOVAL_STATES = { + PRESENT: 'present', + DELETED: 'deleted', + MISSING: 'missing', +}; + +const REMOVAL_COLUMNS = ['removed', 'removed_reason', 'removed_at', 'removed_detail']; + +const KNOWN_REASONS = new Set(Object.values(REMOVAL_REASONS)); + +/** + * @param {Object} row - A videos row (raw or model instance); `removed` may be 0/1 or boolean + * @returns {string} One of REMOVAL_STATES + */ +function removalState(row) { + if (!row.removed) return REMOVAL_STATES.PRESENT; + return row.removed_reason === REMOVAL_REASONS.NOT_FOUND ? REMOVAL_STATES.MISSING : REMOVAL_STATES.DELETED; +} + +/** + * Columns to write when a row loses its file. + * @param {string} reason - One of REMOVAL_REASONS + * @param {{ detail?: string|null, at?: Date }} [options] - detail: the rule, for automatic cleanup + */ +function removedColumns(reason, { detail = null, at = new Date() } = {}) { + if (!KNOWN_REASONS.has(reason)) { + throw new Error(`Unknown removal reason: ${reason}`); + } + return { removed: true, removed_reason: reason, removed_at: at, removed_detail: detail }; +} + +/** Columns to write when a row's file is on disk (again). */ +function presentColumns() { + return { removed: false, removed_reason: null, removed_at: null, removed_detail: null }; +} + +function assertFilterableState(state) { + if (state !== REMOVAL_STATES.DELETED && state !== REMOVAL_STATES.MISSING) { + throw new Error(`Unknown removal state: ${state}`); + } +} + +/** Sequelize `where` matching rows in the given state ('deleted' | 'missing'). */ +function stateWhere(state) { + assertFilterableState(state); + if (state === REMOVAL_STATES.MISSING) { + return { removed: true, removed_reason: REMOVAL_REASONS.NOT_FOUND }; + } + return { + removed: true, + [Op.or]: [{ removed_reason: null }, { removed_reason: { [Op.ne]: REMOVAL_REASONS.NOT_FOUND } }], + }; +} + +/** Sequelize `where` matching every row NOT in the given state, for 'exclude' filters. */ +function notStateWhere(state) { + assertFilterableState(state); + if (state === REMOVAL_STATES.MISSING) { + return { + [Op.or]: [ + { removed: false }, + { removed_reason: null }, + { removed_reason: { [Op.ne]: REMOVAL_REASONS.NOT_FOUND } }, + ], + }; + } + return { [Op.or]: [{ removed: false }, { removed_reason: REMOVAL_REASONS.NOT_FOUND }] }; +} + +module.exports = { + REMOVAL_REASONS, + REMOVAL_STATES, + REMOVAL_COLUMNS, + removalState, + removedColumns, + presentColumns, + stateWhere, + notStateWhere, +}; diff --git a/server/modules/videoTrashModule.js b/server/modules/videoTrashModule.js new file mode 100644 index 000000000..829a1bd56 --- /dev/null +++ b/server/modules/videoTrashModule.js @@ -0,0 +1,247 @@ +// The trash: videos that have no file any more (see videoRemovalState for +// deleted vs. missing). Counts them, turns missing videos into deleted ones, +// and (purge) forgets them entirely. +const path = require('path'); +const fs = require('fs'); +const { Op } = require('sequelize'); +const { sequelize } = require('../db.js'); +const { Video, JobVideo, VideoWatchStatus, PlaylistVideo } = require('../models'); +const configModule = require('./configModule'); +const messageEmitter = require('./messageEmitter'); +const archiveModule = require('./archiveModule'); +const archiveAccess = require('./download/archiveAccess'); +const logger = require('../logger'); +const { checkLibraryRoot } = require('./libraryRootCheck'); +const createLimiter = require('./subscriptionImport/concurrencyLimiter'); +const { REMOVAL_REASONS, REMOVAL_STATES, stateWhere, removedColumns } = require('./videoRemovalState'); + +// Rows are forgotten in chunks of this size, each in its own transaction. +const PURGE_CHUNK_SIZE = 500; +// Cached files (info.json, thumbnails) are unlinked this many at a time. +const UNLINK_CONCURRENCY = 16; + +// Thrown when an action over every missing video is asked for while the +// downloads folder looks unmounted: those videos may well be fine. +class LibraryUnavailableError extends Error { + constructor(libraryCheck) { + super(libraryCheck.message); + this.name = 'LibraryUnavailableError'; + this.code = 'LIBRARY_UNAVAILABLE'; + this.libraryCheck = libraryCheck; + } +} + +class PurgeRunningError extends Error { + constructor() { + super('A purge is already running'); + this.name = 'PurgeRunningError'; + this.code = 'PURGE_RUNNING'; + } +} + +function normalizeLibraryCheck(check) { + return { + ok: check.ok, + reason: check.reason ?? null, + path: check.path ?? null, + message: check.message ?? null, + }; +} + +class VideoTrashModule { + constructor() { + this._purgeRunning = false; + } + + /** + * @returns {Promise<{deleted: number, missing: number, legacy: number, libraryCheck: Object}>} + * legacy: deleted rows removed before the reason was recorded (a subset of deleted) + */ + async getRemovedSummary() { + const deleted = await Video.count({ where: stateWhere(REMOVAL_STATES.DELETED) }); + const missing = await Video.count({ where: stateWhere(REMOVAL_STATES.MISSING) }); + const legacy = await Video.count({ where: { removed: true, removed_reason: null } }); + const libraryCheck = await checkLibraryRoot(configModule.directoryPath); + return { deleted, missing, legacy, libraryCheck: normalizeLibraryCheck(libraryCheck) }; + } + + /** + * Turn missing videos into deleted ones. Only rows that are missing change; + * present and deleted ids in the selection are left alone. + * @param {{videoIds?: (number[]|null), all?: boolean}} options + * @returns {Promise<{updated: number}>} + * @throws {LibraryUnavailableError} for `all` while the downloads folder looks unmounted + */ + async markMissingAsDeleted({ videoIds = null, all = false } = {}) { + if (all) { + const libraryCheck = await checkLibraryRoot(configModule.directoryPath); + if (!libraryCheck.ok) throw new LibraryUnavailableError(libraryCheck); + } + + const where = { ...stateWhere(REMOVAL_STATES.MISSING) }; + if (!all) where.id = videoIds; + + const [updated] = await Video.update(removedColumns(REMOVAL_REASONS.MARKED_DELETED), { where }); + if (updated > 0) { + messageEmitter.emitMessage('broadcast', null, 'videos', 'videosUpdated', { source: 'mark-deleted', updated }); + } + return { updated }; + } + + isPurgeRunning() { + return this._purgeRunning; + } + + /** + * Forget removed videos entirely: their videos row, job links, synced watch + * status, playlist download dates, cached info.json and thumbnails, and (if + * asked) their download archive entries. Nothing on disk under the + * downloads folder is touched. + * @param {{states: string[], removeFromArchive: boolean}} options - states: 'deleted' and/or 'missing' + * @param {{reloadJobVideos?: Function}} [deps] - Download History holds jobs in memory; given, it is + * called with the affected job ids before the pages are told to refetch, so they never see ghosts + * @returns {Promise<{purged: number, archiveEntriesRemoved: number, jobIds: string[]}>} + * @throws {PurgeRunningError} while another purge runs + * @throws {LibraryUnavailableError} for missing videos while the downloads folder looks unmounted + * @throws with code ARCHIVE_BUSY if archive removal would overlap a download + * @throws a chunk failure after refreshing history and listings for committed chunks; + * archive failures roll back that chunk's database changes so it can be retried + */ + async purgeRemoved({ states, removeFromArchive }, { reloadJobVideos = null } = {}) { + if (this._purgeRunning) throw new PurgeRunningError(); + // Taken before the library check awaits, so a second request cannot slip in meanwhile. + this._purgeRunning = true; + let releaseArchive = null; + try { + if (removeFromArchive) releaseArchive = archiveAccess.acquirePurge(); + if (states.includes(REMOVAL_STATES.MISSING)) { + const libraryCheck = await checkLibraryRoot(configModule.directoryPath); + if (!libraryCheck.ok) throw new LibraryUnavailableError(libraryCheck); + } + + const jobIds = new Set(); + const purgedYoutubeIds = []; + const archiveProgress = { removed: 0 }; + let failure = null; + try { + await this._forgetInChunks(states.map((state) => stateWhere(state)), jobIds, purgedYoutubeIds, removeFromArchive, archiveProgress); + } catch (err) { + // Chunks that committed before the failure are gone for good, so the + // steps below still run for them; the failure is rethrown after. + failure = err; + } + + const purged = purgedYoutubeIds.length; + const archiveEntriesRemoved = archiveProgress.removed; + + // Refresh the in-memory jobs first: the pages refetch on the broadcast, + // and a refetch that lands before the reload would still show the ghosts. + if (jobIds.size > 0 && reloadJobVideos) { + try { + await reloadJobVideos([...jobIds]); + } catch (err) { + logger.error({ err, jobsAffected: jobIds.size }, 'Purged videos but could not refresh the download history'); + } + } + + if (purged > 0) { + logger.info( + { purged, archiveEntriesRemoved, states, jobsAffected: jobIds.size }, + failure ? 'Emptied part of the trash before a failure' : 'Emptied the trash' + ); + messageEmitter.emitMessage('broadcast', null, 'videos', 'videosUpdated', { source: 'purge', purged }); + messageEmitter.emitMessage('broadcast', null, 'videos', 'jobsUpdated', { source: 'purge' }); + } + if (failure) throw failure; + return { purged, archiveEntriesRemoved, jobIds: [...jobIds] }; + } finally { + if (releaseArchive) releaseArchive(); + this._purgeRunning = false; + } + } + + /** + * Forget the matching rows chunk by chunk, each chunk in its own transaction. + * Each chunk's job ids and YouTube ids are added to the given collections + * once it commits, so the caller still has them if a later chunk throws. + * @param {Object[]} stateClauses - stateWhere() clauses, any of which a row may match + * @param {Set<string>} jobIds - Jobs that lost videos + * @param {string[]} purgedYoutubeIds - Videos forgotten so far + * @param {boolean} removeFromArchive - Remove archive entries before committing each chunk + * @param {{removed: number}} archiveProgress - Archive entries removed by committed chunks + */ + async _forgetInChunks(stateClauses, jobIds, purgedYoutubeIds, removeFromArchive, archiveProgress) { + const where = { [Op.or]: stateClauses }; + for (;;) { + // Each chunk's rows are gone once it commits, so the same query finds the next chunk. + const candidates = await Video.findAll({ where, attributes: ['id', 'youtubeId'], limit: PURGE_CHUNK_SIZE, raw: true }); + if (candidates.length === 0) return; + + let linkedJobIds = []; + let archiveEntriesRemoved = 0; + const confirmed = await sequelize.transaction(async (transaction) => { + // Re-read under lock with the state predicate: a row re-downloaded or + // restored since the select is present again and must stay. + const rows = await Video.findAll({ + where: { id: candidates.map((row) => row.id), [Op.or]: stateClauses }, + attributes: ['id', 'youtubeId'], + transaction, + lock: true, + raw: true, + }); + if (rows.length === 0) return rows; + const ids = rows.map((row) => row.id); + const youtubeIds = rows.map((row) => row.youtubeId); + + const links = await JobVideo.findAll({ where: { video_id: ids }, attributes: ['job_id'], transaction, raw: true }); + linkedJobIds = links.map((link) => link.job_id); + // jobvideos.video_id has a restricting foreign key: children first. + await VideoWatchStatus.destroy({ where: { video_id: ids }, transaction }); + await JobVideo.destroy({ where: { video_id: ids }, transaction }); + await PlaylistVideo.update({ downloaded_at: null }, { where: { youtube_id: youtubeIds }, transaction }); + await Video.destroy({ where: { id: ids, [Op.or]: stateClauses }, transaction }); + // An archive error must abort the transaction: otherwise its IDs would + // be lost and another purge could never retry the requested cleanup. + // If the subsequent commit fails, rows remain and removing their + // already-absent archive entries on retry is harmless. + if (removeFromArchive) { + archiveEntriesRemoved = await archiveModule.removeVideosFromArchive(youtubeIds); + } + return rows; + }); + + if (confirmed.length === 0) { + logger.warn({ count: candidates.length }, 'Purge chunk had nothing left to forget; stopping'); + return; + } + for (const jobId of linkedJobIds) jobIds.add(jobId); + archiveProgress.removed += archiveEntriesRemoved; + const youtubeIds = confirmed.map((row) => row.youtubeId); + purgedYoutubeIds.push(...youtubeIds); + // The cached info.json is what library repair would recreate the row from. + await this._removeCachedFiles(youtubeIds); + } + } + + async _removeCachedFiles(youtubeIds) { + const infoDir = path.join(configModule.getJobsPath(), 'info'); + const imageDir = configModule.getImagePath(); + const limit = createLimiter(UNLINK_CONCURRENCY); + const paths = youtubeIds.flatMap((youtubeId) => [ + path.join(infoDir, `${youtubeId}.info.json`), + path.join(imageDir, `videothumb-${youtubeId}.jpg`), + path.join(imageDir, `videothumb-${youtubeId}-small.jpg`), + ]); + await Promise.all(paths.map((filePath) => limit(async () => { + try { + await fs.promises.unlink(filePath); + } catch (err) { + if (err.code !== 'ENOENT') { + logger.warn({ err, filePath }, 'Could not remove a purged video\'s cached file'); + } + } + }))); + } +} + +module.exports = Object.assign(new VideoTrashModule(), { LibraryUnavailableError, PurgeRunningError }); diff --git a/server/modules/videosModule.js b/server/modules/videosModule.js index 3a27c1068..79b2ac059 100644 --- a/server/modules/videosModule.js +++ b/server/modules/videosModule.js @@ -18,6 +18,8 @@ const createLimiter = require('./subscriptionImport/concurrencyLimiter'); const { isRescanCandidate, resolveRescanUpdate } = require('./rescanRowUpdate'); const { unchangedSinceRead, GUARDED_COLUMNS } = require('./videoRowGuard'); const youtubeExistenceCheck = require('./youtubeExistenceCheck'); +const { LIBRARY_CHECK_REASONS, checkLibraryRoot } = require('./libraryRootCheck'); +const { REMOVAL_STATES, stateWhere, notStateWhere } = require('./videoRemovalState'); const { EXISTENCE } = youtubeExistenceCheck; @@ -52,6 +54,7 @@ class VideosModule { channelFilter = '', protectedFilter = 'off', missingFilter = 'off', + deletedFilter = 'off', watchedFilter = 'off', maxRating = null, } = options; @@ -90,10 +93,18 @@ class VideosModule { where.protected = false; } - if (missingFilter === 'only') { - where.removed = true; - } else if (missingFilter === 'exclude') { - where.removed = false; + // Missing (file not found) and deleted (removed by Youtarr, or before + // the reason was recorded) are both `removed` rows; see videoRemovalState. + const stateFilter = (mode, state) => { + if (mode !== 'only' && mode !== 'exclude') return null; + return mode === 'only' ? stateWhere(state) : notStateWhere(state); + }; + const missingWhere = stateFilter(missingFilter, REMOVAL_STATES.MISSING); + const deletedWhere = stateFilter(deletedFilter, REMOVAL_STATES.DELETED); + for (const clause of [missingWhere, deletedWhere]) { + if (!clause) continue; + where[Sequelize.Op.and] ??= []; + where[Sequelize.Op.and].push(clause); } if (watchedFilter === 'only' || watchedFilter === 'exclude') { @@ -134,6 +145,18 @@ class VideosModule { distinct: true, }); + // How many deleted videos the default view hides, for the "N deleted + // hidden" note: the same filters with "deleted only" in place of the + // exclusion. + let hiddenDeletedCount = null; + if (deletedFilter === 'exclude') { + const hiddenWhere = { + ...where, + [Sequelize.Op.and]: where[Sequelize.Op.and].map((clause) => (clause === deletedWhere ? stateWhere(REMOVAL_STATES.DELETED) : clause)), + }; + hiddenDeletedCount = await Video.count({ ...options, where: hiddenWhere, distinct: true }); + } + // Define attributes options.attributes = { include: [ @@ -172,7 +195,7 @@ class VideosModule { // Real-time file check for videos that have a known file path // Only check videos with an existing filePath to avoid incorrectly marking videos as removed // Videos without a filePath will be handled by the backfill process - const { videos: checkedVideos, updates } = await fileCheckModule.checkVideoFiles(videos); + const { videos: checkedVideos, updates, libraryWarning } = await fileCheckModule.checkVideoFiles(videos); // Update the videos array with the checked results for (let i = 0; i < videos.length; i++) { @@ -270,7 +293,10 @@ class VideosModule { page, totalPages: Math.ceil(total / limit), channels, - enabledChannels: enabledChannels.map(ch => ({ channel_id: ch.channel_id, uploader: ch.uploader })) + enabledChannels: enabledChannels.map(ch => ({ channel_id: ch.channel_id, uploader: ch.uploader })), + // Set when rows were not marked missing because the downloads folder looks unmounted. + libraryWarning, + hiddenDeletedCount }; } catch (err) { logger.error({ err }, 'Error in getVideosPaginated'); @@ -498,10 +524,12 @@ class VideosModule { * Deliberately does not check the run's time limit: once started, a slice's * writes complete, so the expensive work already done is never discarded. * @param {Array<{row: Object, fileInfo: Object|undefined, probedResolution: string|null}>} candidates - * @returns {Promise<{updated: number, removed: number, kept: number, skipped: number, failed: number}>} + * @param {{suppressMissing?: boolean}} [options] - suppressMissing: the downloads folder looks + * partly unmounted, so rows that would be marked missing are left alone and counted + * @returns {Promise<{updated: number, removed: number, kept: number, skipped: number, failed: number, notMarkedMissing: number}>} */ - async _applyRescanUpdates(candidates) { - const counts = { updated: 0, removed: 0, kept: 0, skipped: 0, failed: 0 }; + async _applyRescanUpdates(candidates, { suppressMissing = false } = {}) { + const counts = { updated: 0, removed: 0, kept: 0, skipped: 0, failed: 0, notMarkedMissing: 0 }; const limit = createLimiter(RESCAN_WRITE_CONCURRENCY); await Promise.all(candidates.map(({ row, fileInfo, probedResolution }) => limit(async () => { try { @@ -512,6 +540,10 @@ class VideosModule { counts.kept++; return; } + if (suppressMissing && changes.removed === true) { + counts.notMarkedMissing++; + return; + } const [affected] = await Video.update(changes, { where: unchangedSinceRead(row) }); if (affected === 0) { counts.skipped++; @@ -552,8 +584,12 @@ class VideosModule { let totalKept = 0; let totalSkipped = 0; let totalFailed = 0; + let totalNotMarkedMissing = 0; let fileMapSize = 0; let unreadableOnDisk = 0; + // Set when the downloads folder looks partly unmounted: the scan runs, but + // nothing is marked missing (see libraryRootCheck). + let libraryCheckMessage = null; let result; try { @@ -572,6 +608,39 @@ class VideosModule { return; } + // An unmounted drive or share would make every present video look + // gone. With no present rows there is nothing to protect (a fresh + // install with an empty folder), so the scan simply runs. + let suppressMissing = false; + const libraryCheck = await checkLibraryRoot(outputDir); + if (!libraryCheck.ok && (await Video.count({ where: { removed: false } })) > 0) { + if (libraryCheck.reason === LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY) { + suppressMissing = true; + libraryCheckMessage = libraryCheck.message; + logger.warn({ libraryCheck }, 'A subfolder looks unmounted; the rescan will not mark videos missing'); + } else { + logger.warn({ libraryCheck }, 'The downloads folder looks unmounted; skipping the rescan'); + result = { + trigger, + startedAt: startedAtIso, + completedAt: new Date().toISOString(), + status: 'error', + errorMessage: `${libraryCheck.message} Nothing was marked missing; is the drive mounted?`, + libraryCheckMessage: libraryCheck.message, + processed: 0, + filesOnDisk: 0, + unreadableOnDisk: 0, + updated: 0, + removed: 0, + kept: 0, + skippedChanged: 0, + failed: 0, + notMarkedMissing: 0 + }; + return result; + } + } + // Check time limit before expensive operations const checkTimeLimit = () => { if (Date.now() - startTime > timeLimit) { @@ -663,12 +732,13 @@ class VideosModule { // re-probed next run and never converge. if (candidates.length > 0) { logProgress(`Checking ${candidates.length} records (chunk ${Math.floor(offset / VIDEO_CHUNK_SIZE) + 1})...`); - const counts = await this._applyRescanUpdates(candidates); + const counts = await this._applyRescanUpdates(candidates, { suppressMissing }); totalUpdated += counts.updated; totalRemoved += counts.removed; totalKept += counts.kept; totalSkipped += counts.skipped; totalFailed += counts.failed; + totalNotMarkedMissing += counts.notMarkedMissing; } } @@ -691,7 +761,8 @@ class VideosModule { removed: totalRemoved, kept: totalKept, skippedChanged: totalSkipped, - failed: totalFailed + failed: totalFailed, + notMarkedMissing: totalNotMarkedMissing }, 'Video metadata backfill completed'); result = { @@ -703,6 +774,8 @@ class VideosModule { kept: totalKept, skippedChanged: totalSkipped, failed: totalFailed, + notMarkedMissing: totalNotMarkedMissing, + libraryCheckMessage, timeElapsed: elapsed, trigger, startedAt: startedAtIso, @@ -728,7 +801,9 @@ class VideosModule { removed: totalRemoved, kept: totalKept, skippedChanged: totalSkipped, - failed: totalFailed + failed: totalFailed, + notMarkedMissing: totalNotMarkedMissing, + libraryCheckMessage }; return result; } @@ -746,7 +821,9 @@ class VideosModule { removed: totalRemoved, kept: totalKept, skippedChanged: totalSkipped, - failed: totalFailed + failed: totalFailed, + notMarkedMissing: totalNotMarkedMissing, + libraryCheckMessage }; // Resolve with the failure and the counters it reached rather than // rethrow, so every caller and the run history keep the partial progress. diff --git a/server/routes/__tests__/playlists.test.js b/server/routes/__tests__/playlists.test.js index e45a53095..aa441884b 100644 --- a/server/routes/__tests__/playlists.test.js +++ b/server/routes/__tests__/playlists.test.js @@ -1172,6 +1172,9 @@ describe('GET /api/playlists/:playlistId/videos', () => { youtubeId: 'gone1', youTubeVideoName: 'Deleted local file', removed: true, + removed_reason: 'auto_removal', + removed_at: new Date('2026-10-03T00:00:00Z'), + removed_detail: 'older than 30 days', youtube_removed: false, filePath: '/videos/gone1.mp4', fileSize: 1024, @@ -1189,6 +1192,10 @@ describe('GET /api/playlists/:playlistId/videos', () => { youtube_id: 'gone1', downloaded: false, previously_downloaded: true, + // Why it is gone, so the page can tell Deleted from Missing. + removed_reason: 'auto_removal', + removed_at: new Date('2026-10-03T00:00:00Z'), + removed_detail: 'older than 30 days', }); }); diff --git a/server/routes/__tests__/videoTrash.test.js b/server/routes/__tests__/videoTrash.test.js new file mode 100644 index 000000000..78333cc1e --- /dev/null +++ b/server/routes/__tests__/videoTrash.test.js @@ -0,0 +1,168 @@ +/* eslint-env jest */ +const express = require('express'); +const supertest = require('supertest'); +const createVideoTrashRoutes = require('../videoTrash'); + +class LibraryUnavailableError extends Error { + constructor(message) { + super(message); + this.code = 'LIBRARY_UNAVAILABLE'; + } +} + +class PurgeRunningError extends Error { + constructor() { + super('A purge is already running'); + this.code = 'PURGE_RUNNING'; + } +} + +function makeModule(overrides = {}) { + return { + LibraryUnavailableError, + PurgeRunningError, + getRemovedSummary: jest.fn().mockResolvedValue({ deleted: 2, missing: 1, legacy: 0, libraryCheck: { ok: true, reason: null, path: null, message: null } }), + markMissingAsDeleted: jest.fn().mockResolvedValue({ updated: 2 }), + purgeRemoved: jest.fn().mockResolvedValue({ purged: 0, archiveEntriesRemoved: 0, jobIds: [] }), + ...overrides, + }; +} + +function makeApp(videoTrashModule, jobModule = { reloadJobVideos: jest.fn().mockResolvedValue(undefined) }) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.log = { error: jest.fn(), warn: jest.fn(), info: jest.fn() }; + next(); + }); + app.use(createVideoTrashRoutes({ verifyToken: (req, _res, next) => next(), videoTrashModule, jobModule })); + return app; +} + +describe('GET /api/videos/removed-summary', () => { + test('returns the module summary', async () => { + const module = makeModule(); + const res = await supertest(makeApp(module)).get('/api/videos/removed-summary'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ deleted: 2, missing: 1, legacy: 0, libraryCheck: { ok: true, reason: null, path: null, message: null } }); + }); + + test('returns 500 with the standard error shape when the summary fails', async () => { + const module = makeModule({ getRemovedSummary: jest.fn().mockRejectedValue(new Error('db down')) }); + const res = await supertest(makeApp(module)).get('/api/videos/removed-summary'); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Failed to read the deleted and missing video counts' }); + }); +}); + +describe('POST /api/videos/mark-deleted', () => { + test.each([ + ['nothing', {}], + ['both a selection and all', { videoIds: [1], all: true }], + ['an empty selection', { videoIds: [] }], + ['a non-integer id', { videoIds: ['x'] }], + ['all set to false alone', { all: false }], + ])('rejects %s with 400', async (_label, body) => { + const module = makeModule(); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send(body); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Send either videoIds (1 to 1000 video ids) or all: true' }); + expect(module.markMissingAsDeleted).not.toHaveBeenCalled(); + }); + + test('marks a selection', async () => { + const module = makeModule(); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ videoIds: [1, 2] }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ updated: 2 }); + expect(module.markMissingAsDeleted).toHaveBeenCalledWith({ videoIds: [1, 2], all: false }); + }); + + test('marks all missing videos', async () => { + const module = makeModule({ markMissingAsDeleted: jest.fn().mockResolvedValue({ updated: 40 }) }); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ updated: 40 }); + expect(module.markMissingAsDeleted).toHaveBeenCalledWith({ videoIds: null, all: true }); + }); + + test('answers 409 with the reason while the downloads folder looks unmounted', async () => { + const module = makeModule({ + markMissingAsDeleted: jest.fn().mockRejectedValue(new LibraryUnavailableError('The downloads folder /d is empty.')), + }); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: 'The downloads folder /d is empty.', reason: 'library-unavailable' }); + }); + + test('answers 500 with the standard error shape on any other failure', async () => { + const module = makeModule({ markMissingAsDeleted: jest.fn().mockRejectedValue(new Error('boom')) }); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true }); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Failed to mark videos as deleted' }); + }); +}); + +describe('POST /api/videos/purge-removed', () => { + test.each([ + ['no states', { removeFromArchive: false }], + ['an empty states list', { states: [], removeFromArchive: false }], + ['an unknown state', { states: ['bogus'], removeFromArchive: false }], + ['a missing archive choice', { states: ['deleted'] }], + ['a non-boolean archive choice', { states: ['deleted'], removeFromArchive: 'yes' }], + ])('rejects %s with 400', async (_label, body) => { + const module = makeModule(); + const res = await supertest(makeApp(module)).post('/api/videos/purge-removed').send(body); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Send states (deleted and/or missing) and removeFromArchive (true or false)' }); + expect(module.purgeRemoved).not.toHaveBeenCalled(); + }); + + test('purges, handing the module the download history reload, and reports what it did', async () => { + const module = makeModule({ + purgeRemoved: jest.fn().mockResolvedValue({ purged: 5, archiveEntriesRemoved: 5, jobIds: ['j1', 'j2'] }), + }); + const jobModule = { reloadJobVideos: jest.fn().mockResolvedValue(undefined) }; + + const res = await supertest(makeApp(module, jobModule)).post('/api/videos/purge-removed').send({ states: ['deleted', 'missing'], removeFromArchive: true }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ purged: 5, archiveEntriesRemoved: 5, jobsAffected: 2 }); + expect(module.purgeRemoved).toHaveBeenCalledWith( + { states: ['deleted', 'missing'], removeFromArchive: true }, + { reloadJobVideos: expect.any(Function) } + ); + // The module calls this before it broadcasts; it must reach jobModule. + await module.purgeRemoved.mock.calls[0][1].reloadJobVideos(['j1', 'j2']); + expect(jobModule.reloadJobVideos).toHaveBeenCalledWith(['j1', 'j2']); + }); + + test('answers 409 while another purge is running', async () => { + const module = makeModule({ purgeRemoved: jest.fn().mockRejectedValue(new PurgeRunningError()) }); + const res = await supertest(makeApp(module)).post('/api/videos/purge-removed').send({ states: ['deleted'], removeFromArchive: false }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: 'A purge is already running', reason: 'running' }); + }); + + test('answers 409 when a download prevents archive removal', async () => { + const error = Object.assign(new Error('Wait for active downloads to finish.'), { code: 'ARCHIVE_BUSY' }); + const module = makeModule({ purgeRemoved: jest.fn().mockRejectedValue(error) }); + const res = await supertest(makeApp(module)).post('/api/videos/purge-removed').send({ states: ['deleted'], removeFromArchive: true }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: error.message, reason: 'archive-busy' }); + }); + + test('answers 409 with the reason while the downloads folder looks unmounted', async () => { + const module = makeModule({ purgeRemoved: jest.fn().mockRejectedValue(new LibraryUnavailableError('The downloads folder /d is empty.')) }); + const res = await supertest(makeApp(module)).post('/api/videos/purge-removed').send({ states: ['missing'], removeFromArchive: false }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: 'The downloads folder /d is empty.', reason: 'library-unavailable' }); + }); + + test('answers 500 with the standard error shape on any other failure', async () => { + const module = makeModule({ purgeRemoved: jest.fn().mockRejectedValue(new Error('boom')) }); + const res = await supertest(makeApp(module)).post('/api/videos/purge-removed').send({ states: ['deleted'], removeFromArchive: false }); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Failed to empty the trash' }); + }); +}); diff --git a/server/routes/__tests__/videos.routes.test.js b/server/routes/__tests__/videos.routes.test.js index 29e8870cc..8a25728bd 100644 --- a/server/routes/__tests__/videos.routes.test.js +++ b/server/routes/__tests__/videos.routes.test.js @@ -549,3 +549,52 @@ describe('POST /triggerchanneldownloads', () => { expect(res.status).toHaveBeenCalledWith(500); }); }); + +describe('GET /getVideos', () => { + const loggerMock = { info: jest.fn(), error: jest.fn() }; + + const createResponse = () => { + const res = {}; + res.status = jest.fn(() => res); + res.json = jest.fn(() => res); + return res; + }; + + const getHandler = (videosModuleMock) => { + const router = createVideoRoutes({ + verifyToken: (req, res, next) => next(), + videosModule: videosModuleMock, + downloadModule: {}, + ratingMapper: require('../../modules/ratingMapper'), + youtubeUrlParser, + }); + const app = express(); + app.use(router); + return findRouteHandler(app, 'get', '/getVideos'); + }; + + test('passes the missing and deleted tri-state filters to the listing', async () => { + const videosModuleMock = { getVideosPaginated: jest.fn().mockResolvedValue({ videos: [] }) }; + const handler = getHandler(videosModuleMock); + const res = createResponse(); + + await handler({ query: { missingFilter: 'only', deletedFilter: 'exclude' }, log: loggerMock }, res); + + expect(videosModuleMock.getVideosPaginated).toHaveBeenCalledWith(expect.objectContaining({ + missingFilter: 'only', + deletedFilter: 'exclude', + })); + expect(res.json).toHaveBeenCalledWith({ videos: [] }); + }); + + test('treats an unknown deleted filter value as off', async () => { + const videosModuleMock = { getVideosPaginated: jest.fn().mockResolvedValue({ videos: [] }) }; + const handler = getHandler(videosModuleMock); + + await handler({ query: { deletedFilter: 'bogus' }, log: loggerMock }, createResponse()); + + expect(videosModuleMock.getVideosPaginated).toHaveBeenCalledWith(expect.objectContaining({ + deletedFilter: 'off', + })); + }); +}); diff --git a/server/routes/channels.js b/server/routes/channels.js index aaa8460f1..fee80f806 100644 --- a/server/routes/channels.js +++ b/server/routes/channels.js @@ -893,6 +893,18 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch * default: off * description: Tri-state filter on watched status (per the configured watched rule). `only` keeps watched videos, `exclude` hides them. * - in: query + * name: missingFilter + * schema: + * type: string + * enum: [off, only, exclude] + * description: Tri-state filter on missing videos (previously downloaded; a file was expected on disk and not found) + * - in: query + * name: deletedFilter + * schema: + * type: string + * enum: [off, only, exclude] + * description: Tri-state filter on deleted videos (previously downloaded; deleted in Youtarr or by automatic cleanup, marked as deleted, or removed before Youtarr recorded why) + * - in: query * name: maxRating * schema: * type: string @@ -929,13 +941,14 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch const missingFilter = parseFilterMode(req.query.missingFilter); const ignoredFilter = parseFilterMode(req.query.ignoredFilter); const watchedFilter = parseFilterMode(req.query.watchedFilter); + const deletedFilter = parseFilterMode(req.query.deletedFilter); const maxRating = ratingMapper.parseMaxRatingParam(req.query.maxRating); if (!maxRating.valid) { return res.status(400).json({ error: 'Invalid maxRating' }); } let result; try { - result = await channelModule.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating.value); + result = await channelModule.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating.value, deletedFilter); } catch (error) { if (error.code === 'CHANNEL_NOT_FOUND') { return res.status(404).json({ error: 'Channel not found' }); diff --git a/server/routes/index.js b/server/routes/index.js index fd821f1e9..b1ae1403f 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -19,7 +19,9 @@ const createMaintenanceRoutes = require('./maintenance'); const createSubfolderRoutes = require('./subfolders'); const createSchedulesRoutes = require('./schedules'); const createLogRoutes = require('./logs'); +const createVideoTrashRoutes = require('./videoTrash'); const videoMetadataModule = require('../modules/videoMetadataModule'); +const videoTrashModule = require('../modules/videoTrashModule'); const videoOembedEnricher = require('../modules/videoOembedEnricher'); const playlistModule = require('../modules/playlistModule'); const m3uGenerator = require('../modules/m3uGenerator'); @@ -103,6 +105,9 @@ function registerRoutes(app, deps) { youtubeUrlParser, })); + // Trash routes: deleted and missing videos (counts, mark as deleted, purge) + app.use(createVideoTrashRoutes({ verifyToken, videoTrashModule, jobModule })); + // Video search routes app.use(createVideoSearchRoutes({ verifyToken, videoSearchModule })); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index e55d58abc..c3b7222a2 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -544,7 +544,7 @@ function createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3u if (youtubeIds.length > 0 && Video) { const downloaded = await Video.findAll({ where: { youtubeId: youtubeIds }, - attributes: ['id', 'youtubeId', 'youTubeVideoName', 'youTubeChannelName', 'duration', 'originalDate', 'removed', 'youtube_removed', 'filePath', 'fileSize', 'audioFilePath', 'audioFileSize', 'video_resolution'], + attributes: ['id', 'youtubeId', 'youTubeVideoName', 'youTubeChannelName', 'duration', 'originalDate', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'youtube_removed', 'filePath', 'fileSize', 'audioFilePath', 'audioFileSize', 'video_resolution'], }); downloaded.forEach((v) => downloadedById.set(v.youtubeId, v)); } @@ -586,6 +586,10 @@ function createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3u thumbnail: youtubeRemoved ? localThumb : flatThumb, downloaded: isDownloaded, previously_downloaded: previouslyDownloaded, + // Why the file is gone (see videoRemovalState): tells Deleted from Missing. + removed_reason: dl?.removed_reason ?? null, + removed_at: dl?.removed_at ?? null, + removed_detail: dl?.removed_detail ?? null, youtube_removed: youtubeRemoved, video_id: dl?.id ?? null, file_path: dl?.filePath ?? null, diff --git a/server/routes/videoTrash.js b/server/routes/videoTrash.js new file mode 100644 index 000000000..7d5262c74 --- /dev/null +++ b/server/routes/videoTrash.js @@ -0,0 +1,253 @@ +const express = require('express'); + +// A selection can name this many videos at once. +const MAX_SELECTION_SIZE = 1000; +const PURGE_STATES = ['deleted', 'missing']; + +const isPositiveInteger = (value) => Number.isInteger(value) && value > 0; + +/** + * Routes for the trash: videos with no file left (deleted by Youtarr, or + * missing from disk). See modules/videoTrashModule.js. + * @param {Object} deps + * @param {Function} deps.verifyToken + * @param {Object} deps.videoTrashModule + * @param {Object} deps.jobModule - Download History holds jobs in memory; a purge reloads the affected ones + */ +module.exports = function createVideoTrashRoutes({ verifyToken, videoTrashModule, jobModule }) { + const router = express.Router(); + + /** + * @swagger + * /api/videos/removed-summary: + * get: + * summary: Count the videos whose file is gone + * description: > + * Deleted = Youtarr removed the file (a delete in the app, automatic cleanup, or a missing video the user + * marked as deleted), including videos removed before Youtarr recorded why (`legacy`, a subset of `deleted`). + * Missing = a file was expected on disk and not found. `libraryCheck` says whether the downloads folder + * looks mounted; while it does not, actions over all missing videos are refused. + * tags: [Videos] + * responses: + * 200: + * description: The counts and the library check + * content: + * application/json: + * schema: + * type: object + * properties: + * deleted: + * type: integer + * missing: + * type: integer + * legacy: + * type: integer + * libraryCheck: + * type: object + * properties: + * ok: + * type: boolean + * reason: + * type: string + * nullable: true + * enum: [missing, empty, unreadable, subfolder-empty] + * path: + * type: string + * nullable: true + * message: + * type: string + * nullable: true + * 401: + * description: Authentication required + * 500: + * description: The counts could not be read + */ + router.get('/api/videos/removed-summary', verifyToken, async (req, res) => { + try { + res.json(await videoTrashModule.getRemovedSummary()); + } catch (err) { + req.log.error({ err }, 'Failed to read the removed video summary'); + res.status(500).json({ error: 'Failed to read the deleted and missing video counts' }); + } + }); + + /** + * @swagger + * /api/videos/mark-deleted: + * post: + * summary: Mark missing videos as deleted + * description: > + * Turns missing videos (a file was expected on disk and not found) into deleted ones, which the Videos page + * hides by default. Only missing rows change; present and deleted ids in a selection are left alone. + * The records are kept, so the videos can still be re-downloaded, and a file that turns up again is marked + * downloaded by the rescan. Send either `videoIds` or `all: true`. + * tags: [Videos] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * videoIds: + * type: array + * minItems: 1 + * maxItems: 1000 + * items: + * type: integer + * description: Database video ids to mark + * all: + * type: boolean + * description: Mark every missing video + * responses: + * 200: + * description: How many videos were marked + * content: + * application/json: + * schema: + * type: object + * properties: + * updated: + * type: integer + * 400: + * description: Neither a valid selection nor all, or both + * 401: + * description: Authentication required + * 409: + * description: all was asked for while the downloads folder looks unmounted (reason library-unavailable); the videos may be fine + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * reason: + * type: string + * enum: [library-unavailable] + * 500: + * description: The videos could not be marked + */ + router.post('/api/videos/mark-deleted', verifyToken, async (req, res) => { + const { videoIds, all } = req.body || {}; + const wantsAll = all === true; + const selection = Array.isArray(videoIds) + && videoIds.length >= 1 + && videoIds.length <= MAX_SELECTION_SIZE + && videoIds.every(isPositiveInteger) + ? videoIds + : null; + + if ((wantsAll && videoIds !== undefined) || (!wantsAll && !selection)) { + return res.status(400).json({ error: `Send either videoIds (1 to ${MAX_SELECTION_SIZE} video ids) or all: true` }); + } + + try { + const result = await videoTrashModule.markMissingAsDeleted({ videoIds: wantsAll ? null : selection, all: wantsAll }); + res.json(result); + } catch (err) { + if (err.code === 'LIBRARY_UNAVAILABLE') { + return res.status(409).json({ error: err.message, reason: 'library-unavailable' }); + } + req.log.error({ err, all: wantsAll, count: selection ? selection.length : null }, 'Failed to mark videos as deleted'); + res.status(500).json({ error: 'Failed to mark videos as deleted' }); + } + }); + + /** + * @swagger + * /api/videos/purge-removed: + * post: + * summary: Empty the trash + * description: > + * Forgets deleted and/or missing videos entirely. This cannot be undone. For each video it removes the + * database record (so it leaves the Videos page and Download History and its channel and playlist pages + * show it as not downloaded), the watch history Youtarr synced for it from media servers, the playlist + * download date, and the cached metadata and thumbnail. Nothing on disk under the downloads folder is + * touched; a missing video's file that turns up later is not recognised. With `removeFromArchive` the + * videos also leave the download archive (`complete.list`), so automatic channel and playlist downloads + * treat them as new and may download them again; without it they keep being skipped. + * tags: [Videos] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [states, removeFromArchive] + * properties: + * states: + * type: array + * minItems: 1 + * items: + * type: string + * enum: [deleted, missing] + * removeFromArchive: + * type: boolean + * responses: + * 200: + * description: What was removed + * content: + * application/json: + * schema: + * type: object + * properties: + * purged: + * type: integer + * archiveEntriesRemoved: + * type: integer + * jobsAffected: + * type: integer + * description: Download History jobs that lost videos + * 400: + * description: Invalid states or archive choice + * 401: + * description: Authentication required + * 409: + * description: Another purge is running (reason running), downloads are using the archive (reason archive-busy), or missing videos were included while the downloads folder looks unmounted (reason library-unavailable) + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * reason: + * type: string + * enum: [running, library-unavailable, archive-busy] + * 500: + * description: The purge failed; rows already purged stay purged + */ + router.post('/api/videos/purge-removed', verifyToken, async (req, res) => { + const { states, removeFromArchive } = req.body || {}; + const validStates = Array.isArray(states) + && states.length > 0 + && states.every((state) => PURGE_STATES.includes(state)) + && new Set(states).size === states.length; + if (!validStates || typeof removeFromArchive !== 'boolean') { + return res.status(400).json({ error: 'Send states (deleted and/or missing) and removeFromArchive (true or false)' }); + } + + try { + const result = await videoTrashModule.purgeRemoved( + { states, removeFromArchive }, + { reloadJobVideos: (jobIds) => jobModule.reloadJobVideos(jobIds) } + ); + res.json({ purged: result.purged, archiveEntriesRemoved: result.archiveEntriesRemoved, jobsAffected: result.jobIds.length }); + } catch (err) { + if (err.code === 'PURGE_RUNNING') { + return res.status(409).json({ error: err.message, reason: 'running' }); + } + if (err.code === 'ARCHIVE_BUSY') { + return res.status(409).json({ error: err.message, reason: 'archive-busy' }); + } + if (err.code === 'LIBRARY_UNAVAILABLE') { + return res.status(409).json({ error: err.message, reason: 'library-unavailable' }); + } + req.log.error({ err, states, removeFromArchive }, 'Failed to empty the trash'); + res.status(500).json({ error: 'Failed to empty the trash' }); + } + }); + + return router; +}; diff --git a/server/routes/videos.js b/server/routes/videos.js index 05aa1d0e5..230fe7e57 100644 --- a/server/routes/videos.js +++ b/server/routes/videos.js @@ -101,7 +101,8 @@ module.exports = function createVideoRoutes({ * type: string * status: * type: string - * enum: [never_downloaded, missing, downloaded] + * enum: [never_downloaded, downloaded, deleted, missing] + * description: deleted = Youtarr removed the file (or the video was marked as deleted); missing = a file was expected on disk and not found. `removedReason`, `removedAt` and `removedDetail` say why. * inArchive: * type: boolean * description: Listed in the download archive without a database record (ignored videos excluded); a download skips it unless re-downloading is allowed @@ -196,7 +197,14 @@ module.exports = function createVideoRoutes({ * type: string * enum: [off, only, exclude] * default: off - * description: Tri-state filter on missing (file removed) videos + * description: Tri-state filter on missing videos (a file was expected on disk and not found) + * - in: query + * name: deletedFilter + * schema: + * type: string + * enum: [off, only, exclude] + * default: off + * description: Tri-state filter on deleted videos (deleted in Youtarr or by automatic cleanup, marked as deleted, or removed before Youtarr recorded why). With `exclude`, the response's `hiddenDeletedCount` says how many were hidden. * - in: query * name: watchedFilter * schema: @@ -212,7 +220,7 @@ module.exports = function createVideoRoutes({ * description: Hide videos rated above this rating. Unrated videos are always included. * responses: * 200: - * description: Paginated list of videos + * description: Paginated list of videos. Each row carries `removed`, `removed_reason`, `removed_at` and `removed_detail`. `libraryWarning` is set when videos were not marked missing because the downloads folder looked unmounted. * 400: * description: Invalid maxRating * 500: @@ -222,7 +230,7 @@ module.exports = function createVideoRoutes({ req.log.info('Getting videos'); try { - const { page, limit, search, dateFrom, dateTo, sortBy, sortOrder, channelFilter, protectedFilter, missingFilter, watchedFilter } = req.query; + const { page, limit, search, dateFrom, dateTo, sortBy, sortOrder, channelFilter, protectedFilter, missingFilter, deletedFilter, watchedFilter } = req.query; const maxRating = ratingMapper.parseMaxRatingParam(req.query.maxRating); if (!maxRating.valid) { @@ -242,6 +250,7 @@ module.exports = function createVideoRoutes({ channelFilter: channelFilter || '', protectedFilter: parseFilterMode(protectedFilter), missingFilter: parseFilterMode(missingFilter), + deletedFilter: parseFilterMode(deletedFilter), watchedFilter: parseFilterMode(watchedFilter), maxRating: maxRating.value, }; From ffc6d89410031eff62580ff72ec3cf920dda3473 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Sun, 18 Oct 2026 14:13:34 -0700 Subject: [PATCH 2/4] fix: harden the unmounted-drive guard and trash UI While a drive or share was down, the downloads folder could still look mounted once Youtarr had created its own temp folder, playlists folder, or an interrupted write test on the bare mount point, and the page-load check and rescan then marked the whole library missing. The check now ignores the entries Youtarr creates itself, reports an unreadable subfolder on its own so the rescan keeps going, and is consulted by automatic cleanup, the orphan folder sweep, and manual deletes, which could otherwise stamp videos as deleted while their files sat on the unmounted share. - Add "Rescan anyway" to the Maintenance card: a manual rescan that skips the check once, for a downloads folder emptied on purpose. The scheduled rescan never skips it and the run record says when it was skipped. - Guard the file check's writes so a delete that lands mid-request keeps its reason, leave the removal columns alone when job recovery cannot find the file, and pass plain rows to the channel page's file check so its results show on the same load. - Reload from page one after a delete only in hot-load mode, where a later page's refetch kept the removed rows; classic pagination stays on its page. - Send Mark as deleted in batches of 1000 and show the server's message when it is refused. - Open Empty trash on the first available choice, disable it when nothing can be purged, and disable archive removal up front while a download is running. - Show a present file as downloaded in the playlist modal even when YouTube has removed the video. - Derive the filter chips' aria-pressed from the resting state. Refs: #670 --- CLAUDE.md | 2 +- client/src/components/PlaylistPage.tsx | 35 +----- .../__tests__/playlistVideoHelpers.test.tsx | 27 +++- .../components/playlistVideoHelpers.tsx | 34 +++++ .../Settings/MaintenanceSection.tsx | 6 +- .../__tests__/MaintenanceSection.test.tsx | 20 ++- .../Settings/components/EmptyTrashDialog.tsx | 35 ++++-- .../Settings/components/RemovedVideosCard.tsx | 77 +++++++++++- .../components/RescanAnywayDialog.tsx | 53 ++++++++ .../__tests__/EmptyTrashDialog.test.tsx | 19 +++ .../__tests__/RemovedVideosCard.test.tsx | 101 ++++++++++++++- .../__tests__/RescanAnywayDialog.test.tsx | 46 +++++++ .../Settings/hooks/useRemovedVideos.ts | 2 + .../hooks/__tests__/useMarkDeleted.test.ts | 55 ++++++-- .../VideosPage/hooks/useMarkDeleted.ts | 46 ++++--- client/src/components/VideosPage/index.tsx | 46 ++++--- .../components/__tests__/VideosPage.test.tsx | 112 +++++++++++++++++ .../VideoList/filters/ToggleChipFilter.tsx | 4 +- .../__tests__/ToggleChipFilter.test.tsx | 32 +++++ .../hooks/__tests__/useRescanStatus.test.ts | 33 ++++- client/src/hooks/useRescanStatus.ts | 21 +++- docs/TROUBLESHOOTING.md | 2 +- docs/USAGE_GUIDE.md | 2 +- server/__tests__/server.routes.test.js | 5 +- server/modules/__tests__/cronJobs.test.js | 52 +++++++- .../modules/__tests__/fileCheckModule.test.js | 20 +-- server/modules/__tests__/jobModule.test.js | 27 ++++ .../__tests__/libraryRootCheck.test.js | 58 ++++++++- .../__tests__/rescanRunSummary.test.js | 7 ++ .../__tests__/videoDeletionModule.test.js | 117 +++++++++++++++++- .../__tests__/videoTrashModule.test.js | 18 ++- server/modules/__tests__/videosModule.test.js | 44 ++++++- .../__tests__/channelVideoQuery.test.js | 34 ++++- server/modules/channel/channelVideoQuery.js | 13 +- server/modules/cronJobs.js | 22 +++- .../download/__tests__/archiveAccess.test.js | 10 ++ .../__tests__/downloadExecutor.test.js | 30 +++-- server/modules/download/archiveAccess.js | 5 + server/modules/download/downloadExecutor.js | 3 +- server/modules/download/tempPathManager.js | 3 +- server/modules/fileCheckModule.js | 7 +- server/modules/filesystem/constants.js | 14 +++ server/modules/jobModule.js | 5 +- server/modules/libraryRootCheck.js | 34 ++++- server/modules/m3uGenerator.js | 2 +- server/modules/rescanRunSummary.js | 7 +- server/modules/videoDeletionModule.js | 65 +++++++++- server/modules/videoTrashModule.js | 13 +- server/modules/videosModule.js | 18 ++- server/routes/__tests__/maintenance.test.js | 21 +++- server/routes/maintenance.js | 21 ++++ server/routes/videoTrash.js | 5 +- 52 files changed, 1306 insertions(+), 184 deletions(-) create mode 100644 client/src/components/Settings/components/RescanAnywayDialog.tsx create mode 100644 client/src/components/Settings/components/__tests__/RescanAnywayDialog.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 62bf16a78..e7e472a4a 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 `<positive int>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: <title>` (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`, with a one-sentence `message`). The file check runs it lazily, once per pass and only when a row would be marked missing, holds those rows back, 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). The rescan checks first: a failing root with present rows ends the run as `error` ("Nothing was marked missing; is the drive mounted?"), an empty `__subfolder` lets it finish but hold back missing marks (`notMarkedMissing`, outcome `partial`, named in the message), and with no present rows (a fresh install) it runs normally. `/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 plus the library check) 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 fails, a selection is not), 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 fails; 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`, with a one-sentence `message`). 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 that subfolder). The file check runs it lazily, once per pass and only when a row would be marked missing, holds those rows back, 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: 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 (`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`) records a `skipped` run with the check's message while the check fails and present rows exist (a dry run never checks), 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, 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 fails, a selection is not), 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 fails; 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/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. - Playlist batch/following endpoints: `GET /api/playlists/:playlistId/download-preview` previews publication/position choices; `POST /api/playlists/:playlistId/download-batch` queues explicit ids; `POST /api/playlists/:playlistId/following` sets up/resumes following or resets the starting point while preserving pause state. All require auth and an enabled playlist. Swagger annotations in `routes/playlists.js` describe validation and errors. diff --git a/client/src/components/PlaylistPage.tsx b/client/src/components/PlaylistPage.tsx index 3998049c0..e3e2e74b1 100644 --- a/client/src/components/PlaylistPage.tsx +++ b/client/src/components/PlaylistPage.tsx @@ -20,8 +20,7 @@ import { usePlaylistDetail, PlaylistSortOrder, PlaylistDownloadState, PlaylistWa import { useDownloadListingsRefresh } from '../hooks/useDownloadListingsRefresh'; import { usePlaylistMutations } from '../hooks/usePlaylistMutations'; import { useMediaServerStatus } from '../hooks/useMediaServerStatus'; -import { MediaServerType, PlaylistSubscribeSettings, PlaylistVideo } from '../types/playlist'; -import { removalFromRow, removedState } from '../utils/videoRemoval'; +import { MediaServerType, PlaylistSubscribeSettings } from '../types/playlist'; import PlaylistHeader from './PlaylistPage/components/PlaylistHeader'; import NoMediaServerWarning from './PlaylistPage/components/NoMediaServerWarning'; import PlaylistVideoList from './PlaylistPage/components/PlaylistVideoList'; @@ -34,7 +33,7 @@ import { SelectionAction } from './shared/VideoList/types'; import { Download as DownloadIcon } from '../lib/icons'; import PlaylistFollowingDialog, { FollowingDialogMode } from './PlaylistPage/components/PlaylistFollowingDialog'; import PlaylistSettingsDialog from './PlaylistPage/components/PlaylistSettingsDialog'; -import { toDownloadFileProps } from './PlaylistPage/components/playlistVideoHelpers'; +import { toDownloadFileProps, toModalData } from './PlaylistPage/components/playlistVideoHelpers'; import DownloadSettingsDialog from './DownloadManager/ManualDownload/DownloadSettingsDialog'; import { DownloadSettings } from './DownloadManager/ManualDownload/types'; import SubscriptionsBackButton from './shared/SubscriptionsBackButton'; @@ -51,36 +50,6 @@ interface SnackbarState { severity: 'success' | 'error' | 'info' | 'warning'; } -function toModalData(v: PlaylistVideo): VideoModalData { - const status: VideoModalData['status'] = v.youtube_removed - ? 'missing' - : v.previously_downloaded - ? removedState(v.removed_reason) - : v.downloaded - ? 'downloaded' - : 'never_downloaded'; - return { - removal: removalFromRow(v), - youtubeId: v.youtube_id, - title: v.title || v.youtube_id, - channelName: v.channel_name || '', - thumbnailUrl: v.thumbnail || `https://i.ytimg.com/vi/${v.youtube_id}/hqdefault.jpg`, - duration: v.duration, - publishedAt: v.published_at, - addedAt: v.downloaded_at ?? null, - mediaType: 'video', - status, - isDownloaded: v.downloaded, - ...toDownloadFileProps(v), - isProtected: false, - isIgnored: v.ignored, - normalizedRating: null, - ratingSource: null, - databaseId: v.video_id, - channelId: v.channel_id, - }; -} - function PlaylistPage({ token }: PlaylistPageProps) { const { id: playlistIdParam } = useParams<{ id: string }>(); const playlistId = playlistIdParam || null; diff --git a/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx b/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx index 00988c787..f5a73ca1a 100644 --- a/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx +++ b/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import { isDownloadable, statusLabel, PublishedDate, toDownloadFileProps } from '../playlistVideoHelpers'; +import { isDownloadable, statusLabel, PublishedDate, toDownloadFileProps, toModalData } from '../playlistVideoHelpers'; import { PlaylistVideo } from '../../../../types/playlist'; function makeVideo(overrides: Partial<PlaylistVideo> = {}): PlaylistVideo { @@ -121,6 +121,31 @@ describe('toDownloadFileProps', () => { }); }); +describe('toModalData', () => { + test('shows a present file as downloaded even when YouTube removed the video', () => { + const data = toModalData(makeVideo({ downloaded: true, youtube_removed: true, file_path: '/d/Chan/v [v1].mp4' })); + expect(data.status).toBe('downloaded'); + expect(data.isDownloaded).toBe(true); + }); + + test('shows a previously downloaded video whose file was not found as missing, YouTube-removed or not', () => { + const data = toModalData(makeVideo({ previously_downloaded: true, youtube_removed: true, removed_reason: 'not_found' })); + expect(data.status).toBe('missing'); + }); + + test('shows a previously downloaded video Youtarr removed as deleted', () => { + expect(toModalData(makeVideo({ previously_downloaded: true, removed_reason: 'user_deleted' })).status).toBe('deleted'); + }); + + test('shows a never downloaded video that YouTube removed as missing', () => { + expect(toModalData(makeVideo({ youtube_removed: true })).status).toBe('missing'); + }); + + test('shows a tracked video as never downloaded', () => { + expect(toModalData(makeVideo()).status).toBe('never_downloaded'); + }); +}); + describe('PublishedDate', () => { test('renders an italic Unknown when there is no date', () => { render(<PublishedDate value={null} />); diff --git a/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx b/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx index 9ff3b55c1..8d5433698 100644 --- a/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx +++ b/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx @@ -2,6 +2,7 @@ import React from 'react'; 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'; export type PlaylistVideoStatusColor = 'success' | 'default' | 'warning' | 'error' | 'info'; @@ -36,6 +37,39 @@ export function toDownloadFileProps(v: PlaylistVideo): { }; } +// The modal's status follows the file first: a present file is downloaded +// whatever YouTube did since, a previously downloaded one reads its removal +// state, and only a never-downloaded video that YouTube removed is missing. +export function toModalData(v: PlaylistVideo): VideoModalData { + const status: VideoModalData['status'] = v.downloaded + ? 'downloaded' + : v.previously_downloaded + ? removedState(v.removed_reason) + : v.youtube_removed + ? 'missing' + : 'never_downloaded'; + return { + removal: removalFromRow(v), + youtubeId: v.youtube_id, + title: v.title || v.youtube_id, + channelName: v.channel_name || '', + thumbnailUrl: v.thumbnail || `https://i.ytimg.com/vi/${v.youtube_id}/hqdefault.jpg`, + duration: v.duration, + publishedAt: v.published_at, + addedAt: v.downloaded_at ?? null, + mediaType: 'video', + status, + isDownloaded: v.downloaded, + ...toDownloadFileProps(v), + isProtected: false, + isIgnored: v.ignored, + normalizedRating: null, + ratingSource: null, + databaseId: v.video_id, + channelId: v.channel_id, + }; +} + export function statusLabel(v: PlaylistVideo): PlaylistVideoStatus { if (v.activity) return { label: v.activity === 'queued' ? 'Queued…' : 'Downloading…', color: 'info' }; // "Excluded" is the user-facing name for the per-playlist `ignored` flag: diff --git a/client/src/components/Settings/MaintenanceSection.tsx b/client/src/components/Settings/MaintenanceSection.tsx index 1dba6899e..c28365d00 100644 --- a/client/src/components/Settings/MaintenanceSection.tsx +++ b/client/src/components/Settings/MaintenanceSection.tsx @@ -79,7 +79,11 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) { </div> </ConfigurationCard> - <RemovedVideosCard token={token} /> + <RemovedVideosCard + token={token} + onRescanAnyway={() => triggerRescan({ ignoreLibraryCheck: true })} + rescanRunning={running} + /> </div> ); } diff --git a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx index fde441e53..c2fef11f4 100644 --- a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx +++ b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx @@ -5,6 +5,7 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MaintenanceSection } from '../MaintenanceSection'; import { UseRescanStatusReturn, RescanLastRun } from '../../../hooks/useRescanStatus'; +import type { RemovedVideosLibraryCheck } from '../hooks/useRemovedVideos'; jest.mock('../../../hooks/useRescanStatus', () => ({ useRescanStatus: jest.fn() @@ -21,8 +22,11 @@ const { useRemovedVideos } = require('../hooks/useRemovedVideos') as { useRemovedVideos: jest.Mock; }; -function setup(overrides: Partial<UseRescanStatusReturn> = {}) { - const triggerRescan = jest.fn().mockResolvedValue(undefined); +function setup( + overrides: Partial<UseRescanStatusReturn> = {}, + libraryCheck: RemovedVideosLibraryCheck = { ok: true, reason: null, path: null, message: null } +) { + const triggerRescan = jest.fn().mockResolvedValue({ started: true }); useRescanStatus.mockReturnValue({ running: false, lastRun: null, @@ -32,7 +36,7 @@ function setup(overrides: Partial<UseRescanStatusReturn> = {}) { ...overrides }); useRemovedVideos.mockReturnValue({ - summary: { deleted: 2, missing: 1, legacy: 0, libraryCheck: { ok: true, reason: null, path: null, message: null } }, + summary: { deleted: 2, missing: 1, legacy: 0, libraryCheck }, loading: false, error: null, refetch: jest.fn(), @@ -60,6 +64,16 @@ describe('MaintenanceSection', () => { expect(screen.getByText('Deleted: 2. Missing: 1.')).toBeInTheDocument(); }); + 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 /d is empty.' }); + + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + + expect(triggerRescan).toHaveBeenCalledWith({ ignoreLibraryCheck: true }); + }); + test('renders idle with last-run summary', () => { const lastRun: RescanLastRun = { startedAt: '2026-05-04T15:00:00.000Z', diff --git a/client/src/components/Settings/components/EmptyTrashDialog.tsx b/client/src/components/Settings/components/EmptyTrashDialog.tsx index 565024ae4..3a166c167 100644 --- a/client/src/components/Settings/components/EmptyTrashDialog.tsx +++ b/client/src/components/Settings/components/EmptyTrashDialog.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useId, useState } from 'react'; +import React, { useEffect, useId, useRef, useState } from 'react'; import { Alert, Button, @@ -22,9 +22,17 @@ interface EmptyTrashDialogProps { counts: { deleted: number; missing: number }; // Why the missing choices are unavailable (the downloads folder looks unmounted), or null. missingDisabledReason: string | null; + // Why archive entries cannot be removed right now (a download is running), or null. + archiveRemovalDisabledReason: string | null; pending: boolean; } +// The first choice that is both available and non-empty, so the dialog never +// opens on a choice the user cannot confirm. +function defaultChoice(counts: { deleted: number; missing: number }, missingDisabled: boolean): WhichChoice { + return counts.deleted === 0 && !missingDisabled ? 'missing' : 'deleted'; +} + const STATES_FOR: Record<WhichChoice, TrashState[]> = { deleted: ['deleted'], missing: ['missing'], @@ -67,23 +75,33 @@ const EmptyTrashDialog: React.FC<EmptyTrashDialogProps> = ({ onConfirm, counts, missingDisabledReason, + archiveRemovalDisabledReason, pending, }) => { const id = useId(); - const [which, setWhich] = useState<WhichChoice>('deleted'); + const missingDisabled = missingDisabledReason !== null || counts.missing === 0; + const [which, setWhich] = useState<WhichChoice>(() => defaultChoice(counts, missingDisabled)); const [archive, setArchive] = useState<ArchiveChoice>('keep'); const [acknowledged, setAcknowledged] = useState(false); - // Every opening starts from the safe defaults. + // Every opening starts from the safe defaults. Only an opening resets, from + // the counts current at that moment: the ref is refreshed after each render + // (never during one), so a parent re-render cannot wipe the user's choices. + const openingChoice = useRef(which); + useEffect(() => { + openingChoice.current = defaultChoice(counts, missingDisabled); + }); useEffect(() => { if (open) { - setWhich('deleted'); + setWhich(openingChoice.current); setArchive('keep'); setAcknowledged(false); } }, [open]); - const missingDisabled = missingDisabledReason !== null || counts.missing === 0; + // Archive removal is refused while a download runs; the choice falls back to keep. + const archiveRemovalDisabled = archiveRemovalDisabledReason !== null; + const effectiveArchive: ArchiveChoice = archiveRemovalDisabled ? 'keep' : archive; const total = counts.deleted + counts.missing; const count = which === 'deleted' ? counts.deleted : which === 'missing' ? counts.missing : total; @@ -124,7 +142,7 @@ const EmptyTrashDialog: React.FC<EmptyTrashDialogProps> = ({ <fieldset className="space-y-3"> <legend className="text-sm font-semibold mb-2">Download archive</legend> - <RadioGroup value={archive} onValueChange={(value) => setArchive(value as ArchiveChoice)} className="space-y-3"> + <RadioGroup value={effectiveArchive} onValueChange={(value) => setArchive(value as ArchiveChoice)} className="space-y-3"> <Choice id={`${id}-keep`} value="keep" @@ -135,7 +153,8 @@ const EmptyTrashDialog: React.FC<EmptyTrashDialogProps> = ({ id={`${id}-remove`} value="remove" label="Remove them from the download archive" - help="Automatic downloads treat them as new and may download them again at the next run." + help={archiveRemovalDisabledReason ?? 'Automatic downloads treat them as new and may download them again at the next run.'} + disabled={archiveRemovalDisabled} /> </RadioGroup> </fieldset> @@ -174,7 +193,7 @@ const EmptyTrashDialog: React.FC<EmptyTrashDialogProps> = ({ Cancel </Button> <Button - onClick={() => onConfirm({ states: STATES_FOR[which], removeFromArchive: archive === 'remove' })} + onClick={() => onConfirm({ states: STATES_FOR[which], removeFromArchive: effectiveArchive === 'remove' })} variant="outlined" color="error" disabled={!acknowledged || count === 0 || pending} diff --git a/client/src/components/Settings/components/RemovedVideosCard.tsx b/client/src/components/Settings/components/RemovedVideosCard.tsx index c95de1ed7..4a5a2ae32 100644 --- a/client/src/components/Settings/components/RemovedVideosCard.tsx +++ b/client/src/components/Settings/components/RemovedVideosCard.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Alert, Button, @@ -11,28 +11,68 @@ import { import { ConfigurationCard } from '../../Configuration/common/ConfigurationCard'; import { useRemovedVideos, type EmptyTrashOptions } from '../hooks/useRemovedVideos'; import EmptyTrashDialog from './EmptyTrashDialog'; +import RescanAnywayDialog from './RescanAnywayDialog'; interface RemovedVideosCardProps { token: string | null; + // Starts a rescan that skips the unmounted-drive check; Maintenance wires it + // in. Resolves started: false when the server refused or the request failed. + onRescanAnyway?: () => Promise<{ started: boolean }>; + rescanRunning?: boolean; } +const RESCAN_NOT_STARTED = 'The rescan did not start. The rescan card above shows why.'; + const videos = (n: number) => (n === 1 ? 'video' : 'videos'); +// Check results that can also mean the user emptied the folder on purpose, +// the one case where rescanning anyway makes sense. +const EMPTY_FOLDER_REASONS = ['empty', 'subfolder-empty']; + // Settings > Maintenance: what Deleted and Missing mean, how many there are, // and the two ways to clear them out. -function RemovedVideosCard({ token }: RemovedVideosCardProps) { - const { summary, loading, error, markAllMissingDeleted, emptyTrash, actionPending, actionError } = useRemovedVideos(token); +function RemovedVideosCard({ token, onRescanAnyway, rescanRunning = false }: RemovedVideosCardProps) { + const { summary, loading, error, refetch, markAllMissingDeleted, emptyTrash, actionPending, actionError } = useRemovedVideos(token); const [markAllOpen, setMarkAllOpen] = useState(false); const [emptyTrashOpen, setEmptyTrashOpen] = useState(false); + const [rescanAnywayOpen, setRescanAnywayOpen] = useState(false); + const [rescanAnywayError, setRescanAnywayError] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null); const deleted = summary?.deleted ?? 0; const missing = summary?.missing ?? 0; const legacy = summary?.legacy ?? 0; const libraryWarning = summary && !summary.libraryCheck.ok ? summary.libraryCheck.message : null; + // Missing videos cannot be purged while the folder looks unmounted, so with + // no deleted ones there would be nothing the dialog could offer. + const nothingToPurge = deleted === 0 && (missing === 0 || libraryWarning !== null); + const archiveRemovalDisabledReason = summary?.downloadsActive ? 'Unavailable while a download is running.' : null; + const canRescanAnyway = Boolean(onRescanAnyway) && summary !== null && !summary.libraryCheck.ok + && EMPTY_FOLDER_REASONS.includes(summary.libraryCheck.reason ?? ''); + + // A rescan that just finished may have marked videos missing: reload the counts. + const wasRescanRunning = useRef(rescanRunning); + useEffect(() => { + if (wasRescanRunning.current && !rescanRunning) void refetch(); + wasRescanRunning.current = rescanRunning; + }, [rescanRunning, refetch]); + + const handleRescanAnyway = async () => { + if (!onRescanAnyway) return; + setSuccessMessage(null); + setRescanAnywayError(null); + const { started } = await onRescanAnyway(); + setRescanAnywayOpen(false); + if (started) { + setSuccessMessage('Rescan started. When it finishes, the missing videos can be marked as deleted here.'); + } else { + setRescanAnywayError(RESCAN_NOT_STARTED); + } + }; const handleMarkAll = async () => { setSuccessMessage(null); + setRescanAnywayError(null); const result = await markAllMissingDeleted(); setMarkAllOpen(false); if (result) setSuccessMessage(`Marked ${result.updated} ${videos(result.updated)} as deleted.`); @@ -40,6 +80,7 @@ function RemovedVideosCard({ token }: RemovedVideosCardProps) { const handleEmptyTrash = async (options: EmptyTrashOptions) => { setSuccessMessage(null); + setRescanAnywayError(null); const result = await emptyTrash(options); setEmptyTrashOpen(false); if (!result) return; @@ -71,10 +112,14 @@ function RemovedVideosCard({ token }: RemovedVideosCardProps) { {libraryWarning && ( <Alert severity="warning"> {libraryWarning} Marking or emptying missing videos is disabled until it is back. + {canRescanAnyway && ( + ' If the folder really is empty because you removed the files yourself, you can rescan anyway and then mark the missing videos as deleted.' + )} </Alert> )} {error && <Alert severity="error">{error}</Alert>} {actionError && <Alert severity="error">{actionError}</Alert>} + {rescanAnywayError && <Alert severity="error">{rescanAnywayError}</Alert>} {successMessage && <Alert severity="success">{successMessage}</Alert>} <div className="flex flex-wrap gap-2"> @@ -88,14 +133,35 @@ function RemovedVideosCard({ token }: RemovedVideosCardProps) { <Button variant="outlined" color="error" - disabled={!summary || deleted + missing === 0 || actionPending} - onClick={() => setEmptyTrashOpen(true)} + disabled={!summary || nothingToPurge || actionPending} + onClick={() => { + // The download state is a snapshot; refresh it as the dialog opens. + void refetch(); + setEmptyTrashOpen(true); + }} > Empty trash... </Button> + {canRescanAnyway && ( + <Button + variant="outlined" + color="warning" + disabled={rescanRunning || actionPending} + onClick={() => setRescanAnywayOpen(true)} + > + Rescan anyway... + </Button> + )} </div> </div> + <RescanAnywayDialog + open={rescanAnywayOpen} + onClose={() => setRescanAnywayOpen(false)} + onConfirm={() => { void handleRescanAnyway(); }} + pending={rescanRunning} + /> + <Dialog open={markAllOpen} onClose={() => setMarkAllOpen(false)} maxWidth="sm" fullWidth> <DialogTitle>Mark all missing videos as deleted</DialogTitle> <DialogContent> @@ -121,6 +187,7 @@ function RemovedVideosCard({ token }: RemovedVideosCardProps) { onConfirm={handleEmptyTrash} counts={{ deleted, missing }} missingDisabledReason={libraryWarning} + archiveRemovalDisabledReason={archiveRemovalDisabledReason} pending={actionPending} /> </ConfigurationCard> diff --git a/client/src/components/Settings/components/RescanAnywayDialog.tsx b/client/src/components/Settings/components/RescanAnywayDialog.tsx new file mode 100644 index 000000000..e7846e33b --- /dev/null +++ b/client/src/components/Settings/components/RescanAnywayDialog.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '../../ui'; + +interface RescanAnywayDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + pending: boolean; +} + +// Confirms a rescan that skips the unmounted-drive check: the escape for a +// downloads folder the user emptied on purpose. +const RescanAnywayDialog: React.FC<RescanAnywayDialogProps> = ({ open, onClose, onConfirm, pending }) => ( + <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> + <DialogTitle>Rescan anyway</DialogTitle> + + <DialogContent> + <div className="space-y-3"> + <Typography variant="body2"> + Youtarr is not marking videos missing because the downloads folder looks unmounted. Only continue if you + removed the files yourself and the downloads folder really is empty. If a drive or network share is + unmounted, mount it instead. + </Typography> + <Typography variant="body2" color="text.secondary"> + Every downloaded video whose file is not found will be marked missing. You can then mark them as deleted + from this card. + </Typography> + <Alert severity="warning"> + Nothing on disk is touched, but with a drive unmounted this would mark your whole library missing. + </Alert> + </div> + </DialogContent> + + <DialogActions> + <Button onClick={onClose} variant="contained" color="primary" autoFocus> + Cancel + </Button> + <Button onClick={onConfirm} variant="outlined" color="warning" disabled={pending}> + Rescan anyway + </Button> + </DialogActions> + </Dialog> +); + +export default RescanAnywayDialog; diff --git a/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx b/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx index 88a740d4a..bd09b8166 100644 --- a/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx +++ b/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx @@ -15,6 +15,7 @@ const renderDialog = (overrides: Partial<React.ComponentProps<typeof EmptyTrashD onConfirm={onConfirm} counts={{ deleted: 12, missing: 4 }} missingDisabledReason={null} + archiveRemovalDisabledReason={null} pending={false} {...overrides} /> @@ -25,6 +26,24 @@ const renderDialog = (overrides: Partial<React.ComponentProps<typeof EmptyTrashD const confirmButton = () => screen.getByRole('button', { name: /^Empty trash \(/ }); describe('EmptyTrashDialog', () => { + test('opens on Missing when there are no deleted videos', () => { + renderDialog({ counts: { deleted: 0, missing: 4 } }); + + expect(screen.getByRole('radio', { name: 'Missing (4)' })).toHaveAttribute('aria-checked', 'true'); + expect(confirmButton()).toHaveTextContent('Empty trash (4 videos)'); + }); + + test('disables removing archive entries with the reason while a download runs, and keeps them', async () => { + const { onConfirm, user } = renderDialog({ archiveRemovalDisabledReason: 'Unavailable while a download is running.' }); + + expect(screen.getByRole('radio', { name: 'Remove them from the download archive' })).toBeDisabled(); + expect(screen.getByText('Unavailable while a download is running.')).toBeInTheDocument(); + + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(confirmButton()); + expect(onConfirm).toHaveBeenCalledWith({ states: ['deleted'], removeFromArchive: false }); + }); + test('starts with Deleted and Keep selected, the confirm button disabled until the risk is acknowledged', async () => { const { user } = renderDialog(); diff --git a/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx b/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx index 07640238e..7c104d34e 100644 --- a/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx +++ b/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx @@ -19,10 +19,20 @@ const summary = (overrides: Partial<RemovedVideosSummary> = {}): RemovedVideosSu missing: 4, legacy: 0, libraryCheck: { ok: true, reason: null, path: null, message: null }, + downloadsActive: false, ...overrides, }); -function setup(summaryValue: RemovedVideosSummary | null = summary(), overrides: Partial<UseRemovedVideosReturn> = {}) { +type CardProps = Partial<React.ComponentProps<typeof RemovedVideosCard>>; + +const EMPTY_ROOT = { ok: false, reason: 'empty', path: '/d', message: 'The downloads folder /d is empty.' }; +const rescanThatStarts = () => jest.fn().mockResolvedValue({ started: true }); + +function setup( + summaryValue: RemovedVideosSummary | null = summary(), + overrides: Partial<UseRemovedVideosReturn> = {}, + props: CardProps = {} +) { const hook: UseRemovedVideosReturn = { summary: summaryValue, loading: false, @@ -35,8 +45,8 @@ function setup(summaryValue: RemovedVideosSummary | null = summary(), overrides: ...overrides, }; useRemovedVideos.mockReturnValue(hook); - renderWithProviders(<RemovedVideosCard token="tok" />); - return { hook, user: userEvent.setup() }; + const view = renderWithProviders(<RemovedVideosCard token="tok" {...props} />); + return { hook, user: userEvent.setup(), rerender: view.rerender }; } describe('RemovedVideosCard', () => { @@ -61,6 +71,91 @@ describe('RemovedVideosCard', () => { expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeEnabled(); }); + test('offers Rescan anyway while the downloads folder looks empty', () => { + setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway: rescanThatStarts(), rescanRunning: false }); + expect(screen.getByText(/If the folder really is empty because you removed the files yourself/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Rescan anyway...' })).toBeEnabled(); + }); + + test('does not offer Rescan anyway when the downloads folder is missing', () => { + const missingRoot = { ok: false, reason: 'missing', path: '/d', message: 'The downloads folder /d does not exist.' }; + setup(summary({ libraryCheck: missingRoot }), {}, { onRescanAnyway: rescanThatStarts(), rescanRunning: false }); + expect(screen.queryByRole('button', { name: 'Rescan anyway...' })).not.toBeInTheDocument(); + }); + + test('runs the rescan anyway after a confirmation that explains it', async () => { + const onRescanAnyway = rescanThatStarts(); + const { user } = setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway, rescanRunning: false }); + + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + expect(screen.getByText(/Only continue if you removed the files yourself/)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + + expect(onRescanAnyway).toHaveBeenCalledTimes(1); + expect(await screen.findByText('Rescan started. When it finishes, the missing videos can be marked as deleted here.')).toBeInTheDocument(); + }); + + test('does not claim a rescan started when it was refused', async () => { + const onRescanAnyway = jest.fn().mockResolvedValue({ started: false }); + const { user } = setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway, rescanRunning: false }); + + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + + expect(await screen.findByText('The rescan did not start. The rescan card above shows why.')).toBeInTheDocument(); + expect(screen.queryByText(/Rescan started\./)).not.toBeInTheDocument(); + }); + + test('reloads the summary when Empty trash opens, so the download state is current', async () => { + const { hook, user } = setup(); + + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + + expect(hook.refetch).toHaveBeenCalledTimes(1); + }); + + test('clears a stale rescan message when another action runs', async () => { + const onRescanAnyway = jest.fn().mockResolvedValue({ started: false }); + const { user } = setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway, rescanRunning: false }); + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + expect(await screen.findByText('The rescan did not start. The rescan card above shows why.')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(screen.getByRole('button', { name: /^Empty trash \(/ })); + + expect(await screen.findByText(/Emptied trash:/)).toBeInTheDocument(); + expect(screen.queryByText('The rescan did not start. The rescan card above shows why.')).not.toBeInTheDocument(); + }); + + test('reloads the counts when a rescan finishes', () => { + const onRescanAnyway = rescanThatStarts(); + const { hook, rerender } = setup(summary(), {}, { onRescanAnyway, rescanRunning: true }); + expect(hook.refetch).not.toHaveBeenCalled(); + + rerender(<RemovedVideosCard token="tok" onRescanAnyway={onRescanAnyway} rescanRunning={false} />); + + expect(hook.refetch).toHaveBeenCalledTimes(1); + }); + + test('disables Empty trash when only missing videos remain and the folder looks unmounted', () => { + setup(summary({ deleted: 0, missing: 5, libraryCheck: EMPTY_ROOT })); + expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeDisabled(); + }); + + test('keeps Empty trash available for missing videos alone while the folder is mounted', () => { + setup(summary({ deleted: 0, missing: 5 })); + expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeEnabled(); + }); + + test('tells the dialog when a download is running so archive removal is disabled up front', async () => { + const { user } = setup(summary({ downloadsActive: true })); + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + expect(screen.getByRole('radio', { name: 'Remove them from the download archive' })).toBeDisabled(); + expect(screen.getByText('Unavailable while a download is running.')).toBeInTheDocument(); + }); + test('disables both actions when the trash is empty', () => { setup(summary({ deleted: 0, missing: 0 })); expect(screen.getByRole('button', { name: 'Mark all missing as deleted' })).toBeDisabled(); diff --git a/client/src/components/Settings/components/__tests__/RescanAnywayDialog.test.tsx b/client/src/components/Settings/components/__tests__/RescanAnywayDialog.test.tsx new file mode 100644 index 000000000..522ee015a --- /dev/null +++ b/client/src/components/Settings/components/__tests__/RescanAnywayDialog.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import RescanAnywayDialog from '../RescanAnywayDialog'; +import { renderWithProviders } from '../../../../test-utils'; + +const renderDialog = (overrides: Partial<React.ComponentProps<typeof RescanAnywayDialog>> = {}) => { + const onClose = jest.fn(); + const onConfirm = jest.fn(); + renderWithProviders( + <RescanAnywayDialog open onClose={onClose} onConfirm={onConfirm} pending={false} {...overrides} /> + ); + return { onClose, onConfirm, user: userEvent.setup() }; +}; + +describe('RescanAnywayDialog', () => { + test('explains when to use it and what it does', () => { + renderDialog(); + expect(screen.getByText(/Only continue if you removed the files yourself and the downloads folder really is empty\./)).toBeInTheDocument(); + expect(screen.getByText(/If a drive or network share is unmounted, mount it instead\./)).toBeInTheDocument(); + expect(screen.getByText(/Every downloaded video whose file is not found will be marked missing\./)).toBeInTheDocument(); + }); + + test('confirms', async () => { + const { onConfirm, user } = renderDialog(); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + test('cancels', async () => { + const { onClose, user } = renderDialog(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('disables the confirm button while pending', () => { + renderDialog({ pending: true }); + expect(screen.getByRole('button', { name: 'Rescan anyway' })).toBeDisabled(); + }); + + test('renders nothing when closed', () => { + renderDialog({ open: false }); + expect(screen.queryByText('Rescan anyway')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Settings/hooks/useRemovedVideos.ts b/client/src/components/Settings/hooks/useRemovedVideos.ts index 72f7f792b..064f46de4 100644 --- a/client/src/components/Settings/hooks/useRemovedVideos.ts +++ b/client/src/components/Settings/hooks/useRemovedVideos.ts @@ -14,6 +14,8 @@ export interface RemovedVideosSummary { // Deleted videos removed before Youtarr recorded why (a subset of deleted). legacy: number; libraryCheck: RemovedVideosLibraryCheck; + // A download is running, so emptying the trash with archive removal would be refused. + downloadsActive: boolean; } export type TrashState = 'deleted' | 'missing'; diff --git a/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts b/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts index ce64ee676..e3d3cf503 100644 --- a/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts +++ b/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts @@ -7,7 +7,9 @@ jest.mock('axios', () => ({ const axios = require('axios'); -const { useMarkDeleted } = require('../useMarkDeleted'); +const { useMarkDeleted, MARK_DELETED_BATCH_SIZE } = require('../useMarkDeleted'); + +const ids = (count: number) => Array.from({ length: count }, (_, i) => i + 1); describe('useMarkDeleted', () => { beforeEach(() => { @@ -18,32 +20,64 @@ describe('useMarkDeleted', () => { axios.post.mockResolvedValueOnce({ data: { updated: 2 } }); const { result } = renderHook(() => useMarkDeleted('tok')); - let outcome: { updated: number } | null = null; + let outcome: { updated: number; error: string | null } | null = null; await act(async () => { outcome = await result.current.markDeleted([4, 9]); }); - expect(outcome).toEqual({ updated: 2 }); + expect(outcome).toEqual({ updated: 2, error: null }); expect(axios.post).toHaveBeenCalledWith( '/api/videos/mark-deleted', { videoIds: [4, 9] }, { headers: { 'x-access-token': 'tok' } } ); - expect(result.current.error).toBeNull(); await waitFor(() => expect(result.current.loading).toBe(false)); }); - test('exposes the server error message and returns null when the request fails', async () => { + test('splits a large selection into batches the server accepts and sums the counts', async () => { + axios.post + .mockResolvedValueOnce({ data: { updated: 1000 } }) + .mockResolvedValueOnce({ data: { updated: 300 } }); + const { result } = renderHook(() => useMarkDeleted('tok')); + + let outcome: { updated: number; error: string | null } | null = null; + await act(async () => { + outcome = await result.current.markDeleted(ids(1500)); + }); + + expect(MARK_DELETED_BATCH_SIZE).toBe(1000); + expect(outcome).toEqual({ updated: 1300, error: null }); + expect(axios.post).toHaveBeenCalledTimes(2); + expect(axios.post.mock.calls[0][1].videoIds).toHaveLength(1000); + expect(axios.post.mock.calls[1][1].videoIds).toEqual(ids(1500).slice(1000)); + }); + + test('stops at the first failing batch and reports what was marked before it', async () => { + axios.post + .mockResolvedValueOnce({ data: { updated: 1000 } }) + .mockRejectedValueOnce({ isAxiosError: true, response: { data: { error: 'The downloads folder /d is empty.' } } }); + const { result } = renderHook(() => useMarkDeleted('tok')); + + let outcome: { updated: number; error: string | null } | null = null; + await act(async () => { + outcome = await result.current.markDeleted(ids(2500)); + }); + + expect(outcome).toEqual({ updated: 1000, error: 'The downloads folder /d is empty.' }); + expect(axios.post).toHaveBeenCalledTimes(2); + expect(result.current.loading).toBe(false); + }); + + test('returns the server error message when the request fails', async () => { axios.post.mockRejectedValueOnce({ isAxiosError: true, response: { data: { error: 'The downloads folder /d is empty.' } } }); const { result } = renderHook(() => useMarkDeleted('tok')); - let outcome: { updated: number } | null = { updated: -1 }; + let outcome: { updated: number; error: string | null } | null = null; await act(async () => { outcome = await result.current.markDeleted([4]); }); - expect(outcome).toBeNull(); - expect(result.current.error).toBe('The downloads folder /d is empty.'); + expect(outcome).toEqual({ updated: 0, error: 'The downloads folder /d is empty.' }); expect(result.current.loading).toBe(false); }); @@ -51,10 +85,11 @@ describe('useMarkDeleted', () => { axios.post.mockRejectedValueOnce(new Error('network')); const { result } = renderHook(() => useMarkDeleted('tok')); + let outcome: { updated: number; error: string | null } | null = null; await act(async () => { - await result.current.markDeleted([4]); + outcome = await result.current.markDeleted([4]); }); - expect(result.current.error).toBe('Failed to mark videos as deleted'); + expect(outcome).toEqual({ updated: 0, error: 'Failed to mark videos as deleted' }); }); }); diff --git a/client/src/components/VideosPage/hooks/useMarkDeleted.ts b/client/src/components/VideosPage/hooks/useMarkDeleted.ts index 1b7c96f26..e33bdca47 100644 --- a/client/src/components/VideosPage/hooks/useMarkDeleted.ts +++ b/client/src/components/VideosPage/hooks/useMarkDeleted.ts @@ -1,42 +1,52 @@ import { useCallback, useState } from 'react'; import axios from 'axios'; -export interface MarkDeletedResult { +// The server accepts this many ids per request; larger selections are sent in batches. +export const MARK_DELETED_BATCH_SIZE = 1000; + +export interface MarkDeletedOutcome { + // How many videos were marked, counting the batches that succeeded. updated: number; + // The server's message for the batch that failed, or null when every batch went through. + error: string | null; } export interface UseMarkDeletedReturn { - // Marks the missing videos among `videoIds` as deleted; resolves null on failure (see `error`). - markDeleted: (videoIds: number[]) => Promise<MarkDeletedResult | null>; + markDeleted: (videoIds: number[]) => Promise<MarkDeletedOutcome>; loading: boolean; - error: string | null; } const FALLBACK_ERROR = 'Failed to mark videos as deleted'; +function serverMessage(err: unknown): string { + // The server answers 400/409/500 with { error }; anything else gets the fallback. + const message = (err as { response?: { data?: { error?: unknown } } })?.response?.data?.error; + return typeof message === 'string' ? message : FALLBACK_ERROR; +} + export function useMarkDeleted(token: string | null): UseMarkDeletedReturn { const [loading, setLoading] = useState(false); - const [error, setError] = useState<string | null>(null); - const markDeleted = useCallback(async (videoIds: number[]): Promise<MarkDeletedResult | null> => { + const markDeleted = useCallback(async (videoIds: number[]): Promise<MarkDeletedOutcome> => { setLoading(true); - setError(null); + let updated = 0; try { - const response = await axios.post<MarkDeletedResult>( - '/api/videos/mark-deleted', - { videoIds }, - { headers: { 'x-access-token': token ?? '' } } - ); - return response.data; + for (let start = 0; start < videoIds.length; start += MARK_DELETED_BATCH_SIZE) { + const batch = videoIds.slice(start, start + MARK_DELETED_BATCH_SIZE); + const response = await axios.post<{ updated: number }>( + '/api/videos/mark-deleted', + { videoIds: batch }, + { headers: { 'x-access-token': token ?? '' } } + ); + updated += response.data.updated; + } + return { updated, error: null }; } catch (err: unknown) { - // The server answers 409/500 with { error }; anything else gets the fallback. - const serverMessage = (err as { response?: { data?: { error?: unknown } } })?.response?.data?.error; - setError(typeof serverMessage === 'string' ? serverMessage : FALLBACK_ERROR); - return null; + return { updated, error: serverMessage(err) }; } finally { setLoading(false); } }, [token]); - return { markDeleted, loading, error }; + return { markDeleted, loading }; } diff --git a/client/src/components/VideosPage/index.tsx b/client/src/components/VideosPage/index.tsx index fc1cfd194..c1204ea89 100644 --- a/client/src/components/VideosPage/index.tsx +++ b/client/src/components/VideosPage/index.tsx @@ -127,7 +127,7 @@ function VideosPage({ token }: VideosPageProps) { const loadMoreRef = useRef<HTMLDivElement | null>(null); const { deleteVideos, loading: deleteLoading } = useVideoDeletion(); - const { markDeleted, loading: markDeletedLoading, error: markDeletedError } = useMarkDeleted(token); + const { markDeleted, loading: markDeletedLoading } = useMarkDeleted(token); const configState = useConfig(token); const useInfiniteScroll = configState?.config?.channelVideosHotLoad ?? false; const { @@ -231,6 +231,18 @@ function VideosPage({ token }: VideosPageProps) { } }; + // Reload after rows leave the listing (deleted, marked deleted). In hot-load + // mode a refetch of a later page merges into the rows already shown, so the + // removed rows would stay: go back to page one, which the hook fetches on the + // page change. Classic pagination replaces the page in place, so it stays put. + const reloadFromFirstPage = () => { + if (useInfiniteScroll && page !== 1) { + setPage(1); + } else { + void refetch(); + } + }; + const handleDeleteConfirm = async (selectedIds: number[]) => { setDeleteDialogOpen(false); const deletableIds = selectedIds.filter((id) => { @@ -244,7 +256,7 @@ function VideosPage({ token }: VideosPageProps) { `Successfully deleted ${result.deleted.length} video${result.deleted.length !== 1 ? 's' : ''}` ); selection.clear(); - refetch(); + reloadFromFirstPage(); } else { const deletedCount = result.deleted.length; const failedCount = result.failed.length; @@ -253,7 +265,7 @@ function VideosPage({ token }: VideosPageProps) { `Deleted ${deletedCount} video${deletedCount !== 1 ? 's' : ''}, but ${failedCount} failed` ); selection.clear(); - refetch(); + reloadFromFirstPage(); } else { setErrorMessage( `Failed to delete videos: ${result.failed[0]?.error || 'Unknown error'}` @@ -274,20 +286,16 @@ function VideosPage({ token }: VideosPageProps) { } const result = await markDeleted(ids); setMarkDeletedDialogOpen(false); - if (!result) { - setErrorMessage(markDeletedError || 'Failed to mark videos as deleted'); - return; - } - setSuccessMessage(`Marked ${result.updated} video${result.updated !== 1 ? 's' : ''} as deleted`); - selection.clear(); - // Refetching a later infinite-scroll page would retain stale earlier rows. - // Changing page triggers the hook's fetch; page one needs an explicit fetch. - setVideos([]); - if (page === 1) { - void refetch(); + const marked = `${result.updated} video${result.updated !== 1 ? 's' : ''}`; + if (result.error) { + // A later batch may have failed after earlier ones went through. + setErrorMessage(result.updated > 0 ? `Marked ${marked} as deleted, then failed: ${result.error}` : result.error); } else { - setPage(1); + setSuccessMessage(`Marked ${marked} as deleted`); } + if (result.updated === 0) return; + selection.clear(); + reloadFromFirstPage(); }; const handleApplyRating = async (rating: string | null, selectedIds: number[]) => { @@ -564,9 +572,11 @@ function VideosPage({ token }: VideosPageProps) { </Typography> )} {libraryWarning && ( - <Alert severity="warning" style={{ marginTop: 8 }}> + <Alert severity="warning" className="mt-2"> {libraryWarning.message} Videos are not being marked missing until it is back. If your downloads - live on a drive or network share, check that it is mounted. + live on a drive or network share, check that it is mounted. If the folder really is empty on purpose, + you can rescan anyway from{' '} + <Button variant="text" size="sm" onClick={() => navigate('/settings/maintenance')}>Maintenance</Button>. </Alert> )} </div> @@ -796,7 +806,7 @@ function VideosPage({ token }: VideosPageProps) { token={token} onVideoDeleted={() => { setModalVideo(null); - refetch(); + reloadFromFirstPage(); }} onProtectionChanged={(youtubeId, isProtected) => { setVideos((prev) => diff --git a/client/src/components/__tests__/VideosPage.test.tsx b/client/src/components/__tests__/VideosPage.test.tsx index 9c12358ae..b16f5cdc7 100644 --- a/client/src/components/__tests__/VideosPage.test.tsx +++ b/client/src/components/__tests__/VideosPage.test.tsx @@ -508,6 +508,21 @@ describe('VideosPage Component', () => { expect(await screen.findByText(/The downloads folder \/data\/youtube is empty\. Videos are not being marked missing until it is back\./)).toBeInTheDocument(); }); + test('links the unmounted-folder warning to Maintenance for a folder emptied on purpose', async () => { + const user = setupUser(); + axios.get.mockResolvedValueOnce({ + data: { + ...mockPaginatedResponse(mockVideos), + libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder /data/youtube is empty.', heldBack: 2 }, + }, + }); + + render(<VideosPage token={mockToken} />); + + await user.click(await screen.findByRole('button', { name: 'Maintenance' })); + expect(mockNavigate).toHaveBeenCalledWith('/settings/maintenance'); + }); + test('fetches and displays videos when token is provided', async () => { axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -1341,6 +1356,23 @@ describe('VideosPage Component', () => { expect(await screen.findByText('Marked 1 video as deleted')).toBeInTheDocument(); }); + test('shows the server message when marking as deleted is refused', async () => { + const user = setupUser(); + const missingVideo = { ...mockVideos[0], removed: true, removed_reason: 'not_found' as const }; + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([missingVideo]) }); + axios.post.mockRejectedValueOnce({ response: { data: { error: 'Send either videoIds (1 to 1000 video ids) or all: true' } } }); + + render(<VideosPage token={mockToken} />); + await screen.findByText('How to Code'); + + await user.click(screen.getAllByRole('checkbox')[1]); + await user.click(screen.getByText('Actions (1)')); + await user.click(screen.getByText('Mark as deleted Selected')); + await user.click(await screen.findByRole('button', { name: 'Mark 1 as deleted' })); + + expect(await screen.findByText('Send either videoIds (1 to 1000 video ids) or all: true')).toBeInTheDocument(); + }); + test('offers Mark as deleted only when the selection holds a missing video', async () => { const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -1403,6 +1435,86 @@ describe('VideosPage Component', () => { } }); + test('reloads page one after deleting a video from a later infinite-scroll page', async () => { + const { useConfig } = require('../../hooks/useConfig'); + const originalObserver = window.IntersectionObserver; + let intersect = () => {}; + window.IntersectionObserver = jest.fn().mockImplementation((callback: IntersectionObserverCallback) => { + const observer = { + observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn(), + takeRecords: () => [], root: null, rootMargin: '', thresholds: [0], + }; + intersect = () => callback([{ isIntersecting: true } as IntersectionObserverEntry], observer); + return observer; + }); + useConfig.mockReturnValue({ config: { channelVideosHotLoad: true } }); + useTableView(); + const user = setupUser(); + axios.get + .mockResolvedValueOnce({ data: { ...mockPaginatedResponse([mockVideos[0]]), total: 2, totalPages: 2 } }) + .mockResolvedValueOnce({ data: { ...mockPaginatedResponse([mockVideos[1]]), page: 2, total: 2, totalPages: 2 } }) + .mockResolvedValueOnce({ data: mockPaginatedResponse([mockVideos[1]]) }); + mockDeleteVideos.mockResolvedValueOnce({ success: true, deleted: [mockVideos[0].id], failed: [] }); + + const { unmount } = render(<VideosPage token={mockToken} />); + try { + await screen.findByText('How to Code'); + act(() => intersect()); + await screen.findByText('Game Review'); + + await user.click(screen.getAllByRole('checkbox')[1]); + await user.click(screen.getByText('Actions (1)')); + await user.click(screen.getByText('Delete Selected')); + await user.click(screen.getByTestId('dialog-confirm')); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(3)); + expect(axios.get.mock.calls[2][0]).toContain('page=1'); + await screen.findByText('Game Review'); + expect(screen.queryByText('How to Code')).not.toBeInTheDocument(); + } finally { + unmount(); + window.IntersectionObserver = originalObserver; + useConfig.mockReturnValue({ config: { channelVideosHotLoad: false } }); + } + }); + + test('stays on the current page after deleting in classic pagination', async () => { + const user = setupUser(); + useTableView(); + const manyVideos = Array.from({ length: 15 }, (_, i) => ({ + id: i, + youtubeId: `video${i}`, + youTubeChannelName: 'Channel', + youTubeVideoName: `Video ${i}`, + timeCreated: '2024-01-15T10:30:00', + originalDate: '20240110', + duration: 600, + description: 'Description', + removed: false, + fileSize: null + })); + axios.get + .mockResolvedValueOnce({ data: mockPaginatedResponse(manyVideos, 1, 12) }) + .mockResolvedValueOnce({ data: mockPaginatedResponse(manyVideos, 2, 12) }) + .mockResolvedValueOnce({ data: mockPaginatedResponse(manyVideos.filter((v) => v.id !== 12), 2, 12) }); + mockDeleteVideos.mockResolvedValueOnce({ success: true, deleted: [12], failed: [] }); + + render(<VideosPage token={mockToken} />); + await screen.findByText('Video 0'); + await user.click(screen.getByRole('button', { name: /go to page 2/i })); + await screen.findByText('Video 12'); + + await user.click(screen.getAllByRole('checkbox')[1]); + await user.click(screen.getByText('Actions (1)')); + await user.click(screen.getByText('Delete Selected')); + await user.click(screen.getByTestId('dialog-confirm')); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(3)); + expect(axios.get.mock.calls[2][0]).toContain('page=2'); + expect(await screen.findByText('Video 13')).toBeInTheDocument(); + expect(screen.queryByText('Video 12')).not.toBeInTheDocument(); + }); + test('opens delete dialog when delete button is clicked', async () => { const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); diff --git a/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx b/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx index 4d53b2d5f..13d32bd6d 100644 --- a/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx +++ b/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx @@ -47,8 +47,8 @@ function ToggleChipFilter({ : offIsActive ? 'primary' : 'default'; const isResting = value === restingValue; const variant = isResting ? 'outlined' : 'filled'; - const ariaPressed: boolean | 'mixed' = - value === 'off' ? false : value === 'only' ? true : 'mixed'; + // Pressed means "moved off the resting value"; the label carries which mode. + const ariaPressed = !isResting; return ( <Chip diff --git a/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx b/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx index 33aa2e6f7..172a87e32 100644 --- a/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx +++ b/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx @@ -99,6 +99,25 @@ describe('ToggleChipFilter', () => { expect(screen.queryByLabelText('Remove')).not.toBeInTheDocument(); }); + test('announces only and exclude as pressed, and off as not pressed', () => { + const { unmount } = renderWithProviders( + <ToggleChipFilter {...defaultProps} value="exclude" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true'); + unmount(); + + const { unmount: unmountOnly } = renderWithProviders( + <ToggleChipFilter {...defaultProps} value="only" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true'); + unmountOnly(); + + renderWithProviders( + <ToggleChipFilter {...defaultProps} value="off" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false'); + }); + describe('with a resting value other than off', () => { const restingProps = { ...defaultProps, @@ -117,6 +136,19 @@ describe('ToggleChipFilter', () => { expect(screen.queryByLabelText('Remove')).not.toBeInTheDocument(); }); + test('announces the resting state as not pressed and the active off state as pressed', () => { + const { unmount } = renderWithProviders( + <ToggleChipFilter {...restingProps} value="exclude" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false'); + unmount(); + + renderWithProviders( + <ToggleChipFilter {...restingProps} value="off" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true'); + }); + test('labels the off state as showing, with a delete affordance back to resting', async () => { const user = userEvent.setup(); const onChange = jest.fn(); diff --git a/client/src/hooks/__tests__/useRescanStatus.test.ts b/client/src/hooks/__tests__/useRescanStatus.test.ts index c4b8b931c..f8446986e 100644 --- a/client/src/hooks/__tests__/useRescanStatus.test.ts +++ b/client/src/hooks/__tests__/useRescanStatus.test.ts @@ -133,8 +133,9 @@ describe('useRescanStatus', () => { }); await waitFor(() => expect(result.current.loading).toBe(false)); + let outcome: { started: boolean } | undefined; await act(async () => { - await result.current.triggerRescan(); + outcome = await result.current.triggerRescan(); }); expect(mockedAxios.post).toHaveBeenCalledWith( @@ -142,10 +143,32 @@ describe('useRescanStatus', () => { undefined, expect.objectContaining({ headers: { 'x-access-token': 'tok' } }) ); + expect(outcome).toEqual({ started: true }); expect(result.current.error).toBeNull(); expect(result.current.running).toBe(true); }); + test('triggerRescan can ask to skip the downloads folder check', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: { running: false, lastRun: null } }); + mockedAxios.post.mockResolvedValueOnce({ status: 202, data: { status: 'started' } }); + + const { result } = renderHook(() => useRescanStatus('tok'), { + wrapper: makeWrapper([]) + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.triggerRescan({ ignoreLibraryCheck: true }); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/maintenance/rescan-files', + { ignoreLibraryCheck: true }, + expect.objectContaining({ headers: { 'x-access-token': 'tok' } }) + ); + expect(result.current.running).toBe(true); + }); + test('triggerRescan surfaces 409 as a user-friendly error', async () => { mockedAxios.get.mockResolvedValueOnce({ data: { running: false, lastRun: null } }); const err = new Error('Conflict') as Error & { @@ -159,10 +182,12 @@ describe('useRescanStatus', () => { }); await waitFor(() => expect(result.current.loading).toBe(false)); + let outcome: { started: boolean } | undefined; await act(async () => { - await result.current.triggerRescan(); + outcome = await result.current.triggerRescan(); }); + expect(outcome).toEqual({ started: false }); expect(result.current.error).toBe('Rescan already in progress'); expect(result.current.running).toBe(true); }); @@ -176,10 +201,12 @@ describe('useRescanStatus', () => { }); await waitFor(() => expect(result.current.loading).toBe(false)); + let outcome: { started: boolean } | undefined; await act(async () => { - await result.current.triggerRescan(); + outcome = await result.current.triggerRescan(); }); + expect(outcome).toEqual({ started: false }); expect(result.current.running).toBe(false); expect(result.current.error).toBe('Network Error'); }); diff --git a/client/src/hooks/useRescanStatus.ts b/client/src/hooks/useRescanStatus.ts index 21cbc089a..8d1803b85 100644 --- a/client/src/hooks/useRescanStatus.ts +++ b/client/src/hooks/useRescanStatus.ts @@ -28,12 +28,22 @@ interface RescanStatusPayload { lastRun?: RescanLastRun | null; } +export interface TriggerRescanOptions { + // Rescan anyway: skip the unmounted-drive check, for a folder emptied on purpose. + ignoreLibraryCheck?: boolean; +} + +export interface TriggerRescanOutcome { + // False when the server refused (already running) or the request failed; `error` says why. + started: boolean; +} + export interface UseRescanStatusReturn { running: boolean; lastRun: RescanLastRun | null; loading: boolean; error: string | null; - triggerRescan: () => Promise<void>; + triggerRescan: (options?: TriggerRescanOptions) => Promise<TriggerRescanOutcome>; } export function useRescanStatus(token: string | null): UseRescanStatusReturn { @@ -88,21 +98,24 @@ export function useRescanStatus(token: string | null): UseRescanStatusReturn { return () => ws.unsubscribe(callback); }, [ws]); - const triggerRescan = useCallback(async () => { + const triggerRescan = useCallback(async (options?: TriggerRescanOptions): Promise<TriggerRescanOutcome> => { setError(null); setRunning(true); const headers = token ? { 'x-access-token': token } : undefined; + const body = options?.ignoreLibraryCheck ? { ignoreLibraryCheck: true } : undefined; try { - await axios.post('/api/maintenance/rescan-files', undefined, { headers }); + await axios.post('/api/maintenance/rescan-files', body, { headers }); + return { started: true }; } catch (err: unknown) { if (axios.isAxiosError(err) && err.response?.status === 409) { const data = err.response.data as { error?: string } | undefined; setError(data?.error ?? 'Rescan already in progress'); - return; + return { started: false }; } setRunning(false); const message = err instanceof Error ? err.message : 'Failed to start rescan'; setError(message); + return { started: false }; } }, [token]); diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 4cec56396..1a20b9d8e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -162,7 +162,7 @@ Youtarr keeps a record of every video it has downloaded, even after the file is - **Deleted** (neutral grey chip): Youtarr removed the file on purpose, through a delete in the app or automatic cleanup, or you marked a missing video as deleted. Videos removed before Youtarr started recording the reason also show as Deleted ("Removed before Youtarr recorded why"). The Videos page hides deleted videos by default; the **Deleted** filter chip (set to "Hide" unless you change it, which is remembered in your browser) and the "N deleted videos hidden. Show" note bring them back. Channel and playlist pages keep listing them. - **Missing** (red chip): Youtarr expected a file on disk and could not find it. Missing videos stay visible so you can look into them. Hover or tap the chip, or open the video, to see the path Youtarr looked at and when. -**The downloads folder looks unmounted**: if your downloads live on a drive or network share and it is not mounted, every file looks gone. Youtarr checks that the downloads folder (and each `__subfolder` in it) exists and is not empty before marking anything missing. While that check fails, the Videos page shows a warning instead of marking videos missing, the scheduled rescan records a partial run that names the folder, and the "all missing videos" actions below are disabled. Mount the share and the warning goes away on its own; nothing was changed in the meantime. +**The downloads folder looks unmounted**: if your downloads live on a drive or network share and it is not mounted, every file looks gone. Youtarr checks that the downloads folder (and each `__subfolder` in it) exists and is not empty before marking anything missing. While that check fails, the Videos page shows a warning instead of marking videos missing, the scheduled rescan records a partial run that names the folder, and the "all missing videos" actions below are disabled. Mount the share and the warning goes away on its own; nothing was changed in the meantime. While the check fails, automatic cleanup records a skipped run instead of stamping videos it cannot reach as deleted, and deleting a video in the app is refused with the same message. If the folder really is empty because you removed the files yourself, use **Settings -> Maintenance & Rescan -> Rescan anyway...**: it runs the rescan without the check, marks every video whose file is not found as missing, and you can then mark them as deleted. Only do this when you know the files are gone. **Mark as deleted**: when a missing video's file really is gone, select it on the Videos page and choose **Mark as deleted**, or use **Settings -> Maintenance & Rescan -> Mark all missing as deleted**. The record is kept (the video can still be re-downloaded, and a file that turns up again is marked downloaded by the rescan); only how the Videos page treats it changes. diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index 288500f21..8bc3cd1b1 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -411,7 +411,7 @@ You can also start a rescan with **Run now** on the Rescan files on disk card in A scan also runs daily on a schedule and once at server startup, so changes you make outside Youtarr will eventually be picked up even if you don't trigger a manual rescan. -If the downloads folder (or one of its `__subfolders`) is missing or empty when the scan runs, as it is while a drive or network share is unmounted, the scan does not mark anything missing: it records a run that names the folder and asks whether the drive is mounted, and the Videos page shows the same warning. Mount the share and run it again. +If the downloads folder (or one of its `__subfolders`) is missing or empty when the scan runs, as it is while a drive or network share is unmounted, the scan does not mark anything missing: it records a run that names the folder and asks whether the drive is mounted, and the Videos page shows the same warning. Mount the share and run it again. If the folder really is empty because you removed the files yourself, **Rescan anyway...** on the Deleted and missing videos card runs the scan without that check; it marks every video whose file is gone as missing, and you can then mark them as deleted. **Supported file extensions**: `.mp4`, `.webm`, `.mkv`, `.m4v`, `.avi` for video, plus `.mp3` for audio-only downloads. Youtarr only writes `.mp4` (or `.mp3` for audio-only), but the rescan recognizes any of these so transcoding outside Youtarr won't orphan your library. Files must keep the `[<youtube-id>]` segment in their filename (the 11-character ID in brackets that yt-dlp writes by default) for Youtarr to match them back to the database. diff --git a/server/__tests__/server.routes.test.js b/server/__tests__/server.routes.test.js index 6c1fac626..05df2a972 100644 --- a/server/__tests__/server.routes.test.js +++ b/server/__tests__/server.routes.test.js @@ -1143,7 +1143,8 @@ describe('server routes - channels', () => { searchQuery: 'test search', sortBy: 'title', sortOrder: 'asc', - watchedFilter: 'only' + watchedFilter: 'only', + deletedFilter: 'only' } }); const res = createMockResponse(); @@ -1168,7 +1169,7 @@ describe('server routes - channels', () => { 'off', // default ignoredFilter 'only', // watchedFilter null, // default maxRating - 'off' // default deletedFilter + 'only' // deletedFilter ); expect(res.statusCode).toBe(200); }); diff --git a/server/modules/__tests__/cronJobs.test.js b/server/modules/__tests__/cronJobs.test.js index 5ffd9ddf9..d60287c7f 100644 --- a/server/modules/__tests__/cronJobs.test.js +++ b/server/modules/__tests__/cronJobs.test.js @@ -179,6 +179,36 @@ describe('CronJobs', () => { expect(mockLogger.info).toHaveBeenCalledWith('Running automatic video cleanup cron job'); }); + test('records a skipped run and leaves folders alone when cleanup skipped an unmounted downloads folder', async () => { + mockVideoDeletionModule.performAutomaticCleanup.mockResolvedValue({ + skipped: { reason: 'empty', message: 'The downloads folder /d is empty.' }, + totalDeleted: 0, + freedBytes: 0, + errors: [] + }); + const scheduledTasks = require('../scheduledTaskManager'); + + const outcome = await scheduledTasks.runNow('autoRemovalFrequency', { trigger: 'manual' }); + const record = await outcome.completion; + + expect(record).toEqual(expect.objectContaining({ status: 'skipped', outcome: 'skipped', message: 'The downloads folder /d is empty.' })); + expect(mockVideoDeletionModule.cleanupOrphanDirectories).not.toHaveBeenCalled(); + }); + + test('records a skipped run when only the orphan sweep ran and it skipped an unmounted downloads folder', async () => { + // Auto-removal off: performAutomaticCleanup returns before its own check. + mockVideoDeletionModule.performAutomaticCleanup.mockResolvedValue({ totalDeleted: 0, freedBytes: 0, errors: [] }); + mockVideoDeletionModule.cleanupOrphanDirectories.mockResolvedValue({ + removed: [], errors: [], skipped: { reason: 'empty', message: 'The downloads folder /d is empty.' } + }); + const scheduledTasks = require('../scheduledTaskManager'); + + const outcome = await scheduledTasks.runNow('autoRemovalFrequency', { trigger: 'manual' }); + const record = await outcome.completion; + + expect(record).toEqual(expect.objectContaining({ status: 'skipped', outcome: 'skipped', message: 'The downloads folder /d is empty.' })); + }); + test('should log success when videos are deleted', async () => { mockVideoDeletionModule.performAutomaticCleanup.mockResolvedValue({ totalDeleted: 5, @@ -430,6 +460,26 @@ describe('CronJobs', () => { expect(mockLogger.info).toHaveBeenCalledWith({ trigger: 'scheduled' }, 'Starting video metadata backfill'); }); + test('never skips the downloads folder check on a scheduled run', async () => { + mockVideosModule.backfillVideoMetadata.mockResolvedValue({ timedOut: false }); + + await backfillCallback(); + + expect(mockVideosModule.backfillVideoMetadata).toHaveBeenCalledWith({ trigger: 'scheduled', ignoreLibraryCheck: false }); + }); + + test('passes a manual request to skip the downloads folder check through to the backfill', async () => { + mockVideosModule.backfillVideoMetadata.mockResolvedValue({ timedOut: false }); + const scheduledTasks = require('../scheduledTaskManager'); + + const outcome = await scheduledTasks.runNow('videoRescanFrequency', { + trigger: 'manual', enforceCooldown: false, args: { ignoreLibraryCheck: true }, + }); + await outcome.completion; + + expect(mockVideosModule.backfillVideoMetadata).toHaveBeenCalledWith({ trigger: 'manual', ignoreLibraryCheck: true }); + }); + test('should log success when backfill completes without timeout', async () => { const resolvedPromise = Promise.resolve({ timedOut: false }); mockVideosModule.backfillVideoMetadata.mockReturnValue(resolvedPromise); @@ -901,7 +951,7 @@ describe('CronJobs', () => { const scheduledTaskManager = require('../scheduledTaskManager'); const outcome = await scheduledTaskManager.runNow('videoRescanFrequency', { trigger: 'manual' }); await outcome.completion; - expect(mockVideosModule.backfillVideoMetadata).toHaveBeenCalledWith({ trigger: 'manual' }); + expect(mockVideosModule.backfillVideoMetadata).toHaveBeenCalledWith({ trigger: 'manual', ignoreLibraryCheck: false }); }); test('the rescan reports as running while the module lock is held', () => { diff --git a/server/modules/__tests__/fileCheckModule.test.js b/server/modules/__tests__/fileCheckModule.test.js index e47848bc5..49aaf69e8 100644 --- a/server/modules/__tests__/fileCheckModule.test.js +++ b/server/modules/__tests__/fileCheckModule.test.js @@ -621,7 +621,7 @@ describe('FileCheckModule', () => { ); }); - test('should update removed status only', async () => { + test('marks missing only while the row is still present, so a delete landing meanwhile wins', async () => { const updates = [ { id: 1, removed: true } ]; @@ -631,7 +631,7 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenCalledTimes(1); expect(mockVideo.update).toHaveBeenCalledWith( { removed: true }, - { where: { id: 1 } }, + { where: { id: 1, removed: false } }, ); }); @@ -643,11 +643,11 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenCalledWith( { removed: true, removed_reason: 'not_found', removed_at: at, removed_detail: null }, - { where: { id: 1 } }, + { where: { id: 1, removed: false } }, ); }); - test('should update both fileSize and removed status', async () => { + test('restores a row only while it is still removed', async () => { const updates = [ { id: 1, fileSize: 2000, removed: false } ]; @@ -657,7 +657,7 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenCalledTimes(1); expect(mockVideo.update).toHaveBeenCalledWith( { fileSize: 2000, removed: false }, - { where: { id: 1 } }, + { where: { id: 1, removed: true } }, ); }); @@ -674,12 +674,12 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenNthCalledWith( 1, { fileSize: 2000, removed: false }, - { where: { id: 1 } }, + { where: { id: 1, removed: true } }, ); expect(mockVideo.update).toHaveBeenNthCalledWith( 2, { removed: true }, - { where: { id: 2 } }, + { where: { id: 2, removed: false } }, ); expect(mockVideo.update).toHaveBeenNthCalledWith( 3, @@ -697,7 +697,7 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenCalledWith( { removed: false }, - { where: { id: 1 } }, + { where: { id: 1, removed: true } }, ); }); @@ -710,7 +710,7 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenCalledWith( { removed: true }, - { where: { id: 1 } }, + { where: { id: 1, removed: false } }, ); }); @@ -773,7 +773,7 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenCalledWith( { filePath: '/videos/channel/video [abc123].mkv', fileSize: 5000, removed: false }, - { where: { id: 1 } }, + { where: { id: 1, removed: true } }, ); }); diff --git a/server/modules/__tests__/jobModule.test.js b/server/modules/__tests__/jobModule.test.js index 09049c57f..9083c879b 100644 --- a/server/modules/__tests__/jobModule.test.js +++ b/server/modules/__tests__/jobModule.test.js @@ -633,6 +633,33 @@ describe('JobModule', () => { expect(Video.create).not.toHaveBeenCalled(); }); + test('leaves the removal columns alone when the recovered file is not found', async () => { + JobVideoDownload.findAll.mockResolvedValue([ + { job_id: 'job-1', youtube_id: 'video-1', status: 'completed' } + ]); + fsPromises.readFile.mockResolvedValue(JSON.stringify({ + id: 'video-1', + uploader: 'Channel 1', + title: 'Video 1', + duration: 100, + upload_date: '20240101', + channel_id: 'channel-1', + _actual_filepath: '/data/Channel 1/Channel 1 - Video 1 - video-1/Channel 1 - Video 1 [video-1].mp4' + })); + fsPromises.access.mockResolvedValue(); + fsPromises.stat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + Job.findOne.mockResolvedValue({ id: 'job-1' }); + Video.findOne.mockResolvedValue(null); + + await JobModule.recoverCompletedVideos('job-1'); + + expect(Video.create).toHaveBeenCalledTimes(1); + const created = Video.create.mock.calls[0][0]; + expect(created.fileSize).toBeNull(); + expect(created).not.toHaveProperty('removed'); + expect(created).not.toHaveProperty('removed_reason'); + }); + test('should skip videos with missing info.json files', async () => { JobVideoDownload.findAll.mockResolvedValue([ diff --git a/server/modules/__tests__/libraryRootCheck.test.js b/server/modules/__tests__/libraryRootCheck.test.js index bce1da75c..b1fdc585d 100644 --- a/server/modules/__tests__/libraryRootCheck.test.js +++ b/server/modules/__tests__/libraryRootCheck.test.js @@ -1,6 +1,6 @@ /* eslint-env jest */ const path = require('path'); -const { LIBRARY_CHECK_REASONS, checkLibraryRoot, describeLibraryCheck } = require('../libraryRootCheck'); +const { LIBRARY_CHECK_REASONS, checkLibraryRoot, describeLibraryCheck, isUnderLibraryCheckPath } = require('../libraryRootCheck'); const ROOT = '/data/youtube'; @@ -60,6 +60,29 @@ describe('checkLibraryRoot', () => { }); }); + test('reports empty when the root holds only Youtarr\'s temp folder', async () => { + const result = await checkLibraryRoot(ROOT, { readdir: diskWith({ [ROOT]: [dir('.youtarr_tmp')] }) }); + expect(result).toEqual(expect.objectContaining({ ok: false, reason: 'empty', path: ROOT })); + }); + + test('reports empty when the root holds only entries Youtarr itself creates', async () => { + const readdir = diskWith({ + [ROOT]: [dir('.youtarr_tmp'), dir('__playlists__'), file('.youtarr_healthcheck_1234')], + [path.join(ROOT, '__playlists__')]: [], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual( + expect.objectContaining({ ok: false, reason: 'empty', path: ROOT }) + ); + }); + + test('does not treat the playlists folder as a library subfolder', async () => { + const readdir = diskWith({ + [ROOT]: [dir('Channel A'), dir('__playlists__')], + [path.join(ROOT, '__playlists__')]: [], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ ok: true }); + }); + test('fails when a __subfolder exists but is empty, naming that subfolder', async () => { const music = path.join(ROOT, '__Music'); const readdir = diskWith({ @@ -88,12 +111,15 @@ describe('checkLibraryRoot', () => { await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ ok: true }); }); - test('fails as unreadable when a __subfolder cannot be listed', async () => { + test('fails as subfolder-unreadable when a __subfolder cannot be listed, naming that subfolder', async () => { const music = path.join(ROOT, '__Music'); const readdir = diskWith({ [ROOT]: [dir('__Music')], [music]: 'EIO' }); - await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual( - expect.objectContaining({ ok: false, reason: 'unreadable', path: music }) - ); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ + ok: false, + reason: 'subfolder-unreadable', + path: music, + message: 'The subfolder /data/youtube/__Music could not be read.', + }); }); test('treats no configured folder as missing', async () => { @@ -111,5 +137,27 @@ describe('describeLibraryCheck', () => { expect(describeLibraryCheck({ reason: 'empty', path: '/d' })).toBe('The downloads folder /d is empty.'); expect(describeLibraryCheck({ reason: 'unreadable', path: '/d' })).toBe('The downloads folder /d could not be read.'); expect(describeLibraryCheck({ reason: 'subfolder-empty', path: '/d/__Music' })).toBe('The subfolder /d/__Music is empty.'); + expect(describeLibraryCheck({ reason: 'subfolder-unreadable', path: '/d/__Music' })).toBe('The subfolder /d/__Music could not be read.'); + }); +}); + +describe('isUnderLibraryCheckPath', () => { + const music = path.join(ROOT, '__Music'); + + test('a failing root covers every file path', () => { + expect(isUnderLibraryCheckPath({ ok: false, reason: 'empty', path: ROOT }, '/data/youtube/Channel/v [abc].mp4')).toBe(true); + expect(isUnderLibraryCheckPath({ ok: false, reason: 'missing', path: ROOT }, '/elsewhere/v [abc].mp4')).toBe(true); + }); + + test('a failing subfolder covers only the files under it', () => { + const check = { ok: false, reason: 'subfolder-empty', path: music }; + expect(isUnderLibraryCheckPath(check, path.join(music, 'Artist', 'song [abc].mp3'))).toBe(true); + expect(isUnderLibraryCheckPath(check, path.join(ROOT, '__Music2', 'song [abc].mp3'))).toBe(false); + expect(isUnderLibraryCheckPath(check, path.join(ROOT, 'Channel', 'v [abc].mp4'))).toBe(false); + }); + + test('a passing check and a video without a file path cover nothing', () => { + expect(isUnderLibraryCheckPath({ ok: true }, '/data/youtube/Channel/v [abc].mp4')).toBe(false); + expect(isUnderLibraryCheckPath({ ok: false, reason: 'empty', path: ROOT }, null)).toBe(false); }); }); diff --git a/server/modules/__tests__/rescanRunSummary.test.js b/server/modules/__tests__/rescanRunSummary.test.js index 01e92df00..e46b31f94 100644 --- a/server/modules/__tests__/rescanRunSummary.test.js +++ b/server/modules/__tests__/rescanRunSummary.test.js @@ -26,6 +26,13 @@ describe('rescanRunSummary', () => { }); }); + test('says when the downloads folder check was skipped on request', () => { + const record = toRunRecord({ status: 'completed', processed: 5, updated: 1, removed: 2, libraryCheckSkipped: true }); + expect(record.status).toBe('success'); + expect(record.message).toBe('Scanned 5 videos: 1 updated, 2 marked missing. The downloads folder check was skipped.'); + expect(record.details.libraryCheckSkipped).toBe(true); + }); + test('records a scan that held back missing marks behind a library check as partial, naming the cause', () => { const record = toRunRecord({ status: 'completed', processed: 10, updated: 1, removed: 0, diff --git a/server/modules/__tests__/videoDeletionModule.test.js b/server/modules/__tests__/videoDeletionModule.test.js index 4b5e7a056..c8d7318c9 100644 --- a/server/modules/__tests__/videoDeletionModule.test.js +++ b/server/modules/__tests__/videoDeletionModule.test.js @@ -17,6 +17,8 @@ jest.mock('../storageGuard', () => ({ // beforeEach installs persists across resetModules and only has `promises`, // which fs-extra (required by directoryManager) cannot load against. const { isVideoDirectoryFor } = jest.requireActual('../filesystem/directoryManager'); +// The real path predicate; only the check itself is mocked. +const { isUnderLibraryCheckPath } = jest.requireActual('../libraryRootCheck'); // What a delete in the app writes on the row: removed, by the user, now. const USER_DELETED_COLUMNS = expect.objectContaining({ @@ -33,6 +35,7 @@ describe('VideoDeletionModule', () => { let mockLogger; let mockFilesystem; let m3uGenerator; + let mockCheckLibraryRoot; beforeEach(() => { jest.resetModules(); @@ -46,9 +49,17 @@ describe('VideoDeletionModule', () => { // Mock the Video model mockVideo = { findByPk: jest.fn(), - findOne: jest.fn() + findOne: jest.fn(), + count: jest.fn() }; + // The downloads folder looks mounted unless a test says otherwise. + mockCheckLibraryRoot = jest.fn().mockResolvedValue({ ok: true }); + jest.doMock('../libraryRootCheck', () => ({ + checkLibraryRoot: mockCheckLibraryRoot, + isUnderLibraryCheckPath + })); + // Mock fs.promises (only the methods videoDeletionModule still calls directly, // i.e. flat-mode readdir/unlink). The nested-mode rm path now goes through // filesystem.removeDirectoryResilient and is mocked there. @@ -767,6 +778,50 @@ describe('VideoDeletionModule', () => { }); }); + describe('while the downloads folder looks unmounted', () => { + const emptyRoot = { ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder /test/output is empty.' }; + const emptySubfolder = { ok: false, reason: 'subfolder-empty', path: '/test/output/__Music', message: 'The subfolder /test/output/__Music is empty.' }; + const video = (id, youtubeId, filePath) => ({ id, youtubeId, filePath, removed: false, update: jest.fn().mockResolvedValue() }); + + test('refuses every video with the check message and touches nothing', async () => { + mockCheckLibraryRoot.mockResolvedValue(emptyRoot); + const mockVideo1 = video(1, 'abc123', '/test/output/Channel/Channel - Video1 - abc123/video.mp4'); + const mockVideo2 = video(2, 'def456', '/test/output/Channel/Channel - Video2 - def456/video.mp4'); + mockVideo.findByPk.mockResolvedValueOnce(mockVideo1).mockResolvedValueOnce(mockVideo2); + + const result = await VideoDeletionModule.deleteVideos([1, 2]); + + const error = 'The downloads folder /test/output is empty. Nothing was deleted; check that the drive is mounted.'; + expect(result).toEqual({ success: false, deleted: [], failed: [{ videoId: 1, error }, { videoId: 2, error }] }); + expect(mockVideo1.update).not.toHaveBeenCalled(); + expect(mockFilesystem.removeDirectoryResilient).not.toHaveBeenCalled(); + }); + + test('refuses only the videos under an empty subfolder', async () => { + mockCheckLibraryRoot.mockResolvedValue(emptySubfolder); + const inMusic = video(1, 'abc123', '/test/output/__Music/Channel/Channel - Video1 - abc123/video.mp4'); + const inRoot = video(2, 'def456', '/test/output/Channel/Channel - Video2 - def456/video.mp4'); + mockVideo.findByPk.mockResolvedValueOnce(inMusic).mockResolvedValueOnce(inRoot); + + const result = await VideoDeletionModule.deleteVideos([1, 2]); + + expect(result.deleted).toEqual([2]); + expect(result.failed).toEqual([{ videoId: 1, error: 'The subfolder /test/output/__Music is empty. Nothing was deleted; check that the drive is mounted.' }]); + expect(inMusic.update).not.toHaveBeenCalled(); + expect(inRoot.update).toHaveBeenCalledWith(USER_DELETED_COLUMNS); + }); + + test('checks the downloads folder once per batch', async () => { + mockVideo.findByPk + .mockResolvedValueOnce(video(1, 'abc123', '/test/output/Channel/Channel - Video1 - abc123/video.mp4')) + .mockResolvedValueOnce(video(2, 'def456', '/test/output/Channel/Channel - Video2 - def456/video.mp4')); + + await VideoDeletionModule.deleteVideos([1, 2]); + + expect(mockCheckLibraryRoot).toHaveBeenCalledTimes(1); + }); + }); + test('should handle all videos failing', async () => { mockVideo.findByPk.mockResolvedValue(null); @@ -1424,6 +1479,43 @@ describe('VideoDeletionModule', () => { VideoDeletionModule = require('../videoDeletionModule'); }); + describe('while the downloads folder looks unmounted', () => { + const emptyRoot = { ok: false, reason: 'empty', path: '/test', message: 'The downloads folder /test is empty.' }; + + beforeEach(() => { + mockConfigModule.getConfig.mockReturnValue({ autoRemovalEnabled: true, autoRemovalVideoAgeThreshold: '30' }); + mockCheckLibraryRoot.mockResolvedValue(emptyRoot); + mockSequelize.query.mockResolvedValue([]); + }); + + test('skips the run with the check message while downloaded videos are recorded', async () => { + mockVideo.count.mockResolvedValue(12); + + const result = await VideoDeletionModule.performAutomaticCleanup(); + + expect(result.skipped).toEqual({ reason: 'empty', message: 'The downloads folder /test is empty.' }); + expect(result.totalDeleted).toBe(0); + expect(mockAutoRemovalQueries.getChannelKeepRecentIds).not.toHaveBeenCalled(); + expect(mockSequelize.query).not.toHaveBeenCalled(); + }); + + test('runs normally when nothing downloaded is recorded, as on a fresh install', async () => { + mockVideo.count.mockResolvedValue(0); + + const result = await VideoDeletionModule.performAutomaticCleanup(); + + expect(result.skipped).toBeUndefined(); + expect(mockAutoRemovalQueries.getChannelKeepRecentIds).toHaveBeenCalled(); + }); + + test('does not consult the check for a dry run', async () => { + const result = await VideoDeletionModule.performAutomaticCleanup({ dryRun: true }); + + expect(mockCheckLibraryRoot).not.toHaveBeenCalled(); + expect(result.skipped).toBeUndefined(); + }); + }); + test('should skip cleanup when auto-removal is disabled', async () => { mockConfigModule.getConfig.mockReturnValue({ autoRemovalEnabled: false, @@ -2394,6 +2486,29 @@ describe('VideoDeletionModule', () => { expect(result).toEqual({ removed: [], errors: [] }); }); + test('skips the sweep while the downloads folder looks unmounted and videos are recorded', async () => { + mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder /test/output is empty.' }); + mockVideo.count.mockResolvedValue(3); + mockFilesystem.listSubdirectories.mockResolvedValue(['/test/output/__Music']); + + const result = await VideoDeletionModule.cleanupOrphanDirectories(); + + expect(result).toEqual({ removed: [], errors: [], skipped: { reason: 'empty', message: 'The downloads folder /test/output is empty.' } }); + expect(mockFilesystem.listSubdirectories).not.toHaveBeenCalled(); + expect(mockFilesystem.cleanupEmptyParents).not.toHaveBeenCalled(); + }); + + test('sweeps normally when the folder looks unmounted but nothing downloaded is recorded', async () => { + mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder /test/output is empty.' }); + mockVideo.count.mockResolvedValue(0); + mockFilesystem.listSubdirectories.mockResolvedValue([]); + + const result = await VideoDeletionModule.cleanupOrphanDirectories(); + + expect(result).toEqual({ removed: [], errors: [] }); + expect(mockFilesystem.listSubdirectories).toHaveBeenCalledWith('/test/output'); + }); + test('should clean up root-level empty channel directories', async () => { mockFilesystem.listSubdirectories.mockResolvedValue([ '/test/output/Empty Channel', diff --git a/server/modules/__tests__/videoTrashModule.test.js b/server/modules/__tests__/videoTrashModule.test.js index f47fe60cf..82a3bbf19 100644 --- a/server/modules/__tests__/videoTrashModule.test.js +++ b/server/modules/__tests__/videoTrashModule.test.js @@ -58,12 +58,22 @@ describe('getRemovedSummary', () => { missing: 4, legacy: 3, libraryCheck: { ok: true, reason: null, path: null, message: null }, + downloadsActive: false, }); expect(Video.count).toHaveBeenNthCalledWith(1, { where: stateWhere('deleted') }); expect(Video.count).toHaveBeenNthCalledWith(2, { where: stateWhere('missing') }); expect(Video.count).toHaveBeenNthCalledWith(3, { where: { removed: true, removed_reason: null } }); }); + test('says when a download is active, so the archive choice can be disabled up front', async () => { + Video.count.mockResolvedValue(0); + + await archiveAccess.withDownload(async () => { + const summary = await videoTrashModule.getRemovedSummary(); + expect(summary.downloadsActive).toBe(true); + }); + }); + test('passes a failed library check through so the page can disable the missing actions', async () => { Video.count.mockResolvedValue(0); checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); @@ -457,8 +467,12 @@ describe('purgeRemoved', () => { const rejected = expect(purge).rejects.toBeInstanceOf(LibraryUnavailableError); const run = jest.fn().mockResolvedValue(undefined); const download = archiveAccess.withDownload(run); - expect(run).not.toHaveBeenCalled(); - finishCheck(LIBRARY_EMPTY); + try { + expect(run).not.toHaveBeenCalled(); + } finally { + // Always let the purge finish, or it would hold the lock for the rest of the file. + finishCheck(LIBRARY_EMPTY); + } await rejected; await download; expect(run).toHaveBeenCalledTimes(1); diff --git a/server/modules/__tests__/videosModule.test.js b/server/modules/__tests__/videosModule.test.js index 8a3dc030e..d423f466c 100644 --- a/server/modules/__tests__/videosModule.test.js +++ b/server/modules/__tests__/videosModule.test.js @@ -140,7 +140,7 @@ describe('VideosModule', () => { // The downloads folder looks mounted unless a test says otherwise. mockLibraryRootCheck = { - LIBRARY_CHECK_REASONS: { MISSING: 'missing', EMPTY: 'empty', UNREADABLE: 'unreadable', SUBFOLDER_EMPTY: 'subfolder-empty' }, + LIBRARY_CHECK_REASONS: { MISSING: 'missing', EMPTY: 'empty', UNREADABLE: 'unreadable', SUBFOLDER_EMPTY: 'subfolder-empty', SUBFOLDER_UNREADABLE: 'subfolder-unreadable' }, checkLibraryRoot: jest.fn().mockResolvedValue({ ok: true }), }; jest.doMock('../libraryRootCheck', () => mockLibraryRootCheck); @@ -715,10 +715,11 @@ describe('VideosModule', () => { // Check update was called expect(mockVideo.update).toHaveBeenCalledTimes(2); + // Marked missing only while still present: a delete landing meanwhile keeps its reason. expect(mockVideo.update).toHaveBeenNthCalledWith( 1, { ...MISSING_COLUMNS }, - { where: { id: 1 } }, + { where: { id: 1, removed: false } }, ); expect(mockVideo.update).toHaveBeenNthCalledWith( 2, @@ -1542,6 +1543,45 @@ describe('VideosModule', () => { expect(result.libraryCheckMessage).toBe(emptySubfolder.message); expect(mockVideo.update).not.toHaveBeenCalled(); }); + + test('finishes the scan but holds back missing marks while a subfolder cannot be read', async () => { + const unreadableSubfolder = { ok: false, reason: 'subfolder-unreadable', path: '/test/output/dir/__Music', message: 'The subfolder /test/output/dir/__Music could not be read.' }; + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(unreadableSubfolder); + mockVideo.count.mockResolvedValueOnce(1); // present rows + mockVideo.count.mockResolvedValueOnce(1); // total rows + mockFs.readdir.mockResolvedValueOnce([]); + mockVideo.findAll.mockResolvedValueOnce([ + dbRow({ youtubeId: 'missing123', filePath: '/test/output/dir/__Music/missing.mp4', fileSize: '1000' }) + ]); + mockFs.stat.mockRejectedValue(fsError('ENOENT')); + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('completed'); + expect(result.removed).toBe(0); + expect(result.notMarkedMissing).toBe(1); + expect(result.libraryCheckMessage).toBe(unreadableSubfolder.message); + expect(mockVideo.update).not.toHaveBeenCalled(); + }); + + test('skips the folder check when a manual run asks to, marking missing and saying so', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(missingRoot); + mockVideo.count.mockResolvedValueOnce(1); // total rows + mockFs.readdir.mockResolvedValueOnce([]); + mockVideo.findAll.mockResolvedValueOnce([ + dbRow({ youtubeId: 'missing123', filePath: '/test/output/dir/Channel/missing.mp4', fileSize: '1000' }) + ]); + mockFs.stat.mockRejectedValue(fsError('ENOENT')); + mockVideo.update.mockResolvedValue([1]); + + const result = await VideosModule.backfillVideoMetadata({ trigger: 'manual', ignoreLibraryCheck: true }); + + expect(mockLibraryRootCheck.checkLibraryRoot).not.toHaveBeenCalled(); + expect(result.status).toBe('completed'); + expect(result.removed).toBe(1); + expect(result.notMarkedMissing).toBe(0); + expect(result.libraryCheckSkipped).toBe(true); + }); }); test('should clear removed flag when a previously-missing file reappears (raw row removed=1)', async () => { diff --git a/server/modules/channel/__tests__/channelVideoQuery.test.js b/server/modules/channel/__tests__/channelVideoQuery.test.js index 99decea75..faac2ca26 100644 --- a/server/modules/channel/__tests__/channelVideoQuery.test.js +++ b/server/modules/channel/__tests__/channelVideoQuery.test.js @@ -48,7 +48,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2', 'video3'] }, - attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -95,7 +96,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -103,6 +105,22 @@ describe('channelVideoQuery', () => { expect(result[1].removed).toBe(false); }); + test('normalizes the raw 0/1 flags of plain rows to booleans', async () => { + const Video = require('../../../models/video'); + const videos = [{ youtube_id: 'video1' }, { youtube_id: 'video2' }]; + Video.findAll = jest.fn().mockResolvedValue([ + { youtubeId: 'video1', removed: 1, protected: 1 }, + { youtubeId: 'video2', removed: 0, protected: 0 } + ]); + + const result = await channelVideoQuery.enrichVideosWithDownloadStatus(videos); + + expect(result[0].removed).toBe(true); + expect(result[0].protected).toBe(true); + expect(result[1].removed).toBe(false); + expect(result[1].protected).toBe(false); + }); + test('should correctly handle removed videos', async () => { const Video = require('../../../models/video'); @@ -152,7 +170,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -181,7 +200,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2', 'video3'] }, - attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); // Video1 - not downloaded @@ -209,7 +229,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: [] }, - attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); expect(result).toEqual([]); }); @@ -373,7 +394,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); diff --git a/server/modules/channel/channelVideoQuery.js b/server/modules/channel/channelVideoQuery.js index 5f4bc6741..2275ba755 100644 --- a/server/modules/channel/channelVideoQuery.js +++ b/server/modules/channel/channelVideoQuery.js @@ -39,7 +39,11 @@ class ChannelVideoQuery { 'protected', 'last_downloaded_at', 'video_resolution' - ] + ], + // Plain rows: the file check spreads these ({ ...video }) to build its + // updated copies, and spreading a Sequelize instance drops the attribute + // getters, so the updated rows would never match the status map below. + raw: true }); // Create Maps for O(1) lookup of download status @@ -47,10 +51,11 @@ class ChannelVideoQuery { const videosToCheck = []; downloadedVideos.forEach(v => { + // Plain rows carry TINYINT flags as 0/1; keep the listing's booleans. downloadStatusMap.set(v.youtubeId, { id: v.id, added: true, - removed: v.removed, + removed: Boolean(v.removed), removed_reason: v.removed_reason ?? null, removed_at: v.removed_at ?? null, removed_detail: v.removed_detail ?? null, @@ -59,7 +64,7 @@ class ChannelVideoQuery { audioFilePath: v.audioFilePath, audioFileSize: v.audioFileSize, normalized_rating: v.normalized_rating, - protected: v.protected, + protected: Boolean(v.protected), last_downloaded_at: v.last_downloaded_at, video_resolution: v.video_resolution }); @@ -78,7 +83,7 @@ class ChannelVideoQuery { checkedVideos.forEach(v => { const status = downloadStatusMap.get(v.youtubeId); if (status) { - status.removed = v.removed; + status.removed = Boolean(v.removed); status.removed_reason = v.removed_reason ?? null; status.removed_at = v.removed_at ?? null; status.removed_detail = v.removed_detail ?? null; diff --git a/server/modules/cronJobs.js b/server/modules/cronJobs.js index b81901f1c..7502aa160 100644 --- a/server/modules/cronJobs.js +++ b/server/modules/cronJobs.js @@ -78,6 +78,9 @@ function initialize(deps = {}) { let failure = null; try { result = await videoDeletionModule.performAutomaticCleanup(); + // Skipped while the downloads folder looks unmounted: nothing to report + // or tidy, and the run history should say why nothing happened. + if (result.skipped) return skippedRun(result.skipped.message); if (result.totalDeleted > 0) { logger.info({ @@ -105,11 +108,18 @@ function initialize(deps = {}) { let orphans = NO_ORPHAN_RESULT; try { const removal = await videoDeletionModule.cleanupOrphanDirectories(); - orphans = { removed: (removal && removal.removed) || [], errors: (removal && removal.errors) || [] }; + orphans = { + removed: (removal && removal.removed) || [], + errors: (removal && removal.errors) || [], + skipped: (removal && removal.skipped) || null, + }; } catch (error) { logger.error({ err: error }, 'Error during orphan directory cleanup'); orphans = { removed: [], errors: [error.message || 'Unknown error'] }; } + // With auto-removal off, the sweep is the run's only work, so its skip + // (downloads folder looks unmounted) is the run's outcome. + if (!failure && orphans.skipped && result.totalDeleted === 0) return skippedRun(orphans.skipped.message); return failure || describeCleanup(result, orphans); }; @@ -151,11 +161,15 @@ function initialize(deps = {}) { // ============================================================================ // VIDEO METADATA BACKFILL // ============================================================================ - jobs.videoRescanFrequency = async ({ trigger = 'scheduled' } = {}) => { + jobs.videoRescanFrequency = async ({ trigger = 'scheduled', ignoreLibraryCheck = false } = {}) => { logger.info({ trigger }, 'Starting video metadata backfill'); try { - // Await completion so another occurrence can't overlap this one. - const result = await videosModule.backfillVideoMetadata({ trigger }); + // Await completion so another occurrence can't overlap this one. Only a + // manual run (Rescan anyway) may skip the unmounted-drive check. + const result = await videosModule.backfillVideoMetadata({ + trigger, + ignoreLibraryCheck: trigger === 'manual' && ignoreLibraryCheck === true, + }); if (result && result.timedOut) { logger.info('Video metadata backfill reached time limit, will continue at the next scheduled run'); } else if (!result || result.status !== 'error') { diff --git a/server/modules/download/__tests__/archiveAccess.test.js b/server/modules/download/__tests__/archiveAccess.test.js index e823dc3cc..ff51c1d55 100644 --- a/server/modules/download/__tests__/archiveAccess.test.js +++ b/server/modules/download/__tests__/archiveAccess.test.js @@ -29,6 +29,16 @@ describe('archive access', () => { expect(run).toHaveBeenCalledTimes(1); }); + test('reports whether a download is active', async () => { + let finish; + expect(access.isDownloading()).toBe(false); + const download = access.withDownload(() => new Promise((resolve) => { finish = resolve; })); + expect(access.isDownloading()).toBe(true); + finish(); + await download; + expect(access.isDownloading()).toBe(false); + }); + test('releases access even when a download throws', async () => { await expect(access.withDownload(async () => { throw new Error('failed'); })).rejects.toThrow('failed'); const release = access.acquirePurge(); diff --git a/server/modules/download/__tests__/downloadExecutor.test.js b/server/modules/download/__tests__/downloadExecutor.test.js index 9feb28742..ab9308c06 100644 --- a/server/modules/download/__tests__/downloadExecutor.test.js +++ b/server/modules/download/__tests__/downloadExecutor.test.js @@ -304,15 +304,27 @@ describe('DownloadExecutor', () => { describe('archive access during downloads', () => { it('defers startup until the purge releases the archive', async () => { const release = archiveAccess.acquirePurge(); - const download = executor.doDownload([], 'job-123', 'Channel Downloads'); - await Promise.resolve(); - expect(archiveModule.readCompleteListLines).not.toHaveBeenCalled(); - expect(mockSpawn).not.toHaveBeenCalled(); - release(); - await new Promise((resolve) => setImmediate(resolve)); - expect(mockSpawn).toHaveBeenCalled(); - endProcess(0, null); - await download; + // Released in finally too: a failed assertion must not leave the lock + // held, or every later download in this file would wait forever. + let released = false; + const releasePurge = () => { + if (released) return; + released = true; + release(); + }; + try { + const download = executor.doDownload([], 'job-123', 'Channel Downloads'); + await Promise.resolve(); + expect(archiveModule.readCompleteListLines).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + releasePurge(); + await new Promise((resolve) => setImmediate(resolve)); + expect(mockSpawn).toHaveBeenCalled(); + endProcess(0, null); + await download; + } finally { + releasePurge(); + } }); it('blocks archive removal through finalization and releases it afterward', async () => { diff --git a/server/modules/download/archiveAccess.js b/server/modules/download/archiveAccess.js index c0ffaf673..46f800cdf 100644 --- a/server/modules/download/archiveAccess.js +++ b/server/modules/download/archiveAccess.js @@ -16,6 +16,11 @@ class ArchiveAccess { } } + // Whether a purge that removes archive entries would be refused right now. + isDownloading() { + return this.downloads > 0; + } + // Claim synchronously, before the purge's first await. New downloads wait // for release; a purge requested during a download is refused immediately. acquirePurge() { diff --git a/server/modules/download/downloadExecutor.js b/server/modules/download/downloadExecutor.js index 746dc526d..b11f18f35 100644 --- a/server/modules/download/downloadExecutor.js +++ b/server/modules/download/downloadExecutor.js @@ -18,6 +18,7 @@ const DownloadTimeoutController = require('./DownloadTimeoutController'); const { YtdlpErrorTracker } = require('./YtdlpErrorTracker'); const downloadRunTracker = require('./downloadRunTracker'); const archiveAccess = require('./archiveAccess'); +const { HEALTHCHECK_FILE_PREFIX } = require('../filesystem/constants'); // Tells the job's download run about a job that failed before yt-dlp ran, so // the run counts it instead of treating the job as quietly finished. @@ -123,7 +124,7 @@ class DownloadExecutor { const fsPromises = require('fs').promises; const crypto = require('crypto'); - const testFile = path.join(outputDir, `.youtarr_healthcheck_${crypto.randomUUID()}`); + const testFile = path.join(outputDir, `${HEALTHCHECK_FILE_PREFIX}${crypto.randomUUID()}`); let timer; const timeoutPromise = new Promise((_, reject) => { diff --git a/server/modules/download/tempPathManager.js b/server/modules/download/tempPathManager.js index 87c30fd6e..9e24e6d3f 100644 --- a/server/modules/download/tempPathManager.js +++ b/server/modules/download/tempPathManager.js @@ -3,9 +3,8 @@ const fs = require('fs-extra'); const configModule = require('../configModule'); const directoryManager = require('../filesystem/directoryManager'); const logger = require('../../logger'); - // Hidden temp directory name (dot-prefix hides from media servers like Plex/Jellyfin) -const LOCAL_TEMP_DIR_NAME = '.youtarr_tmp'; +const { LOCAL_TEMP_DIR_NAME } = require('../filesystem/constants'); /** * Manages temporary download paths and conversions between temp and final locations. diff --git a/server/modules/fileCheckModule.js b/server/modules/fileCheckModule.js index 4adbc6caf..16816c212 100644 --- a/server/modules/fileCheckModule.js +++ b/server/modules/fileCheckModule.js @@ -186,7 +186,12 @@ class FileCheckModule { }; if (Object.values(attributes).some((v) => v !== undefined)) { - await Video.update(attributes, { where: { id: update.id } }); + // Only a present row becomes missing and only a removed row is + // restored, so a delete that lands between the check's read and this + // write keeps its reason instead of being overwritten with not_found. + const where = { id: update.id }; + if (update.removed !== undefined) where.removed = !update.removed; + await Video.update(attributes, { where }); } } } diff --git a/server/modules/filesystem/constants.js b/server/modules/filesystem/constants.js index 1e83796e5..4071bc623 100644 --- a/server/modules/filesystem/constants.js +++ b/server/modules/filesystem/constants.js @@ -173,8 +173,22 @@ const M3U_FILE_PATTERN = /\.m3u(\.tmp)?$/i; */ const APPLEDOUBLE_FILE_PATTERN = /^\._/; +/** + * Entries Youtarr itself creates at the top of the downloads folder: the + * local temp folder, the playlist m3u folder, and the write test the download + * preflight leaves behind if it is interrupted. Youtarr may create them on a + * bare mount point while a share is down, so the library check ignores them + * when deciding whether the folder looks mounted. + */ +const LOCAL_TEMP_DIR_NAME = '.youtarr_tmp'; +const M3U_FOLDER_NAME = '__playlists__'; +const HEALTHCHECK_FILE_PREFIX = '.youtarr_healthcheck_'; + module.exports = { SUBFOLDER_PREFIX, + LOCAL_TEMP_DIR_NAME, + M3U_FOLDER_NAME, + HEALTHCHECK_FILE_PREFIX, GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL, VIDEO_EXTENSIONS, diff --git a/server/modules/jobModule.js b/server/modules/jobModule.js index 0972145c3..93aaf9bc0 100644 --- a/server/modules/jobModule.js +++ b/server/modules/jobModule.js @@ -235,11 +235,12 @@ class JobModule { videoData.fileSize = resolvedStats?.size !== undefined ? resolvedStats.size.toString() : null; Object.assign(videoData, presentColumns()); } else { - // File not found, but we still want to record the expected metadata location + // File not found: record the expected location but leave the removal + // columns alone, so a video deleted in-app while its job ran stays + // deleted and a new row simply awaits the file check. const assumedPath = actualFilePath || `${fallbackBasePath}.mp4`; videoData.filePath = assumedPath; videoData.fileSize = null; - Object.assign(videoData, presentColumns()); } // Upsert video into Videos table and ensure JobVideo relationship exists diff --git a/server/modules/libraryRootCheck.js b/server/modules/libraryRootCheck.js index 6c407c65d..e88b90396 100644 --- a/server/modules/libraryRootCheck.js +++ b/server/modules/libraryRootCheck.js @@ -6,13 +6,16 @@ const path = require('path'); // pathBuilder, not the filesystem index: the index loads fileOperations, which // binds child_process at require time, and this check needs neither. const { isSubfolderDirectory } = require('./filesystem/pathBuilder'); +const { LOCAL_TEMP_DIR_NAME, M3U_FOLDER_NAME, HEALTHCHECK_FILE_PREFIX } = require('./filesystem/constants'); const LIBRARY_CHECK_REASONS = { MISSING: 'missing', EMPTY: 'empty', UNREADABLE: 'unreadable', SUBFOLDER_EMPTY: 'subfolder-empty', + SUBFOLDER_UNREADABLE: 'subfolder-unreadable', }; +const SUBFOLDER_REASONS = [LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY, LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE]; // ENOTDIR: the folder's path now leads to a regular file, so the folder is gone. const ABSENT_CODES = ['ENOENT', 'ENOTDIR']; @@ -27,6 +30,8 @@ function describeLibraryCheck({ reason, path: checkedPath }) { return `The downloads folder ${checkedPath} could not be read.`; case LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY: return `The subfolder ${checkedPath} is empty.`; + case LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE: + return `The subfolder ${checkedPath} could not be read.`; default: return `The downloads folder ${checkedPath} could not be checked.`; } @@ -36,9 +41,16 @@ function failure(reason, checkedPath) { return { ok: false, reason, path: checkedPath, message: describeLibraryCheck({ reason, path: checkedPath }) }; } +// Entries Youtarr creates itself, possibly on a bare mount point while the +// share is down, so they say nothing about whether the library is there. +function isYoutarrManagedEntry(name) { + return name === LOCAL_TEMP_DIR_NAME || name === M3U_FOLDER_NAME || name.startsWith(HEALTHCHECK_FILE_PREFIX); +} + /** * The root must exist and hold something; every `__subfolder` in it (a share * of its own, on some setups) must hold something too. First failure wins. + * Entries Youtarr creates itself do not count (see isYoutarrManagedEntry). * @param {string} baseDir - The downloads root * @param {{ readdir?: Function }} [deps] - fs.promises.readdir, injectable for tests * @returns {Promise<{ok: true}|{ok: false, reason: string, path: string, message: string}>} @@ -48,7 +60,7 @@ async function checkLibraryRoot(baseDir, { readdir = fs.promises.readdir } = {}) let entries; try { - entries = await readdir(baseDir, { withFileTypes: true }); + entries = (await readdir(baseDir, { withFileTypes: true })).filter((entry) => !isYoutarrManagedEntry(entry.name)); } catch (err) { const reason = ABSENT_CODES.includes(err.code) ? LIBRARY_CHECK_REASONS.MISSING : LIBRARY_CHECK_REASONS.UNREADABLE; return failure(reason, baseDir); @@ -64,7 +76,7 @@ async function checkLibraryRoot(baseDir, { readdir = fs.promises.readdir } = {}) } catch (err) { // Gone since the root was listed: not a mount problem. if (ABSENT_CODES.includes(err.code)) continue; - return failure(LIBRARY_CHECK_REASONS.UNREADABLE, subfolder); + return failure(LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE, subfolder); } if (children.length === 0) return failure(LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY, subfolder); } @@ -72,4 +84,20 @@ async function checkLibraryRoot(baseDir, { readdir = fs.promises.readdir } = {}) return { ok: true }; } -module.exports = { LIBRARY_CHECK_REASONS, checkLibraryRoot, describeLibraryCheck }; +/** + * Whether a failing check puts the given file in doubt: a root failure covers + * every file, a subfolder failure only the files under that subfolder. + * @param {{ok: boolean, reason?: string, path?: string}} check - a checkLibraryRoot result + * @param {string|null|undefined} filePath + * @returns {boolean} + */ +function isUnderLibraryCheckPath(check, filePath) { + if (!check || check.ok || !filePath) return false; + if (!SUBFOLDER_REASONS.includes(check.reason)) return true; + const prefix = check.path.endsWith(path.sep) ? check.path : `${check.path}${path.sep}`; + return filePath.startsWith(prefix); +} + +module.exports = { + LIBRARY_CHECK_REASONS, checkLibraryRoot, describeLibraryCheck, isYoutarrManagedEntry, isUnderLibraryCheckPath, +}; diff --git a/server/modules/m3uGenerator.js b/server/modules/m3uGenerator.js index 37d038853..748a1b12d 100644 --- a/server/modules/m3uGenerator.js +++ b/server/modules/m3uGenerator.js @@ -9,8 +9,8 @@ const { resolveChannelFolderName, } = require('./filesystem/pathBuilder'); const configModule = require('./configModule'); +const { M3U_FOLDER_NAME } = require('./filesystem/constants'); -const M3U_FOLDER_NAME = '__playlists__'; // Empty marker file that tells the Jellyfin and Emby library scanners to skip // this folder. Without it, those servers auto-import the .m3u files as duplicate // (and on Jellyfin, empty) playlists alongside the ones created via native API diff --git a/server/modules/rescanRunSummary.js b/server/modules/rescanRunSummary.js index d7f7aa5b2..08070a38f 100644 --- a/server/modules/rescanRunSummary.js +++ b/server/modules/rescanRunSummary.js @@ -28,13 +28,15 @@ function describeRowProblems(result) { function describe(outcome, result) { const scanned = count(result.processed).toLocaleString('en-US'); const problems = describeRowProblems(result); + // A manual "Rescan anyway" marked videos missing without the unmounted-drive check. + const skipped = result.libraryCheckSkipped ? ' The downloads folder check was skipped.' : ''; if (outcome === 'error') return result.errorMessage || 'Unknown error'; if (outcome === 'timed-out') { const suffix = problems.length > 0 ? ` (${problems.join(', ')})` : ''; - return `Reached the time limit after ${scanned} videos${suffix}; continues at the next run.`; + return `Reached the time limit after ${scanned} videos${suffix}; continues at the next run.${skipped}`; } const counts = [`${count(result.updated)} updated`, `${count(result.removed)} marked missing`, ...problems]; - return `Scanned ${scanned} videos: ${counts.join(', ')}.`; + return `Scanned ${scanned} videos: ${counts.join(', ')}.${skipped}`; } function toRunRecord(result = {}) { @@ -66,6 +68,7 @@ function toRunRecord(result = {}) { videosFailed: count(result.failed), // Rows left unmarked because the downloads folder looked unmounted. videosNotMarkedMissing: count(result.notMarkedMissing), + ...(result.libraryCheckSkipped ? { libraryCheckSkipped: true } : {}), }, }; } diff --git a/server/modules/videoDeletionModule.js b/server/modules/videoDeletionModule.js index 6dcbd31ba..c7b006ef8 100644 --- a/server/modules/videoDeletionModule.js +++ b/server/modules/videoDeletionModule.js @@ -9,6 +9,7 @@ const storageUsage = require('./storageUsage'); const { STORED_BYTES_SQL } = storageUsage; const storageGuard = require('./storageGuard'); const { REMOVAL_REASONS, removedColumns } = require('./videoRemovalState'); +const { checkLibraryRoot, isUnderLibraryCheckPath } = require('./libraryRootCheck'); // Oldest-first removal (free-space and total-usage strategies) works in // batches and stops after a bounded number of them per run. @@ -102,9 +103,12 @@ class VideoDeletionModule { * Deletes the video directory from disk and marks the video as removed in the database * @param {number} videoId - The database ID of the video to delete * @param {{reason: string, detail: (string|null)}} [removal] - Why the video is removed (see videoRemovalState) + * @param {{libraryCheck?: (Object|null)}} [options] - libraryCheck: a checkLibraryRoot result; when it fails + * and covers this video's file, the delete is refused rather than stamping a row whose file may be on an + * unmounted share * @returns {Promise<{success: boolean, videoId: number, error?: string}>} */ - async deleteVideoById(videoId, removal = USER_DELETED_REMOVAL) { + async deleteVideoById(videoId, removal = USER_DELETED_REMOVAL, { libraryCheck = null } = {}) { try { // Fetch video from database const video = await Video.findByPk(videoId); @@ -141,6 +145,14 @@ class VideoDeletionModule { }; } + if (libraryCheck && isUnderLibraryCheckPath(libraryCheck, primaryPath)) { + return { + success: false, + videoId, + error: `${libraryCheck.message} Nothing was deleted; check that the drive is mounted.` + }; + } + // Get the video directory path // Nested: filePath = /path/to/channel/channel - title - id/video.mp4 // Flat: filePath = /path/to/channel/video.mp4 @@ -228,6 +240,27 @@ class VideoDeletionModule { } } + // One check per batch: a delete must not stamp a row whose file sits on an + // unmounted share. A check that itself fails does not block deletes. + async _checkLibraryForDeletes() { + try { + return await checkLibraryRoot(configModule.directoryPath); + } catch (err) { + logger.warn({ err }, 'Could not check the downloads folder before deleting; deleting anyway'); + return null; + } + } + + // Why the library must not be touched right now: the downloads folder looks + // unmounted (see libraryRootCheck) while downloaded videos are on record. A + // fresh install with nothing recorded is never blocked. Null when fine. + async _libraryBlock() { + const libraryCheck = await checkLibraryRoot(configModule.directoryPath); + if (libraryCheck.ok) return null; + if ((await Video.count({ where: { removed: false } })) === 0) return null; + return { reason: libraryCheck.reason, message: libraryCheck.message }; + } + /** * Delete multiple videos * @param {number[]} videoIds - Array of video IDs to delete @@ -238,10 +271,11 @@ class VideoDeletionModule { const deleted = []; const failed = []; const affectedChannelIds = []; + const libraryCheck = videoIds.length > 0 ? await this._checkLibraryForDeletes() : null; // Process deletions sequentially to avoid overwhelming the file system for (const videoId of videoIds) { - const result = await this.deleteVideoById(videoId, removal); + const result = await this.deleteVideoById(videoId, removal, { libraryCheck }); if (result.success) { deleted.push(videoId); @@ -274,6 +308,7 @@ class VideoDeletionModule { const deleted = []; const failed = []; const affectedChannelIds = []; + const libraryCheck = youtubeIds.length > 0 ? await this._checkLibraryForDeletes() : null; for (const youtubeId of youtubeIds) { try { @@ -291,7 +326,7 @@ class VideoDeletionModule { } // Delete using the database ID - const result = await this.deleteVideoById(video.id, removal); + const result = await this.deleteVideoById(video.id, removal, { libraryCheck }); if (result.success) { deleted.push(youtubeId); @@ -453,7 +488,8 @@ ${excludeClause} ORDER BY timeCreated ASC * Unlike _tryCleanupChannelDirectory (which only runs after a video deletion), this * proactively finds directories that are already empty (or contain only ignorable files * like poster.jpg) and cleans them up. Handles both root-level and subfolder-level channels. - * @returns {Promise<{removed: string[], errors: string[]}>} + * @returns {Promise<{removed: string[], errors: string[], skipped?: {reason: string, message: string}}>} + * skipped: the sweep did not run because the downloads folder looked unmounted */ async cleanupOrphanDirectories() { const baseDir = configModule.directoryPath; @@ -465,6 +501,14 @@ ${excludeClause} ORDER BY timeCreated ASC return { removed, errors }; } + // Never tidy folders while the library looks unmounted: an empty subfolder + // mount point would read as an orphan. + const block = await this._libraryBlock(); + if (block) { + logger.warn({ libraryCheck: block }, '[Orphan Cleanup] The downloads folder looks unmounted; skipping the sweep'); + return { removed, errors, skipped: block }; + } + try { const topLevelDirs = await listSubdirectories(baseDir); @@ -740,6 +784,19 @@ ${excludeClause} ORDER BY timeCreated ASC return result; } + // An unmounted drive makes every file look gone, and a run then would stamp + // healthy videos "deleted by automatic cleanup". With downloaded videos on + // record and the folder looking unmounted, the run is skipped; a fresh + // install with nothing recorded runs normally. + if (!dryRun) { + const block = await this._libraryBlock(); + if (block) { + logger.warn({ libraryCheck: block }, '[Auto-Removal] The downloads folder looks unmounted; skipping cleanup'); + result.skipped = block; + return result; + } + } + // The N most recently downloaded videos are protected from every strategy. // If the guard query fails we abort: running without it would delete the // videos this setting exists to keep. diff --git a/server/modules/videoTrashModule.js b/server/modules/videoTrashModule.js index 829a1bd56..ecdb4c04a 100644 --- a/server/modules/videoTrashModule.js +++ b/server/modules/videoTrashModule.js @@ -54,15 +54,22 @@ class VideoTrashModule { } /** - * @returns {Promise<{deleted: number, missing: number, legacy: number, libraryCheck: Object}>} - * legacy: deleted rows removed before the reason was recorded (a subset of deleted) + * @returns {Promise<{deleted: number, missing: number, legacy: number, libraryCheck: Object, downloadsActive: boolean}>} + * legacy: deleted rows removed before the reason was recorded (a subset of deleted); + * downloadsActive: a purge that removes archive entries would be refused right now */ async getRemovedSummary() { const deleted = await Video.count({ where: stateWhere(REMOVAL_STATES.DELETED) }); const missing = await Video.count({ where: stateWhere(REMOVAL_STATES.MISSING) }); const legacy = await Video.count({ where: { removed: true, removed_reason: null } }); const libraryCheck = await checkLibraryRoot(configModule.directoryPath); - return { deleted, missing, legacy, libraryCheck: normalizeLibraryCheck(libraryCheck) }; + return { + deleted, + missing, + legacy, + libraryCheck: normalizeLibraryCheck(libraryCheck), + downloadsActive: archiveAccess.isDownloading(), + }; } /** diff --git a/server/modules/videosModule.js b/server/modules/videosModule.js index 79b2ac059..a1729dc81 100644 --- a/server/modules/videosModule.js +++ b/server/modules/videosModule.js @@ -564,6 +564,8 @@ class VideosModule { const opts = typeof arg === 'number' ? { timeLimit: arg } : arg; const timeLimit = opts.timeLimit ?? 5 * 60 * 1000; const trigger = opts.trigger ?? 'scheduled'; + // Rescan anyway: a manual run over a folder the user emptied on purpose. + const ignoreLibraryCheck = opts.ignoreLibraryCheck === true; if (this._backfillRunning) { logger.info({ trigger }, 'Backfill already running, skipping'); @@ -612,12 +614,15 @@ class VideosModule { // gone. With no present rows there is nothing to protect (a fresh // install with an empty folder), so the scan simply runs. let suppressMissing = false; - const libraryCheck = await checkLibraryRoot(outputDir); + if (ignoreLibraryCheck) logger.warn({ trigger }, 'Rescan is skipping the downloads folder check by request'); + const libraryCheck = ignoreLibraryCheck ? { ok: true } : await checkLibraryRoot(outputDir); if (!libraryCheck.ok && (await Video.count({ where: { removed: false } })) > 0) { - if (libraryCheck.reason === LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY) { + const subfolderOnly = libraryCheck.reason === LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY + || libraryCheck.reason === LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE; + if (subfolderOnly) { suppressMissing = true; libraryCheckMessage = libraryCheck.message; - logger.warn({ libraryCheck }, 'A subfolder looks unmounted; the rescan will not mark videos missing'); + logger.warn({ libraryCheck }, 'A subfolder looks unmounted or unreadable; the rescan will not mark videos missing'); } else { logger.warn({ libraryCheck }, 'The downloads folder looks unmounted; skipping the rescan'); result = { @@ -776,6 +781,7 @@ class VideosModule { failed: totalFailed, notMarkedMissing: totalNotMarkedMissing, libraryCheckMessage, + libraryCheckSkipped: ignoreLibraryCheck, timeElapsed: elapsed, trigger, startedAt: startedAtIso, @@ -803,7 +809,8 @@ class VideosModule { skippedChanged: totalSkipped, failed: totalFailed, notMarkedMissing: totalNotMarkedMissing, - libraryCheckMessage + libraryCheckMessage, + libraryCheckSkipped: ignoreLibraryCheck }; return result; } @@ -823,7 +830,8 @@ class VideosModule { skippedChanged: totalSkipped, failed: totalFailed, notMarkedMissing: totalNotMarkedMissing, - libraryCheckMessage + libraryCheckMessage, + libraryCheckSkipped: ignoreLibraryCheck }; // Resolve with the failure and the counters it reached rather than // rethrow, so every caller and the run history keep the partial progress. diff --git a/server/routes/__tests__/maintenance.test.js b/server/routes/__tests__/maintenance.test.js index 70f6bec11..b10a9fd4f 100644 --- a/server/routes/__tests__/maintenance.test.js +++ b/server/routes/__tests__/maintenance.test.js @@ -59,10 +59,29 @@ describe('Maintenance routes', () => { expect(res.status).toBe(202); expect(res.body).toEqual({ status: 'started', trigger: 'manual' }); expect(mockScheduledTaskManager.runNow).toHaveBeenCalledWith( - 'videoRescanFrequency', { trigger: 'manual', enforceCooldown: false } + 'videoRescanFrequency', { trigger: 'manual', enforceCooldown: false, args: { ignoreLibraryCheck: false } } ); }); + test('forwards a request to skip the downloads folder check', async () => { + mockScheduledTaskManager.runNow.mockResolvedValue({ started: true, completion: Promise.resolve({}) }); + + const res = await request(app).post('/api/maintenance/rescan-files').send({ ignoreLibraryCheck: true }); + + expect(res.status).toBe(202); + expect(mockScheduledTaskManager.runNow).toHaveBeenCalledWith( + 'videoRescanFrequency', { trigger: 'manual', enforceCooldown: false, args: { ignoreLibraryCheck: true } } + ); + }); + + test('rejects a non-boolean ignoreLibraryCheck', async () => { + const res = await request(app).post('/api/maintenance/rescan-files').send({ ignoreLibraryCheck: 'yes' }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'ignoreLibraryCheck must be true or false' }); + expect(mockScheduledTaskManager.runNow).not.toHaveBeenCalled(); + }); + test('returns 409 when already running', async () => { mockScheduledTaskManager.runNow.mockResolvedValue({ started: false, reason: 'running', message: 'x', availableAt: null diff --git a/server/routes/maintenance.js b/server/routes/maintenance.js index b431f0c20..4b90597c0 100644 --- a/server/routes/maintenance.js +++ b/server/routes/maintenance.js @@ -21,20 +21,41 @@ function createMaintenanceRoutes({ * /api/maintenance/rescan-files: * post: * summary: Kick off a manual filesystem rescan + * description: > + * With `ignoreLibraryCheck: true` ("Rescan anyway") the scan skips the check that keeps an unmounted + * drive or share from getting every video marked missing. Only for a downloads folder the user emptied + * on purpose; the scheduled rescan never skips it. * tags: [Maintenance] + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * ignoreLibraryCheck: + * type: boolean + * default: false * responses: * 202: * description: Rescan started + * 400: + * description: ignoreLibraryCheck was not a boolean * 409: * description: A rescan is already in progress, or the task cannot start (reason in the body) * 503: * description: The task is not registered yet (server still starting or database unavailable) */ router.post('/api/maintenance/rescan-files', verifyToken, async (req, res) => { + const { ignoreLibraryCheck = false } = req.body || {}; + if (typeof ignoreLibraryCheck !== 'boolean') { + return res.status(400).json({ error: 'ignoreLibraryCheck must be true or false' }); + } try { const outcome = await scheduledTaskManager.runNow('videoRescanFrequency', { trigger: 'manual', enforceCooldown: false, + args: { ignoreLibraryCheck }, }); if (!outcome.started) { return sendRunBlocked(res, outcome, { running: 'Rescan already in progress' }); diff --git a/server/routes/videoTrash.js b/server/routes/videoTrash.js index 7d5262c74..487593029 100644 --- a/server/routes/videoTrash.js +++ b/server/routes/videoTrash.js @@ -50,13 +50,16 @@ module.exports = function createVideoTrashRoutes({ verifyToken, videoTrashModule * reason: * type: string * nullable: true - * enum: [missing, empty, unreadable, subfolder-empty] + * enum: [missing, empty, unreadable, subfolder-empty, subfolder-unreadable] * path: * type: string * nullable: true * message: * type: string * nullable: true + * downloadsActive: + * type: boolean + * description: A download is running, so a purge that removes archive entries would be refused * 401: * description: Authentication required * 500: From 0eb698ad23efa3023150bf600f6a37954bae9ae5 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Sun, 18 Oct 2026 19:33:23 -0700 Subject: [PATCH 3/4] fix: scope the unmounted-drive guard by subfolder One failing __subfolder stopped the check, and with any present video on record it then held back the rescan, automatic cleanup, the orphan sweep, and the "all missing" trash actions for the whole library. An empty subfolder nothing was stored in (one a channel had been moved out of, say) looked like an unmounted share for good, and the check's messages named the container path, which means nothing on the host. The check now reports every failing subfolder, with the strictest reason winning so an unreadable share is never looked past as emptied on purpose, and callers ask whether the failure covers any stored video before acting on it. The rescan and the page-load file check hold back only the videos stored under a failing subfolder, cleanup and the sweep skip only while present videos are affected, the removed-summary reports a failure that covers no present or missing video as passing, and messages name a subfolder by its own name only. - Let Mark all missing as deleted look past the check, with an explicit acknowledgement in the Maintenance card, when the folder is only empty, never when it is gone or unreadable, so Rescan anyway is no longer a dead end. - Add Mark as deleted for one missing video to the video modal, on every page. - Make the Videos page unmounted-folder banner dismissible and offer Maintenance only for the empty reasons. - Open an Availability chip's tooltip on a touch-screen tap without reaching the row underneath; mouse clicks still pass through. - Show the Deleted filter chip as neutral at rest, keep the File Deleted/Missing overlay readable on any thumbnail and within a phone's player, and say in every removal sentence that the video can be downloaded again. Refs: #670 --- CLAUDE.md | 4 +- .../components/ChannelPage/VideoTableView.tsx | 2 +- .../__tests__/VideoTableView.test.tsx | 6 + .../DownloadManager/DownloadHistory.tsx | 4 +- .../__tests__/DownloadHistory.test.tsx | 13 ++ .../__tests__/playlistVideoHelpers.test.tsx | 4 +- .../__tests__/MaintenanceSection.test.tsx | 2 +- .../Settings/components/EmptyTrashDialog.tsx | 2 +- .../Settings/components/RemovedVideosCard.tsx | 106 +++++++++--- .../components/RescanAnywayDialog.tsx | 16 +- .../__tests__/EmptyTrashDialog.test.tsx | 6 +- .../__tests__/RemovedVideosCard.test.tsx | 92 ++++++++++- .../__tests__/RescanAnywayDialog.test.tsx | 16 +- .../hooks/__tests__/useRemovedVideos.test.ts | 20 ++- .../Settings/hooks/useRemovedVideos.ts | 19 ++- .../components/MarkDeletedDialog.tsx | 4 +- .../hooks/__tests__/useMarkDeleted.test.ts | 8 +- .../hooks/__tests__/useVideosData.test.ts | 4 +- client/src/components/VideosPage/index.tsx | 42 ++++- .../components/__tests__/VideosPage.test.tsx | 90 +++++++++- .../components/shared/AvailabilityChip.tsx | 37 +++-- .../VideoList/filters/ToggleChipFilter.tsx | 8 +- .../__tests__/ToggleChipFilter.test.tsx | 8 + .../VideoModal/__tests__/VideoModal.test.tsx | 56 +++++++ .../VideoModal/components/VideoActions.tsx | 24 ++- .../VideoModal/components/VideoPlayer.tsx | 28 +++- .../VideoModal/components/VideoTechnical.tsx | 17 +- .../components/__tests__/VideoPlayer.test.tsx | 12 ++ .../__tests__/VideoTechnical.test.tsx | 15 +- .../VideoModal/hooks/useVideoModalActions.ts | 37 +++++ .../components/shared/VideoModal/index.tsx | 6 + .../src/components/shared/VideoModal/types.ts | 2 + .../__tests__/AvailabilityChip.test.tsx | 69 ++++++++ .../src/utils/__tests__/videoRemoval.test.ts | 10 +- client/src/utils/videoRemoval.ts | 6 +- docs/TROUBLESHOOTING.md | 6 +- docs/USAGE_GUIDE.md | 4 +- server/modules/__tests__/cronJobs.test.js | 8 +- .../modules/__tests__/fileCheckModule.test.js | 50 +++++- .../__tests__/libraryRootCheck.test.js | 156 ++++++++++++++++-- .../__tests__/rescanRunSummary.test.js | 4 +- .../__tests__/videoDeletionModule.test.js | 52 ++++-- .../__tests__/videoTrashModule.test.js | 95 ++++++++++- server/modules/__tests__/videosModule.test.js | 44 ++++- server/modules/fileCheckModule.js | 9 +- server/modules/libraryRootCheck.js | 126 ++++++++++++-- server/modules/rescanRunSummary.js | 3 +- server/modules/videoDeletionModule.js | 12 +- server/modules/videoTrashModule.js | 50 ++++-- server/modules/videosModule.js | 27 +-- server/routes/__tests__/videoTrash.test.js | 27 ++- server/routes/videoTrash.js | 33 +++- 52 files changed, 1270 insertions(+), 231 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e7e472a4a..fe0a441b6 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 `<positive int>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: <title>` (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`, with a one-sentence `message`). 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 that subfolder). The file check runs it lazily, once per pass and only when a row would be marked missing, holds those rows back, 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: 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 (`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`) records a `skipped` run with the check's message while the check fails and present rows exist (a dry run never checks), 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, 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 fails, a selection is not), 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 fails; 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 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/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. - Playlist batch/following endpoints: `GET /api/playlists/:playlistId/download-preview` previews publication/position choices; `POST /api/playlists/:playlistId/download-batch` queues explicit ids; `POST /api/playlists/:playlistId/following` sets up/resumes following or resets the starting point while preserving pause state. All require auth and an enabled playlist. Swagger annotations in `routes/playlists.js` describe validation and errors. @@ -61,7 +61,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). The Videos page has the matching per-selection "Mark as deleted" action (`VideosPage/components/MarkDeletedDialog.tsx`, `VideosPage/hooks/useMarkDeleted.ts`), 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. `/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), 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/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/VideoTableView.tsx b/client/src/components/ChannelPage/VideoTableView.tsx index 899c516e5..690a07c62 100644 --- a/client/src/components/ChannelPage/VideoTableView.tsx +++ b/client/src/components/ChannelPage/VideoTableView.tsx @@ -330,7 +330,7 @@ function VideoTableView({ /> </td> <td style={{ whiteSpace: 'nowrap', padding: '8px 4px' }}> - {(video.filePath || video.audioFilePath) ? ( + {!video.removed && (video.filePath || video.audioFilePath) ? ( <DownloadFormatIndicator filePath={video.filePath} audioFilePath={video.audioFilePath} diff --git a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx index 87b9d7c60..bb375de60 100644 --- a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx @@ -201,6 +201,12 @@ describe('VideoTableView Component', () => { expect(screen.getByTestId('VideoFormatIcon')).toBeInTheDocument(); }); + test('shows no format chips for a deleted video, whose stored path is only history', () => { + const deletedVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'user_deleted' as const, filePath: '/path/to/video.mp4' }; + renderWithProviders(<VideoTableView {...defaultProps} videos={[deletedVideo]} />); + expect(screen.queryByTestId('VideoFormatIcon')).not.toBeInTheDocument(); + }); + test('renders dash when no file path exists', () => { const videoNoFile = { ...mockVideo, filePath: undefined }; renderWithProviders(<VideoTableView {...defaultProps} videos={[videoNoFile]} />); diff --git a/client/src/components/DownloadManager/DownloadHistory.tsx b/client/src/components/DownloadManager/DownloadHistory.tsx index 28b4300bc..5a04bfbe7 100644 --- a/client/src/components/DownloadManager/DownloadHistory.tsx +++ b/client/src/components/DownloadManager/DownloadHistory.tsx @@ -311,7 +311,7 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ <AvailabilityChip state="deleted" label={`${deletedCount} deleted`} - tooltip={`${deletedCount} of ${videos.length} videos were deleted by Youtarr or marked as deleted`} + tooltip={`${deletedCount} of ${videos.length} videos ${deletedCount === 1 ? 'was' : 'were'} deleted by Youtarr or marked as deleted`} /> )} {failedForJob.length > 0 && ( @@ -530,7 +530,7 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ <AvailabilityChip state="deleted" label={`${deletedCount} deleted`} - tooltip={`${deletedCount} of ${videos.length} videos were deleted by Youtarr or marked as deleted`} + tooltip={`${deletedCount} of ${videos.length} videos ${deletedCount === 1 ? 'was' : 'were'} deleted by Youtarr or marked as deleted`} /> )} {failedForJob.length > 0 && ( diff --git a/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx b/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx index 3466d3cca..82ecb511f 100644 --- a/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx +++ b/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx @@ -752,6 +752,19 @@ describe('DownloadHistory', () => { expect(screen.getByText('Missing')).toBeInTheDocument(); }); + test('explains a single deleted video in the singular', async () => { + const user = userEvent.setup(); + const job = makeMultiVideoJob('job-one-deleted', [ + makeVideo({ id: 1, youtubeId: 'v1', youTubeVideoName: 'Video A' }), + makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Video B', removed: true, removed_reason: 'user_deleted' }), + ]); + + render(<DownloadHistory {...defaultProps} jobs={[job]} />); + await user.hover(screen.getByText('1 deleted')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent('1 of 2 videos was deleted by Youtarr or marked as deleted'); + }); + test('desktop summary row hides the aggregate chip when no videos are removed', () => { const job = makeMultiVideoJob('job-multi-none', [ makeVideo({ id: 1, youtubeId: 'v1' }), diff --git a/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx b/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx index f5a73ca1a..d35d66ce0 100644 --- a/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx +++ b/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx @@ -71,14 +71,14 @@ describe('statusLabel', () => { 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 from its channel or playlist page.', + tooltip: '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.', + tooltip: 'Removed before Youtarr recorded why. It can be downloaded again.', }); }); test('returns Tracked for a plain tracked video', () => { diff --git a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx index c2fef11f4..51a484529 100644 --- a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx +++ b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx @@ -66,7 +66,7 @@ describe('MaintenanceSection', () => { 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 /d is empty.' }); + const { triggerRescan } = setup({}, { ok: false, reason: 'empty', path: '/d', message: 'The downloads folder is empty.' }); await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); diff --git a/client/src/components/Settings/components/EmptyTrashDialog.tsx b/client/src/components/Settings/components/EmptyTrashDialog.tsx index 3a166c167..1084aca79 100644 --- a/client/src/components/Settings/components/EmptyTrashDialog.tsx +++ b/client/src/components/Settings/components/EmptyTrashDialog.tsx @@ -174,7 +174,7 @@ const EmptyTrashDialog: React.FC<EmptyTrashDialogProps> = ({ Watch history Youtarr synced for them from your media servers is removed. </Typography> <Typography component="li" variant="body2" color="text.secondary"> - Nothing on disk is touched. If a missing video's file turns up later, Youtarr will not know about it; download it again instead. + No files in your downloads folder are touched. If a missing video's file turns up later, Youtarr will not know about it; download it again instead. </Typography> </ul> </div> diff --git a/client/src/components/Settings/components/RemovedVideosCard.tsx b/client/src/components/Settings/components/RemovedVideosCard.tsx index 4a5a2ae32..750a90dd4 100644 --- a/client/src/components/Settings/components/RemovedVideosCard.tsx +++ b/client/src/components/Settings/components/RemovedVideosCard.tsx @@ -8,6 +8,7 @@ import { DialogTitle, Typography, } from '../../ui'; +import { Checkbox, FormControlLabel } from '../../ui/form'; import { ConfigurationCard } from '../../Configuration/common/ConfigurationCard'; import { useRemovedVideos, type EmptyTrashOptions } from '../hooks/useRemovedVideos'; import EmptyTrashDialog from './EmptyTrashDialog'; @@ -22,11 +23,15 @@ interface RemovedVideosCardProps { } const RESCAN_NOT_STARTED = 'The rescan did not start. The rescan card above shows why.'; +const RESCAN_STARTED = 'Rescan started. When it finishes, the missing videos can be marked as deleted here.'; +const RESCAN_FINISHED = 'Rescan finished. Videos it could not find now count as missing and can be marked as deleted.'; +const EMPTIED_MYSELF = 'I removed these files myself; they are not on an unmounted drive'; const videos = (n: number) => (n === 1 ? 'video' : 'videos'); // Check results that can also mean the user emptied the folder on purpose, -// the one case where rescanning anyway makes sense. +// the one case where rescanning anyway, or marking every missing video as +// deleted despite the check, makes sense. const EMPTY_FOLDER_REASONS = ['empty', 'subfolder-empty']; // Settings > Maintenance: what Deleted and Missing mean, how many there are, @@ -38,22 +43,35 @@ function RemovedVideosCard({ token, onRescanAnyway, rescanRunning = false }: Rem const [rescanAnywayOpen, setRescanAnywayOpen] = useState(false); const [rescanAnywayError, setRescanAnywayError] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null); + const [emptiedAcknowledged, setEmptiedAcknowledged] = useState(false); + // A rescan anyway was asked for and has not finished yet. + const awaitingRescanAnyway = useRef(false); const deleted = summary?.deleted ?? 0; const missing = summary?.missing ?? 0; const legacy = summary?.legacy ?? 0; const libraryWarning = summary && !summary.libraryCheck.ok ? summary.libraryCheck.message : null; - // Missing videos cannot be purged while the folder looks unmounted, so with - // no deleted ones there would be nothing the dialog could offer. - const nothingToPurge = deleted === 0 && (missing === 0 || libraryWarning !== null); + // The failing folder holds missing videos, so they are in doubt. + const missingBlocked = summary?.missingActionsBlocked ?? false; + // Missing videos cannot be purged while that holds, so with no deleted ones + // there would be nothing the dialog could offer. + const nothingToPurge = deleted === 0 && (missing === 0 || missingBlocked); const archiveRemovalDisabledReason = summary?.downloadsActive ? 'Unavailable while a download is running.' : null; - const canRescanAnyway = Boolean(onRescanAnyway) && summary !== null && !summary.libraryCheck.ok - && EMPTY_FOLDER_REASONS.includes(summary.libraryCheck.reason ?? ''); + // The folder may simply have been emptied on purpose: the user can say so. + const folderLooksEmpty = libraryWarning !== null && EMPTY_FOLDER_REASONS.includes(summary?.libraryCheck.reason ?? ''); + const canRescanAnyway = Boolean(onRescanAnyway) && folderLooksEmpty; + const needsEmptiedAcknowledgement = missingBlocked && folderLooksEmpty; // A rescan that just finished may have marked videos missing: reload the counts. const wasRescanRunning = useRef(rescanRunning); useEffect(() => { - if (wasRescanRunning.current && !rescanRunning) void refetch(); + if (wasRescanRunning.current && !rescanRunning) { + void refetch(); + if (awaitingRescanAnyway.current) { + awaitingRescanAnyway.current = false; + setSuccessMessage(RESCAN_FINISHED); + } + } wasRescanRunning.current = rescanRunning; }, [rescanRunning, refetch]); @@ -61,19 +79,32 @@ function RemovedVideosCard({ token, onRescanAnyway, rescanRunning = false }: Rem if (!onRescanAnyway) return; setSuccessMessage(null); setRescanAnywayError(null); + awaitingRescanAnyway.current = true; const { started } = await onRescanAnyway(); setRescanAnywayOpen(false); - if (started) { - setSuccessMessage('Rescan started. When it finishes, the missing videos can be marked as deleted here.'); - } else { + if (!started) { + awaitingRescanAnyway.current = false; + // A refused start flips the rescan's running state on and off, which + // may already have reported it finished. + setSuccessMessage(null); setRescanAnywayError(RESCAN_NOT_STARTED); + } else if (awaitingRescanAnyway.current) { + // Unless a quick rescan already finished and said so. + setSuccessMessage(RESCAN_STARTED); } }; + const openMarkAll = () => { + setEmptiedAcknowledged(false); + setMarkAllOpen(true); + }; + const handleMarkAll = async () => { setSuccessMessage(null); setRescanAnywayError(null); - const result = await markAllMissingDeleted(); + // This result is the news now; a rescan finishing later must not replace it. + awaitingRescanAnyway.current = false; + const result = await markAllMissingDeleted({ ignoreLibraryCheck: needsEmptiedAcknowledgement }); setMarkAllOpen(false); if (result) setSuccessMessage(`Marked ${result.updated} ${videos(result.updated)} as deleted.`); }; @@ -81,6 +112,7 @@ function RemovedVideosCard({ token, onRescanAnyway, rescanRunning = false }: Rem const handleEmptyTrash = async (options: EmptyTrashOptions) => { setSuccessMessage(null); setRescanAnywayError(null); + awaitingRescanAnyway.current = false; const result = await emptyTrash(options); setEmptyTrashOpen(false); if (!result) return; @@ -110,11 +142,15 @@ function RemovedVideosCard({ token, onRescanAnyway, rescanRunning = false }: Rem )} {libraryWarning && ( - <Alert severity="warning"> - {libraryWarning} Marking or emptying missing videos is disabled until it is back. - {canRescanAnyway && ( - ' If the folder really is empty because you removed the files yourself, you can rescan anyway and then mark the missing videos as deleted.' - )} + <Alert severity="warning" className="break-words"> + {missingBlocked + ? folderLooksEmpty + ? `${libraryWarning} Missing videos stored there cannot be emptied from the trash until it is back;` + + ' Mark all missing as deleted asks you to confirm that you removed the files yourself.' + + (canRescanAnyway ? ' Rescan anyway first if some of the videos stored there still show as available.' : '') + : `${libraryWarning} Marking or emptying missing videos is disabled until it is back.` + : `${libraryWarning} Videos stored there are not being marked missing until it is back.` + + (canRescanAnyway ? ' If you removed the files yourself, rescan anyway to mark them missing.' : '')} </Alert> )} {error && <Alert severity="error">{error}</Alert>} @@ -125,8 +161,8 @@ function RemovedVideosCard({ token, onRescanAnyway, rescanRunning = false }: Rem <div className="flex flex-wrap gap-2"> <Button variant="outlined" - disabled={!summary || missing === 0 || libraryWarning !== null || actionPending} - onClick={() => setMarkAllOpen(true)} + disabled={!summary || missing === 0 || (missingBlocked && !folderLooksEmpty) || rescanRunning || actionPending} + onClick={openMarkAll} > Mark all missing as deleted </Button> @@ -160,22 +196,42 @@ function RemovedVideosCard({ token, onRescanAnyway, rescanRunning = false }: Rem onClose={() => setRescanAnywayOpen(false)} onConfirm={() => { void handleRescanAnyway(); }} pending={rescanRunning} + folderMessage={libraryWarning} /> <Dialog open={markAllOpen} onClose={() => setMarkAllOpen(false)} maxWidth="sm" fullWidth> <DialogTitle>Mark all missing videos as deleted</DialogTitle> <DialogContent> - <Typography variant="body2"> - {missing} missing {videos(missing)} will be marked as deleted and hidden from the Videos page by default. - Their records are kept, so they can still be re-downloaded, and if a file turns up again the rescan - marks it downloaded. Only do this if the files are really gone, not sitting on an unmounted drive. - </Typography> + <div className="space-y-3"> + <Typography variant="body2"> + {missing === 1 + ? '1 missing video will be marked as deleted and hidden from the Videos page by default. Its record is kept, so it can still be re-downloaded, and if its file turns up again the rescan marks it downloaded.' + : `${missing} missing videos will be marked as deleted and hidden from the Videos page by default. Their records are kept, so they can still be re-downloaded, and if a file turns up again the rescan marks it downloaded.`} + {' '}Only do this if the files are really gone, not sitting on an unmounted drive. + </Typography> + {needsEmptiedAcknowledgement && ( + <> + <Alert severity="warning" className="break-words">{libraryWarning}</Alert> + <FormControlLabel + control={ + <Checkbox checked={emptiedAcknowledged} onChange={(event) => setEmptiedAcknowledged(event.target.checked)} /> + } + label={EMPTIED_MYSELF} + /> + </> + )} + </div> </DialogContent> <DialogActions> <Button onClick={() => setMarkAllOpen(false)} variant="contained" color="primary" autoFocus> Cancel </Button> - <Button onClick={handleMarkAll} variant="outlined" color="warning" disabled={actionPending}> + <Button + onClick={handleMarkAll} + variant="outlined" + color="warning" + disabled={actionPending || (needsEmptiedAcknowledgement && !emptiedAcknowledged)} + > Mark {missing} as deleted </Button> </DialogActions> @@ -186,7 +242,7 @@ function RemovedVideosCard({ token, onRescanAnyway, rescanRunning = false }: Rem onClose={() => setEmptyTrashOpen(false)} onConfirm={handleEmptyTrash} counts={{ deleted, missing }} - missingDisabledReason={libraryWarning} + missingDisabledReason={missingBlocked ? libraryWarning : null} archiveRemovalDisabledReason={archiveRemovalDisabledReason} pending={actionPending} /> diff --git a/client/src/components/Settings/components/RescanAnywayDialog.tsx b/client/src/components/Settings/components/RescanAnywayDialog.tsx index e7846e33b..bd5ca6fa4 100644 --- a/client/src/components/Settings/components/RescanAnywayDialog.tsx +++ b/client/src/components/Settings/components/RescanAnywayDialog.tsx @@ -14,27 +14,29 @@ interface RescanAnywayDialogProps { onClose: () => void; onConfirm: () => void; pending: boolean; + // The library check's message naming the folder that looks empty, or null. + folderMessage: string | null; } // Confirms a rescan that skips the unmounted-drive check: the escape for a -// downloads folder the user emptied on purpose. -const RescanAnywayDialog: React.FC<RescanAnywayDialogProps> = ({ open, onClose, onConfirm, pending }) => ( +// downloads folder (or subfolder) the user emptied on purpose. +const RescanAnywayDialog: React.FC<RescanAnywayDialogProps> = ({ open, onClose, onConfirm, pending, folderMessage }) => ( <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <DialogTitle>Rescan anyway</DialogTitle> <DialogContent> <div className="space-y-3"> - <Typography variant="body2"> - Youtarr is not marking videos missing because the downloads folder looks unmounted. Only continue if you - removed the files yourself and the downloads folder really is empty. If a drive or network share is - unmounted, mount it instead. + <Typography variant="body2" className="break-words"> + {folderMessage ?? 'The downloads folder looks empty.'} Youtarr is not marking the videos stored there + missing, in case a drive or network share is unmounted. Only continue if you removed the files yourself. + If a drive or network share is unmounted, mount it instead. </Typography> <Typography variant="body2" color="text.secondary"> Every downloaded video whose file is not found will be marked missing. You can then mark them as deleted from this card. </Typography> <Alert severity="warning"> - Nothing on disk is touched, but with a drive unmounted this would mark your whole library missing. + Nothing on disk is touched, but with a drive unmounted this would mark every video on it missing. </Alert> </div> </DialogContent> diff --git a/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx b/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx index bd09b8166..f74bb6eef 100644 --- a/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx +++ b/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx @@ -61,7 +61,7 @@ describe('EmptyTrashDialog', () => { expect(screen.getByText('They disappear from the Videos page and from Download History.')).toBeInTheDocument(); expect(screen.getByText('Their channel and playlist pages show them as not downloaded.')).toBeInTheDocument(); expect(screen.getByText('Watch history Youtarr synced for them from your media servers is removed.')).toBeInTheDocument(); - expect(screen.getByText("Nothing on disk is touched. If a missing video's file turns up later, Youtarr will not know about it; download it again instead.")).toBeInTheDocument(); + expect(screen.getByText("No files in your downloads folder are touched. If a missing video's file turns up later, Youtarr will not know about it; download it again instead.")).toBeInTheDocument(); expect(screen.getByText('This cannot be undone.')).toBeInTheDocument(); expect(screen.getByText('Automatic channel and playlist downloads keep skipping them, as they do now.')).toBeInTheDocument(); expect(screen.getByText('Automatic downloads treat them as new and may download them again at the next run.')).toBeInTheDocument(); @@ -91,11 +91,11 @@ describe('EmptyTrashDialog', () => { }); test('disables the missing choices and shows why while the downloads folder looks unmounted', () => { - renderDialog({ missingDisabledReason: 'The downloads folder /d is empty.' }); + renderDialog({ missingDisabledReason: 'The downloads folder is empty.' }); expect(screen.getByRole('radio', { name: 'Missing (4)' })).toBeDisabled(); expect(screen.getByRole('radio', { name: 'Both (16)' })).toBeDisabled(); - expect(screen.getByText('The downloads folder /d is empty.')).toBeInTheDocument(); + expect(screen.getByText('The downloads folder is empty.')).toBeInTheDocument(); }); test('disables the missing choices when there are no missing videos', () => { diff --git a/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx b/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx index 7c104d34e..0edef84e6 100644 --- a/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx +++ b/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen, waitFor } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import RemovedVideosCard from '../RemovedVideosCard'; @@ -19,13 +19,14 @@ const summary = (overrides: Partial<RemovedVideosSummary> = {}): RemovedVideosSu missing: 4, legacy: 0, libraryCheck: { ok: true, reason: null, path: null, message: null }, + missingActionsBlocked: false, downloadsActive: false, ...overrides, }); type CardProps = Partial<React.ComponentProps<typeof RemovedVideosCard>>; -const EMPTY_ROOT = { ok: false, reason: 'empty', path: '/d', message: 'The downloads folder /d is empty.' }; +const EMPTY_ROOT = { ok: false, reason: 'empty', path: '/d', message: 'The downloads folder is empty.' }; const rescanThatStarts = () => jest.fn().mockResolvedValue({ started: true }); function setup( @@ -65,20 +66,64 @@ describe('RemovedVideosCard', () => { }); test('disables the missing action with the warning while the downloads folder looks unmounted', () => { - setup(summary({ libraryCheck: { ok: false, reason: 'empty', path: '/d', message: 'The downloads folder /d is empty.' } })); - expect(screen.getByText('The downloads folder /d is empty. Marking or emptying missing videos is disabled until it is back.')).toBeInTheDocument(); + setup(summary({ libraryCheck: { ok: false, reason: 'missing', path: '/d', message: 'The downloads folder does not exist.' }, missingActionsBlocked: true })); + expect(screen.getByText('The downloads folder does not exist. Marking or emptying missing videos is disabled until it is back.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Mark all missing as deleted' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeEnabled(); }); test('offers Rescan anyway while the downloads folder looks empty', () => { setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway: rescanThatStarts(), rescanRunning: false }); - expect(screen.getByText(/If the folder really is empty because you removed the files yourself/)).toBeInTheDocument(); + expect(screen.getByText(/If you removed the files yourself, rescan anyway to mark them missing\./)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Rescan anyway...' })).toBeEnabled(); }); + test('points to Mark all once the emptied folder\'s videos are missing', () => { + setup(summary({ libraryCheck: EMPTY_ROOT, missingActionsBlocked: true }), {}, { onRescanAnyway: rescanThatStarts(), rescanRunning: false }); + expect(screen.getByText(/Mark all missing as deleted asks you to confirm that you removed the files yourself/)).toBeInTheDocument(); + }); + + test('keeps the missing actions open, without asking, when the failing folder holds no missing videos', async () => { + const { hook, user } = setup(summary({ libraryCheck: EMPTY_ROOT, missingActionsBlocked: false })); + + await user.click(screen.getByRole('button', { name: 'Mark all missing as deleted' })); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Mark 4 as deleted' })); + + expect(hook.markAllMissingDeleted).toHaveBeenCalledWith({ ignoreLibraryCheck: false }); + }); + + test('offers missing videos in Empty trash when the failing folder holds none of them', async () => { + const { user } = setup(summary({ libraryCheck: EMPTY_ROOT, missingActionsBlocked: false })); + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + expect(screen.getByRole('radio', { name: 'Missing (4)' })).toBeEnabled(); + }); + + test('disables Mark all while a rescan is running, so it cannot mark a half-scanned set', () => { + setup(summary(), {}, { onRescanAnyway: rescanThatStarts(), rescanRunning: true }); + expect(screen.getByRole('button', { name: 'Mark all missing as deleted' })).toBeDisabled(); + }); + + test('lets Mark all look past an empty folder once the user says they emptied it', async () => { + const { hook, user } = setup(summary({ libraryCheck: EMPTY_ROOT, missingActionsBlocked: true })); + + await user.click(screen.getByRole('button', { name: 'Mark all missing as deleted' })); + const confirm = screen.getByRole('button', { name: 'Mark 4 as deleted' }); + expect(confirm).toBeDisabled(); + await user.click(screen.getByRole('checkbox', { name: 'I removed these files myself; they are not on an unmounted drive' })); + await user.click(confirm); + + expect(hook.markAllMissingDeleted).toHaveBeenCalledWith({ ignoreLibraryCheck: true }); + }); + + test('speaks of a single missing video in the singular', async () => { + const { user } = setup(summary({ missing: 1 })); + await user.click(screen.getByRole('button', { name: 'Mark all missing as deleted' })); + expect(screen.getByText(/1 missing video will be marked as deleted.*Its record is kept, so it can still be re-downloaded/)).toBeInTheDocument(); + }); + test('does not offer Rescan anyway when the downloads folder is missing', () => { - const missingRoot = { ok: false, reason: 'missing', path: '/d', message: 'The downloads folder /d does not exist.' }; + const missingRoot = { ok: false, reason: 'missing', path: '/d', message: 'The downloads folder does not exist.' }; setup(summary({ libraryCheck: missingRoot }), {}, { onRescanAnyway: rescanThatStarts(), rescanRunning: false }); expect(screen.queryByRole('button', { name: 'Rescan anyway...' })).not.toBeInTheDocument(); }); @@ -95,6 +140,37 @@ describe('RemovedVideosCard', () => { expect(await screen.findByText('Rescan started. When it finishes, the missing videos can be marked as deleted here.')).toBeInTheDocument(); }); + test('says when the rescan anyway has finished, replacing the started message', async () => { + const onRescanAnyway = rescanThatStarts(); + const { user, rerender } = setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway, rescanRunning: false }); + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + expect(await screen.findByText(/^Rescan started\./)).toBeInTheDocument(); + + rerender(<RemovedVideosCard token="tok" onRescanAnyway={onRescanAnyway} rescanRunning />); + rerender(<RemovedVideosCard token="tok" onRescanAnyway={onRescanAnyway} rescanRunning={false} />); + + expect(screen.getByText('Rescan finished. Videos it could not find now count as missing and can be marked as deleted.')).toBeInTheDocument(); + expect(screen.queryByText(/^Rescan started\./)).not.toBeInTheDocument(); + }); + + // The rescan hook flips running on optimistically and off again on a refusal, + // which can land before the refusal itself does. + test('does not claim a rescan finished when it was refused after an optimistic start', async () => { + let refuse: (outcome: { started: boolean }) => void = () => {}; + const onRescanAnyway = jest.fn(() => new Promise<{ started: boolean }>((resolve) => { refuse = resolve; })); + const { user, rerender } = setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway, rescanRunning: false }); + + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + rerender(<RemovedVideosCard token="tok" onRescanAnyway={onRescanAnyway} rescanRunning />); + rerender(<RemovedVideosCard token="tok" onRescanAnyway={onRescanAnyway} rescanRunning={false} />); + await act(async () => refuse({ started: false })); + + expect(screen.getByText('The rescan did not start. The rescan card above shows why.')).toBeInTheDocument(); + expect(screen.queryByText(/^Rescan finished\./)).not.toBeInTheDocument(); + }); + test('does not claim a rescan started when it was refused', async () => { const onRescanAnyway = jest.fn().mockResolvedValue({ started: false }); const { user } = setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway, rescanRunning: false }); @@ -140,7 +216,7 @@ describe('RemovedVideosCard', () => { }); test('disables Empty trash when only missing videos remain and the folder looks unmounted', () => { - setup(summary({ deleted: 0, missing: 5, libraryCheck: EMPTY_ROOT })); + setup(summary({ deleted: 0, missing: 5, libraryCheck: EMPTY_ROOT, missingActionsBlocked: true })); expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeDisabled(); }); @@ -172,7 +248,7 @@ describe('RemovedVideosCard', () => { await user.click(screen.getByRole('button', { name: 'Mark 4 as deleted' })); - expect(hook.markAllMissingDeleted).toHaveBeenCalledTimes(1); + expect(hook.markAllMissingDeleted).toHaveBeenCalledWith({ ignoreLibraryCheck: false }); expect(await screen.findByText('Marked 4 videos as deleted.')).toBeInTheDocument(); }); diff --git a/client/src/components/Settings/components/__tests__/RescanAnywayDialog.test.tsx b/client/src/components/Settings/components/__tests__/RescanAnywayDialog.test.tsx index 522ee015a..a6059ecdf 100644 --- a/client/src/components/Settings/components/__tests__/RescanAnywayDialog.test.tsx +++ b/client/src/components/Settings/components/__tests__/RescanAnywayDialog.test.tsx @@ -9,15 +9,27 @@ const renderDialog = (overrides: Partial<React.ComponentProps<typeof RescanAnywa const onClose = jest.fn(); const onConfirm = jest.fn(); renderWithProviders( - <RescanAnywayDialog open onClose={onClose} onConfirm={onConfirm} pending={false} {...overrides} /> + <RescanAnywayDialog + open + onClose={onClose} + onConfirm={onConfirm} + pending={false} + folderMessage="The subfolder __Kids is empty." + {...overrides} + /> ); return { onClose, onConfirm, user: userEvent.setup() }; }; describe('RescanAnywayDialog', () => { + test('names the folder that looks empty', () => { + renderDialog(); + expect(screen.getByText(/The subfolder __Kids is empty\./)).toBeInTheDocument(); + }); + test('explains when to use it and what it does', () => { renderDialog(); - expect(screen.getByText(/Only continue if you removed the files yourself and the downloads folder really is empty\./)).toBeInTheDocument(); + expect(screen.getByText(/Only continue if you removed the files yourself\./)).toBeInTheDocument(); expect(screen.getByText(/If a drive or network share is unmounted, mount it instead\./)).toBeInTheDocument(); expect(screen.getByText(/Every downloaded video whose file is not found will be marked missing\./)).toBeInTheDocument(); }); diff --git a/client/src/components/Settings/hooks/__tests__/useRemovedVideos.test.ts b/client/src/components/Settings/hooks/__tests__/useRemovedVideos.test.ts index 851c6d77e..267b46f22 100644 --- a/client/src/components/Settings/hooks/__tests__/useRemovedVideos.test.ts +++ b/client/src/components/Settings/hooks/__tests__/useRemovedVideos.test.ts @@ -61,6 +61,22 @@ describe('useRemovedVideos', () => { expect(result.current.actionPending).toBe(false); }); + test('markAllMissingDeleted can ask the server to look past an empty downloads folder', async () => { + axios.post.mockResolvedValueOnce({ data: { updated: 4 } }); + const { result } = renderHook(() => useRemovedVideos('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.markAllMissingDeleted({ ignoreLibraryCheck: true }); + }); + + expect(axios.post).toHaveBeenCalledWith( + '/api/videos/mark-deleted', + { all: true, ignoreLibraryCheck: true }, + { headers: { 'x-access-token': 'tok' } } + ); + }); + test('emptyTrash posts the options and returns the result', async () => { axios.post.mockResolvedValueOnce({ data: { purged: 16, archiveEntriesRemoved: 16, jobsAffected: 3 } }); const { result } = renderHook(() => useRemovedVideos('tok')); @@ -80,7 +96,7 @@ describe('useRemovedVideos', () => { }); test('surfaces the server message when an action is refused', async () => { - axios.post.mockRejectedValueOnce({ response: { status: 409, data: { error: 'The downloads folder /d is empty.', reason: 'library-unavailable' } } }); + axios.post.mockRejectedValueOnce({ response: { status: 409, data: { error: 'The downloads folder is empty.', reason: 'library-unavailable' } } }); const { result } = renderHook(() => useRemovedVideos('tok')); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -90,7 +106,7 @@ describe('useRemovedVideos', () => { }); expect(outcome).toBeNull(); - expect(result.current.actionError).toBe('The downloads folder /d is empty.'); + expect(result.current.actionError).toBe('The downloads folder is empty.'); expect(result.current.actionPending).toBe(false); }); diff --git a/client/src/components/Settings/hooks/useRemovedVideos.ts b/client/src/components/Settings/hooks/useRemovedVideos.ts index 064f46de4..9f8070ed3 100644 --- a/client/src/components/Settings/hooks/useRemovedVideos.ts +++ b/client/src/components/Settings/hooks/useRemovedVideos.ts @@ -14,6 +14,9 @@ export interface RemovedVideosSummary { // Deleted videos removed before Youtarr recorded why (a subset of deleted). legacy: number; libraryCheck: RemovedVideosLibraryCheck; + // The failing library check covers missing videos: marking all of them as + // deleted needs the user's say-so (empty folder only) and emptying them is refused. + missingActionsBlocked: boolean; // A download is running, so emptying the trash with archive removal would be refused. downloadsActive: boolean; } @@ -25,6 +28,12 @@ export interface EmptyTrashOptions { removeFromArchive: boolean; } +export interface MarkAllMissingOptions { + // The user emptied the downloads folder (or a subfolder) on purpose; the + // server honours it only while the check fails because a folder is empty. + ignoreLibraryCheck?: boolean; +} + export interface EmptyTrashResult { purged: number; archiveEntriesRemoved: number; @@ -37,7 +46,7 @@ export interface UseRemovedVideosReturn { error: string | null; refetch: () => Promise<void>; // Both resolve null on failure, with the server's message in actionError. - markAllMissingDeleted: () => Promise<{ updated: number } | null>; + markAllMissingDeleted: (options?: MarkAllMissingOptions) => Promise<{ updated: number } | null>; emptyTrash: (options: EmptyTrashOptions) => Promise<EmptyTrashResult | null>; actionPending: boolean; actionError: string | null; @@ -95,8 +104,12 @@ export function useRemovedVideos(token: string | null): UseRemovedVideosReturn { }, [refetch]); const markAllMissingDeleted = useCallback( - () => runAction( - () => axios.post<{ updated: number }>('/api/videos/mark-deleted', { all: true }, headers()), + ({ ignoreLibraryCheck = false }: MarkAllMissingOptions = {}) => runAction( + () => axios.post<{ updated: number }>( + '/api/videos/mark-deleted', + ignoreLibraryCheck ? { all: true, ignoreLibraryCheck: true } : { all: true }, + headers() + ), 'Failed to mark videos as deleted' ), [runAction, headers] diff --git a/client/src/components/VideosPage/components/MarkDeletedDialog.tsx b/client/src/components/VideosPage/components/MarkDeletedDialog.tsx index b27b7bd8a..f2a2918e1 100644 --- a/client/src/components/VideosPage/components/MarkDeletedDialog.tsx +++ b/client/src/components/VideosPage/components/MarkDeletedDialog.tsx @@ -7,7 +7,7 @@ import { Button, Typography, } from '../../ui'; -import { CloudOff as CloudOffIcon } from '../../../lib/icons'; +import { FileX as MarkDeletedIcon } from 'lucide-react'; interface MarkDeletedDialogProps { open: boolean; @@ -34,7 +34,7 @@ const MarkDeletedDialog: React.FC<MarkDeletedDialogProps> = ({ }) => ( <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <DialogTitle> - <CloudOffIcon size={20} className="shrink-0" /> + <MarkDeletedIcon size={20} className="shrink-0" /> Mark as deleted </DialogTitle> diff --git a/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts b/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts index e3d3cf503..33752c827 100644 --- a/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts +++ b/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts @@ -55,7 +55,7 @@ describe('useMarkDeleted', () => { test('stops at the first failing batch and reports what was marked before it', async () => { axios.post .mockResolvedValueOnce({ data: { updated: 1000 } }) - .mockRejectedValueOnce({ isAxiosError: true, response: { data: { error: 'The downloads folder /d is empty.' } } }); + .mockRejectedValueOnce({ isAxiosError: true, response: { data: { error: 'The downloads folder is empty.' } } }); const { result } = renderHook(() => useMarkDeleted('tok')); let outcome: { updated: number; error: string | null } | null = null; @@ -63,13 +63,13 @@ describe('useMarkDeleted', () => { outcome = await result.current.markDeleted(ids(2500)); }); - expect(outcome).toEqual({ updated: 1000, error: 'The downloads folder /d is empty.' }); + expect(outcome).toEqual({ updated: 1000, error: 'The downloads folder is empty.' }); expect(axios.post).toHaveBeenCalledTimes(2); expect(result.current.loading).toBe(false); }); test('returns the server error message when the request fails', async () => { - axios.post.mockRejectedValueOnce({ isAxiosError: true, response: { data: { error: 'The downloads folder /d is empty.' } } }); + axios.post.mockRejectedValueOnce({ isAxiosError: true, response: { data: { error: 'The downloads folder is empty.' } } }); const { result } = renderHook(() => useMarkDeleted('tok')); let outcome: { updated: number; error: string | null } | null = null; @@ -77,7 +77,7 @@ describe('useMarkDeleted', () => { outcome = await result.current.markDeleted([4]); }); - expect(outcome).toEqual({ updated: 0, error: 'The downloads folder /d is empty.' }); + expect(outcome).toEqual({ updated: 0, error: 'The downloads folder is empty.' }); expect(result.current.loading).toBe(false); }); diff --git a/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts b/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts index 3f001c293..046243d19 100644 --- a/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts +++ b/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts @@ -111,13 +111,13 @@ describe('useVideosData', () => { test('exposes the hidden deleted count and the library warning from the response', async () => { const response = buildResponse([{ id: 1, youtubeId: 'a' }]); response.data.hiddenDeletedCount = 4; - response.data.libraryWarning = { reason: 'empty', path: '/d', message: 'The downloads folder /d is empty.', heldBack: 2 }; + response.data.libraryWarning = { reason: 'empty', path: '/d', message: 'The downloads folder is empty.', heldBack: 2 }; axios.get.mockResolvedValueOnce(response); const { result } = renderHook(() => useVideosData(baseParams)); await waitFor(() => expect(result.current.hiddenDeletedCount).toBe(4)); - expect(result.current.libraryWarning).toEqual({ reason: 'empty', path: '/d', message: 'The downloads folder /d is empty.', heldBack: 2 }); + expect(result.current.libraryWarning).toEqual({ reason: 'empty', path: '/d', message: 'The downloads folder is empty.', heldBack: 2 }); }); test('reports no hidden deleted count and no warning when the response has none', async () => { diff --git a/client/src/components/VideosPage/index.tsx b/client/src/components/VideosPage/index.tsx index c1204ea89..c3374804b 100644 --- a/client/src/components/VideosPage/index.tsx +++ b/client/src/components/VideosPage/index.tsx @@ -4,7 +4,8 @@ import axios from 'axios'; import { useNavigate } from 'react-router-dom'; import { useSwipeable } from 'react-swipeable'; import { Alert, Box, Button, Grid, Snackbar, Typography } from '../ui'; -import { Trash2 as DeleteIcon, Star as RatingIcon, Download as DownloadIcon, CloudOff as CloudOffIcon } from '../../lib/icons'; +import { Trash2 as DeleteIcon, Star as RatingIcon, Download as DownloadIcon } from '../../lib/icons'; +import { FileX as MarkDeletedIcon } from 'lucide-react'; import { useMediaQuery } from '../../hooks/useMediaQuery'; import { useConfig } from '../../hooks/useConfig'; import { useDownloadListingsRefresh } from '../../hooks/useDownloadListingsRefresh'; @@ -48,7 +49,11 @@ interface VideosPageProps { const VIEW_MODE_STORAGE_KEY = 'youtarr:videosPageViewMode'; // Deleted videos are hidden by default; showing them is remembered in this browser. -const DELETED_FILTER_STORAGE_KEY = 'youtarr.videosPage.deletedFilter'; +const DELETED_FILTER_STORAGE_KEY = 'youtarr:videosPageDeletedFilter'; +// Check results that can mean the user emptied the folder on purpose, the only +// ones Maintenance offers "Rescan anyway" for (a mix with an unreadable share is +// reported as unreadable, so it never qualifies). +const EMPTY_FOLDER_REASONS = ['empty', 'subfolder-empty']; const YOUTUBE_CHANNEL_ID_PATTERN = /^UC[a-zA-Z0-9_-]{22}$/; @@ -109,6 +114,9 @@ function VideosPage({ token }: VideosPageProps) { const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({}); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [markDeletedDialogOpen, setMarkDeletedDialogOpen] = useState(false); + // The message the user closed; a different one, or the same one after the + // folder came back and failed again, shows the banner again. + const [dismissedLibraryWarning, setDismissedLibraryWarning] = useState<string | null>(null); const [ratingDialogOpen, setRatingDialogOpen] = useState(false); const [successMessage, setSuccessMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null); @@ -175,6 +183,10 @@ function VideosPage({ token }: VideosPageProps) { useInfiniteScroll, }); + useEffect(() => { + if (!libraryWarning) setDismissedLibraryWarning(null); + }, [libraryWarning]); + const videoMetaRef = useRef<Map<number, VideoSelectionMeta>>(new Map()); useEffect(() => { @@ -357,7 +369,7 @@ function VideosPage({ token }: VideosPageProps) { { id: 'mark-deleted', label: 'Mark as deleted', - icon: <CloudOffIcon size={14} />, + icon: <MarkDeletedIcon size={14} />, intent: 'warning', disabled: (ids) => markDeletedLoading || @@ -571,12 +583,23 @@ function VideosPage({ token }: VideosPageProps) { <Button variant="text" size="sm" onClick={showDeleted}>Show</Button> </Typography> )} - {libraryWarning && ( - <Alert severity="warning" className="mt-2"> - {libraryWarning.message} Videos are not being marked missing until it is back. If your downloads - live on a drive or network share, check that it is mounted. If the folder really is empty on purpose, - you can rescan anyway from{' '} - <Button variant="text" size="sm" onClick={() => navigate('/settings/maintenance')}>Maintenance</Button>. + {libraryWarning && libraryWarning.message !== dismissedLibraryWarning && ( + <Alert severity="warning" className="mt-2 break-words" onClose={() => setDismissedLibraryWarning(libraryWarning.message)}> + {libraryWarning.message} Videos stored there are not being marked missing until it is back; check that + the drive or share is mounted. + {EMPTY_FOLDER_REASONS.includes(libraryWarning.reason) && ( + <> + {' '}If you emptied it on purpose, you can rescan anyway from{' '} + <Button + variant="link" + className="h-auto p-0 align-baseline text-sm" + onClick={() => navigate('/settings/maintenance')} + > + Maintenance + </Button> + . + </> + )} </Alert> )} </div> @@ -808,6 +831,7 @@ function VideosPage({ token }: VideosPageProps) { setModalVideo(null); reloadFromFirstPage(); }} + onMarkedDeleted={reloadFromFirstPage} onProtectionChanged={(youtubeId, isProtected) => { setVideos((prev) => prev.map((v) => diff --git a/client/src/components/__tests__/VideosPage.test.tsx b/client/src/components/__tests__/VideosPage.test.tsx index b16f5cdc7..5af4ce083 100644 --- a/client/src/components/__tests__/VideosPage.test.tsx +++ b/client/src/components/__tests__/VideosPage.test.tsx @@ -116,7 +116,10 @@ jest.mock('../shared/VideoModal', () => ({ return React.createElement('div', { 'data-testid': 'video-modal', 'data-video-title': props.video?.title, - }, props.video?.title); + }, props.video?.title, React.createElement('button', { + type: 'button', + onClick: () => props.onMarkedDeleted?.(props.video?.youtubeId), + }, 'Modal mark as deleted')); } })); @@ -472,7 +475,7 @@ describe('VideosPage Component', () => { await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); expect(axios.get.mock.calls[1][0]).not.toContain('deletedFilter'); - expect(window.localStorage.getItem('youtarr.videosPage.deletedFilter')).toBe('off'); + expect(window.localStorage.getItem('youtarr:videosPageDeletedFilter')).toBe('off'); }); test('shows no hidden note when nothing is hidden', async () => { @@ -485,7 +488,7 @@ describe('VideosPage Component', () => { }); test('remembers that deleted videos were shown', async () => { - window.localStorage.setItem('youtarr.videosPage.deletedFilter', 'off'); + window.localStorage.setItem('youtarr:videosPageDeletedFilter', 'off'); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); render(<VideosPage token={mockToken} />); @@ -499,13 +502,74 @@ describe('VideosPage Component', () => { axios.get.mockResolvedValueOnce({ data: { ...mockPaginatedResponse(mockVideos), - libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder /data/youtube is empty.', heldBack: 2 }, + libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder is empty.', heldBack: 2 }, }, }); render(<VideosPage token={mockToken} />); - expect(await screen.findByText(/The downloads folder \/data\/youtube is empty\. Videos are not being marked missing until it is back\./)).toBeInTheDocument(); + expect(await screen.findByText(/The downloads folder is empty\. Videos stored there are not being marked missing until it is back/)).toBeInTheDocument(); + }); + + test('hides the unmounted-folder warning once dismissed', async () => { + const user = setupUser(); + axios.get.mockResolvedValueOnce({ + data: { + ...mockPaginatedResponse(mockVideos), + libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder is empty.', heldBack: 2 }, + }, + }); + + render(<VideosPage token={mockToken} />); + await screen.findByText(/Videos stored there are not being marked missing/); + + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(screen.queryByText(/Videos stored there are not being marked missing/)).not.toBeInTheDocument(); + }); + + test('shows the warning again when a later problem follows one that cleared', async () => { + const user = setupUser(); + const warned = { + data: { + ...mockPaginatedResponse(mockVideos), + libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder is empty.', heldBack: 2 }, + }, + }; + axios.get + .mockResolvedValueOnce(warned) + .mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }) + .mockResolvedValueOnce(warned); + + render(<VideosPage token={mockToken} />); + await screen.findByText(/Videos stored there are not being marked missing/); + await user.click(screen.getByRole('button', { name: 'Close' })); + + // Each mark from the modal reloads the list: first with the folder back, then failing again. + await user.click(screen.getByRole('button', { name: 'How to Code' })); + await user.click(screen.getByRole('button', { name: 'Modal mark as deleted' })); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + await user.click(screen.getByRole('button', { name: 'Modal mark as deleted' })); + + expect(await screen.findByText(/Videos stored there are not being marked missing/)).toBeInTheDocument(); + }); + + test('does not suggest rescanning anyway when a folder could not be read', async () => { + axios.get.mockResolvedValueOnce({ + data: { + ...mockPaginatedResponse(mockVideos), + libraryWarning: { + reason: 'subfolder-unreadable', path: '/data/youtube/__Kids', + message: 'The subfolder __Music is empty. The subfolder __Kids could not be read.', heldBack: 2, + }, + }, + }); + + render(<VideosPage token={mockToken} />); + + expect(await screen.findByText(/Videos stored there are not being marked missing/)).toBeInTheDocument(); + expect(screen.queryByText(/rescan anyway/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Maintenance' })).not.toBeInTheDocument(); }); test('links the unmounted-folder warning to Maintenance for a folder emptied on purpose', async () => { @@ -513,7 +577,7 @@ describe('VideosPage Component', () => { axios.get.mockResolvedValueOnce({ data: { ...mockPaginatedResponse(mockVideos), - libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder /data/youtube is empty.', heldBack: 2 }, + libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder is empty.', heldBack: 2 }, }, }); @@ -577,6 +641,20 @@ describe('VideosPage Component', () => { expect(screen.getByTestId('video-modal')).toHaveAttribute('data-video-title', 'How to Code'); }); + test('reloads the list after the modal marks a video as deleted', async () => { + const user = setupUser(); + axios.get.mockResolvedValue({ data: mockPaginatedResponse([mockVideos[0]]) }); + + render(<VideosPage token={mockToken} />); + await screen.findByText('How to Code'); + await user.click(screen.getByRole('button', { name: 'How to Code' })); + const callsBefore = axios.get.mock.calls.length; + + await user.click(screen.getByRole('button', { name: 'Modal mark as deleted' })); + + await waitFor(() => expect(axios.get.mock.calls.length).toBe(callsBefore + 1)); + }); + test('renders the channel name as a link to the channel page', async () => { const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponseWithEnabledChannels([mockVideos[0]]) }); diff --git a/client/src/components/shared/AvailabilityChip.tsx b/client/src/components/shared/AvailabilityChip.tsx index 78d3ae370..2e5b18092 100644 --- a/client/src/components/shared/AvailabilityChip.tsx +++ b/client/src/components/shared/AvailabilityChip.tsx @@ -1,11 +1,12 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { Chip, Tooltip } from '../ui'; import { - AlertCircle as ErrorOutlineIcon, CheckCircle as CheckCircleIcon, + CloudOff as MissingIcon, Trash2 as DeletedIcon, } from 'lucide-react'; import { SHARED_THEMED_CHIP_SMALL_STYLE, SHARED_COMPACT_CHIP_OVERRIDES } from './chipStyles'; +import { useMediaQuery } from '../../hooks/useMediaQuery'; import type { RemovalState } from '../../utils/videoRemoval'; export interface AvailabilityChipProps { @@ -46,7 +47,7 @@ const VARIANTS: Record<RemovalState, Variant> = { tooltip: 'Video file not found on disk', color: 'error', variant: 'filled', - icon: <ErrorOutlineIcon size={12} />, + icon: <MissingIcon size={12} data-testid="CloudOffIcon" />, style: { ...SHARED_THEMED_CHIP_SMALL_STYLE, backgroundColor: 'var(--destructive)', @@ -70,17 +71,31 @@ const VARIANTS: Record<RemovalState, Variant> = { function AvailabilityChip({ state, tooltip, label, compact = false }: AvailabilityChipProps) { const variant = VARIANTS[state]; const style = compact ? { ...variant.style, ...SHARED_COMPACT_CHIP_OVERRIDES } : variant.style; + // On a touch device a tap opens the tooltip (the only way to read the reason + // on a phone), so it must not also select or expand the row underneath. + // Mouse clicks still pass through, as rows that expand on click expect, and + // so do taps where the tooltip does not open on tap: the same query as Tooltip. + const lastPointerType = useRef('mouse'); + const tapOpensTooltip = useMediaQuery('(pointer: coarse)'); return ( <Tooltip title={tooltip ?? variant.tooltip} enterTouchDelay={0}> - <Chip - size="small" - icon={variant.icon} - label={label ?? variant.label} - color={variant.color} - variant={variant.variant} - style={style} - /> + <span + className="inline-flex" + onPointerDown={(event) => { lastPointerType.current = event.pointerType; }} + onClick={(event) => { + if (tapOpensTooltip && lastPointerType.current !== 'mouse') event.stopPropagation(); + }} + > + <Chip + size="small" + icon={variant.icon} + label={label ?? variant.label} + color={variant.color} + variant={variant.variant} + style={style} + /> + </span> </Tooltip> ); } diff --git a/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx b/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx index 13d32bd6d..5865f279c 100644 --- a/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx +++ b/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx @@ -40,12 +40,10 @@ function ToggleChipFilter({ : value === 'exclude' ? excludeLabel : (offIsActive && offLabel) || inactiveLabel; - const color = value === 'only' - ? 'primary' - : value === 'exclude' - ? 'warning' - : offIsActive ? 'primary' : 'default'; const isResting = value === restingValue; + // A chip at rest is not filtering anything the user chose, so it looks + // neutral even when its resting label reads "Hide: ...". + const color = isResting ? 'default' : value === 'exclude' ? 'warning' : 'primary'; const variant = isResting ? 'outlined' : 'filled'; // Pressed means "moved off the resting value"; the label carries which mode. const ariaPressed = !isResting; diff --git a/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx b/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx index 172a87e32..c1d38a2d5 100644 --- a/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx +++ b/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx @@ -136,6 +136,14 @@ describe('ToggleChipFilter', () => { expect(screen.queryByLabelText('Remove')).not.toBeInTheDocument(); }); + test('looks neutral while resting, like other chips that are not filtering', () => { + renderWithProviders( + <ToggleChipFilter {...restingProps} value="exclude" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveClass('chip-default'); + expect(screen.getByRole('button')).not.toHaveClass('chip-warning'); + }); + test('announces the resting state as not pressed and the active off state as pressed', () => { const { unmount } = renderWithProviders( <ToggleChipFilter {...restingProps} value="exclude" onChange={jest.fn()} /> diff --git a/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx b/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx index 58091058b..0237d9519 100644 --- a/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx +++ b/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx @@ -685,6 +685,62 @@ describe('VideoModal', () => { }); }); + describe('handleMarkDeleted', () => { + const missingVideo: VideoModalData = { + ...baseVideo, + status: 'missing', + isDownloaded: false, + removal: { reason: 'not_found', at: '2026-10-18T21:41:04.000Z', detail: null }, + }; + + test('marks a missing video as deleted and tells the page', async () => { + const onMarkedDeleted = jest.fn(); + axios.post.mockResolvedValueOnce({ data: { updated: 1 } }); + + renderModal({ video: missingVideo, onMarkedDeleted }); + await userEvent.click(screen.getByRole('button', { name: 'Mark as deleted' })); + + await waitFor(() => { + expect(axios.post).toHaveBeenCalledWith( + '/api/videos/mark-deleted', + { videoIds: [42] }, + { headers: { 'x-access-token': 'test-token' } } + ); + }); + expect(await screen.findByText('Marked as deleted')).toBeInTheDocument(); + expect(screen.getByText('Deleted')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Mark as deleted' })).not.toBeInTheDocument(); + expect(onMarkedDeleted).toHaveBeenCalledWith('test123'); + }); + + test('says nothing changed when the video was no longer missing', async () => { + const onMarkedDeleted = jest.fn(); + axios.post.mockResolvedValueOnce({ data: { updated: 0 } }); + + renderModal({ video: missingVideo, onMarkedDeleted }); + await userEvent.click(screen.getByRole('button', { name: 'Mark as deleted' })); + + expect(await screen.findByText('Not marked: this video is no longer missing.')).toBeInTheDocument(); + expect(screen.queryByText('Marked as deleted')).not.toBeInTheDocument(); + expect(onMarkedDeleted).not.toHaveBeenCalled(); + }); + + test('is not offered for a video that is already deleted', () => { + renderModal({ video: { ...missingVideo, status: 'deleted', removal: { reason: 'user_deleted', at: null, detail: null } } }); + expect(screen.queryByRole('button', { name: 'Mark as deleted' })).not.toBeInTheDocument(); + }); + + test('shows the server message when marking fails', async () => { + axios.post.mockRejectedValueOnce({ response: { data: { error: 'Failed to mark videos as deleted' } } }); + + renderModal({ video: missingVideo }); + await userEvent.click(screen.getByRole('button', { name: 'Mark as deleted' })); + + expect(await screen.findByText('Failed to mark videos as deleted')).toBeInTheDocument(); + expect(screen.getByText('Missing')).toBeInTheDocument(); + }); + }); + describe('handleRatingApply', () => { test('calls rating API and fires onRatingChanged on success', async () => { const onRatingChanged = jest.fn(); diff --git a/client/src/components/shared/VideoModal/components/VideoActions.tsx b/client/src/components/shared/VideoModal/components/VideoActions.tsx index 99a82e4c6..3a84fb0e3 100644 --- a/client/src/components/shared/VideoModal/components/VideoActions.tsx +++ b/client/src/components/shared/VideoModal/components/VideoActions.tsx @@ -9,7 +9,7 @@ import { Shield as ShieldIcon, ShieldCheck as ShieldOutlinedIcon, } from '../../../../lib/icons'; -import { Youtube as YoutubeIcon } from 'lucide-react'; +import { FileX as MarkDeletedIcon, Youtube as YoutubeIcon } from 'lucide-react'; import { VideoModalData } from '../types'; import { getMediaTypeInfo, @@ -30,7 +30,9 @@ interface VideoActionsProps { onIgnoreToggle: () => void; onDownloadClick: () => void; onRatingClick: () => void; + onMarkDeleted: () => void; protectionLoading: boolean; + markDeletedPending: boolean; isMobile: boolean; allowIgnore?: boolean; } @@ -42,7 +44,9 @@ function VideoActions({ onIgnoreToggle, onDownloadClick, onRatingClick, + onMarkDeleted, protectionLoading, + markDeletedPending, isMobile, allowIgnore = true, }: VideoActionsProps) { @@ -51,6 +55,7 @@ function VideoActions({ const showDownload = !isDownloadedAndPresent && video.status !== 'queued' && video.status !== 'downloading'; const showIgnore = (!isDownloadedAndPresent || video.isIgnored) && allowIgnore; const showDelete = isDownloadedAndPresent; + const showMarkDeleted = video.status === 'missing' && video.databaseId !== null; const mediaTypeInfo = getMediaTypeInfo(video.mediaType); const statusLabel = video.status === 'downloaded' ? 'Available' : getStatusLabel(video.status); const youtubeUrl = `${YOUTUBE_URL_BASE}${video.youtubeId}`; @@ -223,6 +228,23 @@ function VideoActions({ <Box style={{ flex: 1, minWidth: isMobile ? 0 : 12 }} /> + {showMarkDeleted && ( + <Tooltip title="The file is really gone: hide this video from the Videos page by default"> + <Button + size="small" + variant="outlined" + color="warning" + onClick={onMarkDeleted} + disabled={markDeletedPending} + aria-label="Mark as deleted" + startIcon={useIconOnlyActions ? undefined : <MarkDeletedIcon size={16} />} + style={actionButtonStyle} + > + {useIconOnlyActions ? <MarkDeletedIcon size={16} /> : 'Mark as deleted'} + </Button> + </Tooltip> + )} + {showDelete && ( <Tooltip title="Delete video"> <Button diff --git a/client/src/components/shared/VideoModal/components/VideoPlayer.tsx b/client/src/components/shared/VideoModal/components/VideoPlayer.tsx index 2c5e7d9db..28422efb7 100644 --- a/client/src/components/shared/VideoModal/components/VideoPlayer.tsx +++ b/client/src/components/shared/VideoModal/components/VideoPlayer.tsx @@ -24,6 +24,13 @@ interface VideoPlayerProps { const DEFAULT_LANDSCAPE_ASPECT_RATIO = 16 / 9; const DEFAULT_PORTRAIT_ASPECT_RATIO = 9 / 16; +// Labels over an arbitrary thumbnail: white with a dark shadow reads on light and dark images. +const OVERLAY_TEXT_SHADOW = '0 1px 3px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)'; +const OVERLAY_ICON_SHADOW = 'drop-shadow(0 1px 3px rgba(0,0,0,0.8))'; +// A phone's player is too short for the full-size icon, label, and button stacked. +const MOBILE_OVERLAY_ICON_SIZE = 32; +const MOBILE_DOWNLOAD_BUTTON_SIZE = '56px'; +const MOBILE_DOWNLOAD_ICON_SIZE = '32px'; function VideoPlayer({ video, token, onDownloadClick, isMobile }: VideoPlayerProps) { const [playbackStarted, setPlaybackStarted] = useState(false); @@ -176,7 +183,7 @@ function VideoPlayer({ video, token, onDownloadClick, isMobile }: VideoPlayerPro {streamError ? ( <> <CloudOffIcon size={48} color="white" /> - <Typography variant="body2" sx={{ color: 'common.white' }}> + <Typography variant="body2" sx={{ color: 'white' }}> {playbackType === 'audio' ? 'Unable to stream audio' : 'Unable to stream video'} </Typography> <Link @@ -207,18 +214,18 @@ function VideoPlayer({ video, token, onDownloadClick, isMobile }: VideoPlayerPro ) : video.status === 'ignored' ? ( <> <BlockIcon size={48} color="white" /> - <Typography variant="body1" sx={{ color: 'common.white', fontWeight: 500 }}> + <Typography variant="body1" sx={{ color: 'white', fontWeight: 500 }}> Ignored </Typography> </> ) : isRemovedStatus(video.status) ? ( <> {video.status === 'deleted' ? ( - <DeletedIcon size={48} color="var(--muted-foreground)" /> + <DeletedIcon size={isMobile ? MOBILE_OVERLAY_ICON_SIZE : 48} color="white" style={{ filter: OVERLAY_ICON_SHADOW }} /> ) : ( - <WarningAmberIcon size={48} color="var(--warning)" /> + <WarningAmberIcon size={isMobile ? MOBILE_OVERLAY_ICON_SIZE : 48} color="var(--warning)" style={{ filter: OVERLAY_ICON_SHADOW }} /> )} - <Typography variant="body1" sx={{ color: 'common.white', fontWeight: 500 }}> + <Typography variant="body1" sx={{ color: 'white', fontWeight: 600, textShadow: OVERLAY_TEXT_SHADOW }}> {video.status === 'deleted' ? 'File Deleted' : 'File Missing'} </Typography> <IconButton @@ -227,12 +234,17 @@ function VideoPlayer({ video, token, onDownloadClick, isMobile }: VideoPlayerPro style={{ backgroundColor: 'var(--video-modal-overlay-action-background, var(--media-overlay-background-strong))', color: 'var(--video-modal-overlay-action-foreground, var(--media-overlay-foreground))', - width: 'var(--video-modal-overlay-download-size, 90px)', - height: 'var(--video-modal-overlay-download-size, 90px)', + width: isMobile ? MOBILE_DOWNLOAD_BUTTON_SIZE : 'var(--video-modal-overlay-download-size, 90px)', + height: isMobile ? MOBILE_DOWNLOAD_BUTTON_SIZE : 'var(--video-modal-overlay-download-size, 90px)', zIndex: 2, }} > - <DownloadIcon style={{ width: 'var(--video-modal-overlay-download-icon-size, 50px)', height: 'var(--video-modal-overlay-download-icon-size, 50px)' }} /> + <DownloadIcon + style={{ + width: isMobile ? MOBILE_DOWNLOAD_ICON_SIZE : 'var(--video-modal-overlay-download-icon-size, 50px)', + height: isMobile ? MOBILE_DOWNLOAD_ICON_SIZE : 'var(--video-modal-overlay-download-icon-size, 50px)', + }} + /> </IconButton> </> ) : video.status === 'members_only' ? ( diff --git a/client/src/components/shared/VideoModal/components/VideoTechnical.tsx b/client/src/components/shared/VideoModal/components/VideoTechnical.tsx index 3c3df949f..51e4071ea 100644 --- a/client/src/components/shared/VideoModal/components/VideoTechnical.tsx +++ b/client/src/components/shared/VideoModal/components/VideoTechnical.tsx @@ -57,15 +57,21 @@ function formatDownloadedResolution( interface DetailRowProps { label: string; value: string; + // A sentence rather than a short value: left-aligned so it reads on a phone. + prose?: boolean; } -function DetailRow({ label, value }: DetailRowProps) { +function DetailRow({ label, value, prose = false }: DetailRowProps) { return ( <Box style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, paddingTop: 4, paddingBottom: 4 }}> - <Typography variant="body2" color="text.secondary"> + <Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0 }}> {label} </Typography> - <Typography variant="body2" color="text.primary" sx={{ fontWeight: 500, textAlign: 'right', wordBreak: 'break-word' }}> + <Typography + variant="body2" + color="text.primary" + sx={{ fontWeight: 500, textAlign: prose ? 'left' : 'right', wordBreak: 'break-word' }} + > {value} </Typography> </Box> @@ -150,8 +156,9 @@ function VideoTechnical({ video, metadata, loading }: VideoTechnicalProps) { const addedDate = formatDate(video.addedAt); const showFileDetails = video.isDownloaded || isRemovedStatus(video.status); // Why there is no file, in plain words, so nobody has to find the tooltip. + // The file row below shows the path, so the sentence leaves it out. const removalStatus = isRemovedStatus(video.status) - ? describeRemoval(video.status, video.removal ?? undefined, video.filePath) + ? describeRemoval(video.status, video.removal ?? undefined) : null; return ( @@ -207,7 +214,7 @@ function VideoTechnical({ video, metadata, loading }: VideoTechnicalProps) { <AccordionSummary>File Details</AccordionSummary> <AccordionDetails> <Box> - {removalStatus && <DetailRow label="Status" value={removalStatus} />} + {removalStatus && <DetailRow label="Status" value={removalStatus} prose />} {hasVideoFile && ( <FileRow label="Video" diff --git a/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx b/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx index 8584dae93..7688b6341 100644 --- a/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx +++ b/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx @@ -134,6 +134,18 @@ describe('VideoPlayer', () => { expect(screen.queryByRole('button', { name: 'Play video' })).not.toBeInTheDocument(); }); + // The label sits on an arbitrary thumbnail, so it must be white with a shadow, + // never the page's text color. + test('labels a removed file in white so it reads on any thumbnail', () => { + renderPlayer({ status: 'deleted', isDownloaded: true }); + expect(screen.getByText('File Deleted')).toHaveStyle({ color: 'rgb(255, 255, 255)' }); + }); + + test('uses a smaller re-download button on phones, leaving room for the label', () => { + renderPlayer({ status: 'deleted', isDownloaded: true }, { isMobile: true }); + expect(screen.getByRole('button', { name: /re-download video/i })).toHaveStyle({ width: '56px', height: '56px' }); + }); + test('renders "members only" state with lock icon and no download button', () => { renderPlayer({ status: 'members_only', diff --git a/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx b/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx index 0f9e92e68..b0da1a7bb 100644 --- a/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx +++ b/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx @@ -195,17 +195,26 @@ describe('VideoTechnical', () => { }); expect(screen.getByText('File Details')).toBeInTheDocument(); expect(screen.getByText('Status')).toBeInTheDocument(); - expect(screen.getByText('Deleted by automatic cleanup (older than 30 days). It can be downloaded again from its channel or playlist page.')).toBeInTheDocument(); + expect(screen.getByText('Deleted by automatic cleanup (older than 30 days). It can be downloaded again.')).toBeInTheDocument(); }); - test('tells a missing video from a deleted one in the Status row', () => { + test('tells a missing video from a deleted one in the Status row, leaving the path to the file row below', () => { renderTech({ isDownloaded: false, status: 'missing', filePath: '/usr/src/app/data/Channel/video.mp4', removal: { reason: 'not_found', at: null, detail: null }, }); - expect(screen.getByText(/Youtarr expected a file at .*video\.mp4 and could not find it\./)).toBeInTheDocument(); + expect(screen.getByText(/^Youtarr expected a file and could not find it\./)).toBeInTheDocument(); + }); + + test('lays the Status sentence out left-aligned, as prose', () => { + renderTech({ + isDownloaded: false, + status: 'deleted', + removal: { reason: 'user_deleted', at: null, detail: null }, + }); + expect(screen.getByText(/^Deleted in Youtarr\./)).toHaveStyle({ textAlign: 'left' }); }); test('has no Status row for a present file', () => { diff --git a/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts b/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts index e7de79c08..00c392ce1 100644 --- a/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts +++ b/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts @@ -23,6 +23,7 @@ interface UseVideoModalActionsParams { video: VideoModalData; token: string | null; onVideoDeleted?: (youtubeId: string) => void; + onMarkedDeleted?: (youtubeId: string) => void; onProtectionChanged?: (youtubeId: string, isProtected: boolean) => void; onIgnoreChanged?: (youtubeId: string, isIgnored: boolean) => void; onDownloadQueued?: (youtubeId: string) => void; @@ -37,12 +38,14 @@ interface UseVideoModalActionsReturn { downloadDialogOpen: boolean; ratingDialogOpen: boolean; protectionLoading: boolean; + markDeletedPending: boolean; setDeleteDialogOpen: (open: boolean) => void; setDownloadDialogOpen: (open: boolean) => void; setRatingDialogOpen: (open: boolean) => void; handleProtectionToggle: () => Promise<void>; handleDeleteConfirm: () => Promise<void>; handleIgnoreToggle: () => Promise<void>; + handleMarkDeleted: () => Promise<void>; handleDownloadConfirm: (settings: DownloadSettings | null) => Promise<void>; handleRatingApply: (rating: string | null) => Promise<void>; handleSnackbarClose: () => void; @@ -59,6 +62,7 @@ export function useVideoModalActions({ video, token, onVideoDeleted, + onMarkedDeleted, onProtectionChanged, onIgnoreChanged, onDownloadQueued, @@ -93,6 +97,7 @@ export function useVideoModalActions({ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); const [ratingDialogOpen, setRatingDialogOpen] = useState(false); + const [markDeletedPending, setMarkDeletedPending] = useState(false); const [snackbar, setSnackbar] = useState<SnackbarState>({ open: false, message: '', @@ -192,6 +197,36 @@ export function useVideoModalActions({ } }, [localVideo.isIgnored, localVideo.youtubeId, localVideo.channelId, token, showSnackbar, onIgnoreChanged]); + // A missing video whose file is really gone becomes deleted; the record stays. + const handleMarkDeleted = useCallback(async () => { + if (!localVideo.databaseId) return; + setMarkDeletedPending(true); + try { + const response = await axios.post<{ updated: number }>( + '/api/videos/mark-deleted', + { videoIds: [localVideo.databaseId] }, + { headers: { 'x-access-token': token || '' } } + ); + // Zero rows: the file came back, or the video was marked elsewhere, while + // the modal was open. Say so rather than claim a change that did not happen. + if (response.data.updated === 0) { + showSnackbar('Not marked: this video is no longer missing.', 'error'); + return; + } + setLocalVideo((prev) => ({ + ...prev, + status: 'deleted', + removal: { reason: 'marked_deleted', at: new Date().toISOString(), detail: null }, + })); + onMarkedDeleted?.(localVideo.youtubeId); + showSnackbar('Marked as deleted', 'success'); + } catch (err: unknown) { + showSnackbar(extractErrorMessage(err, 'Failed to mark the video as deleted'), 'error'); + } finally { + setMarkDeletedPending(false); + } + }, [localVideo.databaseId, localVideo.youtubeId, token, showSnackbar, onMarkedDeleted]); + const handleDownloadConfirm = useCallback(async (settings: DownloadSettings | null) => { setDownloadDialogOpen(false); if (snapshot.videos[localVideo.youtubeId]?.state) { @@ -261,12 +296,14 @@ export function useVideoModalActions({ downloadDialogOpen, ratingDialogOpen, protectionLoading: protection.loading, + markDeletedPending, setDeleteDialogOpen, setDownloadDialogOpen, setRatingDialogOpen, handleProtectionToggle, handleDeleteConfirm, handleIgnoreToggle, + handleMarkDeleted, handleDownloadConfirm, handleRatingApply, handleSnackbarClose, diff --git a/client/src/components/shared/VideoModal/index.tsx b/client/src/components/shared/VideoModal/index.tsx index 1b54a6719..4ed911aff 100644 --- a/client/src/components/shared/VideoModal/index.tsx +++ b/client/src/components/shared/VideoModal/index.tsx @@ -37,6 +37,7 @@ function VideoModal({ video, token, onVideoDeleted, + onMarkedDeleted, onProtectionChanged, onIgnoreChanged, onDownloadQueued, @@ -54,12 +55,14 @@ function VideoModal({ downloadDialogOpen, ratingDialogOpen, protectionLoading, + markDeletedPending, setDeleteDialogOpen, setDownloadDialogOpen, setRatingDialogOpen, handleProtectionToggle, handleDeleteConfirm, handleIgnoreToggle, + handleMarkDeleted, handleDownloadConfirm, handleRatingApply, handleSnackbarClose, @@ -67,6 +70,7 @@ function VideoModal({ video, token, onVideoDeleted, + onMarkedDeleted, onProtectionChanged, onIgnoreChanged, onDownloadQueued, @@ -272,7 +276,9 @@ function VideoModal({ onIgnoreToggle={handleIgnoreToggle} onDownloadClick={() => setDownloadDialogOpen(true)} onRatingClick={() => setRatingDialogOpen(true)} + onMarkDeleted={() => { void handleMarkDeleted(); }} protectionLoading={protectionLoading} + markDeletedPending={markDeletedPending} isMobile={isMobile} allowIgnore={allowIgnore} /> diff --git a/client/src/components/shared/VideoModal/types.ts b/client/src/components/shared/VideoModal/types.ts index 57b2d5c23..23c41d1b7 100644 --- a/client/src/components/shared/VideoModal/types.ts +++ b/client/src/components/shared/VideoModal/types.ts @@ -35,6 +35,8 @@ export interface VideoModalProps { video: VideoModalData; token: string | null; onVideoDeleted?: (youtubeId: string) => void; + // A missing video was marked as deleted from the modal. + onMarkedDeleted?: (youtubeId: string) => void; onProtectionChanged?: (youtubeId: string, isProtected: boolean) => void; onIgnoreChanged?: (youtubeId: string, isIgnored: boolean) => void; onDownloadQueued?: (youtubeId: string) => void; diff --git a/client/src/components/shared/__tests__/AvailabilityChip.test.tsx b/client/src/components/shared/__tests__/AvailabilityChip.test.tsx index 8198f6b7d..e41439835 100644 --- a/client/src/components/shared/__tests__/AvailabilityChip.test.tsx +++ b/client/src/components/shared/__tests__/AvailabilityChip.test.tsx @@ -18,6 +18,75 @@ describe('AvailabilityChip', () => { expect(screen.queryByText('Available')).not.toBeInTheDocument(); }); + test('shows the same crossed-out cloud for Missing as the status chips elsewhere', () => { + renderWithProviders(<AvailabilityChip state="missing" />); + expect(screen.getByTestId('CloudOffIcon')).toBeInTheDocument(); + }); + + describe('on a touch screen', () => { + const originalMatchMedia = window.matchMedia; + const pointerIs = (coarse: boolean) => { + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: coarse && query === '(pointer: coarse)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + }; + const renderInRow = (onRowClick: jest.Mock) => renderWithProviders( + <div onClick={onRowClick}> + <AvailabilityChip state="missing" tooltip="Youtarr expected a file and could not find it." /> + </div> + ); + + afterEach(() => { + window.matchMedia = originalMatchMedia; + }); + + test('keeps a tap from reaching the row, so the tap shows the reason', async () => { + pointerIs(true); + const user = userEvent.setup(); + const onRowClick = jest.fn(); + renderInRow(onRowClick); + + await user.pointer({ keys: '[TouchA]', target: screen.getByText('Missing') }); + + expect(onRowClick).not.toHaveBeenCalled(); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Youtarr expected a file and could not find it.'); + }); + + // A laptop with a touchscreen reports a fine pointer; there the tooltip + // does not open on tap, so the tap must keep working on the row. + test('lets a tap through to the row where the device reports a fine pointer', async () => { + pointerIs(false); + const user = userEvent.setup(); + const onRowClick = jest.fn(); + renderInRow(onRowClick); + + await user.pointer({ keys: '[TouchA]', target: screen.getByText('Missing') }); + + expect(onRowClick).toHaveBeenCalledTimes(1); + }); + }); + + test('lets a mouse click through to the row, which may expand on click', async () => { + const user = userEvent.setup(); + const onRowClick = jest.fn(); + renderWithProviders( + <div onClick={onRowClick}> + <AvailabilityChip state="missing" tooltip="Youtarr expected a file and could not find it." /> + </div> + ); + + await user.click(screen.getByText('Missing')); + + expect(onRowClick).toHaveBeenCalledTimes(1); + }); + test('renders Deleted for a file Youtarr removed', () => { renderWithProviders(<AvailabilityChip state="deleted" />); expect(screen.getByText('Deleted')).toBeInTheDocument(); diff --git a/client/src/utils/__tests__/videoRemoval.test.ts b/client/src/utils/__tests__/videoRemoval.test.ts index a9b0ff3ff..755a2cc33 100644 --- a/client/src/utils/__tests__/videoRemoval.test.ts +++ b/client/src/utils/__tests__/videoRemoval.test.ts @@ -33,27 +33,27 @@ describe('describeRemoval', () => { }); test('explains a row removed before the reason was recorded', () => { - expect(describeRemoval('deleted', { reason: null, at: null, detail: null })).toBe('Removed before Youtarr recorded why.'); + expect(describeRemoval('deleted', { reason: null, at: null, detail: null })).toBe('Removed before Youtarr recorded why. It can be downloaded again.'); }); test('explains a delete made in Youtarr', () => { expect(describeRemoval('deleted', { reason: 'user_deleted', at: AT, detail: null })).toBe( - `Deleted in Youtarr${ON}. It can be downloaded again from its channel or playlist page.` + `Deleted in Youtarr${ON}. It can be downloaded again.` ); }); test('explains automatic cleanup with the rule that removed it', () => { expect(describeRemoval('deleted', { reason: 'auto_removal', at: AT, detail: 'older than 30 days' })).toBe( - `Deleted by automatic cleanup${ON} (older than 30 days). It can be downloaded again from its channel or playlist page.` + `Deleted by automatic cleanup${ON} (older than 30 days). It can be downloaded again.` ); expect(describeRemoval('deleted', { reason: 'auto_removal', at: null, detail: null })).toBe( - 'Deleted by automatic cleanup. It can be downloaded again from its channel or playlist page.' + 'Deleted by automatic cleanup. It can be downloaded again.' ); }); test('explains a missing video the user marked as deleted', () => { expect(describeRemoval('deleted', { reason: 'marked_deleted', at: AT, detail: null })).toBe( - `Marked as deleted${ON}. Its file was missing from disk when it was marked.` + `Marked as deleted${ON}. Its file was missing from disk when it was marked. It can be downloaded again.` ); }); diff --git a/client/src/utils/videoRemoval.ts b/client/src/utils/videoRemoval.ts index 92e5ccd9b..fa45d9543 100644 --- a/client/src/utils/videoRemoval.ts +++ b/client/src/utils/videoRemoval.ts @@ -47,7 +47,7 @@ export function removalFromRow(row: RemovalRow): RemovalInfo { }; } -const CAN_REDOWNLOAD = 'It can be downloaded again from its channel or playlist page.'; +const CAN_REDOWNLOAD = 'It can be downloaded again.'; export function describeRemoval(state: RemovalState, info: RemovalInfo = NO_REMOVAL, filePath?: string | null): string { if (state === 'present') return 'Video file exists on disk'; @@ -66,9 +66,9 @@ export function describeRemoval(state: RemovalState, info: RemovalInfo = NO_REMO case 'auto_removal': return `Deleted by automatic cleanup${on}${info.detail ? ` (${info.detail})` : ''}. ${CAN_REDOWNLOAD}`; case 'marked_deleted': - return `Marked as deleted${on}. Its file was missing from disk when it was marked.`; + return `Marked as deleted${on}. Its file was missing from disk when it was marked. ${CAN_REDOWNLOAD}`; default: - return 'Removed before Youtarr recorded why.'; + return `Removed before Youtarr recorded why. ${CAN_REDOWNLOAD}`; } } diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 1a20b9d8e..c45b6f600 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -162,11 +162,11 @@ Youtarr keeps a record of every video it has downloaded, even after the file is - **Deleted** (neutral grey chip): Youtarr removed the file on purpose, through a delete in the app or automatic cleanup, or you marked a missing video as deleted. Videos removed before Youtarr started recording the reason also show as Deleted ("Removed before Youtarr recorded why"). The Videos page hides deleted videos by default; the **Deleted** filter chip (set to "Hide" unless you change it, which is remembered in your browser) and the "N deleted videos hidden. Show" note bring them back. Channel and playlist pages keep listing them. - **Missing** (red chip): Youtarr expected a file on disk and could not find it. Missing videos stay visible so you can look into them. Hover or tap the chip, or open the video, to see the path Youtarr looked at and when. -**The downloads folder looks unmounted**: if your downloads live on a drive or network share and it is not mounted, every file looks gone. Youtarr checks that the downloads folder (and each `__subfolder` in it) exists and is not empty before marking anything missing. While that check fails, the Videos page shows a warning instead of marking videos missing, the scheduled rescan records a partial run that names the folder, and the "all missing videos" actions below are disabled. Mount the share and the warning goes away on its own; nothing was changed in the meantime. While the check fails, automatic cleanup records a skipped run instead of stamping videos it cannot reach as deleted, and deleting a video in the app is refused with the same message. If the folder really is empty because you removed the files yourself, use **Settings -> Maintenance & Rescan -> Rescan anyway...**: it runs the rescan without the check, marks every video whose file is not found as missing, and you can then mark them as deleted. Only do this when you know the files are gone. +**The downloads folder looks unmounted**: if your downloads live on a drive or network share and it is not mounted, every file looks gone. Youtarr checks that the downloads folder (and each `__subfolder` in it) exists and is not empty before marking anything missing. While that check fails, the Videos page shows a warning instead of marking videos missing, the scheduled rescan records a partial run that names the folder, automatic cleanup records a skipped run instead of stamping videos it cannot reach as deleted, and deleting a video in the app is refused with the same message. If missing videos are stored in the failing folder, **Empty trash** cannot remove them, and **Mark all missing as deleted** is disabled, or, when the folder is only empty (not gone or unreadable), asks you to confirm that you removed the files yourself. Failing `__subfolders` only affect the videos stored in them (every subfolder is checked, so two shares down at once are both covered), and an empty one that no video is stored in (for example one a channel was moved out of) is ignored. Mount the share and the warning goes away on its own; nothing was changed in the meantime. If the folder really is empty because you removed the files yourself, use **Settings -> Maintenance & Rescan -> Rescan anyway...**: it runs the rescan without the check and marks every video whose file is not found as missing; then use **Mark all missing as deleted**. Only do this when you know the files are gone. -**Mark as deleted**: when a missing video's file really is gone, select it on the Videos page and choose **Mark as deleted**, or use **Settings -> Maintenance & Rescan -> Mark all missing as deleted**. The record is kept (the video can still be re-downloaded, and a file that turns up again is marked downloaded by the rescan); only how the Videos page treats it changes. +**Mark as deleted**: when a missing video's file really is gone, select it on the Videos page and choose **Mark as deleted** (the video's window has the same button on every page), or use **Settings -> Maintenance & Rescan -> Mark all missing as deleted**. The record is kept (the video can still be re-downloaded, and a file that turns up again is marked downloaded by the rescan); only how the Videos page treats it changes. -**Empty trash** (`Settings -> Maintenance & Rescan -> Empty trash...`) forgets deleted and/or missing videos entirely. You choose which videos (Deleted, Missing, or both) and what happens to the download archive (`complete.list`): keeping the entries (recommended) means automatic channel and playlist downloads keep skipping those videos; removing them means automatic downloads treat the videos as new and may download them again at the next run. Emptying the trash removes the videos from the Videos page and Download History, makes their channel and playlist pages show them as not downloaded, and removes the watch history Youtarr synced for them from your media servers. Nothing on disk is touched, and if a missing video's file turns up later Youtarr will not know about it. This cannot be undone. +**Empty trash** (`Settings -> Maintenance & Rescan -> Empty trash...`) forgets deleted and/or missing videos entirely. You choose which videos (Deleted, Missing, or both) and what happens to the download archive (`complete.list`): keeping the entries (recommended) means automatic channel and playlist downloads keep skipping those videos; removing them means automatic downloads treat the videos as new and may download them again at the next run. Emptying the trash removes the videos from the Videos page and Download History, makes their channel and playlist pages show them as not downloaded, and removes the watch history Youtarr synced for them from your media servers. No files in your downloads folder are touched, and if a missing video's file turns up later Youtarr will not know about it. This cannot be undone. If you choose to remove download archive entries, wait for active downloads to finish before emptying the trash. Downloads queued during that purge wait for it to finish. If a purge fails partway, earlier batches remain purged; a batch whose archive cleanup failed stays in the trash so you can fix the error and retry. diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index 8bc3cd1b1..66bf38d4e 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -374,7 +374,7 @@ Videos can become "missing" if they're manually deleted from disk. This feature > **Note**: If the file still exists somewhere (you moved it, renamed its folder, or converted it to a different format), use [Rescan Files on Disk](#rescan-files-on-disk) instead. Rescan reconciles Youtarr's database with what's already on disk without re-downloading. -Youtarr tells two kinds of gone-from-disk videos apart. **Deleted** (grey) means Youtarr removed the file itself, through a delete in the app or automatic cleanup, or you marked it as deleted; the Videos page hides these by default behind the **Deleted** filter chip, and remembers it when you turn them back on. **Missing** (red) means Youtarr expected a file and could not find it; these stay visible so you can check on them. Hover over or tap either chip, or open the video, for the reason in plain words. When a missing video really is gone, select it and choose **Mark as deleted**, or mark them all from **Settings -> Maintenance & Rescan**, where **Empty trash...** can also forget deleted and missing videos entirely (see [Troubleshooting](TROUBLESHOOTING.md#deleted-vs-missing-videos-and-emptying-the-trash) for what that removes and how it treats the download archive). Both kinds can be re-downloaded with the steps below. +Youtarr tells two kinds of gone-from-disk videos apart. **Deleted** (grey) means Youtarr removed the file itself, through a delete in the app or automatic cleanup, or you marked it as deleted; the Videos page hides these by default behind the **Deleted** filter chip, and remembers it when you turn them back on. **Missing** (red) means Youtarr expected a file and could not find it; these stay visible so you can check on them. Hover over or tap either chip, or open the video, for the reason in plain words. When a missing video really is gone, select it and choose **Mark as deleted** (or use the same button in the video's window, from any page), or mark them all from **Settings -> Maintenance & Rescan**, where **Empty trash...** can also forget deleted and missing videos entirely (see [Troubleshooting](TROUBLESHOOTING.md#deleted-vs-missing-videos-and-emptying-the-trash) for what that removes and how it treats the download archive). Both kinds can be re-downloaded with the steps below. 1. **Identify missing videos** - Go to "Downloaded Videos" or a specific channel's video page @@ -411,7 +411,7 @@ You can also start a rescan with **Run now** on the Rescan files on disk card in A scan also runs daily on a schedule and once at server startup, so changes you make outside Youtarr will eventually be picked up even if you don't trigger a manual rescan. -If the downloads folder (or one of its `__subfolders`) is missing or empty when the scan runs, as it is while a drive or network share is unmounted, the scan does not mark anything missing: it records a run that names the folder and asks whether the drive is mounted, and the Videos page shows the same warning. Mount the share and run it again. If the folder really is empty because you removed the files yourself, **Rescan anyway...** on the Deleted and missing videos card runs the scan without that check; it marks every video whose file is gone as missing, and you can then mark them as deleted. +If the downloads folder is missing or empty when the scan runs, as it is while a drive or network share is unmounted, the scan does not mark anything missing: it records a run that names the folder and asks whether the drive is mounted, and the Videos page shows the same warning. If only some of its `__subfolders` are empty or unreadable, just the videos stored in those subfolders are left alone; an empty subfolder no video is stored in (for example one a channel was moved out of) does not count. Mount the share and run it again. If the folder really is empty because you removed the files yourself, **Rescan anyway...** on the Deleted and missing videos card runs the scan without that check; it marks every video whose file is gone as missing, and **Mark all missing as deleted** then asks you to confirm that you emptied the folder yourself before marking them. **Supported file extensions**: `.mp4`, `.webm`, `.mkv`, `.m4v`, `.avi` for video, plus `.mp3` for audio-only downloads. Youtarr only writes `.mp4` (or `.mp3` for audio-only), but the rescan recognizes any of these so transcoding outside Youtarr won't orphan your library. Files must keep the `[<youtube-id>]` segment in their filename (the 11-character ID in brackets that yt-dlp writes by default) for Youtarr to match them back to the database. diff --git a/server/modules/__tests__/cronJobs.test.js b/server/modules/__tests__/cronJobs.test.js index d60287c7f..cd156468e 100644 --- a/server/modules/__tests__/cronJobs.test.js +++ b/server/modules/__tests__/cronJobs.test.js @@ -181,7 +181,7 @@ describe('CronJobs', () => { test('records a skipped run and leaves folders alone when cleanup skipped an unmounted downloads folder', async () => { mockVideoDeletionModule.performAutomaticCleanup.mockResolvedValue({ - skipped: { reason: 'empty', message: 'The downloads folder /d is empty.' }, + skipped: { reason: 'empty', message: 'The downloads folder is empty.' }, totalDeleted: 0, freedBytes: 0, errors: [] @@ -191,7 +191,7 @@ describe('CronJobs', () => { const outcome = await scheduledTasks.runNow('autoRemovalFrequency', { trigger: 'manual' }); const record = await outcome.completion; - expect(record).toEqual(expect.objectContaining({ status: 'skipped', outcome: 'skipped', message: 'The downloads folder /d is empty.' })); + expect(record).toEqual(expect.objectContaining({ status: 'skipped', outcome: 'skipped', message: 'The downloads folder is empty.' })); expect(mockVideoDeletionModule.cleanupOrphanDirectories).not.toHaveBeenCalled(); }); @@ -199,14 +199,14 @@ describe('CronJobs', () => { // Auto-removal off: performAutomaticCleanup returns before its own check. mockVideoDeletionModule.performAutomaticCleanup.mockResolvedValue({ totalDeleted: 0, freedBytes: 0, errors: [] }); mockVideoDeletionModule.cleanupOrphanDirectories.mockResolvedValue({ - removed: [], errors: [], skipped: { reason: 'empty', message: 'The downloads folder /d is empty.' } + removed: [], errors: [], skipped: { reason: 'empty', message: 'The downloads folder is empty.' } }); const scheduledTasks = require('../scheduledTaskManager'); const outcome = await scheduledTasks.runNow('autoRemovalFrequency', { trigger: 'manual' }); const record = await outcome.completion; - expect(record).toEqual(expect.objectContaining({ status: 'skipped', outcome: 'skipped', message: 'The downloads folder /d is empty.' })); + expect(record).toEqual(expect.objectContaining({ status: 'skipped', outcome: 'skipped', message: 'The downloads folder is empty.' })); }); test('should log success when videos are deleted', async () => { diff --git a/server/modules/__tests__/fileCheckModule.test.js b/server/modules/__tests__/fileCheckModule.test.js index 49aaf69e8..0abeffda0 100644 --- a/server/modules/__tests__/fileCheckModule.test.js +++ b/server/modules/__tests__/fileCheckModule.test.js @@ -31,7 +31,10 @@ describe('FileCheckModule', () => { })); // The downloads folder looks mounted unless a test says otherwise. - mockLibraryRootCheck = { checkLibraryRoot: jest.fn().mockResolvedValue({ ok: true }) }; + mockLibraryRootCheck = { + ...jest.requireActual('../libraryRootCheck'), + checkLibraryRoot: jest.fn().mockResolvedValue({ ok: true }), + }; jest.doMock('../libraryRootCheck', () => mockLibraryRootCheck); jest.doMock('../configModule', () => ({ directoryPath: '/videos' })); @@ -39,7 +42,7 @@ describe('FileCheckModule', () => { }); describe('while the downloads folder looks unmounted', () => { - const failedCheck = { ok: false, reason: 'empty', path: '/videos', message: 'The downloads folder /videos is empty.' }; + const failedCheck = { ok: false, reason: 'empty', path: '/videos', message: 'The downloads folder is empty.' }; const presentRow = (id, youtubeId) => ({ id, youtubeId, filePath: `/videos/channel/${youtubeId}.mp4`, fileSize: '1000', removed: false }); @@ -67,6 +70,49 @@ describe('FileCheckModule', () => { expect(result.libraryWarning).toBeNull(); }); + describe('when only a subfolder looks unmounted', () => { + const emptySubfolder = { ok: false, reason: 'subfolder-empty', path: '/videos/__Music', message: 'The subfolder __Music is empty.' }; + const rowAt = (id, filePath) => ({ id, youtubeId: `vid${id}`, filePath, fileSize: '1000', removed: false }); + + test('still marks a video stored outside that subfolder missing', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(emptySubfolder); + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + + const result = await fileCheckModule.checkVideoFiles([rowAt(1, '/videos/__Videos/channel/vid1.mp4')]); + + expect(result.updates).toEqual([{ id: 1, ...MISSING_COLUMNS }]); + expect(result.libraryWarning).toBeNull(); + }); + + test('holds back only the videos under that subfolder', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(emptySubfolder); + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + + const result = await fileCheckModule.checkVideoFiles([ + rowAt(1, '/videos/__Music/artist/vid1.mp4'), + rowAt(2, '/videos/__Videos/channel/vid2.mp4'), + ]); + + expect(result.updates).toEqual([{ id: 2, ...MISSING_COLUMNS }]); + expect(result.libraryWarning).toEqual(expect.objectContaining({ reason: 'subfolder-empty', heldBack: 1 })); + }); + + test('holds back the videos under every failing subfolder, not just the first', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue({ + ...emptySubfolder, paths: ['/videos/__Music', '/videos/__Kids'], message: 'The subfolders __Music and __Kids are empty.', + }); + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + + const result = await fileCheckModule.checkVideoFiles([ + rowAt(1, '/videos/__Music/artist/vid1.mp4'), + rowAt(2, '/videos/__Kids/channel/vid2.mp4'), + ]); + + expect(result.updates).toEqual([]); + expect(result.libraryWarning).toEqual(expect.objectContaining({ heldBack: 2 })); + }); + }); + test('does not run the check when no row would be marked missing', async () => { mockFs.stat.mockResolvedValue({ size: 1000 }); diff --git a/server/modules/__tests__/libraryRootCheck.test.js b/server/modules/__tests__/libraryRootCheck.test.js index b1fdc585d..16d1fa257 100644 --- a/server/modules/__tests__/libraryRootCheck.test.js +++ b/server/modules/__tests__/libraryRootCheck.test.js @@ -1,6 +1,9 @@ /* eslint-env jest */ const path = require('path'); -const { LIBRARY_CHECK_REASONS, checkLibraryRoot, describeLibraryCheck, isUnderLibraryCheckPath } = require('../libraryRootCheck'); +const { Op } = require('sequelize'); +const { + LIBRARY_CHECK_REASONS, checkLibraryRoot, describeLibraryCheck, isUnderLibraryCheckPath, isRowUnderLibraryCheck, libraryCheckCoversRows, +} = require('../libraryRootCheck'); const ROOT = '/data/youtube'; @@ -31,7 +34,7 @@ describe('checkLibraryRoot', () => { ok: false, reason: LIBRARY_CHECK_REASONS.MISSING, path: ROOT, - message: 'The downloads folder /data/youtube does not exist.', + message: 'The downloads folder does not exist.', }); }); @@ -46,7 +49,7 @@ describe('checkLibraryRoot', () => { ok: false, reason: 'unreadable', path: ROOT, - message: 'The downloads folder /data/youtube could not be read.', + message: 'The downloads folder could not be read.', }); }); @@ -56,7 +59,7 @@ describe('checkLibraryRoot', () => { ok: false, reason: 'empty', path: ROOT, - message: 'The downloads folder /data/youtube is empty.', + message: 'The downloads folder is empty.', }); }); @@ -93,7 +96,44 @@ describe('checkLibraryRoot', () => { ok: false, reason: 'subfolder-empty', path: music, - message: 'The subfolder /data/youtube/__Music is empty.', + paths: [music], + message: 'The subfolder __Music is empty.', + }); + }); + + // Two shares can be down at once (a NAS reboot); each must stay protected. + test('reports every failing subfolder, not just the first', async () => { + const music = path.join(ROOT, '__Music'); + const kids = path.join(ROOT, '__Kids'); + const readdir = diskWith({ + [ROOT]: [dir('Channel A'), dir('__Music'), dir('__Kids')], + [music]: [], + [kids]: [], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ + ok: false, + reason: 'subfolder-empty', + path: music, + paths: [music, kids], + message: 'The subfolders __Music and __Kids are empty.', + }); + }); + + // An unreadable share must never be looked past as "emptied on purpose". + test('reports the strictest reason when one subfolder is empty and another unreadable', async () => { + const music = path.join(ROOT, '__Music'); + const kids = path.join(ROOT, '__Kids'); + const readdir = diskWith({ + [ROOT]: [dir('__Music'), dir('__Kids')], + [music]: [], + [kids]: 'EIO', + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ + ok: false, + reason: 'subfolder-unreadable', + path: kids, + paths: [music, kids], + message: 'The subfolder __Music is empty. The subfolder __Kids could not be read.', }); }); @@ -118,7 +158,8 @@ describe('checkLibraryRoot', () => { ok: false, reason: 'subfolder-unreadable', path: music, - message: 'The subfolder /data/youtube/__Music could not be read.', + paths: [music], + message: 'The subfolder __Music could not be read.', }); }); @@ -132,12 +173,14 @@ describe('checkLibraryRoot', () => { }); describe('describeLibraryCheck', () => { - test('describes each reason in one sentence', () => { - expect(describeLibraryCheck({ reason: 'missing', path: '/d' })).toBe('The downloads folder /d does not exist.'); - expect(describeLibraryCheck({ reason: 'empty', path: '/d' })).toBe('The downloads folder /d is empty.'); - expect(describeLibraryCheck({ reason: 'unreadable', path: '/d' })).toBe('The downloads folder /d could not be read.'); - expect(describeLibraryCheck({ reason: 'subfolder-empty', path: '/d/__Music' })).toBe('The subfolder /d/__Music is empty.'); - expect(describeLibraryCheck({ reason: 'subfolder-unreadable', path: '/d/__Music' })).toBe('The subfolder /d/__Music could not be read.'); + // The container path (/usr/src/app/data) means nothing on the host, so the + // root is "the downloads folder" and a subfolder goes by its own name. + test('describes each reason in one sentence, without the container path', () => { + expect(describeLibraryCheck({ reason: 'missing', path: '/d' })).toBe('The downloads folder does not exist.'); + expect(describeLibraryCheck({ reason: 'empty', path: '/d' })).toBe('The downloads folder is empty.'); + expect(describeLibraryCheck({ reason: 'unreadable', path: '/d' })).toBe('The downloads folder could not be read.'); + expect(describeLibraryCheck({ reason: 'subfolder-empty', path: '/d/__Music' })).toBe('The subfolder __Music is empty.'); + expect(describeLibraryCheck({ reason: 'subfolder-unreadable', path: '/d/__Music' })).toBe('The subfolder __Music could not be read.'); }); }); @@ -156,8 +199,97 @@ describe('isUnderLibraryCheckPath', () => { expect(isUnderLibraryCheckPath(check, path.join(ROOT, 'Channel', 'v [abc].mp4'))).toBe(false); }); + test('every failing subfolder covers the files under it', () => { + const kids = path.join(ROOT, '__Kids'); + const check = { ok: false, reason: 'subfolder-empty', path: music, paths: [music, kids] }; + expect(isUnderLibraryCheckPath(check, path.join(kids, 'Channel', 'v [abc].mp4'))).toBe(true); + expect(isUnderLibraryCheckPath(check, path.join(music, 'Artist', 'song [abc].mp3'))).toBe(true); + expect(isUnderLibraryCheckPath(check, path.join(ROOT, '__Videos', 'Channel', 'v [abc].mp4'))).toBe(false); + }); + test('a passing check and a video without a file path cover nothing', () => { expect(isUnderLibraryCheckPath({ ok: true }, '/data/youtube/Channel/v [abc].mp4')).toBe(false); expect(isUnderLibraryCheckPath({ ok: false, reason: 'empty', path: ROOT }, null)).toBe(false); }); }); + +describe('isRowUnderLibraryCheck', () => { + const music = path.join(ROOT, '__Music'); + const check = { ok: false, reason: 'subfolder-empty', path: music }; + + test('covers a row whose video file is under the failing subfolder', () => { + expect(isRowUnderLibraryCheck(check, { filePath: path.join(music, 'Artist', 'v [abc].mp4'), audioFilePath: null })).toBe(true); + }); + + test('covers a row whose audio file is under the failing subfolder', () => { + expect(isRowUnderLibraryCheck(check, { filePath: null, audioFilePath: path.join(music, 'Artist', 'v [abc].mp3') })).toBe(true); + }); + + test('leaves a row stored elsewhere alone', () => { + expect(isRowUnderLibraryCheck(check, { filePath: path.join(ROOT, '__Videos', 'Channel', 'v [abc].mp4'), audioFilePath: null })).toBe(false); + }); +}); + +describe('libraryCheckCoversRows', () => { + const music = path.join(ROOT, '__Music'); + const present = { removed: false }; + const videoModel = (count) => ({ count: jest.fn().mockResolvedValue(count) }); + + test('a passing check covers nothing and reads no rows', async () => { + const Video = videoModel(5); + await expect(libraryCheckCoversRows({ ok: true }, Video, present)).resolves.toBe(false); + expect(Video.count).not.toHaveBeenCalled(); + }); + + test('a failing root covers every row the filter matches', async () => { + const Video = videoModel(3); + await expect(libraryCheckCoversRows({ ok: false, reason: 'empty', path: ROOT }, Video, present)).resolves.toBe(true); + expect(Video.count).toHaveBeenCalledWith({ where: present }); + }); + + test('a failing root with no matching rows covers nothing', async () => { + await expect(libraryCheckCoversRows({ ok: false, reason: 'empty', path: ROOT }, videoModel(0), present)).resolves.toBe(false); + }); + + test('a failing subfolder covers only rows stored under it, matching its name literally', async () => { + const Video = videoModel(0); + const check = { ok: false, reason: 'subfolder-empty', path: music }; + + await expect(libraryCheckCoversRows(check, Video, present)).resolves.toBe(false); + + // `_` is a LIKE wildcard, and every subfolder name starts with two of them. + const prefix = `${ROOT}/\\_\\_Music/%`; + expect(Video.count).toHaveBeenCalledWith({ + where: { + [Op.and]: [ + present, + { [Op.or]: [{ filePath: { [Op.like]: prefix } }, { audioFilePath: { [Op.like]: prefix } }] }, + ], + }, + }); + }); + + test('covers rows under any of several failing subfolders', async () => { + const Video = videoModel(1); + const kids = path.join(ROOT, '__Kids'); + const check = { ok: false, reason: 'subfolder-empty', path: music, paths: [music, kids] }; + + await expect(libraryCheckCoversRows(check, Video, present)).resolves.toBe(true); + + const musicPrefix = `${ROOT}/\\_\\_Music/%`; + const kidsPrefix = `${ROOT}/\\_\\_Kids/%`; + expect(Video.count).toHaveBeenCalledWith({ + where: { + [Op.and]: [ + present, + { + [Op.or]: [ + { filePath: { [Op.like]: musicPrefix } }, { audioFilePath: { [Op.like]: musicPrefix } }, + { filePath: { [Op.like]: kidsPrefix } }, { audioFilePath: { [Op.like]: kidsPrefix } }, + ], + }, + ], + }, + }); + }); +}); diff --git a/server/modules/__tests__/rescanRunSummary.test.js b/server/modules/__tests__/rescanRunSummary.test.js index e46b31f94..b85f5936f 100644 --- a/server/modules/__tests__/rescanRunSummary.test.js +++ b/server/modules/__tests__/rescanRunSummary.test.js @@ -36,12 +36,12 @@ describe('rescanRunSummary', () => { test('records a scan that held back missing marks behind a library check as partial, naming the cause', () => { const record = toRunRecord({ status: 'completed', processed: 10, updated: 1, removed: 0, - notMarkedMissing: 2, libraryCheckMessage: 'The subfolder /d/__Music is empty.', + notMarkedMissing: 2, libraryCheckMessage: 'The subfolder __Music is empty.', }); expect(record).toEqual(expect.objectContaining({ status: 'error', outcome: 'partial', - message: 'Scanned 10 videos: 1 updated, 0 marked missing, 2 not marked missing (The subfolder /d/__Music is empty.).', + message: 'Scanned 10 videos: 1 updated, 0 marked missing, 2 not marked missing (The subfolder __Music is empty).', })); expect(record.details.videosNotMarkedMissing).toBe(2); }); diff --git a/server/modules/__tests__/videoDeletionModule.test.js b/server/modules/__tests__/videoDeletionModule.test.js index c8d7318c9..c9bd185e4 100644 --- a/server/modules/__tests__/videoDeletionModule.test.js +++ b/server/modules/__tests__/videoDeletionModule.test.js @@ -17,8 +17,12 @@ jest.mock('../storageGuard', () => ({ // beforeEach installs persists across resetModules and only has `promises`, // which fs-extra (required by directoryManager) cannot load against. const { isVideoDirectoryFor } = jest.requireActual('../filesystem/directoryManager'); -// The real path predicate; only the check itself is mocked. -const { isUnderLibraryCheckPath } = jest.requireActual('../libraryRootCheck'); +// The real path predicates; only the check itself is mocked. +const { isUnderLibraryCheckPath, libraryCheckCoversRows } = jest.requireActual('../libraryRootCheck'); +const { Op } = jest.requireActual('sequelize'); +// A Video.count for a library of `total` videos with none stored under a +// failing subfolder: a count scoped to the subfolder (an Op.and) finds nothing. +const countNoneUnderSubfolder = (total) => async ({ where }) => (where[Op.and] ? 0 : total); // What a delete in the app writes on the row: removed, by the user, now. const USER_DELETED_COLUMNS = expect.objectContaining({ @@ -57,7 +61,8 @@ describe('VideoDeletionModule', () => { mockCheckLibraryRoot = jest.fn().mockResolvedValue({ ok: true }); jest.doMock('../libraryRootCheck', () => ({ checkLibraryRoot: mockCheckLibraryRoot, - isUnderLibraryCheckPath + isUnderLibraryCheckPath, + libraryCheckCoversRows })); // Mock fs.promises (only the methods videoDeletionModule still calls directly, @@ -779,8 +784,8 @@ describe('VideoDeletionModule', () => { }); describe('while the downloads folder looks unmounted', () => { - const emptyRoot = { ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder /test/output is empty.' }; - const emptySubfolder = { ok: false, reason: 'subfolder-empty', path: '/test/output/__Music', message: 'The subfolder /test/output/__Music is empty.' }; + const emptyRoot = { ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder is empty.' }; + const emptySubfolder = { ok: false, reason: 'subfolder-empty', path: '/test/output/__Music', message: 'The subfolder __Music is empty.' }; const video = (id, youtubeId, filePath) => ({ id, youtubeId, filePath, removed: false, update: jest.fn().mockResolvedValue() }); test('refuses every video with the check message and touches nothing', async () => { @@ -791,7 +796,7 @@ describe('VideoDeletionModule', () => { const result = await VideoDeletionModule.deleteVideos([1, 2]); - const error = 'The downloads folder /test/output is empty. Nothing was deleted; check that the drive is mounted.'; + const error = 'The downloads folder is empty. Nothing was deleted; check that the drive is mounted.'; expect(result).toEqual({ success: false, deleted: [], failed: [{ videoId: 1, error }, { videoId: 2, error }] }); expect(mockVideo1.update).not.toHaveBeenCalled(); expect(mockFilesystem.removeDirectoryResilient).not.toHaveBeenCalled(); @@ -806,7 +811,7 @@ describe('VideoDeletionModule', () => { const result = await VideoDeletionModule.deleteVideos([1, 2]); expect(result.deleted).toEqual([2]); - expect(result.failed).toEqual([{ videoId: 1, error: 'The subfolder /test/output/__Music is empty. Nothing was deleted; check that the drive is mounted.' }]); + expect(result.failed).toEqual([{ videoId: 1, error: 'The subfolder __Music is empty. Nothing was deleted; check that the drive is mounted.' }]); expect(inMusic.update).not.toHaveBeenCalled(); expect(inRoot.update).toHaveBeenCalledWith(USER_DELETED_COLUMNS); }); @@ -1480,7 +1485,7 @@ describe('VideoDeletionModule', () => { }); describe('while the downloads folder looks unmounted', () => { - const emptyRoot = { ok: false, reason: 'empty', path: '/test', message: 'The downloads folder /test is empty.' }; + const emptyRoot = { ok: false, reason: 'empty', path: '/test', message: 'The downloads folder is empty.' }; beforeEach(() => { mockConfigModule.getConfig.mockReturnValue({ autoRemovalEnabled: true, autoRemovalVideoAgeThreshold: '30' }); @@ -1493,7 +1498,7 @@ describe('VideoDeletionModule', () => { const result = await VideoDeletionModule.performAutomaticCleanup(); - expect(result.skipped).toEqual({ reason: 'empty', message: 'The downloads folder /test is empty.' }); + expect(result.skipped).toEqual({ reason: 'empty', message: 'The downloads folder is empty.' }); expect(result.totalDeleted).toBe(0); expect(mockAutoRemovalQueries.getChannelKeepRecentIds).not.toHaveBeenCalled(); expect(mockSequelize.query).not.toHaveBeenCalled(); @@ -1508,6 +1513,16 @@ describe('VideoDeletionModule', () => { expect(mockAutoRemovalQueries.getChannelKeepRecentIds).toHaveBeenCalled(); }); + test('runs normally when only an empty subfolder no video is stored in fails the check', async () => { + mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'subfolder-empty', path: '/test/__Kids', message: 'The subfolder __Kids is empty.' }); + mockVideo.count.mockImplementation(countNoneUnderSubfolder(12)); + + const result = await VideoDeletionModule.performAutomaticCleanup(); + + expect(result.skipped).toBeUndefined(); + expect(mockAutoRemovalQueries.getChannelKeepRecentIds).toHaveBeenCalled(); + }); + test('does not consult the check for a dry run', async () => { const result = await VideoDeletionModule.performAutomaticCleanup({ dryRun: true }); @@ -2487,19 +2502,19 @@ describe('VideoDeletionModule', () => { }); test('skips the sweep while the downloads folder looks unmounted and videos are recorded', async () => { - mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder /test/output is empty.' }); + mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder is empty.' }); mockVideo.count.mockResolvedValue(3); mockFilesystem.listSubdirectories.mockResolvedValue(['/test/output/__Music']); const result = await VideoDeletionModule.cleanupOrphanDirectories(); - expect(result).toEqual({ removed: [], errors: [], skipped: { reason: 'empty', message: 'The downloads folder /test/output is empty.' } }); + expect(result).toEqual({ removed: [], errors: [], skipped: { reason: 'empty', message: 'The downloads folder is empty.' } }); expect(mockFilesystem.listSubdirectories).not.toHaveBeenCalled(); expect(mockFilesystem.cleanupEmptyParents).not.toHaveBeenCalled(); }); test('sweeps normally when the folder looks unmounted but nothing downloaded is recorded', async () => { - mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder /test/output is empty.' }); + mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder is empty.' }); mockVideo.count.mockResolvedValue(0); mockFilesystem.listSubdirectories.mockResolvedValue([]); @@ -2509,6 +2524,19 @@ describe('VideoDeletionModule', () => { expect(mockFilesystem.listSubdirectories).toHaveBeenCalledWith('/test/output'); }); + test('sweeps an empty subfolder that no recorded video is stored in, so the check passes again', async () => { + mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'subfolder-empty', path: '/test/output/__Kids', message: 'The subfolder __Kids is empty.' }); + mockVideo.count.mockImplementation(countNoneUnderSubfolder(3)); + mockFilesystem.listSubdirectories + .mockResolvedValueOnce(['/test/output/__Kids']) + .mockResolvedValueOnce([]); + + const result = await VideoDeletionModule.cleanupOrphanDirectories(); + + expect(result.skipped).toBeUndefined(); + expect(mockFilesystem.cleanupEmptyParents).toHaveBeenCalledWith('/test/output/__Kids', '/test/output'); + }); + test('should clean up root-level empty channel directories', async () => { mockFilesystem.listSubdirectories.mockResolvedValue([ '/test/output/Empty Channel', diff --git a/server/modules/__tests__/videoTrashModule.test.js b/server/modules/__tests__/videoTrashModule.test.js index 82a3bbf19..a3c0160a3 100644 --- a/server/modules/__tests__/videoTrashModule.test.js +++ b/server/modules/__tests__/videoTrashModule.test.js @@ -13,7 +13,7 @@ jest.mock('../../models', () => mockModels); jest.mock('../../db.js', () => ({ sequelize: { transaction: jest.fn(async (fn) => fn('tx')) }, })); -jest.mock('../libraryRootCheck', () => ({ checkLibraryRoot: jest.fn() })); +jest.mock('../libraryRootCheck', () => ({ ...jest.requireActual('../libraryRootCheck'), checkLibraryRoot: jest.fn() })); jest.mock('../configModule', () => ({ directoryPath: '/data/youtube', getJobsPath: () => '/app/jobs', @@ -36,7 +36,19 @@ const videoTrashModule = require('../videoTrashModule'); const { LibraryUnavailableError, PurgeRunningError } = videoTrashModule; const LIBRARY_OK = { ok: true }; -const LIBRARY_EMPTY = { ok: false, reason: 'empty', path: '/data/youtube', message: 'The downloads folder /data/youtube is empty.' }; +const LIBRARY_EMPTY = { ok: false, reason: 'empty', path: '/data/youtube', message: 'The downloads folder is empty.' }; +const LIBRARY_MISSING = { ok: false, reason: 'missing', path: '/data/youtube', message: 'The downloads folder does not exist.' }; +const SUBFOLDER_EMPTY = { ok: false, reason: 'subfolder-empty', path: '/data/youtube/__Kids', message: 'The subfolder __Kids is empty.' }; +// A Video.count for a library of `total` videos with none stored under a +// failing subfolder: a count scoped to the subfolder (an Op.and) finds nothing. +const countNoneUnderSubfolder = (total) => async ({ where }) => (where[Op.and] ? 0 : total); +// A Video.count for a failing subfolder that holds present videos but no +// missing ones: a subfolder-scoped count of missing rows finds nothing. +const countOnlyPresentUnderSubfolder = (total) => async ({ where }) => { + if (!where[Op.and]) return total; + const [filter] = where[Op.and]; + return filter.removed_reason === 'not_found' ? 0 : 2; +}; beforeEach(() => { jest.clearAllMocks(); @@ -58,6 +70,7 @@ describe('getRemovedSummary', () => { missing: 4, legacy: 3, libraryCheck: { ok: true, reason: null, path: null, message: null }, + missingActionsBlocked: false, downloadsActive: false, }); expect(Video.count).toHaveBeenNthCalledWith(1, { where: stateWhere('deleted') }); @@ -75,17 +88,43 @@ describe('getRemovedSummary', () => { }); test('passes a failed library check through so the page can disable the missing actions', async () => { - Video.count.mockResolvedValue(0); + Video.count.mockResolvedValue(2); checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); const summary = await videoTrashModule.getRemovedSummary(); expect(summary.libraryCheck).toEqual({ ok: false, reason: 'empty', path: '/data/youtube', message: LIBRARY_EMPTY.message }); + expect(summary.missingActionsBlocked).toBe(true); expect(checkLibraryRoot).toHaveBeenCalledWith('/data/youtube'); }); + + // The warning still shows (the Videos page banner sends people to Rescan + // anyway), but marking and emptying missing videos stay open. + test('warns about a failing subfolder that holds only present videos without blocking the missing actions', async () => { + Video.count.mockImplementation(countOnlyPresentUnderSubfolder(5)); + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + + const summary = await videoTrashModule.getRemovedSummary(); + + expect(summary.libraryCheck.ok).toBe(false); + expect(summary.missingActionsBlocked).toBe(false); + }); + + test('reports the folder as fine when the failing subfolder holds no present or missing video', async () => { + Video.count.mockImplementation(countNoneUnderSubfolder(5)); + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + + const summary = await videoTrashModule.getRemovedSummary(); + + expect(summary.libraryCheck).toEqual({ ok: true, reason: null, path: null, message: null }); + }); }); describe('markMissingAsDeleted', () => { + beforeEach(() => { + Video.count.mockReset().mockResolvedValue(3); // videos on record, so a failing check covers some + }); + test('marks only the selected rows that are missing, leaving present and deleted ones alone', async () => { Video.update.mockResolvedValue([2]); @@ -122,6 +161,37 @@ describe('markMissingAsDeleted', () => { expect(Video.update).not.toHaveBeenCalled(); }); + test('marks all while only an empty subfolder no video is stored in fails the check', async () => { + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + Video.count.mockImplementation(countNoneUnderSubfolder(3)); + Video.update.mockResolvedValue([2]); + + await expect(videoTrashModule.markMissingAsDeleted({ all: true })).resolves.toEqual({ updated: 2 }); + }); + + test('marks all without asking while the failing subfolder holds present videos but no missing ones', async () => { + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + Video.count.mockImplementation(countOnlyPresentUnderSubfolder(3)); + Video.update.mockResolvedValue([2]); + + await expect(videoTrashModule.markMissingAsDeleted({ all: true })).resolves.toEqual({ updated: 2 }); + }); + + test('marks all anyway when asked to, for a folder the user emptied on purpose', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + Video.update.mockResolvedValue([5]); + + await expect(videoTrashModule.markMissingAsDeleted({ all: true, ignoreLibraryCheck: true })).resolves.toEqual({ updated: 5 }); + }); + + test('still refuses to mark all when the folder is gone, even when asked to ignore the check', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_MISSING); + + await expect(videoTrashModule.markMissingAsDeleted({ all: true, ignoreLibraryCheck: true })) + .rejects.toBeInstanceOf(LibraryUnavailableError); + expect(Video.update).not.toHaveBeenCalled(); + }); + test('still marks a chosen selection while the downloads folder looks unmounted', async () => { checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); Video.update.mockResolvedValue([1]); @@ -167,6 +237,7 @@ describe('purgeRemoved', () => { Video.destroy.mockResolvedValue(2); fs.promises.unlink.mockResolvedValue(undefined); archiveModule.removeVideosFromArchive.mockResolvedValue(2); + Video.count.mockReset().mockResolvedValue(3); // videos on record, so a failing check covers some }); test('selects deleted rows only, including ones removed before the reason was recorded', async () => { @@ -327,6 +398,24 @@ describe('purgeRemoved', () => { expect(sequelize.transaction).not.toHaveBeenCalled(); }); + test('purges missing videos while only an empty subfolder no video is stored in fails the check', async () => { + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + Video.count.mockImplementation(countNoneUnderSubfolder(3)); + + await expect(videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: false })).resolves.toEqual( + expect.objectContaining({ purged: 2 }) + ); + }); + + test('purges missing videos while the failing subfolder holds present videos but no missing ones', async () => { + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + Video.count.mockImplementation(countOnlyPresentUnderSubfolder(3)); + + await expect(videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: false })).resolves.toEqual( + expect.objectContaining({ purged: 2 }) + ); + }); + test('still purges deleted videos while the downloads folder looks unmounted', async () => { checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); diff --git a/server/modules/__tests__/videosModule.test.js b/server/modules/__tests__/videosModule.test.js index d423f466c..8fdc6caa2 100644 --- a/server/modules/__tests__/videosModule.test.js +++ b/server/modules/__tests__/videosModule.test.js @@ -140,7 +140,7 @@ describe('VideosModule', () => { // The downloads folder looks mounted unless a test says otherwise. mockLibraryRootCheck = { - LIBRARY_CHECK_REASONS: { MISSING: 'missing', EMPTY: 'empty', UNREADABLE: 'unreadable', SUBFOLDER_EMPTY: 'subfolder-empty', SUBFOLDER_UNREADABLE: 'subfolder-unreadable' }, + ...jest.requireActual('../libraryRootCheck'), checkLibraryRoot: jest.fn().mockResolvedValue({ ok: true }), }; jest.doMock('../libraryRootCheck', () => mockLibraryRootCheck); @@ -742,7 +742,7 @@ describe('VideosModule', () => { youtube_removed_checked_at: null } ]; - const failedCheck = { ok: false, reason: 'empty', path: '/test/output/dir', message: 'The downloads folder /test/output/dir is empty.' }; + const failedCheck = { ok: false, reason: 'empty', path: '/test/output/dir', message: 'The downloads folder is empty.' }; mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(failedCheck); mockVideo.count.mockResolvedValue(1); mockVideo.findAll.mockResolvedValue(mockVideos); @@ -1496,8 +1496,8 @@ describe('VideosModule', () => { }); describe('when the downloads folder looks unmounted', () => { - const missingRoot = { ok: false, reason: 'missing', path: '/test/output/dir', message: 'The downloads folder /test/output/dir does not exist.' }; - const emptySubfolder = { ok: false, reason: 'subfolder-empty', path: '/test/output/dir/__Music', message: 'The subfolder /test/output/dir/__Music is empty.' }; + const missingRoot = { ok: false, reason: 'missing', path: '/test/output/dir', message: 'The downloads folder does not exist.' }; + const emptySubfolder = { ok: false, reason: 'subfolder-empty', path: '/test/output/dir/__Music', message: 'The subfolder __Music is empty.' }; test('stops before the walk, changing nothing, while videos are present', async () => { mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(missingRoot); @@ -1506,7 +1506,7 @@ describe('VideosModule', () => { const result = await VideosModule.backfillVideoMetadata(); expect(result.status).toBe('error'); - expect(result.errorMessage).toBe('The downloads folder /test/output/dir does not exist. Nothing was marked missing; is the drive mounted?'); + expect(result.errorMessage).toBe('The downloads folder does not exist. Nothing was marked missing; is the drive mounted?'); expect(result.libraryCheckMessage).toBe(missingRoot.message); expect(mockFs.readdir).not.toHaveBeenCalled(); expect(mockVideo.update).not.toHaveBeenCalled(); @@ -1544,8 +1544,40 @@ describe('VideosModule', () => { expect(mockVideo.update).not.toHaveBeenCalled(); }); + test('stays quiet about an empty subfolder no present video is stored in', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(emptySubfolder); + // Present rows under the subfolder: none. Then the total row count. + mockVideo.count.mockImplementationOnce(async ({ where }) => (where[Op.and] ? 0 : 1)); + mockVideo.count.mockResolvedValueOnce(0); + mockFs.readdir.mockResolvedValueOnce([]); + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('completed'); + expect(result.libraryCheckMessage).toBeNull(); + expect(mockLogger.warn).not.toHaveBeenCalledWith(expect.anything(), expect.stringMatching(/looks unmounted or unreadable/)); + }); + + test('still marks missing a video stored outside the empty subfolder', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(emptySubfolder); + mockVideo.count.mockResolvedValueOnce(1); // present rows + mockVideo.count.mockResolvedValueOnce(1); // total rows + mockFs.readdir.mockResolvedValueOnce([]); + mockVideo.findAll.mockResolvedValueOnce([ + dbRow({ youtubeId: 'missing123', filePath: '/test/output/dir/__Videos/missing.mp4', fileSize: '1000' }) + ]); + mockFs.stat.mockRejectedValue(fsError('ENOENT')); + mockVideo.update.mockResolvedValue([1]); + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('completed'); + expect(result.removed).toBe(1); + expect(result.notMarkedMissing).toBe(0); + }); + test('finishes the scan but holds back missing marks while a subfolder cannot be read', async () => { - const unreadableSubfolder = { ok: false, reason: 'subfolder-unreadable', path: '/test/output/dir/__Music', message: 'The subfolder /test/output/dir/__Music could not be read.' }; + const unreadableSubfolder = { ok: false, reason: 'subfolder-unreadable', path: '/test/output/dir/__Music', message: 'The subfolder __Music could not be read.' }; mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(unreadableSubfolder); mockVideo.count.mockResolvedValueOnce(1); // present rows mockVideo.count.mockResolvedValueOnce(1); // total rows diff --git a/server/modules/fileCheckModule.js b/server/modules/fileCheckModule.js index 16816c212..e37d890e2 100644 --- a/server/modules/fileCheckModule.js +++ b/server/modules/fileCheckModule.js @@ -4,7 +4,7 @@ const { Video } = require('../models'); const { VIDEO_EXTENSIONS, AUDIO_EXTENSIONS } = require('./filesystem/constants'); const createLimiter = require('./subscriptionImport/concurrencyLimiter'); const { REMOVAL_REASONS, REMOVAL_COLUMNS, removedColumns, presentColumns } = require('./videoRemovalState'); -const { checkLibraryRoot } = require('./libraryRootCheck'); +const { checkLibraryRoot, isRowUnderLibraryCheck } = require('./libraryRootCheck'); const configModule = require('./configModule'); // Per-video checks run concurrently up to this bound: each stat costs a full @@ -65,8 +65,8 @@ class FileCheckModule { /** * @returns {Promise<{videos: Array, updates: Array, libraryWarning: (Object|null)}>} * libraryWarning is set when rows would have been marked missing but the - * downloads folder looks unmounted (see libraryRootCheck): those rows are - * left as they are and counted in `heldBack`. + * downloads folder, or the subfolder they are stored in, looks unmounted + * (see libraryRootCheck): those rows are left as they are and counted in `heldBack`. */ async checkVideoFiles(videos) { const updatedVideos = [...videos]; @@ -144,8 +144,9 @@ class FileCheckModule { } else if (!hasAnyFile && !video.removed) { // A row that is already removed (deleted in Youtarr, or marked as // deleted) is left as it is: only a present row becomes missing. + // A failing subfolder holds back only the rows stored under it. const libraryCheck = await getLibraryCheck(); - if (libraryCheck.ok) { + if (!isRowUnderLibraryCheck(libraryCheck, video)) { Object.assign(update, removedColumns(REMOVAL_REASONS.NOT_FOUND)); hasUpdates = true; } else { diff --git a/server/modules/libraryRootCheck.js b/server/modules/libraryRootCheck.js index e88b90396..b69a2a4e6 100644 --- a/server/modules/libraryRootCheck.js +++ b/server/modules/libraryRootCheck.js @@ -3,6 +3,7 @@ // would then look gone. Nothing may be marked missing while this check fails. const fs = require('fs'); const path = require('path'); +const { Op } = require('sequelize'); // pathBuilder, not the filesystem index: the index loads fileOperations, which // binds child_process at require time, and this check needs neither. const { isSubfolderDirectory } = require('./filesystem/pathBuilder'); @@ -20,20 +21,23 @@ const SUBFOLDER_REASONS = [LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY, LIBRARY_CHECK_ // ENOTDIR: the folder's path now leads to a regular file, so the folder is gone. const ABSENT_CODES = ['ENOENT', 'ENOTDIR']; +// Shown to users, so no paths: the container path means nothing on the host, +// and a subfolder goes by its own name. function describeLibraryCheck({ reason, path: checkedPath }) { + const subfolder = checkedPath ? path.basename(checkedPath) : ''; switch (reason) { case LIBRARY_CHECK_REASONS.MISSING: - return `The downloads folder ${checkedPath} does not exist.`; + return 'The downloads folder does not exist.'; case LIBRARY_CHECK_REASONS.EMPTY: - return `The downloads folder ${checkedPath} is empty.`; + return 'The downloads folder is empty.'; case LIBRARY_CHECK_REASONS.UNREADABLE: - return `The downloads folder ${checkedPath} could not be read.`; + return 'The downloads folder could not be read.'; case LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY: - return `The subfolder ${checkedPath} is empty.`; + return `The subfolder ${subfolder} is empty.`; case LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE: - return `The subfolder ${checkedPath} could not be read.`; + return `The subfolder ${subfolder} could not be read.`; default: - return `The downloads folder ${checkedPath} could not be checked.`; + return 'The downloads folder could not be checked.'; } } @@ -41,6 +45,39 @@ function failure(reason, checkedPath) { return { ok: false, reason, path: checkedPath, message: describeLibraryCheck({ reason, path: checkedPath }) }; } +const SUBFOLDER_FAILURE_WORDING = [ + // Strictest first: an unreadable subfolder can never be explained away as emptied on purpose. + { reason: LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE, one: 'could not be read', many: 'could not be read' }, + { reason: LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY, one: 'is empty', many: 'are empty' }, +]; + +function listNames(names) { + if (names.length <= 2) return names.join(' and '); + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; +} + +/** + * One result for every failing subfolder: `reason` is the strictest one (so an + * unreadable share is never treated as emptied on purpose) and `path` the first + * subfolder failing that way; `paths` lists them all, and `message` names them. + * @param {Array<{reason: string, path: string}>} failures - in listing order + */ +function subfolderFailure(failures) { + const strictest = SUBFOLDER_FAILURE_WORDING.find(({ reason }) => failures.some((f) => f.reason === reason)).reason; + const sentences = [...SUBFOLDER_FAILURE_WORDING].reverse().map(({ reason, one, many }) => { + const names = failures.filter((f) => f.reason === reason).map((f) => path.basename(f.path)); + if (names.length === 0) return null; + return names.length === 1 ? `The subfolder ${names[0]} ${one}.` : `The subfolders ${listNames(names)} ${many}.`; + }).filter(Boolean); + return { + ok: false, + reason: strictest, + path: failures.find((f) => f.reason === strictest).path, + paths: failures.map((f) => f.path), + message: sentences.join(' '), + }; +} + // Entries Youtarr creates itself, possibly on a bare mount point while the // share is down, so they say nothing about whether the library is there. function isYoutarrManagedEntry(name) { @@ -49,11 +86,14 @@ function isYoutarrManagedEntry(name) { /** * The root must exist and hold something; every `__subfolder` in it (a share - * of its own, on some setups) must hold something too. First failure wins. + * of its own, on some setups) must hold something too. A root failure is + * reported alone; every failing subfolder is reported (see subfolderFailure), + * since each one only covers the files under it and two shares can be down at once. * Entries Youtarr creates itself do not count (see isYoutarrManagedEntry). * @param {string} baseDir - The downloads root * @param {{ readdir?: Function }} [deps] - fs.promises.readdir, injectable for tests - * @returns {Promise<{ok: true}|{ok: false, reason: string, path: string, message: string}>} + * @returns {Promise<{ok: true}|{ok: false, reason: string, path: string, paths?: string[], message: string}>} + * paths: every failing subfolder, for subfolder reasons */ async function checkLibraryRoot(baseDir, { readdir = fs.promises.readdir } = {}) { if (!baseDir) return failure(LIBRARY_CHECK_REASONS.MISSING, baseDir || ''); @@ -67,6 +107,7 @@ async function checkLibraryRoot(baseDir, { readdir = fs.promises.readdir } = {}) } if (entries.length === 0) return failure(LIBRARY_CHECK_REASONS.EMPTY, baseDir); + const failures = []; for (const entry of entries) { if (!entry.isDirectory() || !isSubfolderDirectory(entry.name)) continue; const subfolder = path.join(baseDir, entry.name); @@ -76,28 +117,81 @@ async function checkLibraryRoot(baseDir, { readdir = fs.promises.readdir } = {}) } catch (err) { // Gone since the root was listed: not a mount problem. if (ABSENT_CODES.includes(err.code)) continue; - return failure(LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE, subfolder); + failures.push({ reason: LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE, path: subfolder }); + continue; } - if (children.length === 0) return failure(LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY, subfolder); + if (children.length === 0) failures.push({ reason: LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY, path: subfolder }); } - return { ok: true }; + return failures.length > 0 ? subfolderFailure(failures) : { ok: true }; } +// The failing subfolders of a subfolder-reason check. +function failingSubfolders(check) { + return check.paths ?? [check.path]; +} + +const withTrailingSep = (dir) => (dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`); + /** * Whether a failing check puts the given file in doubt: a root failure covers - * every file, a subfolder failure only the files under that subfolder. - * @param {{ok: boolean, reason?: string, path?: string}} check - a checkLibraryRoot result + * every file, a subfolder failure only the files under the failing subfolders. + * @param {{ok: boolean, reason?: string, path?: string, paths?: string[]}} check - a checkLibraryRoot result * @param {string|null|undefined} filePath * @returns {boolean} */ function isUnderLibraryCheckPath(check, filePath) { if (!check || check.ok || !filePath) return false; if (!SUBFOLDER_REASONS.includes(check.reason)) return true; - const prefix = check.path.endsWith(path.sep) ? check.path : `${check.path}${path.sep}`; - return filePath.startsWith(prefix); + return failingSubfolders(check).some((dir) => filePath.startsWith(withTrailingSep(dir))); +} + +/** + * Whether a failing check covers a videos row: either of its files is in doubt. + * @param {{ok: boolean, reason?: string, path?: string}} check - a checkLibraryRoot result + * @param {{filePath?: (string|null), audioFilePath?: (string|null)}} row + * @returns {boolean} + */ +function isRowUnderLibraryCheck(check, row) { + return isUnderLibraryCheckPath(check, row.filePath) || isUnderLibraryCheckPath(check, row.audioFilePath); +} + +// A LIKE pattern matching every path under `dir`, with LIKE's own wildcards +// escaped: every subfolder name starts with two `_`. +function pathPrefixPattern(dir) { + return `${withTrailingSep(dir).replace(/[\\%_]/g, '\\$&')}%`; +} + +/** + * Whether a failing check covers any videos row the filter matches: for a + * root failure every such row, for a subfolder failure only the rows stored + * under the failing subfolders. An empty subfolder nothing was ever downloaded to (or + * that a channel move left behind) covers nothing, so it blocks nothing. + * @param {{ok: boolean, reason?: string, path?: string}} check - a checkLibraryRoot result + * @param {{count: Function}} Video - the Video model + * @param {Object} where - Sequelize filter for the rows that matter to the caller + * @returns {Promise<boolean>} + */ +async function libraryCheckCoversRows(check, Video, where) { + if (!check || check.ok) return false; + if (!SUBFOLDER_REASONS.includes(check.reason)) { + return (await Video.count({ where })) > 0; + } + const underSubfolder = { + [Op.or]: failingSubfolders(check).flatMap((dir) => { + const pattern = pathPrefixPattern(dir); + return [{ filePath: { [Op.like]: pattern } }, { audioFilePath: { [Op.like]: pattern } }]; + }), + }; + return (await Video.count({ where: { [Op.and]: [where, underSubfolder] } })) > 0; } module.exports = { - LIBRARY_CHECK_REASONS, checkLibraryRoot, describeLibraryCheck, isYoutarrManagedEntry, isUnderLibraryCheckPath, + LIBRARY_CHECK_REASONS, + checkLibraryRoot, + describeLibraryCheck, + isYoutarrManagedEntry, + isUnderLibraryCheckPath, + isRowUnderLibraryCheck, + libraryCheckCoversRows, }; diff --git a/server/modules/rescanRunSummary.js b/server/modules/rescanRunSummary.js index 08070a38f..78f1f1276 100644 --- a/server/modules/rescanRunSummary.js +++ b/server/modules/rescanRunSummary.js @@ -19,7 +19,8 @@ function describeRowProblems(result) { if (count(result.skippedChanged) > 0) parts.push(`${count(result.skippedChanged)} skipped (changed during the scan)`); if (count(result.failed) > 0) parts.push(`${count(result.failed)} failed`); if (count(result.notMarkedMissing) > 0) { - const cause = result.libraryCheckMessage ? ` (${result.libraryCheckMessage})` : ''; + // The check's message is a sentence; inside parentheses it drops its period. + const cause = result.libraryCheckMessage ? ` (${result.libraryCheckMessage.replace(/\.$/, '')})` : ''; parts.push(`${count(result.notMarkedMissing)} not marked missing${cause}`); } return parts; diff --git a/server/modules/videoDeletionModule.js b/server/modules/videoDeletionModule.js index c7b006ef8..a69cf4647 100644 --- a/server/modules/videoDeletionModule.js +++ b/server/modules/videoDeletionModule.js @@ -9,7 +9,7 @@ const storageUsage = require('./storageUsage'); const { STORED_BYTES_SQL } = storageUsage; const storageGuard = require('./storageGuard'); const { REMOVAL_REASONS, removedColumns } = require('./videoRemovalState'); -const { checkLibraryRoot, isUnderLibraryCheckPath } = require('./libraryRootCheck'); +const { checkLibraryRoot, isUnderLibraryCheckPath, libraryCheckCoversRows } = require('./libraryRootCheck'); // Oldest-first removal (free-space and total-usage strategies) works in // batches and stops after a bounded number of them per run. @@ -251,13 +251,13 @@ class VideoDeletionModule { } } - // Why the library must not be touched right now: the downloads folder looks - // unmounted (see libraryRootCheck) while downloaded videos are on record. A - // fresh install with nothing recorded is never blocked. Null when fine. + // Why the library must not be touched right now: the downloads folder (or a + // subfolder downloaded videos are stored in) looks unmounted (see + // libraryRootCheck). A fresh install with nothing recorded, or an empty + // subfolder no video is stored in, never blocks. Null when fine. async _libraryBlock() { const libraryCheck = await checkLibraryRoot(configModule.directoryPath); - if (libraryCheck.ok) return null; - if ((await Video.count({ where: { removed: false } })) === 0) return null; + if (!(await libraryCheckCoversRows(libraryCheck, Video, { removed: false }))) return null; return { reason: libraryCheck.reason, message: libraryCheck.message }; } diff --git a/server/modules/videoTrashModule.js b/server/modules/videoTrashModule.js index ecdb4c04a..5e10bf4a0 100644 --- a/server/modules/videoTrashModule.js +++ b/server/modules/videoTrashModule.js @@ -11,12 +11,16 @@ const messageEmitter = require('./messageEmitter'); const archiveModule = require('./archiveModule'); const archiveAccess = require('./download/archiveAccess'); const logger = require('../logger'); -const { checkLibraryRoot } = require('./libraryRootCheck'); +const { LIBRARY_CHECK_REASONS, checkLibraryRoot, libraryCheckCoversRows } = require('./libraryRootCheck'); const createLimiter = require('./subscriptionImport/concurrencyLimiter'); -const { REMOVAL_REASONS, REMOVAL_STATES, stateWhere, removedColumns } = require('./videoRemovalState'); +const { REMOVAL_REASONS, REMOVAL_STATES, stateWhere, notStateWhere, removedColumns } = require('./videoRemovalState'); // Rows are forgotten in chunks of this size, each in its own transaction. const PURGE_CHUNK_SIZE = 500; +// Check results that can also mean the user emptied the folder on purpose, +// the only ones "mark all missing as deleted" may be told to look past. +const EMPTY_FOLDER_REASONS = [LIBRARY_CHECK_REASONS.EMPTY, LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY]; +const PASSING_CHECK = { ok: true }; // Cached files (info.json, thumbnails) are unlinked this many at a time. const UNLINK_CONCURRENCY = 16; @@ -54,8 +58,12 @@ class VideoTrashModule { } /** - * @returns {Promise<{deleted: number, missing: number, legacy: number, libraryCheck: Object, downloadsActive: boolean}>} + * @returns {Promise<{deleted: number, missing: number, legacy: number, libraryCheck: Object, + * missingActionsBlocked: boolean, downloadsActive: boolean}>} * legacy: deleted rows removed before the reason was recorded (a subset of deleted); + * libraryCheck: failing while it covers present or missing videos (the Maintenance card warns and + * offers Rescan anyway), passing otherwise; missingActionsBlocked: it covers missing videos, so + * marking all of them as deleted or emptying them from the trash is refused; * downloadsActive: a purge that removes archive entries would be refused right now */ async getRemovedSummary() { @@ -63,26 +71,48 @@ class VideoTrashModule { const missing = await Video.count({ where: stateWhere(REMOVAL_STATES.MISSING) }); const legacy = await Video.count({ where: { removed: true, removed_reason: null } }); const libraryCheck = await checkLibraryRoot(configModule.directoryPath); + // Present videos count for the warning: their missing marks are held back, + // and Rescan anyway is how a user who emptied the folder releases them. + const coversRecorded = await libraryCheckCoversRows(libraryCheck, Video, notStateWhere(REMOVAL_STATES.DELETED)); + const missingActionsBlocked = coversRecorded + && await libraryCheckCoversRows(libraryCheck, Video, stateWhere(REMOVAL_STATES.MISSING)); return { deleted, missing, legacy, - libraryCheck: normalizeLibraryCheck(libraryCheck), + libraryCheck: normalizeLibraryCheck(coversRecorded ? libraryCheck : PASSING_CHECK), + missingActionsBlocked, downloadsActive: archiveAccess.isDownloading(), }; } + // The failing library check that puts missing videos in doubt, or null. The + // missing-video actions touch nothing else: present videos under a failing + // folder are already held back by the file check and the rescan. + async _checkCoveringMissing() { + const libraryCheck = await checkLibraryRoot(configModule.directoryPath); + const covers = await libraryCheckCoversRows(libraryCheck, Video, stateWhere(REMOVAL_STATES.MISSING)); + return covers ? libraryCheck : null; + } + /** * Turn missing videos into deleted ones. Only rows that are missing change; * present and deleted ids in the selection are left alone. - * @param {{videoIds?: (number[]|null), all?: boolean}} options + * @param {{videoIds?: (number[]|null), all?: boolean, ignoreLibraryCheck?: boolean}} options - + * ignoreLibraryCheck: the user emptied the downloads folder (or a subfolder) on purpose; honoured + * only while the check fails because a folder is empty, never because one is gone or unreadable * @returns {Promise<{updated: number}>} * @throws {LibraryUnavailableError} for `all` while the downloads folder looks unmounted */ - async markMissingAsDeleted({ videoIds = null, all = false } = {}) { + async markMissingAsDeleted({ videoIds = null, all = false, ignoreLibraryCheck = false } = {}) { if (all) { - const libraryCheck = await checkLibraryRoot(configModule.directoryPath); - if (!libraryCheck.ok) throw new LibraryUnavailableError(libraryCheck); + const libraryCheck = await this._checkCoveringMissing(); + if (libraryCheck) { + if (!ignoreLibraryCheck || !EMPTY_FOLDER_REASONS.includes(libraryCheck.reason)) { + throw new LibraryUnavailableError(libraryCheck); + } + logger.warn({ libraryCheck }, 'Marking every missing video as deleted without the downloads folder check, by request'); + } } const where = { ...stateWhere(REMOVAL_STATES.MISSING) }; @@ -122,8 +152,8 @@ class VideoTrashModule { try { if (removeFromArchive) releaseArchive = archiveAccess.acquirePurge(); if (states.includes(REMOVAL_STATES.MISSING)) { - const libraryCheck = await checkLibraryRoot(configModule.directoryPath); - if (!libraryCheck.ok) throw new LibraryUnavailableError(libraryCheck); + const libraryCheck = await this._checkCoveringMissing(); + if (libraryCheck) throw new LibraryUnavailableError(libraryCheck); } const jobIds = new Set(); diff --git a/server/modules/videosModule.js b/server/modules/videosModule.js index a1729dc81..dd0f84c94 100644 --- a/server/modules/videosModule.js +++ b/server/modules/videosModule.js @@ -18,7 +18,7 @@ const createLimiter = require('./subscriptionImport/concurrencyLimiter'); const { isRescanCandidate, resolveRescanUpdate } = require('./rescanRowUpdate'); const { unchangedSinceRead, GUARDED_COLUMNS } = require('./videoRowGuard'); const youtubeExistenceCheck = require('./youtubeExistenceCheck'); -const { LIBRARY_CHECK_REASONS, checkLibraryRoot } = require('./libraryRootCheck'); +const { LIBRARY_CHECK_REASONS, checkLibraryRoot, isRowUnderLibraryCheck, libraryCheckCoversRows } = require('./libraryRootCheck'); const { REMOVAL_STATES, stateWhere, notStateWhere } = require('./videoRemovalState'); const { EXISTENCE } = youtubeExistenceCheck; @@ -524,11 +524,12 @@ class VideosModule { * Deliberately does not check the run's time limit: once started, a slice's * writes complete, so the expensive work already done is never discarded. * @param {Array<{row: Object, fileInfo: Object|undefined, probedResolution: string|null}>} candidates - * @param {{suppressMissing?: boolean}} [options] - suppressMissing: the downloads folder looks - * partly unmounted, so rows that would be marked missing are left alone and counted + * @param {{holdBackMissing?: function(Object): boolean}} [options] - holdBackMissing: true for a row + * stored where the downloads folder looks partly unmounted; if it would be marked missing it is + * left alone and counted * @returns {Promise<{updated: number, removed: number, kept: number, skipped: number, failed: number, notMarkedMissing: number}>} */ - async _applyRescanUpdates(candidates, { suppressMissing = false } = {}) { + async _applyRescanUpdates(candidates, { holdBackMissing = () => false } = {}) { const counts = { updated: 0, removed: 0, kept: 0, skipped: 0, failed: 0, notMarkedMissing: 0 }; const limit = createLimiter(RESCAN_WRITE_CONCURRENCY); await Promise.all(candidates.map(({ row, fileInfo, probedResolution }) => limit(async () => { @@ -540,7 +541,7 @@ class VideosModule { counts.kept++; return; } - if (suppressMissing && changes.removed === true) { + if (changes.removed === true && holdBackMissing(row)) { counts.notMarkedMissing++; return; } @@ -611,18 +612,20 @@ class VideosModule { } // An unmounted drive or share would make every present video look - // gone. With no present rows there is nothing to protect (a fresh - // install with an empty folder), so the scan simply runs. - let suppressMissing = false; + // gone. With no present rows under the failing folder there is nothing + // to protect (a fresh install, or an empty subfolder nothing is stored + // in), so the scan simply runs. + let holdBackMissing = () => false; if (ignoreLibraryCheck) logger.warn({ trigger }, 'Rescan is skipping the downloads folder check by request'); const libraryCheck = ignoreLibraryCheck ? { ok: true } : await checkLibraryRoot(outputDir); - if (!libraryCheck.ok && (await Video.count({ where: { removed: false } })) > 0) { + if (await libraryCheckCoversRows(libraryCheck, Video, { removed: false })) { const subfolderOnly = libraryCheck.reason === LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY || libraryCheck.reason === LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE; if (subfolderOnly) { - suppressMissing = true; + // Only the rows stored under that subfolder are in doubt. + holdBackMissing = (row) => isRowUnderLibraryCheck(libraryCheck, row); libraryCheckMessage = libraryCheck.message; - logger.warn({ libraryCheck }, 'A subfolder looks unmounted or unreadable; the rescan will not mark videos missing'); + logger.warn({ libraryCheck }, 'A subfolder looks unmounted or unreadable; the rescan will not mark videos in it missing'); } else { logger.warn({ libraryCheck }, 'The downloads folder looks unmounted; skipping the rescan'); result = { @@ -737,7 +740,7 @@ class VideosModule { // re-probed next run and never converge. if (candidates.length > 0) { logProgress(`Checking ${candidates.length} records (chunk ${Math.floor(offset / VIDEO_CHUNK_SIZE) + 1})...`); - const counts = await this._applyRescanUpdates(candidates, { suppressMissing }); + const counts = await this._applyRescanUpdates(candidates, { holdBackMissing }); totalUpdated += counts.updated; totalRemoved += counts.removed; totalKept += counts.kept; diff --git a/server/routes/__tests__/videoTrash.test.js b/server/routes/__tests__/videoTrash.test.js index 78333cc1e..b394920d8 100644 --- a/server/routes/__tests__/videoTrash.test.js +++ b/server/routes/__tests__/videoTrash.test.js @@ -75,7 +75,7 @@ describe('POST /api/videos/mark-deleted', () => { const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ videoIds: [1, 2] }); expect(res.status).toBe(200); expect(res.body).toEqual({ updated: 2 }); - expect(module.markMissingAsDeleted).toHaveBeenCalledWith({ videoIds: [1, 2], all: false }); + expect(module.markMissingAsDeleted).toHaveBeenCalledWith({ videoIds: [1, 2], all: false, ignoreLibraryCheck: false }); }); test('marks all missing videos', async () => { @@ -83,16 +83,31 @@ describe('POST /api/videos/mark-deleted', () => { const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true }); expect(res.status).toBe(200); expect(res.body).toEqual({ updated: 40 }); - expect(module.markMissingAsDeleted).toHaveBeenCalledWith({ videoIds: null, all: true }); + expect(module.markMissingAsDeleted).toHaveBeenCalledWith({ videoIds: null, all: true, ignoreLibraryCheck: false }); + }); + + test('marks all past the downloads folder check when asked to', async () => { + const module = makeModule({ markMissingAsDeleted: jest.fn().mockResolvedValue({ updated: 40 }) }); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true, ignoreLibraryCheck: true }); + expect(res.status).toBe(200); + expect(module.markMissingAsDeleted).toHaveBeenCalledWith({ videoIds: null, all: true, ignoreLibraryCheck: true }); + }); + + test('rejects a non-boolean ignoreLibraryCheck with 400', async () => { + const module = makeModule(); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true, ignoreLibraryCheck: 'yes' }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'ignoreLibraryCheck must be true or false' }); + expect(module.markMissingAsDeleted).not.toHaveBeenCalled(); }); test('answers 409 with the reason while the downloads folder looks unmounted', async () => { const module = makeModule({ - markMissingAsDeleted: jest.fn().mockRejectedValue(new LibraryUnavailableError('The downloads folder /d is empty.')), + markMissingAsDeleted: jest.fn().mockRejectedValue(new LibraryUnavailableError('The downloads folder is empty.')), }); const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true }); expect(res.status).toBe(409); - expect(res.body).toEqual({ error: 'The downloads folder /d is empty.', reason: 'library-unavailable' }); + expect(res.body).toEqual({ error: 'The downloads folder is empty.', reason: 'library-unavailable' }); }); test('answers 500 with the standard error shape on any other failure', async () => { @@ -153,10 +168,10 @@ describe('POST /api/videos/purge-removed', () => { }); test('answers 409 with the reason while the downloads folder looks unmounted', async () => { - const module = makeModule({ purgeRemoved: jest.fn().mockRejectedValue(new LibraryUnavailableError('The downloads folder /d is empty.')) }); + const module = makeModule({ purgeRemoved: jest.fn().mockRejectedValue(new LibraryUnavailableError('The downloads folder is empty.')) }); const res = await supertest(makeApp(module)).post('/api/videos/purge-removed').send({ states: ['missing'], removeFromArchive: false }); expect(res.status).toBe(409); - expect(res.body).toEqual({ error: 'The downloads folder /d is empty.', reason: 'library-unavailable' }); + expect(res.body).toEqual({ error: 'The downloads folder is empty.', reason: 'library-unavailable' }); }); test('answers 500 with the standard error shape on any other failure', async () => { diff --git a/server/routes/videoTrash.js b/server/routes/videoTrash.js index 487593029..c541be449 100644 --- a/server/routes/videoTrash.js +++ b/server/routes/videoTrash.js @@ -26,7 +26,9 @@ module.exports = function createVideoTrashRoutes({ verifyToken, videoTrashModule * Deleted = Youtarr removed the file (a delete in the app, automatic cleanup, or a missing video the user * marked as deleted), including videos removed before Youtarr recorded why (`legacy`, a subset of `deleted`). * Missing = a file was expected on disk and not found. `libraryCheck` says whether the downloads folder - * looks mounted; while it does not, actions over all missing videos are refused. + * looks mounted; a failing subfolder that no present or missing video is stored in is reported as passing, + * since it puts nothing in doubt. `missingActionsBlocked` says whether the failing check covers missing + * videos, in which case actions over all missing videos are refused. * tags: [Videos] * responses: * 200: @@ -57,6 +59,9 @@ module.exports = function createVideoTrashRoutes({ verifyToken, videoTrashModule * message: * type: string * nullable: true + * missingActionsBlocked: + * type: boolean + * description: The failing library check covers missing videos, so marking all of them or emptying them is refused * downloadsActive: * type: boolean * description: A download is running, so a purge that removes archive entries would be refused @@ -83,7 +88,10 @@ module.exports = function createVideoTrashRoutes({ verifyToken, videoTrashModule * Turns missing videos (a file was expected on disk and not found) into deleted ones, which the Videos page * hides by default. Only missing rows change; present and deleted ids in a selection are left alone. * The records are kept, so the videos can still be re-downloaded, and a file that turns up again is marked - * downloaded by the rescan. Send either `videoIds` or `all: true`. + * downloaded by the rescan. Send either `videoIds` or `all: true`. `all` is refused while the downloads + * folder, or a subfolder missing videos are stored in, looks unmounted; with + * `ignoreLibraryCheck: true` it goes ahead anyway when the check failed only because a folder is empty + * (one the user emptied on purpose), never when a folder is gone or unreadable. * tags: [Videos] * requestBody: * required: true @@ -102,6 +110,10 @@ module.exports = function createVideoTrashRoutes({ verifyToken, videoTrashModule * all: * type: boolean * description: Mark every missing video + * ignoreLibraryCheck: + * type: boolean + * default: false + * description: With all, go ahead while the downloads folder (or a subfolder) looks empty * responses: * 200: * description: How many videos were marked @@ -113,11 +125,11 @@ module.exports = function createVideoTrashRoutes({ verifyToken, videoTrashModule * updated: * type: integer * 400: - * description: Neither a valid selection nor all, or both + * description: Neither a valid selection nor all, or both, or ignoreLibraryCheck was not a boolean * 401: * description: Authentication required * 409: - * description: all was asked for while the downloads folder looks unmounted (reason library-unavailable); the videos may be fine + * description: all was asked for while the downloads folder, or a subfolder missing videos are stored in, looks unmounted (reason library-unavailable); the videos may be fine * content: * application/json: * schema: @@ -132,7 +144,10 @@ module.exports = function createVideoTrashRoutes({ verifyToken, videoTrashModule * description: The videos could not be marked */ router.post('/api/videos/mark-deleted', verifyToken, async (req, res) => { - const { videoIds, all } = req.body || {}; + const { videoIds, all, ignoreLibraryCheck = false } = req.body || {}; + if (typeof ignoreLibraryCheck !== 'boolean') { + return res.status(400).json({ error: 'ignoreLibraryCheck must be true or false' }); + } const wantsAll = all === true; const selection = Array.isArray(videoIds) && videoIds.length >= 1 @@ -146,7 +161,11 @@ module.exports = function createVideoTrashRoutes({ verifyToken, videoTrashModule } try { - const result = await videoTrashModule.markMissingAsDeleted({ videoIds: wantsAll ? null : selection, all: wantsAll }); + const result = await videoTrashModule.markMissingAsDeleted({ + videoIds: wantsAll ? null : selection, + all: wantsAll, + ignoreLibraryCheck, + }); res.json(result); } catch (err) { if (err.code === 'LIBRARY_UNAVAILABLE') { @@ -207,7 +226,7 @@ module.exports = function createVideoTrashRoutes({ verifyToken, videoTrashModule * 401: * description: Authentication required * 409: - * description: Another purge is running (reason running), downloads are using the archive (reason archive-busy), or missing videos were included while the downloads folder looks unmounted (reason library-unavailable) + * description: Another purge is running (reason running), downloads are using the archive (reason archive-busy), or missing videos were included while the downloads folder, or a subfolder missing videos are stored in, looks unmounted (reason library-unavailable) * content: * application/json: * schema: From 006bb04bdecab63c47317a6afd9eb92d8f40ff35 Mon Sep 17 00:00:00 2001 From: dialmaster <dial.chris@gmail.com> Date: Sun, 18 Oct 2026 20:42:36 -0700 Subject: [PATCH 4/4] fix: tidy trash wording and label mark as deleted - Say "file" instead of "files" in the Mark all missing as deleted dialog when only one video is missing. - Reword the Videos page banner and Maintenance card warnings so they read correctly when the message names more than one subfolder: "Until that changes" replaces "until it is back". - Show "The downloads folder check was skipped." on the Maintenance page's Last rescan line after a Rescan anyway, as the Scheduling page already does. - Keep the label on the video window's Mark as deleted button on phones, where it showed only an icon and acted on the first tap, and let the phone action row wrap so the label is not cut off. Refs: #670 --- .../Settings/MaintenanceSection.tsx | 1 + .../__tests__/MaintenanceSection.test.tsx | 17 +++++++++++++ .../Settings/components/RemovedVideosCard.tsx | 11 ++++---- .../__tests__/RemovedVideosCard.test.tsx | 18 ++++++++++++- client/src/components/VideosPage/index.tsx | 6 ++--- .../components/__tests__/VideosPage.test.tsx | 12 ++++----- .../VideoModal/__tests__/VideoModal.test.tsx | 25 +++++++++++++++++++ .../VideoModal/components/VideoActions.tsx | 10 +++++--- client/src/hooks/useRescanStatus.ts | 2 ++ .../__tests__/rescanRunSummary.test.js | 5 ++++ server/modules/rescanRunSummary.js | 1 + 11 files changed, 88 insertions(+), 20 deletions(-) diff --git a/client/src/components/Settings/MaintenanceSection.tsx b/client/src/components/Settings/MaintenanceSection.tsx index c28365d00..f9a49a491 100644 --- a/client/src/components/Settings/MaintenanceSection.tsx +++ b/client/src/components/Settings/MaintenanceSection.tsx @@ -33,6 +33,7 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) { Last rescan: {formatDateTime(lastRun.completedAt)} ({lastRun.trigger}). Updated{' '} {lastRun.videosUpdated} videos, marked {lastRun.videosMarkedMissing} missing. {lastRun.status === 'timed-out' && ' (timed out; click to continue)'} + {lastRun.libraryCheckSkipped && ' The downloads folder check was skipped.'} </Typography> ); } else { diff --git a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx index 51a484529..026a2ef0f 100644 --- a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx +++ b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx @@ -91,6 +91,23 @@ describe('MaintenanceSection', () => { expect(screen.getByText(/marked 2 missing/i)).toBeInTheDocument(); }); + test('says when the last rescan skipped the downloads folder check', () => { + const lastRun: RescanLastRun = { + startedAt: '2026-05-04T15:00:00.000Z', + completedAt: '2026-05-04T15:01:00.000Z', + trigger: 'manual', + status: 'completed', + videosUpdated: 0, + videosMarkedMissing: 3, + videosScanned: 100, + filesFoundOnDisk: 97, + errorMessage: null, + libraryCheckSkipped: true + }; + setup({ lastRun }); + expect(screen.getByText(/The downloads folder check was skipped\./)).toBeInTheDocument(); + }); + test('disables button while running', () => { setup({ running: true }); expect(screen.getByRole('button', { name: /rescan files on disk/i })).toBeDisabled(); diff --git a/client/src/components/Settings/components/RemovedVideosCard.tsx b/client/src/components/Settings/components/RemovedVideosCard.tsx index 750a90dd4..045dfcdec 100644 --- a/client/src/components/Settings/components/RemovedVideosCard.tsx +++ b/client/src/components/Settings/components/RemovedVideosCard.tsx @@ -145,11 +145,11 @@ function RemovedVideosCard({ token, onRescanAnyway, rescanRunning = false }: Rem <Alert severity="warning" className="break-words"> {missingBlocked ? folderLooksEmpty - ? `${libraryWarning} Missing videos stored there cannot be emptied from the trash until it is back;` + ? `${libraryWarning} Until that changes, missing videos stored there cannot be emptied from the trash;` + ' Mark all missing as deleted asks you to confirm that you removed the files yourself.' + (canRescanAnyway ? ' Rescan anyway first if some of the videos stored there still show as available.' : '') - : `${libraryWarning} Marking or emptying missing videos is disabled until it is back.` - : `${libraryWarning} Videos stored there are not being marked missing until it is back.` + : `${libraryWarning} Until that changes, marking or emptying missing videos is disabled.` + : `${libraryWarning} Until that changes, videos stored there are not being marked missing.` + (canRescanAnyway ? ' If you removed the files yourself, rescan anyway to mark them missing.' : '')} </Alert> )} @@ -205,9 +205,8 @@ function RemovedVideosCard({ token, onRescanAnyway, rescanRunning = false }: Rem <div className="space-y-3"> <Typography variant="body2"> {missing === 1 - ? '1 missing video will be marked as deleted and hidden from the Videos page by default. Its record is kept, so it can still be re-downloaded, and if its file turns up again the rescan marks it downloaded.' - : `${missing} missing videos will be marked as deleted and hidden from the Videos page by default. Their records are kept, so they can still be re-downloaded, and if a file turns up again the rescan marks it downloaded.`} - {' '}Only do this if the files are really gone, not sitting on an unmounted drive. + ? '1 missing video will be marked as deleted and hidden from the Videos page by default. Its record is kept, so it can still be re-downloaded, and if its file turns up again the rescan marks it downloaded. Only do this if the file is really gone, not sitting on an unmounted drive.' + : `${missing} missing videos will be marked as deleted and hidden from the Videos page by default. Their records are kept, so they can still be re-downloaded, and if a file turns up again the rescan marks it downloaded. Only do this if the files are really gone, not sitting on an unmounted drive.`} </Typography> {needsEmptiedAcknowledgement && ( <> diff --git a/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx b/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx index 0edef84e6..d66e3d1ab 100644 --- a/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx +++ b/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx @@ -67,11 +67,19 @@ describe('RemovedVideosCard', () => { test('disables the missing action with the warning while the downloads folder looks unmounted', () => { setup(summary({ libraryCheck: { ok: false, reason: 'missing', path: '/d', message: 'The downloads folder does not exist.' }, missingActionsBlocked: true })); - expect(screen.getByText('The downloads folder does not exist. Marking or emptying missing videos is disabled until it is back.')).toBeInTheDocument(); + expect(screen.getByText('The downloads folder does not exist. Until that changes, marking or emptying missing videos is disabled.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Mark all missing as deleted' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeEnabled(); }); + test('words the warning to fit a message that names several subfolders', () => { + const check = { ok: false, reason: 'subfolder-empty', path: '/d/__Kids', message: 'The subfolders __Kids and __Music are empty.' }; + setup(summary({ libraryCheck: check })); + expect(screen.getByText( + 'The subfolders __Kids and __Music are empty. Until that changes, videos stored there are not being marked missing.' + )).toBeInTheDocument(); + }); + test('offers Rescan anyway while the downloads folder looks empty', () => { setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway: rescanThatStarts(), rescanRunning: false }); expect(screen.getByText(/If you removed the files yourself, rescan anyway to mark them missing\./)).toBeInTheDocument(); @@ -252,6 +260,14 @@ describe('RemovedVideosCard', () => { expect(await screen.findByText('Marked 4 videos as deleted.')).toBeInTheDocument(); }); + test('speaks of one file when marking the only missing video', async () => { + const { user } = setup(summary({ missing: 1 })); + + await user.click(screen.getByRole('button', { name: 'Mark all missing as deleted' })); + + expect(screen.getByText(/Only do this if the file is really gone, not sitting on an unmounted drive\./)).toBeInTheDocument(); + }); + test('empties the trash through the dialog and reports what went', async () => { const { hook, user } = setup(); diff --git a/client/src/components/VideosPage/index.tsx b/client/src/components/VideosPage/index.tsx index c3374804b..030540900 100644 --- a/client/src/components/VideosPage/index.tsx +++ b/client/src/components/VideosPage/index.tsx @@ -585,11 +585,11 @@ function VideosPage({ token }: VideosPageProps) { )} {libraryWarning && libraryWarning.message !== dismissedLibraryWarning && ( <Alert severity="warning" className="mt-2 break-words" onClose={() => setDismissedLibraryWarning(libraryWarning.message)}> - {libraryWarning.message} Videos stored there are not being marked missing until it is back; check that - the drive or share is mounted. + {libraryWarning.message} Until that changes, videos stored there are not being marked missing; check + that the drive or share is mounted. {EMPTY_FOLDER_REASONS.includes(libraryWarning.reason) && ( <> - {' '}If you emptied it on purpose, you can rescan anyway from{' '} + {' '}If you removed the files yourself, you can rescan anyway from{' '} <Button variant="link" className="h-auto p-0 align-baseline text-sm" diff --git a/client/src/components/__tests__/VideosPage.test.tsx b/client/src/components/__tests__/VideosPage.test.tsx index 5af4ce083..e4516865b 100644 --- a/client/src/components/__tests__/VideosPage.test.tsx +++ b/client/src/components/__tests__/VideosPage.test.tsx @@ -508,7 +508,7 @@ describe('VideosPage Component', () => { render(<VideosPage token={mockToken} />); - expect(await screen.findByText(/The downloads folder is empty\. Videos stored there are not being marked missing until it is back/)).toBeInTheDocument(); + expect(await screen.findByText(/The downloads folder is empty\. Until that changes, videos stored there are not being marked missing; check/)).toBeInTheDocument(); }); test('hides the unmounted-folder warning once dismissed', async () => { @@ -521,11 +521,11 @@ describe('VideosPage Component', () => { }); render(<VideosPage token={mockToken} />); - await screen.findByText(/Videos stored there are not being marked missing/); + await screen.findByText(/videos stored there are not being marked missing/); await user.click(screen.getByRole('button', { name: 'Close' })); - expect(screen.queryByText(/Videos stored there are not being marked missing/)).not.toBeInTheDocument(); + expect(screen.queryByText(/videos stored there are not being marked missing/)).not.toBeInTheDocument(); }); test('shows the warning again when a later problem follows one that cleared', async () => { @@ -542,7 +542,7 @@ describe('VideosPage Component', () => { .mockResolvedValueOnce(warned); render(<VideosPage token={mockToken} />); - await screen.findByText(/Videos stored there are not being marked missing/); + await screen.findByText(/videos stored there are not being marked missing/); await user.click(screen.getByRole('button', { name: 'Close' })); // Each mark from the modal reloads the list: first with the folder back, then failing again. @@ -551,7 +551,7 @@ describe('VideosPage Component', () => { await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); await user.click(screen.getByRole('button', { name: 'Modal mark as deleted' })); - expect(await screen.findByText(/Videos stored there are not being marked missing/)).toBeInTheDocument(); + expect(await screen.findByText(/videos stored there are not being marked missing/)).toBeInTheDocument(); }); test('does not suggest rescanning anyway when a folder could not be read', async () => { @@ -567,7 +567,7 @@ describe('VideosPage Component', () => { render(<VideosPage token={mockToken} />); - expect(await screen.findByText(/Videos stored there are not being marked missing/)).toBeInTheDocument(); + expect(await screen.findByText(/videos stored there are not being marked missing/)).toBeInTheDocument(); expect(screen.queryByText(/rescan anyway/)).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Maintenance' })).not.toBeInTheDocument(); }); diff --git a/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx b/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx index 0237d9519..75391a0e0 100644 --- a/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx +++ b/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx @@ -730,6 +730,31 @@ describe('VideoModal', () => { expect(screen.queryByRole('button', { name: 'Mark as deleted' })).not.toBeInTheDocument(); }); + describe('on a phone', () => { + const originalMatchMedia = window.matchMedia; + beforeEach(() => { + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: query === '(max-width: 599px)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + }); + afterEach(() => { + window.matchMedia = originalMatchMedia; + }); + + test('keeps its label while the other actions show only icons', () => { + renderModal({ video: missingVideo }); + expect(screen.getByRole('button', { name: 'Download video' })).not.toHaveTextContent('Download'); + expect(screen.getByRole('button', { name: 'Mark as deleted' })).toHaveTextContent('Mark as deleted'); + }); + }); + test('shows the server message when marking fails', async () => { axios.post.mockRejectedValueOnce({ response: { data: { error: 'Failed to mark videos as deleted' } } }); diff --git a/client/src/components/shared/VideoModal/components/VideoActions.tsx b/client/src/components/shared/VideoModal/components/VideoActions.tsx index 3a84fb0e3..6473828e1 100644 --- a/client/src/components/shared/VideoModal/components/VideoActions.tsx +++ b/client/src/components/shared/VideoModal/components/VideoActions.tsx @@ -89,7 +89,8 @@ function VideoActions({ <Box style={{ display: 'flex', - flexWrap: 'nowrap', + // On a phone, a labeled button that does not fit moves to a second line instead of being cut off. + flexWrap: isMobile ? 'wrap' : 'nowrap', alignItems: 'center', gap: 'var(--video-modal-action-row-gap, 8px)', paddingTop: 'var(--video-modal-action-row-padding-y, 2px)', @@ -229,6 +230,7 @@ function VideoActions({ <Box style={{ flex: 1, minWidth: isMobile ? 0 : 12 }} /> {showMarkDeleted && ( + // Labeled even on a phone: it acts on the first tap, and its icon alone does not say what it does. <Tooltip title="The file is really gone: hide this video from the Videos page by default"> <Button size="small" @@ -237,10 +239,10 @@ function VideoActions({ onClick={onMarkDeleted} disabled={markDeletedPending} aria-label="Mark as deleted" - startIcon={useIconOnlyActions ? undefined : <MarkDeletedIcon size={16} />} - style={actionButtonStyle} + startIcon={<MarkDeletedIcon size={16} />} + style={labeledActionButtonStyle} > - {useIconOnlyActions ? <MarkDeletedIcon size={16} /> : 'Mark as deleted'} + Mark as deleted </Button> </Tooltip> )} diff --git a/client/src/hooks/useRescanStatus.ts b/client/src/hooks/useRescanStatus.ts index 8d1803b85..48b41fc47 100644 --- a/client/src/hooks/useRescanStatus.ts +++ b/client/src/hooks/useRescanStatus.ts @@ -15,6 +15,8 @@ export interface RescanLastRun { videosScanned: number; filesFoundOnDisk: number; errorMessage: string | null; + // A "Rescan anyway" run, which skipped the unmounted-drive check. + libraryCheckSkipped?: boolean; } interface RescanStatusResponse { diff --git a/server/modules/__tests__/rescanRunSummary.test.js b/server/modules/__tests__/rescanRunSummary.test.js index b85f5936f..317c9b516 100644 --- a/server/modules/__tests__/rescanRunSummary.test.js +++ b/server/modules/__tests__/rescanRunSummary.test.js @@ -149,6 +149,11 @@ describe('rescanRunSummary', () => { }); }); + test('carries a skipped downloads folder check through to the maintenance page', () => { + const skippedRun = { ...run, details: { ...run.details, libraryCheckSkipped: true } }; + expect(fromRunRecord(skippedRun).libraryCheckSkipped).toBe(true); + }); + test('reports an interrupted run as an error with its message', () => { expect(fromRunRecord({ ...run, status: 'interrupted', outcome: null, message: 'Interrupted by a restart.', details: null })).toEqual( expect.objectContaining({ status: 'error', errorMessage: 'Interrupted by a restart.', videosScanned: 0 }) diff --git a/server/modules/rescanRunSummary.js b/server/modules/rescanRunSummary.js index 78f1f1276..af701b4f4 100644 --- a/server/modules/rescanRunSummary.js +++ b/server/modules/rescanRunSummary.js @@ -90,6 +90,7 @@ function fromRunRecord(run) { videosScanned: count(details.videosScanned), filesFoundOnDisk: count(details.filesFoundOnDisk), errorMessage: status === 'error' ? (run.message || null) : null, + ...(details.libraryCheckSkipped ? { libraryCheckSkipped: true } : {}), }; }