From a2f601fe17d5deaa47a38021c957f48904180d5a Mon Sep 17 00:00:00 2001 From: dialmaster Date: Mon, 19 Oct 2026 10:59:59 -0700 Subject: [PATCH] feat: delete videos from the playlist page Downloaded videos can now be selected and deleted right from the playlist page, and the video table stays readable on smaller screens. - The table layout now holds down to a window about 900px wide, instead of switching to cards below about 1380px. The channel name sits under the title and Exclude/Include is an icon next to each row's checkbox, which also gives titles more room on wide screens. - Downloaded and not-downloaded videos can be selected together. Download fetches the ones that aren't on disk, Delete removes the downloaded ones, and each confirmation says how many videos it will affect and which it will leave alone. - After a video is deleted, from any page or by automatic cleanup, its playlists' .m3u files and Plex, Jellyfin, and Emby playlists update right away instead of listing the deleted file until the next download. - Deleting the last downloaded video of a playlist empties its Plex playlist and removes its Jellyfin or Emby playlist without an error; they fill back in when videos are downloaded again. - Videos marked missing stay in synced playlists, since their files may come back. --- CLAUDE.md | 5 +- client/src/components/PlaylistPage.tsx | 66 +++++++-- .../components/PlaylistVideoCard.tsx | 4 +- .../components/PlaylistVideoList.tsx | 10 +- .../components/PlaylistVideoTable.tsx | 112 +++++++------- .../__tests__/PlaylistVideoCard.test.tsx | 11 +- .../__tests__/PlaylistVideoList.test.tsx | 10 +- .../__tests__/PlaylistVideoTable.test.tsx | 52 +++++-- .../__tests__/playlistVideoHelpers.test.tsx | 32 +++- .../components/playlistVideoHelpers.tsx | 9 ++ .../usePlaylistVideoDeletion.test.ts | 125 ++++++++++++++++ .../hooks/usePlaylistVideoDeletion.ts | 72 +++++++++ .../__tests__/PlaylistPage.test.tsx | 105 +++++++++++++ .../components/shared/DeleteVideosDialog.tsx | 6 +- .../__tests__/DeleteVideosDialog.test.tsx | 4 +- .../hooks/__tests__/usePlaylistDetail.test.ts | 21 +++ client/src/hooks/usePlaylistDetail.ts | 9 +- docs/MEDIA_SERVER_PLAYLISTS.md | 4 +- docs/USAGE_GUIDE.md | 2 +- server/modules/__tests__/m3uGenerator.test.js | 26 ++++ .../__tests__/playlistOutputRefresh.test.js | 97 ++++++++++++ .../__tests__/videoDeletionModule.test.js | 56 +++++++ server/modules/m3uGenerator.js | 5 +- .../__tests__/mediaServerSync.test.js | 138 ++++++++++++++++++ .../adapters/__tests__/embyAdapter.test.js | 11 ++ .../__tests__/jellyfinAdapter.test.js | 11 ++ .../adapters/__tests__/plexAdapter.test.js | 37 +++++ .../mediaServers/adapters/baseAdapter.js | 8 +- .../mediaServers/adapters/embyAdapter.js | 3 + .../mediaServers/adapters/jellyfinAdapter.js | 3 + .../mediaServers/adapters/plexAdapter.js | 18 ++- .../modules/mediaServers/mediaServerSync.js | 24 ++- server/modules/playlistOutputRefresh.js | 53 +++++++ server/modules/videoDeletionModule.js | 20 +++ 34 files changed, 1055 insertions(+), 114 deletions(-) create mode 100644 client/src/components/PlaylistPage/hooks/__tests__/usePlaylistVideoDeletion.test.ts create mode 100644 client/src/components/PlaylistPage/hooks/usePlaylistVideoDeletion.ts create mode 100644 server/modules/__tests__/playlistOutputRefresh.test.js create mode 100644 server/modules/playlistOutputRefresh.js diff --git a/CLAUDE.md b/CLAUDE.md index fe0a441b6..af907e3cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,13 +37,14 @@ 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, 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: `; 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, and leave out deleted rows (`videoRemovalState.removalState`), which keep their stored paths; missing rows stay listed, since their file may come back; 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. Without allowRedownload, entries the download archive lists without a Videos row (`archiveOnlyIds.findArchiveOnlyIds`; channel-ignored videos excepted, since a download removes their entry) are not eligible on any path: yt-dlp would skip them, so a sweep would queue them again every run. - `modules/download/archiveAccess.js`: coordinates archive-removing purges with `downloadExecutor.doDownload`, including preflight and finalization. Such purges are refused with 409 `archive-busy` while any download holds access; new downloads wait for an already-running purge to release it. Purges that keep archive entries can run during downloads. - `modules/videoRemovalState.js` + `modules/libraryRootCheck.js` + `modules/videoTrashModule.js` (+ `routes/videoTrash.js`): deleted vs. missing videos (issue #670). `videos.removed` stays the single "no file" flag every query reads; `removed_reason` (`user_deleted`, `auto_removal` with the rule in `removed_detail`, e.g. "older than 30 days", `not_found`, `marked_deleted`) and `removed_at` say why, and `videoRemovalState` derives `present | deleted | missing` (missing = removed with `not_found`; every other removed row, including the NULL reason of rows removed before the columns existed, is deleted; `removedColumns(reason)` / `presentColumns()` are the only writers, `stateWhere` / `notStateWhere` the only filters). Deleted stays deleted: the page-load file check (`fileCheckModule`) and the rescan (`rescanRowUpdate`) only mark present rows missing, and a file that reappears clears all three columns in every writer (download persistence, job recovery, library repair, file check, rescan). `libraryRootCheck.checkLibraryRoot(baseDir)` guards every `removed: true` write against an unmounted drive or share: the downloads root must exist and hold something, and so must each `__subfolder` in it (`missing | empty | unreadable | subfolder-empty | subfolder-unreadable`; a root failure is reported alone, while every failing subfolder is reported, in `paths`, with the strictest reason (unreadable beats empty, so an unreadable share is never looked past as emptied on purpose) and `path` its first subfolder; `message` names no path: the root is "the downloads folder" and a subfolder goes by its own name, because the container path means nothing on the host). Entries Youtarr creates itself never count (`.youtarr_tmp`, `__playlists__`, leftover `.youtarr_healthcheck_*` files, named in `filesystem/constants.js`): a sweep or a restart creates them on the bare mount point while a share is down, and they would otherwise make it look mounted for good. `isUnderLibraryCheckPath(check, filePath)` says whether a failing check covers a file (root reasons cover every file, subfolder reasons only the files under the failing subfolders), `isRowUnderLibraryCheck(check, row)` whether it covers either of a row's files, and `libraryCheckCoversRows(check, Video, where)` whether it covers any row the filter matches (a `LIKE` on both path columns, wildcards escaped), so an empty `__subfolder` no matching row is stored in (for example one a channel was moved out of) blocks nothing. The file check runs the check lazily, once per pass and only when a row would be marked missing, holds back the rows it covers, and returns them as `libraryWarning` (`{ reason, path, message, heldBack }`), which `/getVideos` passes through for the Videos page banner (the channel page gets the suppression only; its listing query reads plain rows, `raw: true`, because the check spreads them and a Sequelize instance loses its getters when spread); its writes carry a `removed` predicate, so only a present row becomes missing and only a removed row is restored even if a delete lands inside the request. The rescan checks first, and only counts a failure that covers present rows: a failing root with present rows ends the run as `error` ("Nothing was marked missing; is the drive mounted?"), an empty or unreadable `__subfolder` lets it finish but hold back missing marks for the rows stored in it (`notMarkedMissing`, outcome `partial`, named in the message), and with no present rows (a fresh install) it runs normally. A manual rescan may skip the check: `POST /api/maintenance/rescan-files` with `ignoreLibraryCheck: true` ("Rescan anyway" in the Maintenance card, offered only for the `empty` reasons, for a folder the user emptied on purpose); the scheduled task never passes it, and the run record says the check was skipped. Automatic cleanup (`videoDeletionModule.performAutomaticCleanup`) and the orphan folder sweep record a `skipped` run with the check's message while the check covers present rows (`_libraryBlock`; a dry run never checks; once the sweep runs, it removes an empty `__subfolder` nothing is stored in, which clears the check), and manual deletes (`deleteVideos`, `deleteVideosByYoutubeIds`: one check per batch, fail-open if the check itself throws) refuse each covered video with the message rather than stamping a row whose file may sit on an unmounted share; job recovery likewise leaves the removal columns alone when it cannot find the file. `/getVideos` filters: `missingFilter` is strictly missing, `deletedFilter` (default `off`; the Videos page sends `exclude` and persists the user's choice in localStorage) hides or isolates deleted rows and returns `hiddenDeletedCount` while excluding; the channel listing takes the same `deletedFilter`, appended as the last positional argument through `channelModule` -> `channelVideosService` -> `channelVideoQuery`. The client mirrors the states in `utils/videoRemoval.ts` (`getRemovalState`, `describeRemoval`, the plain-language sentence shown as the chip tooltip and the modal's File Details Status row) and `utils/videoStatus.tsx` (`deleted` status, neutral style, `isRemovedStatus`). `videoTrashModule` serves `GET /api/videos/removed-summary` (deleted, missing, legacy counts, the library check (reported as passing when it covers no present or missing row, so the card's warning and Rescan anyway match the Videos page banner), `missingActionsBlocked` (it covers missing rows, the only rows the trash actions touch), and `downloadsActive` from `archiveAccess.isDownloading()`, which the Empty trash dialog uses to disable archive removal before the user confirms) and `POST /api/videos/mark-deleted` (`{ videoIds }` or `{ all: true }`: only missing rows change, to `marked_deleted`; `all` is refused with 409 `library-unavailable` while the check covers missing rows, unless it also sends `ignoreLibraryCheck: true` and the reason is `empty` or `subfolder-empty` (a folder the user emptied on purpose; never `missing` or unreadable), and a selection is never refused; the video modal marks one missing video through it), and `POST /api/videos/purge-removed` (`{ states: ['deleted'|'missing'], removeFromArchive }`, "Empty trash" in Settings > Maintenance): one purge at a time (409 `running`), refused for `missing` while the check covers missing rows; in chunks of 500, each in a transaction, it deletes `video_watch_status` and `jobvideos` rows (the latter has a restricting FK) before the `videos` row and nulls `playlistvideos.downloaded_at`, then unlinks `jobs/info/<id>.info.json` and the cached `videothumb-<id>*.jpg` (library repair would otherwise recreate the row as missing from the archive), removes the `complete.list` lines only when asked (`archiveModule.removeVideosFromArchive`, an atomic file replacement per chunk before the database transaction commits; keeping them means automatic downloads keep skipping the videos), refreshes the affected in-memory jobs through the injected `jobModule.reloadJobVideos` (the route hands it in) and only then broadcasts `videosUpdated` + `jobsUpdated`, so a Download History refetch never shows ghosts; each chunk is re-read under lock with the state predicate inside its transaction, so a video re-downloaded or restored between the select and the delete is kept and its fresh info.json left alone. An archive rewrite failure rolls back that chunk so its rows remain selectable for retry; earlier committed chunks still get the job reload and broadcasts before the error is rethrown. If the database commit fails after a successful archive rewrite, retrying the retained rows safely removes already-absent entries. Nothing on disk under the downloads folder is touched. +- `modules/playlistOutputRefresh.js`: rebuilds the `.m3u` file and re-syncs media servers (`mediaServerSync.syncPlaylist`) for every enabled playlist that includes (not excluded) any of the given videos, one playlist at a time. `videoDeletionModule` calls it in the background after every delete path (manual by id or YouTube id, and automatic cleanup's strategies), so neither keeps listing a deleted file; downloads use `downloadModule.afterDownloadHook` instead. - `modules/playlistConstants.js`: backend playlist fetch/batch limits, preview default, and shared refresh-conflict text. Client feature defaults live in `components/PlaylistPage/playlistConstants.ts`. - First-setup recovery persists `playlists.auto_download_setup_error`: legacy scheduled incomplete snapshots stay enabled and retry with a visible waiting notice; oversized playlists are disabled with an explanation. Recovery refreshes the cache without setting a baseline or clearing requests and cannot become a successful idle sweep. Successful setup clears the error. Subscribe/restore returns 201 with a warning for expected following failures, disables auto-download for size/completeness failures, and preserves settings on a concurrent refresh. Warnings travel through the subscription hook and router state to the page. - 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. @@ -52,7 +53,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `modules/filesystem/`: path/file abstraction (`pathBuilder`, `directoryManager`, `fileOperations`, `sanitizer`, `constants`). Good example of the sub-module aggregator pattern. - `modules/channel/`: channel domain decomposed from the former 2,851-line channelModule (`fetchRegistry` shared in-flight-fetch guard, `channelYtdlpExecutor` yt-dlp listing runner, `channelIdentity`, `tabState`, `channelMappers`, `videoEntryParser`, `channelVideoWriter`/`channelVideoQuery` channel-video persistence and querying, `channelThumbnails` (avatar + banner caching, poster/backdrop backfill via `backfillChannelImages`), `channelBackdropBackfill` (on `writeBackdropImages` enable: fetches missing channel banners and backfills backdrop.jpg into channel folders), `apiFallbackLogger`, `channelMetadataFetcher`, `tabManager`, `channelProvisioning` (getChannelInfo/upsertChannel), `channelCatalog` (list/enable/disable), `autoDownloadScheduler` (cron + channels.txt generation), `channelVideoFetcher`, `channelVideosService` (getChannelVideos/fetchAllChannelVideos)); `channelModule.js` remains the public facade. - `modules/notifications/`: multi-service notifications via Apprise (`serviceRegistry`, `formatters/`, `senders/`). Good example of a pluggable service registry. -- `modules/mediaServers/`: pluggable adapter registry for native playlist sync (`serverRegistry`, `mediaServerSync` orchestrator, `adapters/{plexAdapter,jellyfinAdapter,embyAdapter,baseAdapter}`). Adapters wrap server-specific REST APIs; `mediaServerSync` decides which adapters apply for a given playlist and updates `playlist_sync_state`. Playlists are media-typed by the playlist's Download Type SETTING, not by what is on disk: an MP3 Only playlist syncs as an audio/music playlist using each item's `Video.audioFilePath` (Plex resolves tracks by listing artist sections with type=10), every other playlist syncs `Video.filePath`, in the playlist's saved `sort_order` ('reversed' flips YouTube playlist order); items lacking a file of the playlist's type are left out (count surfaced as `unsyncable_count` on the playlist detail route and shown on the playlist page, but only while the playlist syncs to at least one configured server). With zero resolved items the sync defers instead of creating or emptying a server playlist - the one exception is a deliberately emptied playlist (every video ignored/removed) with an existing server playlist, which is emptied. Audio syncs also trigger a scan of Plex's music sections before resolving (video sections rely on the post-download refresh instead; Jellyfin/Emby refresh all libraries in one call). `watchStatusSync` (pulls per-video, per-user watch state - played/percent/last-watched - from every enabled server via the adapters' `fetchWatchStates()` contract `{ entries, users }`, matches items to Videos primarily by the trailing `[<youtube-id>].<ext>` filename token with exact `filePath` basename as the fallback (a video matches ALL user entries of its best-scoring path; videos marked missing/removed still sync, since their file may have moved out of Youtarr's view but live on in a server library), and upserts `video_watch_status` rows keyed (video, server, user) plus a `media_server_users` name directory; only items actually returned are written, so a missing row means unknown, never unwatched; Jellyfin/Emby list every user via /Users + per-user /Items, asking only for watched (`isPlayed=true`) and in-progress (`filters=IsResumable`) items, paged (a whole-library listing takes minutes on large servers and outlasts the 30 s request timeout), and report users listed in full as `completeUserIds` (not a user whose paging repeated a page). For those listings a present video (not `removed`) only takes a match sharing its file's parent folder whenever the server shows Youtarr's layout (any match does), so a watched stale copy or another channel's same-named legacy file cannot stand in for an unwatched current copy, while a server with its own layout still matches everything. For those users the sync also resets stored watched/in-progress rows no listed item matched (marked unwatched on the server) to the server's unwatched values; rows holding only a play count or last-watched time (stopped before the resume threshold) are never listed and stay as stored, Plex reads the owner account from section listings (account id '1'; anonymous session on unclaimed-server setups) and all OTHER accounts from the server-local play history endpoint - played-only fidelity, incremental via a viewedAt watermark from stored rows; per-server all-users toggles `plexWatchStatusAllUsers`/`jellyfinWatchStatusAllUsers`/`embyWatchStatusAllUsers` default on; video files only), `watchStatusQueries` (stateless read side of the watch-status rows: `getWatchedByMap()` aggregates listing-page `watchedBy` honoring `watchStatusWatchedRule` ('any' default / 'primary' = owner + configured user only), `buildWatchedExistsSql()` expresses the same watched rule as a raw-SQL EXISTS fragment for videosModule's paginated listing, and `getStatusesForVideo()` shapes the per-user modal API rows with names from `media_server_users`; the three listing pages filter on watched state through this rule - VideosPage/ChannelPage via a tri-state `watchedFilter` query param (`off`/`only`/`exclude`), the playlist videos route via `watchedState` (`all`/`watched`/`not_watched`), where unwatched always includes unknown/never-synced videos) and `watchStatusScheduler` (config-driven cron: `watchStatusSyncEnabled`/`watchStatusSyncFrequency`, default every 4 hours, reschedules on config change). +- `modules/mediaServers/`: pluggable adapter registry for native playlist sync (`serverRegistry`, `mediaServerSync` orchestrator, `adapters/{plexAdapter,jellyfinAdapter,embyAdapter,baseAdapter}`). Adapters wrap server-specific REST APIs; `mediaServerSync` decides which adapters apply for a given playlist and updates `playlist_sync_state`. Playlists are media-typed by the playlist's Download Type SETTING, not by what is on disk: an MP3 Only playlist syncs as an audio/music playlist using each item's `Video.audioFilePath` (Plex resolves tracks by listing artist sections with type=10), every other playlist syncs `Video.filePath`, in the playlist's saved `sort_order` ('reversed' flips YouTube playlist order); items lacking a file of the playlist's type are left out (count surfaced as `unsyncable_count` on the playlist detail route and shown on the playlist page, but only while the playlist syncs to at least one configured server), as are deleted rows (missing rows still sync, since their file may come back). With zero resolved items the sync defers instead of creating or emptying a server playlist - the one exception is a deliberately emptied playlist (every video ignored, or no video with a file left, e.g. all deleted) with an existing server playlist, which is emptied. `replacePlaylistItems` never creates an empty playlist: Plex empties in place, Jellyfin/Emby (replace = delete + recreate) delete it and return `{ id: null }`, which clears `playlist_sync_state.server_playlist_id` so the next sync with items creates a fresh one. Audio syncs also trigger a scan of Plex's music sections before resolving (video sections rely on the post-download refresh instead; Jellyfin/Emby refresh all libraries in one call). `watchStatusSync` (pulls per-video, per-user watch state - played/percent/last-watched - from every enabled server via the adapters' `fetchWatchStates()` contract `{ entries, users }`, matches items to Videos primarily by the trailing `[<youtube-id>].<ext>` filename token with exact `filePath` basename as the fallback (a video matches ALL user entries of its best-scoring path; videos marked missing/removed still sync, since their file may have moved out of Youtarr's view but live on in a server library), and upserts `video_watch_status` rows keyed (video, server, user) plus a `media_server_users` name directory; only items actually returned are written, so a missing row means unknown, never unwatched; Jellyfin/Emby list every user via /Users + per-user /Items, asking only for watched (`isPlayed=true`) and in-progress (`filters=IsResumable`) items, paged (a whole-library listing takes minutes on large servers and outlasts the 30 s request timeout), and report users listed in full as `completeUserIds` (not a user whose paging repeated a page). For those listings a present video (not `removed`) only takes a match sharing its file's parent folder whenever the server shows Youtarr's layout (any match does), so a watched stale copy or another channel's same-named legacy file cannot stand in for an unwatched current copy, while a server with its own layout still matches everything. For those users the sync also resets stored watched/in-progress rows no listed item matched (marked unwatched on the server) to the server's unwatched values; rows holding only a play count or last-watched time (stopped before the resume threshold) are never listed and stay as stored, Plex reads the owner account from section listings (account id '1'; anonymous session on unclaimed-server setups) and all OTHER accounts from the server-local play history endpoint - played-only fidelity, incremental via a viewedAt watermark from stored rows; per-server all-users toggles `plexWatchStatusAllUsers`/`jellyfinWatchStatusAllUsers`/`embyWatchStatusAllUsers` default on; video files only), `watchStatusQueries` (stateless read side of the watch-status rows: `getWatchedByMap()` aggregates listing-page `watchedBy` honoring `watchStatusWatchedRule` ('any' default / 'primary' = owner + configured user only), `buildWatchedExistsSql()` expresses the same watched rule as a raw-SQL EXISTS fragment for videosModule's paginated listing, and `getStatusesForVideo()` shapes the per-user modal API rows with names from `media_server_users`; the three listing pages filter on watched state through this rule - VideosPage/ChannelPage via a tri-state `watchedFilter` query param (`off`/`only`/`exclude`), the playlist videos route via `watchedState` (`all`/`watched`/`not_watched`), where unwatched always includes unknown/never-synced videos) and `watchStatusScheduler` (config-driven cron: `watchStatusSyncEnabled`/`watchStatusSyncFrequency`, default every 4 hours, reschedules on config change). - `modules/subscriptionImport/`: bulk channel import (`importJobRunner`, `takeoutParser`, `cookiesFetcher`, `thumbnailEnricher`, `concurrencyLimiter`, `errorClassifier`). ### Frontend (client/src/) diff --git a/client/src/components/PlaylistPage.tsx b/client/src/components/PlaylistPage.tsx index e3e2e74b1..26c9896a8 100644 --- a/client/src/components/PlaylistPage.tsx +++ b/client/src/components/PlaylistPage.tsx @@ -20,7 +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 } from '../types/playlist'; +import { MediaServerType, PlaylistSubscribeSettings, PlaylistVideo } from '../types/playlist'; import PlaylistHeader from './PlaylistPage/components/PlaylistHeader'; import NoMediaServerWarning from './PlaylistPage/components/NoMediaServerWarning'; import PlaylistVideoList from './PlaylistPage/components/PlaylistVideoList'; @@ -30,10 +30,12 @@ import PlaylistWatchedFilterControl from './PlaylistPage/components/PlaylistWatc import { useVideoSelection } from './shared/VideoList/hooks/useVideoSelection'; import VideoListSelectionPill from './shared/VideoList/VideoListSelectionPill'; import { SelectionAction } from './shared/VideoList/types'; -import { Download as DownloadIcon } from '../lib/icons'; +import { Delete as DeleteIcon, Download as DownloadIcon } from '../lib/icons'; import PlaylistFollowingDialog, { FollowingDialogMode } from './PlaylistPage/components/PlaylistFollowingDialog'; import PlaylistSettingsDialog from './PlaylistPage/components/PlaylistSettingsDialog'; -import { toDownloadFileProps, toModalData } from './PlaylistPage/components/playlistVideoHelpers'; +import { isDeletable, isDownloadable, toModalData } from './PlaylistPage/components/playlistVideoHelpers'; +import { usePlaylistVideoDeletion } from './PlaylistPage/hooks/usePlaylistVideoDeletion'; +import DeleteVideosDialog from './shared/DeleteVideosDialog'; import DownloadSettingsDialog from './DownloadManager/ManualDownload/DownloadSettingsDialog'; import { DownloadSettings } from './DownloadManager/ManualDownload/types'; import SubscriptionsBackButton from './shared/SubscriptionsBackButton'; @@ -160,11 +162,26 @@ function PlaylistPage({ token }: PlaylistPageProps) { const selectionClearRef = useRef<() => void>(() => {}); const loadMoreRef = useRef<HTMLDivElement | null>(null); + // One selection holds downloaded and not-downloaded videos; each action + // applies to the selected videos it fits. + const videosById = useMemo(() => new Map(videos.map((v) => [v.youtube_id, v])), [videos]); + const idsThatFit = useCallback( + (ids: string[], fits: (video: PlaylistVideo) => boolean) => + ids.filter((id) => { + const video = videosById.get(id); + return Boolean(video && fits(video)); + }), + [videosById] + ); + + const deletion = usePlaylistVideoDeletion({ token, markVideoDeleted, refetchMeta }); + const handleDownloadSelected = useCallback((ids: string[]) => { - if (!ids.length) return; - setPendingDownload({ mode: 'selected', ids }); + const downloadable = idsThatFit(ids, isDownloadable); + if (!downloadable.length) return; + setPendingDownload({ mode: 'selected', ids: downloadable }); setDownloadDialogOpen(true); - }, []); + }, [idsThatFit]); const handleConfirmDownload = useCallback( async (settings: DownloadSettings | null) => { @@ -204,22 +221,29 @@ function PlaylistPage({ token }: PlaylistPageProps) { return videos.filter((v) => ids.has(v.youtube_id) && v.previously_downloaded).length; }, [pendingDownload, videos]); - const downloadActions = useMemo<SelectionAction<string>[]>( + const selectionActions = useMemo<SelectionAction<string>[]>( () => [ { id: 'download', - label: 'Download Selected', + label: 'Download', intent: 'success', icon: <DownloadIcon size={14} />, - onClick: (ids) => { - void handleDownloadSelected(ids); - }, + disabled: (ids) => idsThatFit(ids, isDownloadable).length === 0, + onClick: handleDownloadSelected, + }, + { + id: 'delete', + label: 'Delete', + intent: 'danger', + icon: <DeleteIcon size={14} />, + disabled: (ids) => deletion.loading || idsThatFit(ids, isDeletable).length === 0, + onClick: (ids) => deletion.requestDelete(idsThatFit(ids, isDeletable)), }, ], - [handleDownloadSelected] + [handleDownloadSelected, idsThatFit, deletion.loading, deletion.requestDelete] ); - const selection = useVideoSelection<string>({ actions: downloadActions }); + const selection = useVideoSelection<string>({ actions: selectionActions }); useEffect(() => { const eligible = selection.selectedIds.filter(id => !snapshot.videos[id]?.state); if (eligible.length !== selection.selectedIds.length) selection.set(eligible); @@ -316,6 +340,14 @@ function PlaylistPage({ token }: PlaylistPageProps) { [playlist, ignoreVideo, markVideoIgnored, refetchMeta, videos, showSnackbar] ); + const handleConfirmDelete = async () => { + const outcome = await deletion.confirmDelete(); + if (!outcome) return; + showSnackbar(outcome.message, outcome.severity); + // Nothing deleted: keep the selection so the user can retry. + if (outcome.deletedIds.length > 0) selection.clear(); + }; + const handleVideoDeleted = useCallback( (ytId: string) => { markVideoDeleted(ytId); @@ -547,6 +579,14 @@ function PlaylistPage({ token }: PlaylistPageProps) { </Alert> </Snackbar> + <DeleteVideosDialog + open={deletion.dialogOpen} + onClose={deletion.cancelDelete} + onConfirm={() => { void handleConfirmDelete(); }} + videoCount={deletion.pendingCount} + skippedCount={selection.count - deletion.pendingCount} + /> + {modalVideo && ( <VideoModal open diff --git a/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx b/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx index e0a9ded15..2f8e542e4 100644 --- a/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx +++ b/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx @@ -3,7 +3,7 @@ import { PlaylistSortOrder } from '../../../hooks/usePlaylistDetail'; import { Button, Card, CardContent, Checkbox, Chip, Tooltip, Typography } from '../../ui'; import { PlaylistVideo } from '../../../types/playlist'; import { formatDurationClock } from '../../../utils'; -import { isDownloadable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers'; +import { isSelectable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers'; import DownloadFormatIndicator from '../../shared/DownloadFormatIndicator'; import WatchedChip from '../../shared/WatchedChip'; import { SHARED_STATUS_CHIP_SMALL_STYLE, SHARED_COMPACT_CHIP_OVERRIDES } from '../../shared/chipStyles'; @@ -35,7 +35,7 @@ const PlaylistVideoCard: React.FC<PlaylistVideoCardProps> = ({ onToggle, }) => { const status = statusLabel(video); - const selectable = isDownloadable(video); + const selectable = isSelectable(video); const isPending = pendingId === video.youtube_id; return ( diff --git a/client/src/components/PlaylistPage/components/PlaylistVideoList.tsx b/client/src/components/PlaylistPage/components/PlaylistVideoList.tsx index 204ebde44..20c8c6490 100644 --- a/client/src/components/PlaylistPage/components/PlaylistVideoList.tsx +++ b/client/src/components/PlaylistPage/components/PlaylistVideoList.tsx @@ -6,8 +6,12 @@ import { PlaylistVideo } from '../../../types/playlist'; import PlaylistVideoTable from './PlaylistVideoTable'; import PlaylistVideoCard from './PlaylistVideoCard'; -// Account for the sidebar and page gutters, not just the viewport breakpoint. -const MIN_TABLE_WIDTH = 1200; +// Measured on the list itself, so the sidebar and page gutters are accounted +// for. Below this the title column gets too narrow and cards read better. +const MIN_TABLE_WIDTH = 720; +// Used only until the list is measured: roughly the viewport width below +// which the list is narrower than MIN_TABLE_WIDTH with the top navigation. +const NARROW_VIEWPORT_QUERY = '(max-width: 899px)'; interface PlaylistVideoListProps { sortOrder?: PlaylistSortOrder; @@ -36,7 +40,7 @@ const PlaylistVideoList: React.FC<PlaylistVideoListProps> = ({ onSelectAll, onClearSelection, }) => { - const isNarrowViewport = useMediaQuery('(max-width: 1279px)'); + const isNarrowViewport = useMediaQuery(NARROW_VIEWPORT_QUERY); const listRef = useRef<HTMLDivElement>(null); const [isNarrowList, setIsNarrowList] = useState<boolean | null>(null); const showEmpty = !loading && videos.length === 0; diff --git a/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx b/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx index 8f120f126..0ae4dc4fa 100644 --- a/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx +++ b/client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { PlaylistSortOrder } from '../../../hooks/usePlaylistDetail'; import { - Button, Checkbox, Chip, + IconButton, Table, TableBody, TableCell, @@ -14,7 +14,8 @@ import { } from '../../ui'; import { PlaylistVideo } from '../../../types/playlist'; import { formatDurationClock } from '../../../utils'; -import { isDownloadable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers'; +import { Block as BlockIcon, CheckCircleOutline as CheckCircleOutlineIcon } from '../../../lib/icons'; +import { isSelectable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers'; import DownloadFormatIndicator from '../../shared/DownloadFormatIndicator'; import WatchedChip from '../../shared/WatchedChip'; @@ -46,56 +47,88 @@ const PlaylistVideoTable: React.FC<PlaylistVideoTableProps> = ({ onSelectAll, onClearSelection, }) => { - const downloadableIds = videos.filter(isDownloadable).map((v) => v.youtube_id); - const selectedDownloadable = downloadableIds.filter(isSelected); - const allSelected = - downloadableIds.length > 0 && selectedDownloadable.length === downloadableIds.length; - const someSelected = - selectedDownloadable.length > 0 && selectedDownloadable.length < downloadableIds.length; + const selectableIds = videos.filter(isSelectable).map((v) => v.youtube_id); + const selectedCount = selectableIds.filter(isSelected).length; + const allSelected = selectableIds.length > 0 && selectedCount === selectableIds.length; + const someSelected = selectedCount > 0 && selectedCount < selectableIds.length; + // Fixed columns stay narrow so the title column keeps room down to + // PlaylistVideoList's MIN_TABLE_WIDTH; the title column takes the rest. return ( <Table size="small" className="table-fixed"> <TableHead> <TableRow> - <TableCell component="th" className="w-[44px]"> + <TableCell component="th" className="w-[48px]"> <Checkbox indeterminate={someSelected} checked={allSelected} - onChange={(e) => (e.target.checked ? onSelectAll(downloadableIds) : onClearSelection())} - aria-label="Select all downloadable videos" + onChange={(e) => (e.target.checked ? onSelectAll(selectableIds) : onClearSelection())} + aria-label="Select all videos" /> </TableCell> - <TableCell component="th" className="w-[60px]">#</TableCell> + <TableCell component="th" className="w-[48px]">#</TableCell> <TableCell component="th" className="w-[140px]">Thumbnail</TableCell> <TableCell component="th">Title</TableCell> - <TableCell component="th" className="w-[18%]">Channel</TableCell> - <TableCell component="th" className="w-[180px]">Dates</TableCell> - <TableCell component="th" className="w-[90px] whitespace-nowrap">Duration</TableCell> - <TableCell component="th" className="w-[140px] whitespace-nowrap">Status</TableCell> - <TableCell component="th" className="w-[120px] whitespace-nowrap" align="right">Actions</TableCell> + <TableCell component="th" className="w-[112px]">Dates</TableCell> + <TableCell component="th" className="w-[72px] whitespace-nowrap">Duration</TableCell> + <TableCell component="th" className="w-[120px] whitespace-nowrap">Status</TableCell> </TableRow> </TableHead> <TableBody> {videos.map((v) => { + const label = v.title || v.youtube_id; const isPending = pendingId === v.youtube_id; const status = statusLabel(v); - const selectable = isDownloadable(v); + const selectable = isSelectable(v); return ( <TableRow key={`${v.playlist_id}-${v.youtube_id}`} hover onClick={() => onVideoClick(v)} className="cursor-pointer" - aria-label={`Open ${v.title || v.youtube_id}`} + aria-label={`Open ${label}`} > <TableCell onClick={(e) => e.stopPropagation()}> - {selectable && ( - <Checkbox - checked={isSelected(v.youtube_id)} - onChange={() => onToggle(v.youtube_id)} - aria-label={`Select ${v.title || v.youtube_id}`} - /> - )} + <div className="flex flex-col items-center gap-0.5"> + {selectable && ( + <Checkbox + checked={isSelected(v.youtube_id)} + onChange={() => onToggle(v.youtube_id)} + aria-label={`Select ${label}`} + /> + )} + {v.ignored ? ( + <Tooltip title="Include this video in this playlist's downloads and server sync again"> + <IconButton + size="sm" + color="warning" + aria-label={`Include ${label}`} + onClick={() => onUnignore(v.youtube_id)} + disabled={isPending} + > + <CheckCircleOutlineIcon /> + </IconButton> + </Tooltip> + ) : ( + <Tooltip + title={ + v.downloaded + ? 'Exclude from this playlist: removed from synced server playlists and M3U files; the file stays on disk' + : 'Exclude from this playlist: skipped when downloading this playlist' + } + > + <IconButton + size="sm" + className="text-muted-foreground" + aria-label={`Exclude ${label}`} + onClick={() => onIgnore(v.youtube_id)} + disabled={isPending} + > + <BlockIcon /> + </IconButton> + </Tooltip> + )} + </div> </TableCell> <TableCell>{v.position}</TableCell> <TableCell> @@ -116,11 +149,9 @@ const PlaylistVideoTable: React.FC<PlaylistVideoTableProps> = ({ </TableCell> <TableCell> <Typography variant="body2" className="line-clamp-2 whitespace-normal"> - {v.title || v.youtube_id} + {label} </Typography> - </TableCell> - <TableCell> - <Typography variant="body2" className="text-muted-foreground line-clamp-1"> + <Typography variant="caption" className="block mt-0.5 text-muted-foreground line-clamp-1"> {v.channel_name || '-'} </Typography> </TableCell> @@ -149,27 +180,6 @@ const PlaylistVideoTable: React.FC<PlaylistVideoTableProps> = ({ <WatchedChip watchedBy={v.watched_by || []} /> </div> </TableCell> - <TableCell align="right" onClick={(e) => e.stopPropagation()}> - {v.ignored ? ( - <Tooltip title="Include this video in this playlist's downloads and server sync again"> - <Button size="sm" variant="outlined" onClick={() => onUnignore(v.youtube_id)} disabled={isPending}> - Include - </Button> - </Tooltip> - ) : ( - <Tooltip - title={ - v.downloaded - ? 'Exclude from this playlist: removed from synced server playlists and M3U files; the file stays on disk' - : 'Exclude from this playlist: skipped when downloading this playlist' - } - > - <Button size="sm" variant="outlined" onClick={() => onIgnore(v.youtube_id)} disabled={isPending}> - Exclude - </Button> - </Tooltip> - )} - </TableCell> </TableRow> ); })} diff --git a/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoCard.test.tsx b/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoCard.test.tsx index 6d5712ee4..7cb364eb0 100644 --- a/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoCard.test.tsx +++ b/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoCard.test.tsx @@ -34,8 +34,15 @@ describe('PlaylistVideoCard', () => { expect(onToggle).toHaveBeenCalledWith('dl1'); }); - test('renders no checkbox for an already-downloaded video', () => { - render(<PlaylistVideoCard {...baseProps} video={makeVideo({ downloaded: true })} />); + test('renders a checkbox for a downloaded video so it can be deleted', () => { + const onToggle = jest.fn(); + render(<PlaylistVideoCard {...baseProps} onToggle={onToggle} video={makeVideo({ youtube_id: 'done', downloaded: true })} />); + fireEvent.click(screen.getByRole('checkbox')); + expect(onToggle).toHaveBeenCalledWith('done'); + }); + + test('renders no checkbox while the video is downloading', () => { + render(<PlaylistVideoCard {...baseProps} video={makeVideo({ activity: 'downloading' })} />); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); diff --git a/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoList.test.tsx b/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoList.test.tsx index 10cb07463..980dd566c 100644 --- a/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoList.test.tsx +++ b/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoList.test.tsx @@ -124,10 +124,16 @@ describe('PlaylistVideoList container sizing', () => { test('uses the available container width to switch between cards and a table', () => { render(<PlaylistVideoList {...baseProps} videos={[makeVideo({ published_at: '20260827' })]} loading={false} />); expect(screen.getByRole('table')).toBeInTheDocument(); - resize(900); + resize(719); expect(screen.queryByRole('table')).not.toBeInTheDocument(); expect(screen.getByText('2026-08-27')).toBeVisible(); - resize(1200); + resize(720); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + test('keeps the table on a list narrower than a full desktop page', () => { + render(<PlaylistVideoList {...baseProps} videos={[makeVideo()]} loading={false} />); + resize(900); expect(screen.getByRole('table')).toBeInTheDocument(); }); diff --git a/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoTable.test.tsx b/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoTable.test.tsx index 8f663e022..488e07afc 100644 --- a/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoTable.test.tsx +++ b/client/src/components/PlaylistPage/components/__tests__/PlaylistVideoTable.test.tsx @@ -35,26 +35,52 @@ describe('PlaylistVideoTable selection', () => { expect(onToggle).toHaveBeenCalledWith('dl1'); }); - test('does not render a row checkbox for an already-downloaded video', () => { + test('renders a row checkbox for a downloaded video so it can be deleted', () => { + const onToggle = jest.fn(); + render( + <PlaylistVideoTable + {...baseProps} + onToggle={onToggle} + videos={[makeVideo({ youtube_id: 'done', title: 'Done', downloaded: true })]} + /> + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select Done' })); + expect(onToggle).toHaveBeenCalledWith('done'); + }); + + test('does not render a row checkbox for a never-downloaded video removed on YouTube', () => { render( - <PlaylistVideoTable {...baseProps} videos={[makeVideo({ youtube_id: 'done', downloaded: true })]} /> + <PlaylistVideoTable {...baseProps} videos={[makeVideo({ youtube_id: 'gone', title: 'Gone', youtube_removed: true })]} /> ); - // Only the header select-all checkbox exists. - expect(screen.getAllByRole('checkbox')).toHaveLength(1); + expect(screen.queryByRole('checkbox', { name: 'Select Gone' })).not.toBeInTheDocument(); }); - test('header select-all selects all downloadable ids', () => { + test('header select-all selects downloaded and not-downloaded videos', () => { const onSelectAll = jest.fn(); render( <PlaylistVideoTable {...baseProps} onSelectAll={onSelectAll} - videos={[makeVideo({ youtube_id: 'a' }), makeVideo({ youtube_id: 'b', downloaded: true })]} + videos={[ + makeVideo({ youtube_id: 'a' }), + makeVideo({ youtube_id: 'b', downloaded: true }), + makeVideo({ youtube_id: 'c', youtube_removed: true }), + ]} /> ); - const header = screen.getAllByRole('checkbox')[0]; - fireEvent.click(header); - expect(onSelectAll).toHaveBeenCalledWith(['a']); + fireEvent.click(screen.getByRole('checkbox', { name: 'Select all videos' })); + expect(onSelectAll).toHaveBeenCalledWith(['a', 'b']); + }); + + test('shows the channel under the title instead of in its own column', () => { + render(<PlaylistVideoTable {...baseProps} videos={[makeVideo({ channel_name: 'Some Channel' })]} />); + expect(screen.getByText('Some Channel')).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'Channel' })).not.toBeInTheDocument(); + }); + + test('has no separate Actions column', () => { + render(<PlaylistVideoTable {...baseProps} videos={[makeVideo()]} />); + expect(screen.queryByRole('columnheader', { name: 'Actions' })).not.toBeInTheDocument(); }); test('shows italic Unknown when published_at is missing', () => { @@ -65,8 +91,8 @@ describe('PlaylistVideoTable selection', () => { test('renders the Exclude action and fires onIgnore', () => { const onIgnore = jest.fn(); - render(<PlaylistVideoTable {...baseProps} onIgnore={onIgnore} videos={[makeVideo({ youtube_id: 'x' })]} />); - fireEvent.click(screen.getByRole('button', { name: 'Exclude' })); + render(<PlaylistVideoTable {...baseProps} onIgnore={onIgnore} videos={[makeVideo({ youtube_id: 'x', title: 'Clip X' })]} />); + fireEvent.click(screen.getByRole('button', { name: 'Exclude Clip X' })); expect(onIgnore).toHaveBeenCalledWith('x'); }); @@ -76,10 +102,10 @@ describe('PlaylistVideoTable selection', () => { <PlaylistVideoTable {...baseProps} onUnignore={onUnignore} - videos={[makeVideo({ youtube_id: 'x', ignored: true })]} + videos={[makeVideo({ youtube_id: 'x', title: 'Clip X', ignored: true })]} /> ); - fireEvent.click(screen.getByRole('button', { name: 'Include' })); + fireEvent.click(screen.getByRole('button', { name: 'Include Clip X' })); expect(onUnignore).toHaveBeenCalledWith('x'); }); diff --git a/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx b/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx index d35d66ce0..637af3323 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, toModalData } from '../playlistVideoHelpers'; +import { isDeletable, isDownloadable, isSelectable, statusLabel, PublishedDate, toDownloadFileProps, toModalData } from '../playlistVideoHelpers'; import { PlaylistVideo } from '../../../../types/playlist'; function makeVideo(overrides: Partial<PlaylistVideo> = {}): PlaylistVideo { @@ -44,6 +44,36 @@ describe('isDownloadable', () => { }); }); +describe('isDeletable', () => { + test('true for a downloaded video', () => { + expect(isDeletable(makeVideo({ downloaded: true }))).toBe(true); + }); + test('false for a video with no file', () => { + expect(isDeletable(makeVideo({ previously_downloaded: true }))).toBe(false); + }); + test('false while the video is downloading again', () => { + expect(isDeletable(makeVideo({ downloaded: true, activity: 'downloading' }))).toBe(false); + }); + test('true for a downloaded video YouTube has since removed', () => { + expect(isDeletable(makeVideo({ downloaded: true, youtube_removed: true }))).toBe(true); + }); +}); + +describe('isSelectable', () => { + test('true for a video that can be downloaded', () => { + expect(isSelectable(makeVideo())).toBe(true); + }); + test('true for a video that can be deleted', () => { + expect(isSelectable(makeVideo({ downloaded: true }))).toBe(true); + }); + test('false for a never-downloaded video removed on YouTube', () => { + expect(isSelectable(makeVideo({ youtube_removed: true }))).toBe(false); + }); + test('false while the video is queued', () => { + expect(isSelectable(makeVideo({ activity: 'queued' }))).toBe(false); + }); +}); + describe('statusLabel', () => { test('returns Downloaded for a downloaded video', () => { expect(statusLabel(makeVideo({ downloaded: true })).label).toBe('Downloaded'); diff --git a/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx b/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx index 8d5433698..e7442175c 100644 --- a/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx +++ b/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx @@ -20,6 +20,15 @@ export function isDownloadable(v: PlaylistVideo): boolean { return !v.activity && !v.downloaded && !v.youtube_removed; } +export function isDeletable(v: PlaylistVideo): boolean { + return !v.activity && v.downloaded; +} + +// One selection serves both actions: each applies to the selected videos it fits. +export function isSelectable(v: PlaylistVideo): boolean { + return isDownloadable(v) || isDeletable(v); +} + // Maps PlaylistVideo's snake_case file fields to the indicator's camelCase props. export function toDownloadFileProps(v: PlaylistVideo): { filePath: string | null; diff --git a/client/src/components/PlaylistPage/hooks/__tests__/usePlaylistVideoDeletion.test.ts b/client/src/components/PlaylistPage/hooks/__tests__/usePlaylistVideoDeletion.test.ts new file mode 100644 index 000000000..1a9beb242 --- /dev/null +++ b/client/src/components/PlaylistPage/hooks/__tests__/usePlaylistVideoDeletion.test.ts @@ -0,0 +1,125 @@ +import { act, renderHook } from '@testing-library/react'; +import { usePlaylistVideoDeletion } from '../usePlaylistVideoDeletion'; + +const mockDeleteVideosByYoutubeIds = jest.fn(); + +jest.mock('../../../shared/useVideoDeletion', () => ({ + useVideoDeletion: () => ({ + deleteVideos: jest.fn(), + deleteVideosByYoutubeIds: mockDeleteVideosByYoutubeIds, + loading: false, + error: null, + }), +})); + +describe('usePlaylistVideoDeletion', () => { + const markVideoDeleted = jest.fn(); + const refetchMeta = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderDeletion = () => + renderHook(() => usePlaylistVideoDeletion({ token: 't', markVideoDeleted, refetchMeta })); + + const confirmFor = async (ids: string[]) => { + const { result } = renderDeletion(); + act(() => result.current.requestDelete(ids)); + let outcome = null; + await act(async () => { + outcome = await result.current.confirmDelete(); + }); + return { result, outcome }; + }; + + test('opens the confirmation for the requested videos', () => { + const { result } = renderDeletion(); + + act(() => result.current.requestDelete(['a', 'b'])); + + expect(result.current.dialogOpen).toBe(true); + expect(result.current.pendingCount).toBe(2); + }); + + test('stays closed when no video can be deleted', () => { + const { result } = renderDeletion(); + + act(() => result.current.requestDelete([])); + + expect(result.current.dialogOpen).toBe(false); + }); + + test('cancel closes the confirmation without deleting', () => { + const { result } = renderDeletion(); + + act(() => result.current.requestDelete(['a'])); + act(() => result.current.cancelDelete()); + + expect(result.current.dialogOpen).toBe(false); + expect(mockDeleteVideosByYoutubeIds).not.toHaveBeenCalled(); + }); + + test('deletes the requested videos by YouTube id', async () => { + mockDeleteVideosByYoutubeIds.mockResolvedValue({ success: true, deleted: ['a', 'b'], failed: [] }); + + await confirmFor(['a', 'b']); + + expect(mockDeleteVideosByYoutubeIds).toHaveBeenCalledWith(['a', 'b'], 't'); + }); + + test('marks each deleted row in place and refreshes the playlist counts', async () => { + mockDeleteVideosByYoutubeIds.mockResolvedValue({ success: true, deleted: ['a', 'b'], failed: [] }); + + await confirmFor(['a', 'b']); + + expect(markVideoDeleted.mock.calls).toEqual([['a'], ['b']]); + expect(refetchMeta).toHaveBeenCalledTimes(1); + }); + + test('reports a full success', async () => { + mockDeleteVideosByYoutubeIds.mockResolvedValue({ success: true, deleted: ['a', 'b'], failed: [] }); + + const { outcome } = await confirmFor(['a', 'b']); + + expect(outcome).toEqual({ deletedIds: ['a', 'b'], message: 'Deleted 2 videos', severity: 'success' }); + }); + + test('reports a partial failure with the reason', async () => { + mockDeleteVideosByYoutubeIds.mockResolvedValue({ + success: false, + deleted: ['a'], + failed: [{ youtubeId: 'b', error: 'The downloads folder is empty.' }], + }); + + const { outcome } = await confirmFor(['a', 'b']); + + expect(outcome).toEqual({ + deletedIds: ['a'], + message: 'Deleted 1 video; 1 could not be deleted: The downloads folder is empty.', + severity: 'warning', + }); + }); + + test('reports a failure without touching the rows or counts', async () => { + mockDeleteVideosByYoutubeIds.mockResolvedValue({ + success: false, + deleted: [], + failed: [{ youtubeId: 'a', error: 'Network Error' }], + }); + + const { outcome } = await confirmFor(['a']); + + expect(outcome).toEqual({ deletedIds: [], message: 'Failed to delete video: Network Error', severity: 'error' }); + expect(markVideoDeleted).not.toHaveBeenCalled(); + expect(refetchMeta).not.toHaveBeenCalled(); + }); + + test('closes the confirmation once the delete is confirmed', async () => { + mockDeleteVideosByYoutubeIds.mockResolvedValue({ success: true, deleted: ['a'], failed: [] }); + + const { result } = await confirmFor(['a']); + + expect(result.current.dialogOpen).toBe(false); + }); +}); diff --git a/client/src/components/PlaylistPage/hooks/usePlaylistVideoDeletion.ts b/client/src/components/PlaylistPage/hooks/usePlaylistVideoDeletion.ts new file mode 100644 index 000000000..6877fee0f --- /dev/null +++ b/client/src/components/PlaylistPage/hooks/usePlaylistVideoDeletion.ts @@ -0,0 +1,72 @@ +import { useCallback, useState } from 'react'; +import { useVideoDeletion } from '../../shared/useVideoDeletion'; + +interface UsePlaylistVideoDeletionOptions { + token: string | null; + markVideoDeleted: (youtubeId: string) => void; + refetchMeta: () => Promise<void>; +} + +export interface PlaylistDeletionOutcome { + deletedIds: string[]; + message: string; + severity: 'success' | 'warning' | 'error'; +} + +function videoCount(count: number): string { + return `${count} video${count === 1 ? '' : 's'}`; +} + +function describeOutcome( + deletedCount: number, + failed: Array<{ error: string }> +): Omit<PlaylistDeletionOutcome, 'deletedIds'> { + if (failed.length === 0) { + return { message: `Deleted ${videoCount(deletedCount)}`, severity: 'success' }; + } + const reason = failed[0]?.error || 'Unknown error'; + if (deletedCount > 0) { + return { + message: `Deleted ${videoCount(deletedCount)}; ${failed.length} could not be deleted: ${reason}`, + severity: 'warning', + }; + } + return { message: `Failed to delete ${failed.length === 1 ? 'video' : 'videos'}: ${reason}`, severity: 'error' }; +} + +/** + * Confirm-then-delete flow for downloaded playlist videos. Deleted rows are + * updated in place, so a long (paginated) list keeps its scroll position. + */ +export function usePlaylistVideoDeletion({ token, markVideoDeleted, refetchMeta }: UsePlaylistVideoDeletionOptions) { + const { deleteVideosByYoutubeIds, loading } = useVideoDeletion(); + const [pendingIds, setPendingIds] = useState<string[]>([]); + const [dialogOpen, setDialogOpen] = useState(false); + + const requestDelete = useCallback((youtubeIds: string[]) => { + if (youtubeIds.length === 0) return; + setPendingIds(youtubeIds); + setDialogOpen(true); + }, []); + + const cancelDelete = useCallback(() => setDialogOpen(false), []); + + const confirmDelete = async (): Promise<PlaylistDeletionOutcome | null> => { + setDialogOpen(false); + if (pendingIds.length === 0) return null; + const result = await deleteVideosByYoutubeIds(pendingIds, token); + const deletedIds = result.deleted.map(String); + deletedIds.forEach((id) => markVideoDeleted(id)); + if (deletedIds.length > 0) await refetchMeta(); + return { deletedIds, ...describeOutcome(deletedIds.length, result.failed) }; + }; + + return { + dialogOpen, + pendingCount: pendingIds.length, + loading, + requestDelete, + cancelDelete, + confirmDelete, + }; +} diff --git a/client/src/components/__tests__/PlaylistPage.test.tsx b/client/src/components/__tests__/PlaylistPage.test.tsx index af1977cec..ee8fa315d 100644 --- a/client/src/components/__tests__/PlaylistPage.test.tsx +++ b/client/src/components/__tests__/PlaylistPage.test.tsx @@ -12,6 +12,7 @@ const mockMarkVideoDeleted = jest.fn(); const mockRefetchMeta = jest.fn(); const mockIgnoreVideo = jest.fn(); const mockUnignoreVideo = jest.fn(); +const mockDeleteVideosByYoutubeIds = jest.fn(); let mockLocationState: unknown = null; const mockVideo: PlaylistVideo = { @@ -165,6 +166,15 @@ jest.mock('../../hooks/usePlaylistMutations', () => ({ }), })); +jest.mock('../shared/useVideoDeletion', () => ({ + useVideoDeletion: () => ({ + deleteVideos: jest.fn(), + deleteVideosByYoutubeIds: (...args: unknown[]) => mockDeleteVideosByYoutubeIds(...args), + loading: false, + error: null, + }), +})); + jest.mock('../../hooks/useMediaServerStatus', () => ({ useMediaServerStatus: () => ({ status: {}, anyConfigured: true }), })); @@ -359,6 +369,101 @@ describe('PlaylistPage selected-download selection lifecycle', () => { }); }); +describe('PlaylistPage mixed selection actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDeleteVideosByYoutubeIds.mockResolvedValue({ success: true, deleted: ['vidC'], failed: [] }); + }); + + const selectVideos = async (user: ReturnType<typeof userEvent.setup>, titles: string[]) => { + for (const title of titles) { + await user.click(screen.getByRole('checkbox', { name: `Select ${title}` })); + } + }; + + test('Download queues only the selected videos that are not downloaded', async () => { + const user = userEvent.setup(); + mockTriggerDownload.mockResolvedValue(undefined); + renderWithProviders(<PlaylistPage token="t" />); + + await selectVideos(user, ['Vid A', 'Vid C']); + await user.click(screen.getByTestId('selection-action-download')); + await user.click(await screen.findByTestId('mock-confirm-download')); + + await waitFor(() => expect(mockTriggerDownload).toHaveBeenCalledWith(['vidA'], undefined)); + }); + + test('Download is unavailable when only downloaded videos are selected', async () => { + const user = userEvent.setup(); + renderWithProviders(<PlaylistPage token="t" />); + + await selectVideos(user, ['Vid C']); + + expect(screen.getByTestId('selection-action-download')).toBeDisabled(); + }); + + test('Delete is unavailable when no downloaded video is selected', async () => { + const user = userEvent.setup(); + renderWithProviders(<PlaylistPage token="t" />); + + await selectVideos(user, ['Vid A']); + + expect(screen.getByTestId('selection-action-delete')).toBeDisabled(); + }); + + test('Delete confirms and removes only the selected downloaded videos', async () => { + const user = userEvent.setup(); + renderWithProviders(<PlaylistPage token="t" />); + + await selectVideos(user, ['Vid A', 'Vid C']); + await user.click(screen.getByTestId('selection-action-delete')); + expect(await screen.findByText(/permanently delete 1 video from disk/i)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Delete Video' })); + + await waitFor(() => expect(mockDeleteVideosByYoutubeIds).toHaveBeenCalledWith(['vidC'], 't')); + }); + + test('the delete confirmation says which selected videos it leaves alone', async () => { + const user = userEvent.setup(); + renderWithProviders(<PlaylistPage token="t" />); + + await selectVideos(user, ['Vid A', 'Vid C']); + await user.click(screen.getByTestId('selection-action-delete')); + + expect( + await screen.findByText('1 selected video will not be affected because it has no file on disk.') + ).toBeInTheDocument(); + }); + + test('a delete marks the row deleted, reports it, and clears the selection', async () => { + const user = userEvent.setup(); + renderWithProviders(<PlaylistPage token="t" />); + + await selectVideos(user, ['Vid A', 'Vid C']); + await user.click(screen.getByTestId('selection-action-delete')); + await user.click(await screen.findByRole('button', { name: 'Delete Video' })); + + expect(await screen.findByText('Deleted 1 video')).toBeInTheDocument(); + expect(mockMarkVideoDeleted).toHaveBeenCalledWith('vidC'); + expect(screen.queryByText('2 selected')).not.toBeInTheDocument(); + }); + + test('a failed delete keeps the selection so it can be retried', async () => { + const user = userEvent.setup(); + mockDeleteVideosByYoutubeIds.mockResolvedValue({ + success: false, deleted: [], failed: [{ youtubeId: 'vidC', error: 'Network Error' }], + }); + renderWithProviders(<PlaylistPage token="t" />); + + await selectVideos(user, ['Vid C']); + await user.click(screen.getByTestId('selection-action-delete')); + await user.click(await screen.findByRole('button', { name: 'Delete Video' })); + + expect(await screen.findByText('Failed to delete video: Network Error')).toBeInTheDocument(); + expect(screen.getByText('1 selected')).toBeInTheDocument(); + }); +}); + describe('PlaylistPage exclude action feedback', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/client/src/components/shared/DeleteVideosDialog.tsx b/client/src/components/shared/DeleteVideosDialog.tsx index 5187c168e..f2c75a240 100644 --- a/client/src/components/shared/DeleteVideosDialog.tsx +++ b/client/src/components/shared/DeleteVideosDialog.tsx @@ -18,7 +18,7 @@ interface DeleteVideosDialogProps { onClose: () => void; onConfirm: () => void; videoCount: number; - // Selected videos excluded from deletion because their file is already missing + // Selected videos excluded from deletion because they have no file on disk skippedCount?: number; } @@ -58,8 +58,8 @@ const DeleteVideosDialog: React.FC<DeleteVideosDialogProps> = ({ <Alert severity="info"> <Typography variant="body2"> {skippedCount === 1 - ? '1 selected video will not be affected because its file is already missing from disk.' - : `${skippedCount} selected videos will not be affected because their files are already missing from disk.`} + ? '1 selected video will not be affected because it has no file on disk.' + : `${skippedCount} selected videos will not be affected because they have no file on disk.`} </Typography> </Alert> )} diff --git a/client/src/components/shared/__tests__/DeleteVideosDialog.test.tsx b/client/src/components/shared/__tests__/DeleteVideosDialog.test.tsx index e6f5bdb3e..5676077fc 100644 --- a/client/src/components/shared/__tests__/DeleteVideosDialog.test.tsx +++ b/client/src/components/shared/__tests__/DeleteVideosDialog.test.tsx @@ -161,7 +161,7 @@ describe('DeleteVideosDialog', () => { expect( screen.getByText( - '1 selected video will not be affected because its file is already missing from disk.' + '1 selected video will not be affected because it has no file on disk.' ) ).toBeInTheDocument(); }); @@ -171,7 +171,7 @@ describe('DeleteVideosDialog', () => { expect( screen.getByText( - '2 selected videos will not be affected because their files are already missing from disk.' + '2 selected videos will not be affected because they have no file on disk.' ) ).toBeInTheDocument(); }); diff --git a/client/src/hooks/__tests__/usePlaylistDetail.test.ts b/client/src/hooks/__tests__/usePlaylistDetail.test.ts index e6a0c9a37..4fab3d4b0 100644 --- a/client/src/hooks/__tests__/usePlaylistDetail.test.ts +++ b/client/src/hooks/__tests__/usePlaylistDetail.test.ts @@ -463,6 +463,27 @@ describe('usePlaylistDetail sorting and pagination', () => { expect(axios.get).toHaveBeenCalledTimes(callsBefore); }); + test('markVideoDeleted records the row as deleted in Youtarr', async () => { + axios.get.mockImplementation((url: string) => { + if (url.endsWith('/videos')) { + return Promise.resolve({ data: { total: 1, videos: [makeVideo(1, { downloaded: true })] } }); + } + return Promise.resolve({ data: { playlist: { playlist_id: 'PL1' } } }); + }); + + const { result } = renderHook(() => + usePlaylistDetail({ token: 't', playlistId: 'PL1' }) + ); + + await waitFor(() => expect(result.current.videos).toHaveLength(1)); + + act(() => { + result.current.markVideoDeleted('vid1'); + }); + + expect(result.current.videos[0].removed_reason).toBe('user_deleted'); + }); + test('refetchMeta updates the count without reloading the video list', async () => { let count = 5; axios.get.mockImplementation((url: string) => { diff --git a/client/src/hooks/usePlaylistDetail.ts b/client/src/hooks/usePlaylistDetail.ts index 26666605c..53869e603 100644 --- a/client/src/hooks/usePlaylistDetail.ts +++ b/client/src/hooks/usePlaylistDetail.ts @@ -245,7 +245,14 @@ export const usePlaylistDetail = ({ setVideos((prev) => prev.map((v) => v.youtube_id === youtubeId - ? { ...v, downloaded: false, previously_downloaded: true } + ? { + ...v, + downloaded: false, + previously_downloaded: true, + removed_reason: 'user_deleted', + removed_at: new Date().toISOString(), + removed_detail: null, + } : v ) ); diff --git a/docs/MEDIA_SERVER_PLAYLISTS.md b/docs/MEDIA_SERVER_PLAYLISTS.md index 494f5e407..a471d548c 100644 --- a/docs/MEDIA_SERVER_PLAYLISTS.md +++ b/docs/MEDIA_SERVER_PLAYLISTS.md @@ -8,7 +8,7 @@ When you subscribe to a YouTube playlist: 1. Youtarr fetches the playlist metadata and video list from YouTube via `yt-dlp`. 2. Selected existing videos and future discoveries (with auto-download enabled) download into the channel folder for their uploader (the channel is auto-created and hidden if it does not already exist). -3. After each download (and after manual downloads of videos that happen to live in a tracked playlist), Youtarr syncs the playlist to every configured media server and regenerates the M3U file. +3. After each download (and after manual downloads of videos that happen to live in a tracked playlist), Youtarr syncs the playlist to every configured media server and regenerates the M3U file. Deleting a downloaded video that is in a tracked playlist (from any page, or by automatic cleanup) does the same, so neither keeps listing the deleted file. 4. The native server playlists keep the YouTube playlist's order by default; a per-playlist **Playlist order** setting can reverse it (see [Playlist order](#playlist-order)). Partially-downloaded playlists keep their relative ordering: position 1, 3, 5 stay 1, 3, 5, and position 2 slots in correctly when it later downloads. What Youtarr does **not** do for playlists: @@ -96,7 +96,7 @@ Existing downloads are never converted or re-downloaded when the setting changes ## Replace semantics -When a playlist's video list changes (new videos downloaded, ignored videos removed), Youtarr re-syncs: +When a playlist's video list changes (new videos downloaded, ignored videos removed, downloaded files deleted), Youtarr re-syncs. Videos whose file was deleted are left out (a video marked missing stays, since its file may come back). If none of the playlist's videos has a file left, Plex empties the existing playlist, and Jellyfin and Emby, which cannot hold an empty playlist, delete it; it comes back on the next sync with items: - **Plex**: deletes existing items in place and PUTs the new URI list. Same playlist ID retained. - **Jellyfin / Emby**: deletes the entire playlist and recreates it. The `playlist_sync_state.server_playlist_id` is updated to point at the new ID. This is required because both servers' `DELETE /Playlists/{id}/Items` returns errors on current versions; full replace is the only reliable path. diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index 66bf38d4e..b266ce5ea 100644 --- a/docs/USAGE_GUIDE.md +++ b/docs/USAGE_GUIDE.md @@ -227,7 +227,7 @@ Open a playlist to manage it: - **Public on media servers**: makes the playlist visible to other users on Jellyfin and Emby. Plex playlists are always created under one account and shared manually, so this setting doesn't affect Plex. - **Sync now** and **Rebuild .m3u file**: push the current state to your servers or regenerate the `.m3u` on demand. Sync runs in the background and can take a minute or two while your media server's library scan finishes. -In the video list you can sort by playlist order, reverse order, discovery time, download time, or newest publication date, filter by download state with the **Show** control (All videos / Downloaded / Not downloaded), filter by watch status with the **Watched** control (see [Track Watch Status from Media Servers](#track-watch-status-from-media-servers)), exclude videos you don't want, and select specific videos to download with **Download Selected**. Excluded videos are skipped when downloading the playlist and removed from synced server playlists; if the file is already on disk, it stays there. Videos whose file was deleted after downloading count as not downloaded. +In the video list you can sort by playlist order, reverse order, discovery time, download time, or newest publication date, filter by download state with the **Show** control (All videos / Downloaded / Not downloaded), filter by watch status with the **Watched** control (see [Track Watch Status from Media Servers](#track-watch-status-from-media-servers)), exclude videos you don't want, and select videos to act on: **Download** fetches the selected videos that aren't on disk, and **Delete** removes the selected downloaded videos from disk (after a confirmation). Excluded videos are skipped when downloading the playlist and removed from synced server playlists; if the file is already on disk, it stays there. Videos whose file was deleted after downloading count as not downloaded. Discovery means when Youtarr first saw a video in this playlist; it is not the date the owner added it on YouTube. Downloading never changes discovery order. Videos discovered in the same refresh share a timestamp and use playlist order as a tie-break. Publication dates stay visible in every sort, including in the existing-video review list. Sorting by discovery or download time shows that date below Published in both the desktop table and cards on narrower screens. The Downloaded line is omitted for videos without a local file. Tap or click a discovery or download date to expand its full timestamp. Missing dates display as Unknown and sort last. Sorting the page or changing media-server playback order never changes automatic download eligibility. diff --git a/server/modules/__tests__/m3uGenerator.test.js b/server/modules/__tests__/m3uGenerator.test.js index 9b2b0cd53..236f35683 100644 --- a/server/modules/__tests__/m3uGenerator.test.js +++ b/server/modules/__tests__/m3uGenerator.test.js @@ -115,6 +115,32 @@ describe('m3uGenerator', () => { })); }); + describe('removed videos', () => { + const rowFor = (id, removal) => ({ + filePath: `/youtube/Chan/${id}/${id}.mp4`, duration: 60, youTubeVideoName: id, youTubeChannelName: 'Chan', ...removal, + }); + + const contentsFor = async (removal) => { + Playlist.findByPk.mockResolvedValue({ id: 1, playlist_id: 'PL1', title: 'PL' }); + PlaylistVideo.findAll.mockResolvedValue([{ youtube_id: 'v1', position: 1, ignored: false }]); + Video.findOne.mockResolvedValue(rowFor('v1', removal)); + await m3uGenerator.generatePlaylistM3U(1); + return fs.writeFileSync.mock.calls.find((c) => String(c[0]).endsWith('.m3u'))[1]; + }; + + test('leaves out a video whose file was deleted', async () => { + expect(await contentsFor({ removed: true, removed_reason: 'user_deleted' })).not.toContain('v1.mp4'); + }); + + test('leaves out a video removed before its reason was recorded', async () => { + expect(await contentsFor({ removed: true, removed_reason: null })).not.toContain('v1.mp4'); + }); + + test('keeps a missing video, whose file may come back', async () => { + expect(await contentsFor({ removed: true, removed_reason: 'not_found' })).toContain('v1.mp4'); + }); + }); + test('includes audio-only entries via audioFilePath and prefers filePath when both exist', async () => { Playlist.findByPk.mockResolvedValue({ id: 1, playlist_id: 'PL1', title: 'Audio PL' }); PlaylistVideo.findAll.mockResolvedValue([ diff --git a/server/modules/__tests__/playlistOutputRefresh.test.js b/server/modules/__tests__/playlistOutputRefresh.test.js new file mode 100644 index 000000000..b1af7a603 --- /dev/null +++ b/server/modules/__tests__/playlistOutputRefresh.test.js @@ -0,0 +1,97 @@ +/* eslint-env jest */ + +describe('playlistOutputRefresh', () => { + let playlistOutputRefresh; + let Playlist, PlaylistVideo; + let m3uGenerator; + let mediaServerSync; + let logger; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + jest.doMock('../../logger', () => ({ + info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), + })); + jest.doMock('../../models', () => ({ + Playlist: { findAll: jest.fn().mockResolvedValue([]) }, + PlaylistVideo: { findAll: jest.fn().mockResolvedValue([]) }, + })); + jest.doMock('../m3uGenerator', () => ({ generatePlaylistM3U: jest.fn().mockResolvedValue(true) })); + jest.doMock('../mediaServers/mediaServerSync', () => ({ syncPlaylist: jest.fn().mockResolvedValue(undefined) })); + + ({ Playlist, PlaylistVideo } = require('../../models')); + m3uGenerator = require('../m3uGenerator'); + mediaServerSync = require('../mediaServers/mediaServerSync'); + logger = require('../../logger'); + playlistOutputRefresh = require('../playlistOutputRefresh'); + }); + + describe('refreshForVideos', () => { + test('regenerates the M3U and re-syncs each enabled playlist listing the videos', async () => { + PlaylistVideo.findAll.mockResolvedValue([{ playlist_id: 'PL1' }, { playlist_id: 'PL2' }, { playlist_id: 'PL1' }]); + Playlist.findAll.mockResolvedValue([{ id: 1, playlist_id: 'PL1' }, { id: 2, playlist_id: 'PL2' }]); + + await playlistOutputRefresh.refreshForVideos(['v1', 'v2']); + + expect(m3uGenerator.generatePlaylistM3U.mock.calls).toEqual([[1], [2]]); + expect(mediaServerSync.syncPlaylist.mock.calls).toEqual([[1], [2]]); + }); + + test('looks up only playlists that still include the videos', async () => { + await playlistOutputRefresh.refreshForVideos(['v1']); + + expect(PlaylistVideo.findAll).toHaveBeenCalledWith(expect.objectContaining({ + where: { youtube_id: ['v1'], ignored: false }, + })); + }); + + test('skips removed playlists', async () => { + PlaylistVideo.findAll.mockResolvedValue([{ playlist_id: 'PL1' }, { playlist_id: 'PL2' }]); + + await playlistOutputRefresh.refreshForVideos(['v1']); + + expect(Playlist.findAll).toHaveBeenCalledWith({ where: { playlist_id: ['PL1', 'PL2'], enabled: true } }); + }); + + test('does nothing for an empty list', async () => { + await playlistOutputRefresh.refreshForVideos([]); + + expect(PlaylistVideo.findAll).not.toHaveBeenCalled(); + }); + + test('does nothing when no playlist lists the videos', async () => { + await playlistOutputRefresh.refreshForVideos(['v1']); + + expect(Playlist.findAll).not.toHaveBeenCalled(); + }); + + test('keeps refreshing the other playlists when one sync fails', async () => { + PlaylistVideo.findAll.mockResolvedValue([{ playlist_id: 'PL1' }, { playlist_id: 'PL2' }]); + Playlist.findAll.mockResolvedValue([{ id: 1, playlist_id: 'PL1' }, { id: 2, playlist_id: 'PL2' }]); + mediaServerSync.syncPlaylist.mockRejectedValueOnce(new Error('boom')); + + await playlistOutputRefresh.refreshForVideos(['v1']); + + expect(mediaServerSync.syncPlaylist).toHaveBeenCalledWith(2); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ playlist_id: 'PL1' }), + expect.any(String) + ); + }); + }); + + describe('refreshForVideosInBackground', () => { + test('logs a failed lookup instead of rejecting', async () => { + PlaylistVideo.findAll.mockRejectedValue(new Error('db down')); + + playlistOutputRefresh.refreshForVideosInBackground(['v1'], 'video-deletion'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'video-deletion' }), + expect.any(String) + ); + }); + }); +}); diff --git a/server/modules/__tests__/videoDeletionModule.test.js b/server/modules/__tests__/videoDeletionModule.test.js index c9bd185e4..d96dbeaf4 100644 --- a/server/modules/__tests__/videoDeletionModule.test.js +++ b/server/modules/__tests__/videoDeletionModule.test.js @@ -7,6 +7,10 @@ jest.mock('../m3uGenerator', () => ({ generateChannelM3UInBackground: jest.fn() })); +jest.mock('../playlistOutputRefresh', () => ({ + refreshForVideosInBackground: jest.fn() +})); + jest.mock('../storageGuard', () => ({ getStatus: jest.fn(() => ({ paused: false })), refresh: jest.fn().mockResolvedValue({ paused: false }) @@ -39,6 +43,7 @@ describe('VideoDeletionModule', () => { let mockLogger; let mockFilesystem; let m3uGenerator; + let playlistOutputRefresh; let mockCheckLibraryRoot; beforeEach(() => { @@ -49,6 +54,7 @@ describe('VideoDeletionModule', () => { mockLogger = require('../../logger'); m3uGenerator = require('../m3uGenerator'); + playlistOutputRefresh = require('../playlistOutputRefresh'); // Mock the Video model mockVideo = { @@ -129,6 +135,7 @@ describe('VideoDeletionModule', () => { expect(result).toEqual({ success: true, videoId: 1, + youtubeId: 'abc123', message: 'Video deleted successfully' }); expect(mockLogger.info).toHaveBeenCalledWith( @@ -208,6 +215,7 @@ describe('VideoDeletionModule', () => { expect(result).toEqual({ success: true, videoId: 1, + youtubeId: 'abc123', message: 'Video marked as removed (no file path)' }); }); @@ -384,6 +392,7 @@ describe('VideoDeletionModule', () => { expect(result).toEqual({ success: true, videoId: 1, + youtubeId: 'abc123', message: 'Video deleted successfully' }); }); @@ -1101,6 +1110,50 @@ describe('VideoDeletionModule', () => { }); }); + describe('playlist refresh after deletion', () => { + // The no-file-path branch of deleteVideoById: marked removed without fs work. + const makeVideo = (id) => ({ + id, + youtubeId: `yt${id}`, + channel_id: 'UC1', + removed: false, + filePath: null, + update: jest.fn().mockResolvedValue(undefined) + }); + + test('refreshes the playlists of the videos deleteVideos removed', async () => { + mockVideo.findByPk + .mockResolvedValueOnce(makeVideo(1)) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(makeVideo(3)); + + await VideoDeletionModule.deleteVideos([1, 2, 3]); + + expect(playlistOutputRefresh.refreshForVideosInBackground) + .toHaveBeenCalledWith(['yt1', 'yt3'], expect.any(String)); + }); + + test('refreshes the playlists of the videos deleteVideosByYoutubeIds removed', async () => { + mockVideo.findOne + .mockResolvedValueOnce(makeVideo(1)) + .mockResolvedValueOnce(null); + mockVideo.findByPk.mockResolvedValueOnce(makeVideo(1)); + + await VideoDeletionModule.deleteVideosByYoutubeIds(['yt1', 'yt2']); + + expect(playlistOutputRefresh.refreshForVideosInBackground) + .toHaveBeenCalledWith(['yt1'], expect.any(String)); + }); + + test('does not refresh playlists when nothing was deleted', async () => { + mockVideo.findByPk.mockResolvedValue(null); + + await VideoDeletionModule.deleteVideos([99]); + + expect(playlistOutputRefresh.refreshForVideosInBackground).not.toHaveBeenCalled(); + }); + }); + describe('download pause re-check after deletion', () => { let storageGuard; @@ -1481,6 +1534,7 @@ describe('VideoDeletionModule', () => { jest.resetModules(); mockLogger = require('../../logger'); m3uGenerator = require('../m3uGenerator'); + playlistOutputRefresh = require('../playlistOutputRefresh'); VideoDeletionModule = require('../videoDeletionModule'); }); @@ -1720,6 +1774,8 @@ describe('VideoDeletionModule', () => { expect(m3uGenerator.generateChannelM3UInBackground).toHaveBeenCalledTimes(2); expect(m3uGenerator.generateChannelM3UInBackground).toHaveBeenCalledWith('UC1', expect.any(String)); expect(m3uGenerator.generateChannelM3UInBackground).toHaveBeenCalledWith('UC2', expect.any(String)); + expect(playlistOutputRefresh.refreshForVideosInBackground) + .toHaveBeenCalledWith(['yt1', 'yt2', 'yt3'], expect.any(String)); }); test('should skip space cleanup when storage is above threshold', async () => { diff --git a/server/modules/m3uGenerator.js b/server/modules/m3uGenerator.js index 748a1b12d..9bdb2f786 100644 --- a/server/modules/m3uGenerator.js +++ b/server/modules/m3uGenerator.js @@ -10,6 +10,7 @@ const { } = require('./filesystem/pathBuilder'); const configModule = require('./configModule'); const { M3U_FOLDER_NAME } = require('./filesystem/constants'); +const { REMOVAL_STATES, removalState } = require('./videoRemovalState'); // 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 @@ -61,7 +62,9 @@ class M3uGenerator { let included = 0; for (const pv of videos) { const video = await Video.findOne({ where: { youtubeId: pv.youtube_id } }); - const mediaPath = video && (preferAudio + // A deleted row keeps its stored paths, but its file is gone for good. + // A missing one stays listed: its file may come back. + const mediaPath = video && removalState(video) !== REMOVAL_STATES.DELETED && (preferAudio ? (video.audioFilePath || video.filePath) : (video.filePath || video.audioFilePath)); if (!mediaPath) { diff --git a/server/modules/mediaServers/__tests__/mediaServerSync.test.js b/server/modules/mediaServers/__tests__/mediaServerSync.test.js index f4a0d7402..f37bb2791 100644 --- a/server/modules/mediaServers/__tests__/mediaServerSync.test.js +++ b/server/modules/mediaServers/__tests__/mediaServerSync.test.js @@ -677,6 +677,144 @@ describe('mediaServerSync', () => { expect(updateMock).not.toHaveBeenCalled(); }); + test('leaves out videos whose files were deleted', async () => { + Playlist.findByPk.mockResolvedValue({ + id: 1, playlist_id: 'PL1', title: 'PL', + sync_to_plex: true, sync_to_jellyfin: false, sync_to_emby: false, + public_on_servers: false, + }); + PlaylistVideo.findAll.mockResolvedValue([ + { youtube_id: 'v1', position: 1, ignored: false }, + { youtube_id: 'v2', position: 2, ignored: false }, + ]); + Video.findAll.mockResolvedValue([ + { youtubeId: 'v1', filePath: '/youtube/A/v1.mp4', removed: true, removed_reason: 'user_deleted' }, + { youtubeId: 'v2', filePath: '/youtube/B/v2.mp4', removed: false }, + ]); + PlaylistSyncState.findOne.mockResolvedValue({ server_playlist_id: 'existingid', update: jest.fn() }); + + const plexAdapter = makeAdapter('PlexAdapter', { + resolveItemIdByFilepath: jest.fn((p) => Promise.resolve(p === '/youtube/A/v1.mp4' ? 'rk1' : 'rk2')), + replacePlaylistItems: jest.fn().mockResolvedValue({ id: 'existingid' }), + }); + serverRegistry.getEnabledAdapters.mockReturnValue([plexAdapter]); + + await mediaServerSync.syncPlaylist(1); + + expect(plexAdapter.replacePlaylistItems).toHaveBeenCalledWith('existingid', ['rk2'], expect.any(Object)); + }); + + test('empties an existing server playlist once every downloaded file is deleted', async () => { + Playlist.findByPk.mockResolvedValue({ + id: 1, playlist_id: 'PL1', title: 'PL', + sync_to_plex: true, sync_to_jellyfin: false, sync_to_emby: false, + public_on_servers: false, + }); + PlaylistVideo.findAll.mockResolvedValue([ + { youtube_id: 'v1', position: 1, ignored: false }, + { youtube_id: 'v2', position: 2, ignored: false }, + ]); + Video.findAll.mockResolvedValue([{ youtubeId: 'v1', filePath: '/youtube/A/v1.mp4', removed: true, removed_reason: 'auto_removal' }]); + PlaylistSyncState.findOne.mockResolvedValue({ server_playlist_id: 'existingid', update: jest.fn() }); + + const plexAdapter = makeAdapter('PlexAdapter', { + replacePlaylistItems: jest.fn().mockResolvedValue({ id: 'existingid' }), + }); + serverRegistry.getEnabledAdapters.mockReturnValue([plexAdapter]); + + await mediaServerSync.syncPlaylist(1); + + expect(plexAdapter.replacePlaylistItems).toHaveBeenCalledWith('existingid', [], expect.any(Object)); + }); + + test('forgets the server playlist when emptying it leaves none on the server', async () => { + Playlist.findByPk.mockResolvedValue({ + id: 1, playlist_id: 'PL1', title: 'PL', + sync_to_plex: false, sync_to_jellyfin: true, sync_to_emby: false, + public_on_servers: false, + }); + PlaylistVideo.findAll.mockResolvedValue([{ youtube_id: 'v1', position: 1, ignored: false }]); + Video.findAll.mockResolvedValue([{ youtubeId: 'v1', filePath: '/youtube/A/v1.mp4', removed: true, removed_reason: 'user_deleted' }]); + const updateMock = jest.fn(); + PlaylistSyncState.findOne.mockResolvedValue({ server_playlist_id: 'existingid', update: updateMock }); + + const jellyfinAdapter = makeAdapter('JellyfinAdapter', { + replacePlaylistItems: jest.fn().mockResolvedValue({ id: null }), + }); + serverRegistry.getEnabledAdapters.mockReturnValue([jellyfinAdapter]); + + await mediaServerSync.syncPlaylist(1); + + expect(updateMock).toHaveBeenCalledWith(expect.objectContaining({ server_playlist_id: null, last_error: null })); + }); + + test('creates no server playlist when every downloaded file is deleted before the first sync', async () => { + Playlist.findByPk.mockResolvedValue({ + id: 1, playlist_id: 'PL1', title: 'PL', + sync_to_plex: true, sync_to_jellyfin: false, sync_to_emby: false, + public_on_servers: false, + }); + PlaylistVideo.findAll.mockResolvedValue([{ youtube_id: 'v1', position: 1, ignored: false }]); + Video.findAll.mockResolvedValue([{ youtubeId: 'v1', filePath: '/youtube/A/v1.mp4', removed: true, removed_reason: 'user_deleted' }]); + PlaylistSyncState.findOne.mockResolvedValue(null); + + const plexAdapter = makeAdapter('PlexAdapter'); + serverRegistry.getEnabledAdapters.mockReturnValue([plexAdapter]); + + await mediaServerSync.syncPlaylist(1); + + expect(plexAdapter.createPlaylist).not.toHaveBeenCalled(); + }); + + test('keeps syncing a missing video, whose file may come back', async () => { + Playlist.findByPk.mockResolvedValue({ + id: 1, playlist_id: 'PL1', title: 'PL', + sync_to_plex: true, sync_to_jellyfin: false, sync_to_emby: false, + public_on_servers: false, + }); + PlaylistVideo.findAll.mockResolvedValue([{ youtube_id: 'v1', position: 1, ignored: false }]); + Video.findAll.mockResolvedValue([{ youtubeId: 'v1', filePath: '/youtube/A/v1.mp4', removed: true, removed_reason: 'not_found' }]); + PlaylistSyncState.findOne.mockResolvedValue({ server_playlist_id: 'existingid', update: jest.fn() }); + + const plexAdapter = makeAdapter('PlexAdapter', { + resolveItemIdByFilepath: jest.fn().mockResolvedValue('rk1'), + replacePlaylistItems: jest.fn().mockResolvedValue({ id: 'existingid' }), + }); + serverRegistry.getEnabledAdapters.mockReturnValue([plexAdapter]); + + await mediaServerSync.syncPlaylist(1); + + expect(plexAdapter.replacePlaylistItems).toHaveBeenCalledWith('existingid', ['rk1'], expect.any(Object)); + }); + + test('does not empty a server playlist whose files are only missing', async () => { + Playlist.findByPk.mockResolvedValue({ + id: 1, playlist_id: 'PL1', title: 'PL', + sync_to_plex: true, sync_to_jellyfin: false, sync_to_emby: false, + public_on_servers: false, + }); + PlaylistVideo.findAll.mockResolvedValue([{ youtube_id: 'v1', position: 1, ignored: false }]); + Video.findAll.mockResolvedValue([{ youtubeId: 'v1', filePath: '/youtube/A/v1.mp4', removed: true, removed_reason: 'not_found' }]); + PlaylistSyncState.findOne.mockResolvedValue({ server_playlist_id: 'existingid', update: jest.fn() }); + + const plexAdapter = makeAdapter('PlexAdapter', { + resolveItemIdByFilepath: jest.fn().mockResolvedValue(null), + }); + serverRegistry.getEnabledAdapters.mockReturnValue([plexAdapter]); + + const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((cb) => { + cb(); + return 0; + }); + try { + await mediaServerSync.syncPlaylist(1); + } finally { + setTimeoutSpy.mockRestore(); + } + + expect(plexAdapter.replacePlaylistItems).not.toHaveBeenCalled(); + }); + test('reversed sort order queries positions descending and syncs items in that order', async () => { Playlist.findByPk.mockResolvedValue({ id: 1, playlist_id: 'PL1', title: 'PL', sort_order: 'reversed', diff --git a/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js b/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js index 356d7caad..6d21d04d5 100644 --- a/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js +++ b/server/modules/mediaServers/adapters/__tests__/embyAdapter.test.js @@ -182,6 +182,17 @@ describe('EmbyAdapter', () => { expect(result).toEqual({ id: 'new-pl-id' }); }); + test('replacePlaylistItems with no items deletes the playlist instead of creating an empty one', async () => { + axios.delete.mockResolvedValueOnce({}); + const adapter = new EmbyAdapter(cfg); + + const result = await adapter.replacePlaylistItems('old-pl-id', [], { name: 'YT: Something' }); + + expect(axios.delete).toHaveBeenCalledWith(expect.stringContaining('/Items/old-pl-id'), expect.any(Object)); + expect(axios.post).not.toHaveBeenCalled(); + expect(result).toEqual({ id: null }); + }); + test('replacePlaylistItems still creates a fresh playlist when DELETE of stored id fails', async () => { axios.delete.mockRejectedValueOnce({ response: { status: 400 } }); axios.post.mockResolvedValueOnce({ data: { Id: 'new-pl-id' } }); diff --git a/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js b/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js index a065d1a21..6a61fb6c2 100644 --- a/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js +++ b/server/modules/mediaServers/adapters/__tests__/jellyfinAdapter.test.js @@ -168,6 +168,17 @@ describe('JellyfinAdapter', () => { expect(result).toEqual({ id: 'new-pl-id' }); }); + test('replacePlaylistItems with no items deletes the playlist instead of creating an empty one', async () => { + axios.delete.mockResolvedValueOnce({}); + const adapter = new JellyfinAdapter(cfg); + + const result = await adapter.replacePlaylistItems('old-pl-id', [], { name: 'YT: Something' }); + + expect(axios.delete).toHaveBeenCalledWith(expect.stringContaining('/Items/old-pl-id'), expect.any(Object)); + expect(axios.post).not.toHaveBeenCalled(); + expect(result).toEqual({ id: null }); + }); + test('replacePlaylistItems throws when opts.name is missing', async () => { const adapter = new JellyfinAdapter(cfg); await expect(adapter.replacePlaylistItems('old', ['i1'], {})).rejects.toThrow(/opts\.name/); diff --git a/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js b/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js index c00cc6257..416666472 100644 --- a/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js +++ b/server/modules/mediaServers/adapters/__tests__/plexAdapter.test.js @@ -385,6 +385,43 @@ describe('PlexAdapter', () => { expect(result).toEqual({ id: '100' }); }); + describe('replacePlaylistItems with no items', () => { + test('empties the playlist in place without sending an empty item list', async () => { + axios.get.mockResolvedValueOnce({ data: { MediaContainer: { Metadata: [{ ratingKey: '100' }] } } }); + axios.delete.mockResolvedValueOnce({}); + const adapter = new PlexAdapter(cfg); + + const result = await adapter.replacePlaylistItems('100', [], { name: 'PL' }); + + expect(axios.delete).toHaveBeenCalledWith(expect.stringContaining('/playlists/100/items'), expect.any(Object)); + expect(axios.put).not.toHaveBeenCalled(); + expect(result).toEqual({ id: '100' }); + }); + + test('reports no playlist instead of creating an empty one when the stored id is gone', async () => { + axios.get.mockResolvedValueOnce({ data: { MediaContainer: { Metadata: [{ ratingKey: 'stale-id' }] } } }); + axios.delete.mockRejectedValueOnce({ response: { status: 404 } }); + const adapter = new PlexAdapter(cfg); + + const result = await adapter.replacePlaylistItems('stale-id', [], { name: 'PL' }); + + expect(axios.post).not.toHaveBeenCalled(); + expect(result).toEqual({ id: null }); + }); + + test('removes a stranded playlist without creating an empty one', async () => { + axios.get.mockResolvedValueOnce({ data: { MediaContainer: { Metadata: [{ ratingKey: '999', title: 'Other' }] } } }); + axios.delete.mockResolvedValueOnce({}); + const adapter = new PlexAdapter(cfg); + + const result = await adapter.replacePlaylistItems('stranded-id', [], { name: 'PL' }); + + expect(axios.delete).toHaveBeenCalledWith(expect.stringContaining('/playlists/stranded-id'), expect.any(Object)); + expect(axios.post).not.toHaveBeenCalled(); + expect(result).toEqual({ id: null }); + }); + }); + test('replacePlaylistItems falls back to create-fresh when stored id returns 404', async () => { // Visibility check: id present, so we attempt the in-place replace. axios.get.mockResolvedValueOnce({ diff --git a/server/modules/mediaServers/adapters/baseAdapter.js b/server/modules/mediaServers/adapters/baseAdapter.js index e09d626ab..ca26e2b81 100644 --- a/server/modules/mediaServers/adapters/baseAdapter.js +++ b/server/modules/mediaServers/adapters/baseAdapter.js @@ -28,7 +28,13 @@ class BaseAdapter { return results; } async createPlaylist(/* name, itemIds, opts */) { throw new Error('not implemented'); } - async replacePlaylistItems(/* id, itemIds */) { throw new Error('not implemented'); } + /** + * Replace a playlist's items; resolves { id } of the playlist now holding + * them, which may be a recreated one. An empty itemIds never creates a + * playlist (servers reject empty creation): it resolves { id: null } when no + * playlist is left on the server. + */ + async replacePlaylistItems(/* id, itemIds, opts: { name, public, mediaType } */) { throw new Error('not implemented'); } /** * Watch state for file-backed items in the libraries the adapter tracks for diff --git a/server/modules/mediaServers/adapters/embyAdapter.js b/server/modules/mediaServers/adapters/embyAdapter.js index 83dd81968..91e2818fa 100644 --- a/server/modules/mediaServers/adapters/embyAdapter.js +++ b/server/modules/mediaServers/adapters/embyAdapter.js @@ -229,6 +229,9 @@ class EmbyAdapter extends BaseAdapter { const status = err.response?.status; logger.warn({ status, playlistId }, 'emby replacePlaylistItems: delete failed, creating fresh'); } + // An empty playlist cannot be created, so an emptied one stays deleted + // (id null) until the next sync with items creates it again. + if (itemIds.length === 0) return { id: null }; return this.createPlaylist(opts.name, itemIds, { public: !!opts.public, mediaType: opts.mediaType }); } } diff --git a/server/modules/mediaServers/adapters/jellyfinAdapter.js b/server/modules/mediaServers/adapters/jellyfinAdapter.js index f48fb6c4d..959571bbb 100644 --- a/server/modules/mediaServers/adapters/jellyfinAdapter.js +++ b/server/modules/mediaServers/adapters/jellyfinAdapter.js @@ -221,6 +221,9 @@ class JellyfinAdapter extends BaseAdapter { const status = err.response?.status; logger.warn({ status, playlistId }, 'jellyfin replacePlaylistItems: delete failed, creating fresh'); } + // An empty playlist cannot be created, so an emptied one stays deleted + // (id null) until the next sync with items creates it again. + if (itemIds.length === 0) return { id: null }; return this.createPlaylist(opts.name, itemIds, { public: !!opts.public, mediaType: opts.mediaType }); } } diff --git a/server/modules/mediaServers/adapters/plexAdapter.js b/server/modules/mediaServers/adapters/plexAdapter.js index 3a33f4e12..67f3e3639 100644 --- a/server/modules/mediaServers/adapters/plexAdapter.js +++ b/server/modules/mediaServers/adapters/plexAdapter.js @@ -291,17 +291,22 @@ class PlexAdapter extends BaseAdapter { params: this._plParams(), timeout: REQUEST_TIMEOUT_MS, }); - const machineId = await this._getMachineId(); - const uri = `server://${machineId}/com.plexapp.plugins.library/library/metadata/${itemIds.join(',')}`; - await axios.put(`${this.url}/playlists/${playlistId}/items`, null, { - params: this._plParams({ uri }), - timeout: REQUEST_TIMEOUT_MS, - }); + // Deleting the items already emptied it; an empty uri is not a valid item list. + if (itemIds.length > 0) { + const machineId = await this._getMachineId(); + const uri = `server://${machineId}/com.plexapp.plugins.library/library/metadata/${itemIds.join(',')}`; + await axios.put(`${this.url}/playlists/${playlistId}/items`, null, { + params: this._plParams({ uri }), + timeout: REQUEST_TIMEOUT_MS, + }); + } return { id: playlistId }; } catch (err) { const status = err.response?.status; if (status === 404 || status === 403) { logger.warn({ status, playlistId }, 'plex _replaceInPlace: stored id unreachable, creating fresh'); + // Plex cannot create an empty playlist; with nothing to list, none is needed. + if (itemIds.length === 0) return { id: null }; if (!opts.name) throw err; return this.createPlaylist(opts.name, itemIds, { public: !!opts.public, mediaType: opts.mediaType }); } @@ -343,6 +348,7 @@ class PlexAdapter extends BaseAdapter { // pile up duplicates: adopt an existing same-named playlist if present. const sameName = scopePlaylists.find((p) => p.title === opts.name); if (sameName) return this._replaceInPlace(sameName.ratingKey, itemIds, opts); + if (itemIds.length === 0) return { id: null }; return this.createPlaylist(opts.name, itemIds, { public: !!opts.public, mediaType: opts.mediaType }); } } diff --git a/server/modules/mediaServers/mediaServerSync.js b/server/modules/mediaServers/mediaServerSync.js index 3f9f49744..04eb94497 100644 --- a/server/modules/mediaServers/mediaServerSync.js +++ b/server/modules/mediaServers/mediaServerSync.js @@ -3,6 +3,7 @@ const configModule = require('../configModule'); const serverRegistry = require('./serverRegistry'); const { MediaServerUnavailableError, describeHttpError } = require('./adapters/baseAdapter'); const { Playlist, PlaylistVideo, PlaylistSyncState, Video } = require('../../models'); +const { REMOVAL_STATES, removalState } = require('../videoRemovalState'); // Backoff retry for resolving items after library scan. Tuned for typical Plex/Jellyfin // scan completion times — short initial delays, then longer as more time passes. @@ -108,7 +109,9 @@ class MediaServerSync { let mismatched = 0; for (const pv of videos) { const v = byYoutubeId.get(pv.youtube_id); - if (!v) continue; + // A deleted row keeps its stored paths, but its file is gone for good. + // A missing one stays: its file may come back. + if (!v || removalState(v) === REMOVAL_STATES.DELETED) continue; const mediaPath = mediaType === 'audio' ? v.audioFilePath : v.filePath; if (mediaPath) { entries.push({ youtube_id: pv.youtube_id, filePath: mediaPath }); @@ -148,12 +151,15 @@ class MediaServerSync { }); // With zero resolved items, the only case that proceeds is a deliberately - // emptied playlist (every video ignored/removed) with an existing server - // playlist to empty. Everything else defers: the servers reject empty - // playlist creation, and replacing an existing playlist with nothing would - // wipe it over a transient condition (scan lag, or downloads that don't - // match a just-changed Download Type). - if (itemIds.length === 0 && !(state?.server_playlist_id && videos.length === 0)) { + // emptied playlist (every video ignored, or every downloaded file deleted) + // with an existing server playlist to empty (Jellyfin/Emby delete it + // instead; see replacePlaylistItems). Everything else defers: the + // servers reject empty playlist creation, and replacing an existing + // playlist with nothing would wipe it over a transient condition (scan + // lag, a missing file that may come back, or downloads that don't match a + // just-changed Download Type). + const hasFiles = entries.length > 0 || mismatched > 0; + if (itemIds.length === 0 && !(state?.server_playlist_id && !hasFiles)) { const reason = entries.length === 0 ? mismatched > 0 ? `none of the downloaded items has a ${mediaType === 'audio' ? 'mp3' : 'video'} file matching the playlist's Download Type` @@ -176,7 +182,9 @@ class MediaServerSync { const replaced = await adapter.replacePlaylistItems(state.server_playlist_id, itemIds, { name, public: !!playlist.public_on_servers, mediaType, }); - const effectiveId = replaced?.id || state.server_playlist_id; + // id null: emptying it left no playlist on the server (it cannot hold an + // empty one); the next sync with items creates a fresh one. + const effectiveId = replaced?.id === null ? null : (replaced?.id || state.server_playlist_id); if (state.update) await state.update({ server_playlist_id: effectiveId, last_synced_at: new Date(), diff --git a/server/modules/playlistOutputRefresh.js b/server/modules/playlistOutputRefresh.js new file mode 100644 index 000000000..a518b0265 --- /dev/null +++ b/server/modules/playlistOutputRefresh.js @@ -0,0 +1,53 @@ +const logger = require('../logger'); +const { Playlist, PlaylistVideo } = require('../models'); +const m3uGenerator = require('./m3uGenerator'); +const mediaServerSync = require('./mediaServers/mediaServerSync'); + +/** + * Rebuilds what Youtarr publishes for a playlist (its .m3u file and synced + * media server playlists) when its videos lose their files outside a + * download, so neither keeps listing a file that is gone. + */ +class PlaylistOutputRefresh { + /** + * Regenerate the M3U and re-sync media servers for every enabled playlist + * that includes any of the videos, one playlist at a time. + * @param {string[]} youtubeIds + * @returns {Promise<void>} + */ + async refreshForVideos(youtubeIds) { + const ids = [...new Set((youtubeIds || []).filter(Boolean))]; + if (ids.length === 0) return; + + const rows = await PlaylistVideo.findAll({ + where: { youtube_id: ids, ignored: false }, + attributes: ['playlist_id'], + }); + const playlistIds = [...new Set(rows.map((row) => row.playlist_id))]; + if (playlistIds.length === 0) return; + + const playlists = await Playlist.findAll({ where: { playlist_id: playlistIds, enabled: true } }); + for (const playlist of playlists) { + try { + await m3uGenerator.generatePlaylistM3U(playlist.id); + await mediaServerSync.syncPlaylist(playlist.id); + } catch (err) { + logger.error({ err, playlist_id: playlist.playlist_id }, 'Failed to refresh playlist M3U and media server sync'); + } + } + } + + /** + * Fire-and-forget wrapper for callers that should not wait on media server + * syncs (library scans and item lookups can take minutes). + * @param {string[]} youtubeIds + * @param {string} reason - What changed the videos, for the log + */ + refreshForVideosInBackground(youtubeIds, reason) { + this.refreshForVideos(youtubeIds).catch((err) => { + logger.error({ err, reason }, 'Failed to refresh playlists after their videos changed'); + }); + } +} + +module.exports = new PlaylistOutputRefresh(); diff --git a/server/modules/videoDeletionModule.js b/server/modules/videoDeletionModule.js index a69cf4647..f8ad0c680 100644 --- a/server/modules/videoDeletionModule.js +++ b/server/modules/videoDeletionModule.js @@ -5,6 +5,7 @@ const logger = require('../logger'); const configModule = require('./configModule'); const { isVideoDirectoryFor, isFileForVideo, cleanupEmptyChannelDirectory, cleanupEmptyParents, isSubfolderDir, listSubdirectories, removeDirectoryResilient } = require('./filesystem'); const m3uGenerator = require('./m3uGenerator'); +const playlistOutputRefresh = require('./playlistOutputRefresh'); const storageUsage = require('./storageUsage'); const { STORED_BYTES_SQL } = storageUsage; const storageGuard = require('./storageGuard'); @@ -140,6 +141,7 @@ class VideoDeletionModule { return { success: true, videoId, + youtubeId: video.youtubeId, channelId: video.channel_id, message: 'Video marked as removed (no file path)' }; @@ -227,6 +229,7 @@ class VideoDeletionModule { return { success: true, videoId, + youtubeId: video.youtubeId, channelId: video.channel_id, message: 'Video deleted successfully' }; @@ -271,6 +274,7 @@ class VideoDeletionModule { const deleted = []; const failed = []; const affectedChannelIds = []; + const deletedYoutubeIds = []; const libraryCheck = videoIds.length > 0 ? await this._checkLibraryForDeletes() : null; // Process deletions sequentially to avoid overwhelming the file system @@ -280,6 +284,7 @@ class VideoDeletionModule { if (result.success) { deleted.push(videoId); affectedChannelIds.push(result.channelId); + deletedYoutubeIds.push(result.youtubeId); } else { failed.push({ videoId, @@ -289,6 +294,7 @@ class VideoDeletionModule { } this._regenerateM3usForChannels(affectedChannelIds); + this._refreshPlaylistsForVideos(deletedYoutubeIds); if (deleted.length > 0) this._refreshDownloadPauseIfPaused(); return { @@ -346,6 +352,7 @@ class VideoDeletionModule { } this._regenerateM3usForChannels(affectedChannelIds); + this._refreshPlaylistsForVideos(deleted); if (deleted.length > 0) this._refreshDownloadPauseIfPaused(); return { @@ -367,6 +374,16 @@ class VideoDeletionModule { }); } + /** + * Rebuild the playlist .m3u files and media server playlists that list the + * deleted videos, so they stop pointing at files that are gone. + * @param {string[]} youtubeIds + */ + _refreshPlaylistsForVideos(youtubeIds) { + if (youtubeIds.length === 0) return; + playlistOutputRefresh.refreshForVideosInBackground(youtubeIds, 'video-deletion'); + } + /** * Regenerate the channel .m3u playlist for each affected channel, deduped. * @param {Array<string|undefined>} channelIds @@ -579,6 +596,7 @@ ${excludeClause} ORDER BY timeCreated ASC async _removeOldestUntilFreed({ bytesToFree, excludeIds, bucket, deletedKey, label, removal, dryRun, includeSamples, result }) { const triedIds = new Set(); const affectedChannelIds = []; + const deletedYoutubeIds = []; let freedSoFar = 0; let iterations = 0; @@ -628,6 +646,7 @@ ${excludeClause} ORDER BY timeCreated ASC batchFreed += videoSize; batchDeletedCount += 1; affectedChannelIds.push(deleteResult.channelId); + deletedYoutubeIds.push(deleteResult.youtubeId); result[deletedKey] += 1; bucket.deletedCount += 1; @@ -663,6 +682,7 @@ ${excludeClause} ORDER BY timeCreated ASC result.errors.push('Reached maximum iterations, may need additional cleanup'); } this._regenerateM3usForChannels(affectedChannelIds); + this._refreshPlaylistsForVideos(deletedYoutubeIds); } return { selectedIds: Array.from(triedIds) };